In-memory test bench (createBench) for
@sanity/workflow-engine. Wraps the engine and an
in-memory Sanity client so workflow tests stay free of client wiring and actor
boilerplate.
Status: 0.x, public. Intended for testing
@sanity/workflow-engine.
npm install -D @sanity/workflow-engine @sanity/workflow-engine-test
createBench() builds an isolated bench: the real engine, a fake in-memory
Sanity client, an all-access default actor, and a bench-owned clock. Every
wrapped verb injects the bench's client, tag, workflow resource, access
default, clock, and cross-resource resolver (serveResources sugar for
same-store dataset siblings, raw resourceClients for anything else), so a
test reads as the workflow it exercises:
deployDefinitions, deleteDefinition, startInstance,
fireAction, editField, completeEffect (and completePendingEffect,
addressed by effect name), tick, evaluate.setStage, abortInstance. Same injection,
so a forced move into a deadline-gated stage evaluates $now at bench time,
not wall-clock.getInstance, currentStage, activityStatus,
listPendingEffects, findPendingEffects, children, instancesForSubject,
instancesByStage, instancesForDocument, definitionsForDocument,
evaluateStart, query, plus the guard helpers below. Helpers sharing an
engine verb's name take the engine's own args objects and throw its typed
errors — bench reads and engine reads cannot drift.const bench = createBench({now: '2026-01-01T00:00:00Z'})
await bench.deployDefinitions({expectedMinReaderModel: 4, definitions: [reviewWorkflow]})
const {instance} = await bench.startInstance({
definition: 'review-workflow',
initialFields: [subjectField('article-1')],
})
await bench.fireAction({instanceId: instance._id, activity: 'review', action: 'approve'})
expect(await bench.currentStage(instance._id)).toBe('approved')
// Admin overrides ride the same defaults:
await bench.abortInstance({instanceId: instance._id, reason: 'hard stop'})
subjectField(docId) and releaseField(name) build the runtime
initial-field entries for the bench's default resource, so tests never
hand-roll GDR URI strings. For denial paths, pass a restrictive
currentUser / grants / attributes (or a full access) at
construction, or override per call with the actor / grants /
attributes shortcuts or an explicit access. The package re-exports
WriteAccessDeniedError for assertions against those access-control denials.
Time is bench-owned. createBench({now}) starts frozen; setNow /
advance move it. Every wrapped verb evaluates $now and stamps history at
bench time, so deadline predicates are tested by advancing the clock and
ticking — never by waiting.
Some tests need an engine of their own — custom effect handlers, racing two
drainers, a custom clock. createBenchEngine(bench, overrides?)
builds one on the bench's client, inheriting its tag, workflow resource, and
clock (overrides win). The bench exposes tag and workflowResource for
raw workflow.* calls, and the default tag ships as the BENCH_TAG constant.
A workflow stage can deploy mutation guards — temp.system.guard documents
that lock the documents it governs while the stage is active, and are deleted when it
exits. The lake does not enforce this doc type yet: in production, guard
enforcement is the engine's own optimistic evaluation (advisory verdicts
and engine-side write denials), and the lake ACL is the only hard boundary.
The bench installs the intended lake-side contract at its client's write
seam, so tests exercise the rejection semantics guards are designed to have
once the lake enforces them. There are two distinct things you test, and the
bench gives you a helper for each:
bench.editDocument({documentId, patch})
writes through the bench client (patch is a {set?, unset?, insert?} spec),
and a write a deployed guard denies throws the package's re-exported
GuardDeniedError — the rejection the lake is designed to apply to any
client once guard enforcement ships; the bench reproduces it on the client it
mints. action: 'delete' deletes, 'publish' creates or updates the published
document and deletes its draft, and 'unpublish' creates the draft and
deletes the published document. Author guards with publish / unpublish;
the engine compiles those lifecycle actions to the underlying create,
update, and delete mutations. Only hand-seeded Lake guard documents use
those operations directly. The helper resolves to the post-write
document, or undefined once a delete/unpublish removed it.bench.activeGuardsForDocument(id) answers "would
a write be denied right now?" without writing. This is the check a UI uses
to disable a button or show a lock ahead of time. It's a pure read over the
same evaluation path as the bench's write rejection, so its verdict matches
what a bench write would actually do — and it works against any client.bench.listGuards() returns the deployed guard docs for inspection.
import {createBench, GuardDeniedError} from '@sanity/workflow-engine-test'
const bench = createBench({documents: [{_id: 'drafts.doc-1', _type: 'article', body: 'b'}]})
// …deploy a definition whose current stage locks `drafts.doc-1`, start an instance…
// Preflight: is the doc locked for this user right now?
const active = await bench.activeGuardsForDocument('drafts.doc-1')
// Rejection: a guard-violating write throws (bench-simulated lake contract).
await expect(
bench.editDocument({documentId: 'drafts.doc-1', patch: {set: {body: 'edited'}}}),
).rejects.toBeInstanceOf(GuardDeniedError)
To share one store across benches with the write seam intact, pass another bench's already-wired client:
createBench({client: other.client, tag}). ThemutationGuardrides along, so the second bench denies the same locks. Only a bare hand-builtcreateTestClient()opts out of write rejection — the read-time preflight needs no wiring either way.