@sanity-labs/workflow-engine-explore-test
v0.4.0
Published
In-memory test bench (`createBench`) for [`@sanity-labs/workflow-engine-explore`](https://www.npmjs.com/package/@sanity-labs/workflow-engine-explore). Wraps the engine and a [test-client](https://www.npmjs.com/package/@sanity-labs/workflow-engine-explore-
Maintainers
Keywords
Readme
@sanity-labs/workflow-engine-explore-test
In-memory test bench (createBench) for @sanity-labs/workflow-engine-explore. Wraps the engine and a test-client so test call sites stay tight — no client wiring, no actor boilerplate per call.
Status: 0.x POC. API surface mirrors the engine; tracks engine changes minor-for-minor.
npm install -D @sanity-labs/workflow-engine-explore-testWhat the bench is
The bench is the only place in the engine ecosystem that fabricates an actor identity. The engine requires an explicit actor on every call; the bench provides a default ALL_ACCESS_USER so tests don't have to thread one. It also wires:
- A fresh
TestClient(in-memory, groq-js-backed) percreateBench() - A
WILDCARD_GRANTSarray as the default for ACL-gated calls - A
tags: ["bench"]engine scope (override to test multi-tenant isolation)
The result: test call sites look like end-user call sites without the auth/scope overhead.
import { createBench } from "@sanity-labs/workflow-engine-explore-test";
import { defineWorkflow } from "@sanity-labs/workflow-engine-explore/define";
const bench = createBench();
const def = defineWorkflow({
workflowId: "article-review",
version: 1,
name: "Article review",
initialStageId: "draft",
stages: [
{ id: "draft", tasks: [{ id: "write", actions: [{ name: "submit", setStatus: "done" }] }],
transitions: [{ to: "published" }] },
{ id: "published", kind: "terminal" },
],
});
await bench.deployDefinitions({ definitions: [def] });
const inst = await bench.startInstance({ workflowId: "article-review" });
await bench.fireAction({ instanceId: inst._id, taskId: "write", action: "submit" });
expect(await bench.currentStage(inst._id)).toBe("published");API
createBench(options?: CreateBenchOptions): Bench
| Option | Type | Default | What |
|---|---|---|---|
| tags | string[] | ["bench"] | Engine-scope tags. See Tag scoping. |
| client | TestClient | fresh one | Pass to share a store between two benches. |
| currentUser | Actor | ALL_ACCESS_USER | Actor injected into every wrapped engine call. |
| grants | Grant[] | WILDCARD_GRANTS | Sanity ACL grants supplied to fireAction / evaluate. |
| documents | { _id, _type, … }[] | [] | Seed documents into the store at construction. |
Engine wrappers
These mirror the engine API one-for-one, minus client, tags, actor, and grants (all injected by the bench):
bench.deployDefinitions({ definitions })
bench.startInstance({ workflowId, version?, subject?, ancestors?, effectsContext?, instanceId? })
bench.fireAction({ instanceId, taskId, action, idempotent?, grants? /* override */ })
bench.completeEffect({ instanceId, effectKey, status, outputs?, … })
bench.tick({ instanceId })
bench.evaluate({ instanceId, grants? /* override */ })Override the bench's currentUser per-call by passing an actor:
await bench.fireAction({
instanceId, taskId: "approve", action: "approve",
actor: { kind: "user", id: "alice", roles: ["editor"] }, // overrides bench.currentUser
});Read helpers (DX sugar)
bench.getInstance(instanceId) // throws if missing or tag-mismatched
bench.currentStage(instanceId) // → string
bench.pendingEffects(instanceId) // → PendingEffect[]
bench.taskStatus(instanceId, taskId) // → "pending" | "active" | "done" | "skipped" | "failed" | undefined
bench.effectsContextMap(instanceId) // → Record<key, value>
bench.children(parentId, taskId?) // → WorkflowInstance[] (tag-filtered)
bench.instancesForSubject(ref) // → WorkflowInstance[] (tag-filtered)
bench.instancesByStage(workflowId, stageId, { openOnly? })
bench.snapshot() // raw store contents
bench.seedDocuments([...]) // add docs after constructionAll read helpers respect the bench's tags — disjoint-tag benches sharing a client cannot see each other's data through any helper.
Tag scoping
import { createTestClient } from "@sanity-labs/workflow-engine-explore-test-client";
const sharedClient = createTestClient();
const benchA = createBench({ client: sharedClient, tags: ["tenant-a"] });
const benchB = createBench({ client: sharedClient, tags: ["tenant-b"] });
await benchA.deployDefinitions({ definitions: [def] });
const instA = await benchA.startInstance({ workflowId: "article-review" });
await benchB.getInstance(instA._id); // throws: tag mismatch
await benchA.getInstance(instA._id); // returns itTwo benches without a shared client are completely isolated regardless of tags — they have separate stores. The interesting case is shared client + different tags; that's what proves multi-tenant isolation in production.
Tag validation
tags must match /^[a-z0-9][a-z0-9-]*$/. Validated at construction:
createBench({ tags: [] }); // throws: empty
createBench({ tags: ["foo.bar"] }); // throws: dots reserved as ID separator
createBench({ tags: ["FooBar"] }); // throws: ASCII lowercase only
createBench({ tags: ["-leading"] }); // throws: no leading dashDenial-path testing
Pass a non-wildcard currentUser + scoped grants to test what happens when an action is denied:
import { ActionDisabledError } from "@sanity-labs/workflow-engine-explore";
const restricted = createBench({
currentUser: { kind: "user", id: "viewer", roles: ["viewer"] },
grants: [{ filter: '_type == "workflow.instance"', permissions: ["read"] }], // read-only
});
await restricted.deployDefinitions({ definitions: [def] });
const inst = await restricted.startInstance({ workflowId: "article-review" });
await expect(
restricted.fireAction({ instanceId: inst._id, taskId: "approve", action: "approve" }),
).rejects.toBeInstanceOf(ActionDisabledError);License
UNLICENSED — internal Sanity labs exploration.
