New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@sanity/workflows-test

Package Overview
Dependencies
Maintainers
120
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@sanity/workflows-test

In-memory test bench (createBench) for @sanity/workflows.

latest
bootstrap
Source
npmnpm
Version
0.0.0
Version published
Weekly downloads
419
Maintainers
120
Weekly downloads
 
Created
Source

@sanity/workflows-test

In-memory test bench (createBench) for @sanity/workflows. 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/workflows.

Installation

npm install -D @sanity/workflows @sanity/workflows-test

The bench

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, partition, 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:

  • Runtime verbs — deployDefinitions, deleteDefinition, startInstance, fireAction, editField, completeEffect (and completePendingEffect, addressed by effect name), tick, evaluate, and session for a reactive instance session.
  • Instance-admin overrides — abortInstance, resetActivity. Same injection, so a cascade a reset unblocks evaluates $now at bench time, not wall-clock.
  • Reads and probes — 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.

Asserted caller access. By default each wrapped call commits with its own identity's token, and the engine resolves the caller from that token. Pass createBench({committer}) to commit every wrapped call, session, and createBenchEngine engine with a separate write-capable identity, such as a service, while the call's identity (the bench default or a per-call shortcut) reaches the engine as createEngine's access. History names the call's identity and $actor, roles, $can and $attributes judge it, so the same test runs on both paths:

import {type Actor, type WorkflowAccess} from '@sanity/workflows'
import {createBench, WILDCARD_GRANTS} from '@sanity/workflows-test'
import {expect, test} from 'vitest'

import {reviewWorkflow} from './review-workflow' // your definition, with a review/approve action

const EDITOR: Actor = {kind: 'person', id: 'g-editor', roles: ['editor']}
const SERVICE: WorkflowAccess = {
  actor: {kind: 'system', id: 'p-workflows-service'},
  grants: WILDCARD_GRANTS,
}

test.each([{}, {committer: SERVICE}])('approve names the editor (%o)', async (options) => {
  const bench = createBench({...options, currentUser: EDITOR})
  await bench.deployDefinitions({expectedMinReaderModel: 4, definitions: [reviewWorkflow]})
  const {instance} = await bench.startInstance({definition: reviewWorkflow.name})

  const {instance: approved} = await bench.fireAction({
    instanceId: instance._id,
    activity: 'review',
    action: 'approve',
  })
  expect(approved.history.at(-1)).toMatchObject({actor: {id: EDITOR.id}})
})

The fake Content Lake checks each write against the committing identity's grants, so on the asserted path a read-only caller's write still commits. A call identity without grants is judged with wildcard grants on both paths.

Routed writes. createBench({service}) serves a Workflows service on the bench's API host that writes instances as service. A wrapped startInstance, fireAction, editField, abortInstance, or tick, and a session commit, goes to that service when the grants of the identity it commits with cannot create and update instances, as an engine decides in production. The service is a createWorkflowService from @sanity/workflows/service, so it runs the operation exactly as a deployed service does: with its own token and the call's identity asserted, after refusing an instance the call identity cannot read (operation-instance-unreadable). Give the call identity read-only instance grants to run the same test on the routed path. Continuing the example above:

import {type Grant} from '@sanity/workflows'

const EDITOR_GRANTS: Grant[] = [
  {filter: '_type != "sanity.workflow.instance"', permissions: ['create', 'read', 'update']},
  {filter: '_type == "sanity.workflow.instance"', permissions: ['read']},
]

test.each([{}, {service: SERVICE, grants: EDITOR_GRANTS}])(
  'approve names the editor when routed (%o)',
  async (options) => {
    const bench = createBench({...options, currentUser: EDITOR})
    await bench.deployDefinitions({expectedMinReaderModel: 4, definitions: [reviewWorkflow]})
    const {instance} = await bench.startInstance({definition: reviewWorkflow.name})

    const {instance: approved} = await bench.fireAction({
      instanceId: instance._id,
      activity: 'review',
      action: 'approve',
    })
    expect(approved.history.at(-1)).toMatchObject({actor: {id: EDITOR.id}})
  },
)

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.

Out-of-band engines

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 partition, workflow resource, clock, and, on a bench with a committer, the committer's client and the asserted default identity (overrides win). The bench exposes partition and workflowResource for raw workflow.* calls, and the default partition ships as the BENCH_PARTITION constant.

Mutation guards

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:

  • Write-time rejection (simulated) — 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.
  • Read-time preflight — 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/workflows-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)

Query index lag

createBench({laggingQueryIndex: true}) models the Content Lake's query index. The engine commits instance writes at async visibility, and on this bench a GROQ query does not see such a commit until bench.advanceQueryIndex(). For an instance whose definition, or an ancestor's or live child's, declares a query-sourced field or a spawn, the verbs and their cascades commit sync, so those writes are visible at once; effect claims and lease renewals stay async. A sync commit, a write that names no visibility, and documents added with seedDocuments are visible at once. Reads by id (getInstance, getDocument, getDocuments) and the documents a commit returns are always current. bench.queryIndexBehind() reports whether a commit is waiting for an advance.

Use it to prove a flow never reads its own write back through GROQ, and to check that visibility: 'sync' on a verb makes a query right after it fresh:

const bench = createBench({laggingQueryIndex: true})
// …deploy a definition…
const {instance} = await bench.startInstance({definition: 'review', visibility: 'sync'})
await bench.fireAction({instanceId: instance._id, activity: 'review', action: 'approve'})

await bench.client.fetch('*[_id == $id][0].currentStage', {id: instance._id}) // still 'review'
bench.advanceQueryIndex()
await bench.client.fetch('*[_id == $id][0].currentStage', {id: instance._id}) // 'approved'

GROQ queries on a lagging bench run as an all-access identity, and the option cannot be combined with client.

To share one store across benches with the write seam intact, pass another bench's already-wired client: createBench({client: other.client, partition}). The mutationGuard rides along, so the second bench denies the same locks. Only a bare hand-built createTestClient() opts out of write rejection — the read-time preflight needs no wiring either way.

License

MIT

Keywords

sanity

FAQs

Package last updated on 09 Nov 2026

Related posts