@identity-flow/testing
v0.3.0
Published
Public Workflow Test contracts and runner-neutral integration helpers.
Maintainers
Readme
@identity-flow/testing
Public Workflow Test contracts and runner-neutral integration helpers.
Introduction
The public @identity-flow/testing package provides two surfaces:
- the types and editor contract for
workflowTest()and its Vitest setup; and createHookTestHost()— a runner-neutral host for IdentityFlow hook definitions that needs no database, container, or network.
The package deliberately contains no Workflow Engine, Query implementation,
PostgreSQL lifecycle, or private Workflow Test runtime. Calling workflowTest()
or defineWorkflowTestSetup() from the public package fails closed. The
customer-authorized IdentityFlow Test distribution replaces those entrypoints
when it hosts Vitest. Authorized customers obtain the exact-version distribution
from the IdentityFlow download portal; it is never part of npm or the Workshop
Starter.
Version cohort
Gate G3 — package publication. @identity-flow/[email protected] belongs to the
exact IdentityFlow 0.3.0 public package cohort. Use the same exact version for
every @identity-flow/* package in one project. The package contains only its
public contract and helpers and exposes no private runtime dependency.
@identity-flow/sdk/testing is not a valid entry point.
Workflow Test contract
Install
A Workflow Author works in a normal TypeScript project outside this repository. The supported runtime is Node.js 26.8.1 or newer within Node 26, with Vitest 4.1.9 or a compatible 4.x release.
pnpm add @identity-flow/[email protected] @identity-flow/[email protected] @identity-flow/[email protected]
pnpm add --save-dev @identity-flow/[email protected] @types/[email protected] [email protected] [email protected]vitest is a required peer for the public TypeScript contract. Runtime and
PostgreSQL dependencies arrive only in the exact-version Test distribution from
the authorized IdentityFlow download portal.
Repository maintainers can run pnpm --filter @identity-flow/testing run
test:external-consumer to build and verify an isolated consumer project.
Setup contract
This file is type-checked against the public package. It becomes executable only when the matching Test distribution from the authorized download portal hosts Vitest.
import { defineWorkflowTestSetup } from '@identity-flow/testing/vitest';
export default defineWorkflowTestSetup();Register the file as a Vitest global setup:
import { defineConfig } from 'vitest/config';
// Workflow Tests use the `*.case.ts` suffix, which Vitest's default `include`
// does not match. Declaring it here is the only supported way to select them.
export default defineConfig({
test: { include: ['**/*.case.ts'], globalSetup: ['./workflow-test.setup.ts'] },
});The runtime contract selects a local or external server explicitly with
defineWorkflowTestSetup({ postgres: { mode: 'supplied', connection: … } }).
Supplied Mode never falls back to Managed Mode, requires allowRemote: true for
every non-loopback host, and ignores generic PG* environment variables.
Bindings and the Workflow Definition
A fixture-capable Binding needs a stable name from defineBinding(name, factory).
Lookup uses that name, never JavaScript function identity.
import { defineBinding } from '@identity-flow/sdk';
import { vi } from 'vitest';
export interface Directory {
lookup(userId: string): Promise<{ readonly displayName: string }>;
}
// A production project writes the factory directly. This acceptance fixture wraps
// it in a Vitest spy so the tests can prove the Workflow Test never falls back to
// the production Binding.
export const productionDirectory = vi.fn((): Directory => {
throw new Error('The production Directory Binding must not run in a Workflow Test');
});
export const Directory = defineBinding('directory', productionDirectory);import * as v from '@identity-flow/sdk/valibot';
import { defineWorkflow } from '@identity-flow/sdk';
import { Directory } from './directory.binding';
export const approval = defineWorkflow(
{ name: 'approval', version: '1.0.0', schema: v.object({ userId: v.string() }) },
async (flow) => {
const directory = flow.use(Directory);
const person = await flow.do('load person', () => directory.lookup(flow.params.userId));
const decision = await flow.dialog(
'approve access',
{ schema: v.object({ status: v.literal('approved') }) },
() => ({
params: { displayName: person.displayName },
assignees: [{ providerId: 'acme', subject: 'role:approver' }],
}),
);
return { person, decision };
},
);The test
The contract says that workflowTest(options) returns Vitest's ordinary test
function with three per-test fixtures: flow, accounts, and fixture. The
authorized Test distribution supplies that implementation.
import { WorkflowTestError, workflowTest } from '@identity-flow/testing';
import { expect, vi } from 'vitest';
import { approval } from './approval.workflow';
import { Directory, productionDirectory } from './directory.binding';
const test = workflowTest({
workflow: approval,
accounts: {
requester: { providerId: 'acme', principals: ['user:requester'] },
approver: { providerId: 'acme', principals: ['role:approver'] },
blocked: { providerId: 'acme', principals: ['user:blocked'] },
},
});
test('approves access', async ({ flow, accounts, fixture }) => {
const lookup = vi.fn(() => Promise.resolve({ displayName: 'Alex Example' }));
fixture(Directory, { lookup });
const instance = await flow.actAs(accounts.requester).start({ userId: 'user-1234' });
const dialog = await flow.waitFor(instance).toHaveDialog('approve access');
await expect(
flow.actAs(accounts.blocked).completeActivity(dialog, { status: 'approved' }),
).rejects.toBeInstanceOf(WorkflowTestError);
await flow.actAs(accounts.approver).completeActivity(dialog, { status: 'approved' });
const completed = await flow.waitFor(instance).toBeCompleted();
const observed = await flow.observe(instance);
expect(completed.data).toEqual({
person: { displayName: 'Alex Example' },
decision: { status: 'approved' },
});
expect(observed.instance.id).toBe(instance.id);
expect(observed.steps).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'load person', status: 'COMPLETED' }),
expect.objectContaining({ name: 'approve access', status: 'COMPLETED' }),
]),
);
expect(observed.events.length).toBeGreaterThan(0);
expect(lookup).toHaveBeenCalledOnce();
expect(lookup).toHaveBeenCalledWith('user-1234');
expect(productionDirectory).not.toHaveBeenCalled();
});pnpm exec tsc --noEmitThis public-package smoke stops at import and type checking. Do not run the
example with plain vitest: the exact-version Test distribution from the
authorized IdentityFlow download portal owns the supported runtime command and
its release-locked Engine cohort.
Author API
| Member | Contract |
| ---------------------------------- | --------------------------------------------------------------------------- |
| accounts.<name> | opaque handle for a declared Account; no ID or Actor is exposed |
| fixture(binding, value) | typed, synchronous Binding Fixture for this test; sealed at the first start |
| flow.actAs(account) | frozen driver bound to that Account's Actor |
| .start(params) | starts the configured Workflow; resolves when the mutation has committed |
| .completeActivity(step, result) | completes a pending Dialog; resolves when the mutation has committed |
| flow.waitFor(instance, options?) | vi.waitFor semantics with interval and timeout |
| .toHaveDialog(name) | resolves with the pending Dialog Step |
| .toHaveChild(name, definition) | resolves with the child Instance and its typed Workflow Test driver |
| .toBeCompleted() | resolves with the completed Instance and its inferred data |
| flow.observe(instance) | one fresh read of that Instance, its Steps, and its Events |
Declared Principals cross the normal provider boundary, and Dialog assignees are
enforced by the Engine. A declared Account that is not an assignee is denied.
Awaiting a mutation proves the commit; later automatic Steps need waitFor().
Repeated flow.use() returns the same instrumented fixture, duplicate Binding
Names fail at registration, unused fixtures are allowed, and your own vi.fn
spies stay authoritative for calls, this, results, and response sequences.
observe() is an assertion surface, not a logging surface. It returns real data,
so keep production secrets out of Workflow Tests.
Child workflows
Deploy child definitions with workflowTest({ workflow: parent, workflows: [child],
accounts }). Use flow.waitFor(instance, options).toHaveChild(stepName, child)
to obtain { instance, flow }. The child's flow has the same actAs,
completeActivity, waitFor().toBeCompleted(), and observe contract, with the
child's result type. Nested children use the same contract. Accounts and Binding
Fixtures are shared within the isolated test Engine.
Child discovery follows the parent's relying Event to the child's recorded
causation ID, including children that already completed. Discovery and completion
use the existing wait budget. The Engine continues the parent after the child
reaches terminal state; always wait for parent completion separately. Refusal
returned as business data reaches the parent normally. An uncaught child error
propagates through the Engine and produces WF_TEST_INSTANCE_ERRORED.
Diagnostics
Failures raise one public WorkflowTestError with a stable code:
WF_TEST_WAIT_TIMEOUT, WF_TEST_MUTATION_REJECTED,
WF_TEST_BINDING_FIXTURE_MISSING, WF_TEST_UNSUPPORTED_CALL,
WF_TEST_CHILD_DEFINITION_MISMATCH, or
WF_TEST_INSTANCE_ERRORED.
flow.request() remains unsupported at every depth. A wait checks descendants
even when the test has not obtained their handles. It fails promptly with
WF_TEST_UNSUPPORTED_CALL and names the API and Step. A SUBFLOW error before a
child is created, including an undeployed definition or unavailable version,
uses the same code. An unregistered or mismatched definition passed to
toHaveChild fails with WF_TEST_CHILD_DEFINITION_MISMATCH. flow.sleep() uses
real time, so a duration beyond the wait budget reports WF_TEST_WAIT_TIMEOUT.
A Binding without a fixture fails at first use and never calls the production factory:
WorkflowTestError: [WF_TEST_BINDING_FIXTURE_MISSING] An executed Binding has no registered Fixture.
Expected: Binding Fixture "directory" registered before start()
Actual: Binding Fixture "directory" missing at first use
Workflow: "diagnostic missing binding fixture" @ "1.0.0"
Step: "load person" (TASK, ERRORED, attempts 1)
Actor: account "requester"
Cause: (none)
Binding: "directory" (missing)
Events (4 shown, 4 total; chronological, safe metadata tie-break):
- INSTANCE_STARTED
- STEP_STARTED, step "load person", kind TASK, attempt 1
- STEP_ERRORED, step "load person", kind TASK, attempt 1
- INSTANCE_ERRORED
Binding calls: (none)
Next: Register it with fixture("directory", value) before start().
Redaction: params, dialog results, event payloads, Binding arguments/results, secrets, tokens, and raw Error details omitted.Messages use a positive allowlist. Workflow parameters, Dialog values, Event
payloads, Binding arguments and results, Account fields, Principal subjects,
secrets, tokens, technical IDs, and raw errors never appear, and no raw error is
attached as Error.cause.
Test-distribution PostgreSQL contract
| Mode | Server | Teardown |
| ------------------- | --------------------------------------------- | ------------------------------------ |
| Managed (default) | one pinned PostgreSQL 18.6 container per run | container is stopped and removed |
| Supplied (explicit) | the server named in defineWorkflowTestSetup | server and shared extension are kept |
The authorized Test runtime gives every test one isolated schema with an ownership marker, full Engine migrations, a fresh Engine, and its own Accounts, Principals, and Binding Fixture registry. After every test — including failing ones — the Engine closes first, the schema prefix, UUID shape, marker row, and run UUID are verified, and only that exact schema is dropped. A failed check refuses the drop. The MVP never creates or drops a database and never sweeps orphan schemas, so a crashed Supplied Mode run can leave one marked schema behind.
Out of scope
Scenario DSLs, controlled time, sleep(), retry scripting, Engine restart or
replay, pause, resume, terminate, reassignment, browser tests, mock
OIDC, real Connectors, Staging, traces, and embedded PostgreSQL are not part of
this MVP.
Hook-host tests
import * as v from 'valibot';
import { defineHooks } from '@identity-flow/sdk';
import { createHookTestHost } from '@identity-flow/testing';
const hooks = defineHooks('directory', (context) => {
context.identity.provider('directory', {
principals: () => [{ providerId: 'directory', subject: 'user:42' }],
});
context.assignees.resolver('directory', {
context: v.strictObject({ requestId: v.string() }),
resolve: ({ source }) => ({
assignees: [{ providerId: 'directory', subject: `role:${source.context.requestId}` }],
}),
});
});
const host = createHookTestHost();
host.load(hooks);
const principals = await host.identity.resolve('directory', account);
const assignees = await host.assignees.resolve('directory', {
source: { providerId: 'directory', sourceKey: 'request:42', context: { requestId: 'approver' } },
signal: new AbortController().signal,
});host.identity.calls and host.assignees.calls record validated calls in
invocation order. createFakeIdentityProvider() provides the same runner-neutral
call recording when a test only needs the provider boundary.
See the testing strategies guide for the hook-host, engine, and application test boundaries.
HTTP webhooks
Registered webhooks are available in host.http.webhooks. Call
host.http.map(hookName, webhookName, input) to validate a JSON body, invoke
the mapper, and validate every returned change envelope. Validated calls are
recorded in host.http.calls; authentication and raw-byte HMAC behavior remain
host-runtime concerns and are not simulated by this runner-neutral helper.
Lifecycle startup
Call host.lifecycle.start(signal?) to run registered startup adapters. Canonical
change envelopes submitted by those adapters are recorded in
host.lifecycle.submissions; host.lifecycle.stop() runs returned disposers in
reverse order.
License
This project is licensed under the MIT License - see the LICENSE file for details.
