// A reusable, adapter-agnostic behavioural contract for {@link EngineClient} // (ADR 0059 test kit, S1 — issue #157). `runEngineClientContract` registers a // suite of `node:test` cases that exercise an `EngineClient` purely through its // public surface, so the *same* suite can be run against the in-process WASM // adapter (`./wasm-engine.ts`) and, in an integration lane, against the live // `@nanobpm/nano-sdk` adapter. Any adapter that passes agrees on the seam the // runtime depends on — including that a *cancelled* instance reports // `TERMINATED` (the state-mapping bug this kit exists to prevent). // // The suite tolerates a push (asynchronous) worker adapter by polling terminal // state with a bounded {@link waitFor}; against the synchronous WASM adapter the // first poll already succeeds. import { test } from "node:test"; import assert from "node:assert/strict"; import type { EngineClient, EngineJob } from "@nanobpm/urban/runtime"; /** A single service task `work`, job type `work`. Parks at `work` until a worker * serves it — so it doubles as the cancel fixture when no worker is registered. */ const SERVICE_BPMN = bpmn("svc", ` `); /** Two chained service tasks (`one` → `two`): exercises draining across workers * and mid-flight variable carry-over. */ const CHAIN_BPMN = bpmn("chain", ` `); /** A single native user task `review`: parks for a human. */ const USER_TASK_BPMN = bpmn("human", ` `); /** * Register the shared `EngineClient` contract as `node:test` cases, prefixed * with `label`. `makeEngine` must return a fresh, empty engine each call; the * suite closes it after every case. */ export function runEngineClientContract( label: string, makeEngine: () => Promise, ): void { const withEngine = async (run: (e: EngineClient) => Promise) => { const engine = await makeEngine(); try { await run(engine); } finally { await engine.close(); } }; test(`${label}: a registered worker serves a service task to completion`, async () => { await withEngine(async (engine) => { await engine.registerWorker("work", () => ({ served: true })); await engine.deployResources(res(SERVICE_BPMN)); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "svc", awaitCompletion: true, }); assert.ok(processInstanceKey, "expected a process instance key"); await waitFor(async () => (await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey] }))[0] ?.state === "COMPLETED" ); }); }); test(`${label}: a worker receives the instance's input variables`, async () => { await withEngine(async (engine) => { let seen: EngineJob | undefined; await engine.registerWorker("work", (job) => { seen = job; return {}; }); await engine.deployResources(res(SERVICE_BPMN)); await engine.createInstance({ processDefinitionId: "svc", variables: { n: 41 }, awaitCompletion: true, }); assert.equal(seen?.jobType, "work"); assert.equal(seen?.elementId, "work"); assert.equal(seen?.variables.n, 41); }); }); test(`${label}: fetchVariables limits the variables surfaced to a worker`, async () => { await withEngine(async (engine) => { let seen: EngineJob | undefined; await engine.registerWorker( "work", (job) => { seen = job; return {}; }, { fetchVariables: ["keep"] }, ); await engine.deployResources(res(SERVICE_BPMN)); await engine.createInstance({ processDefinitionId: "svc", variables: { keep: 1, drop: 2 }, awaitCompletion: true, }); assert.deepEqual(seen?.variables, { keep: 1 }); }); }); test(`${label}: a cancelled instance reports TERMINATED`, async () => { await withEngine(async (engine) => { // No worker registered → the instance parks at `work`, still ACTIVE. await engine.deployResources(res(SERVICE_BPMN)); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "svc" }); const before = await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey], }); assert.equal(before[0]?.state, "ACTIVE"); await engine.cancelInstance({ processInstanceKey }); await waitFor(async () => (await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey] }))[0] ?.state === "TERMINATED" ); // And it surfaces through the state filter the reconciler actually uses. const terminated = await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey], state: "TERMINATED", }); assert.deepEqual(terminated.map((i) => i.processInstanceKey), [processInstanceKey]); }); }); test(`${label}: searchProcessInstances filters by key and by state`, async () => { await withEngine(async (engine) => { await engine.registerWorker("work", () => ({})); await engine.deployResources(res(SERVICE_BPMN, USER_TASK_BPMN)); const done = await engine.createInstance({ processDefinitionId: "svc", awaitCompletion: true, }); const parked = await engine.createInstance({ processDefinitionId: "human" }); await waitFor(async () => (await engine.searchProcessInstances({ processInstanceKeys: [done.processInstanceKey] }))[0] ?.state === "COMPLETED" ); const completed = await engine.searchProcessInstances({ state: "COMPLETED" }); const completedKeys = completed.map((i) => i.processInstanceKey); assert.ok(completedKeys.includes(done.processInstanceKey)); assert.ok(!completedKeys.includes(parked.processInstanceKey)); const active = await engine.searchProcessInstances({ state: "ACTIVE" }); const activeKeys = active.map((i) => i.processInstanceKey); assert.ok(activeKeys.includes(parked.processInstanceKey)); assert.ok(!activeKeys.includes(done.processInstanceKey)); const byKey = await engine.searchProcessInstances({ processInstanceKeys: [done.processInstanceKey], }); assert.deepEqual(byKey.map((i) => i.processInstanceKey), [done.processInstanceKey]); }); }); test(`${label}: chained workers drain to completion`, async () => { await withEngine(async (engine) => { await engine.registerWorker("one", () => ({ one: true })); await engine.registerWorker("two", () => ({ two: true })); await engine.deployResources(res(CHAIN_BPMN)); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "chain", awaitCompletion: true, }); await waitFor(async () => (await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey] }))[0] ?.state === "COMPLETED" ); }); }); test(`${label}: a user task can be found and completed`, async () => { await withEngine(async (engine) => { await engine.deployResources(res(USER_TASK_BPMN)); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "human" }); const tasks = await waitForValue(async () => { const found = await engine.searchUserTasks({ processInstanceKey }); return found.length > 0 ? found : undefined; }); assert.equal(tasks.length, 1); assert.equal(tasks[0].elementId, "review"); // openUserTasks is the safe accessor: while the task is open it reports the same task // as an unfiltered search, but it pins state=CREATED so a later completion can't leak. // The state-filtered search can lag the unfiltered one on an eventually consistent // adapter, so poll rather than assuming it surfaces immediately. const openBefore = await waitForValue(async () => { const found = await engine.openUserTasks({ processInstanceKey }); return found.length > 0 ? found : undefined; }); assert.equal(openBefore.length, 1, "openUserTasks surfaces the open task"); assert.equal(openBefore[0].userTaskKey, tasks[0].userTaskKey); await engine.completeUserTask(tasks[0].userTaskKey); // Once answered, openUserTasks must never surface it as still-actionable (the footgun a // bare searchUserTasks leaves open). Poll to tolerate an eventually consistent adapter. await waitFor(async () => (await engine.openUserTasks({ processInstanceKey })).length === 0 ); await waitFor(async () => (await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey] }))[0] ?.state === "COMPLETED" ); }); }); test(`${label}: a failing worker raises an incident, not completion`, async () => { await withEngine(async (engine) => { await engine.registerWorker("work", () => { throw new Error("boom"); }); await engine.deployResources(res(SERVICE_BPMN)); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "svc" }); const [inst] = await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey], }); assert.equal(inst?.state, "ACTIVE", "a failed job must not complete the instance"); }); }); test(`${label}: searchElementInstances surfaces the active element a parked instance reached`, async () => { await withEngine(async (engine) => { // No worker registered → the token parks *at* the `work` service task, so it is the // furthest element reached — an active non-user-task element a user-task search can't see. await engine.deployResources(res(SERVICE_BPMN)); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "svc" }); const els = await waitForValue(async () => { const found = await engine.searchElementInstances({ processInstanceKey }); return found.some((e) => e.elementId === "work") ? found : undefined; }); const work = els.find((e) => e.elementId === "work"); assert.ok(work, "expected the parked service-task element instance"); assert.equal(work?.processInstanceKey, processInstanceKey); assert.equal(work?.state, "ACTIVE"); assert.ok(work?.elementInstanceKey, "element instance carries its own key"); // getElementInstance round-trips that key; a blank/unknown key resolves to null (no throw). const byKey = await engine.getElementInstance(work?.elementInstanceKey ?? ""); assert.equal(byKey?.elementInstanceKey, work?.elementInstanceKey); assert.equal(byKey?.elementId, "work"); assert.equal(await engine.getElementInstance(" "), null); // The elementId selector narrows the search. const filtered = await engine.searchElementInstances({ processInstanceKey, elementId: "work" }); assert.deepEqual(filtered.map((e) => e.elementId), ["work"]); // A lifecycle state the read model can't serve here yields nothing, never a wrong row. const completed = await engine.searchElementInstances({ processInstanceKey, elementId: "work", state: "COMPLETED" }); assert.deepEqual(completed, []); }); }); test(`${label}: searchElementInstanceWaitStates surfaces a JOB park (not only user tasks)`, async () => { await withEngine(async (engine) => { // No worker → the `work` service task parks as a JOB wait state — the job/message parks a // user-task search cannot surface, which is the whole point of the wait-states query. await engine.deployResources(res(SERVICE_BPMN)); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "svc" }); const waits = await waitForValue(async () => { const found = await engine.searchElementInstanceWaitStates({ processInstanceKey }); return found.length > 0 ? found : undefined; }); const job = waits.find((w) => w.waitStateType === "JOB"); assert.ok(job, "expected a JOB wait state for the parked service task"); assert.equal(job?.elementId, "work"); assert.equal(job?.processInstanceKey, processInstanceKey); // The element-instance key resolves to the same one searchElementInstances reports. const [byElement] = await engine.searchElementInstances({ processInstanceKey, elementId: "work" }); assert.equal(job?.elementInstanceKey, byElement?.elementInstanceKey); if (job?.waitStateType === "JOB") { assert.equal(job.jobType, "work"); } // The waitStateType selector narrows: this model has no MESSAGE park. const messages = await engine.searchElementInstanceWaitStates({ processInstanceKey, waitStateType: "MESSAGE" }); assert.deepEqual(messages, []); }); }); test(`${label}: a USER_TASK park is surfaced through the wait-state read model (floor now JOB|MESSAGE|USER_TASK)`, async () => { await withEngine(async (engine) => { // A native user task parks for a human. Since Magikcraft/nano-bpm#1042 shipped USER_TASK // wait states in the engine's read model, that park is discoverable BOTH through the // user-task read channel (`searchUserTasks`/`openUserTasks`) AND through the wait-state // read model — the two must agree on its identity. await engine.deployResources(res(USER_TASK_BPMN)); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "human" }); const tasks = await waitForValue(async () => { const found = await engine.openUserTasks({ processInstanceKey }); return found.length > 0 ? found : undefined; }); assert.equal(tasks.length, 1, "the user task is read via the user-task channel"); assert.equal(tasks[0]?.elementId, "review"); // The `waitStateType: "USER_TASK"` filter is now SERVED (200), not rejected — the exact // filter nano-workforce's delivery-graph poller authors (nanobpm/nano-workforce#594/#596). // This leg is adapter-agnostic: run against the WASM emulation here it exercises the // re-extended synthesis; run against the live `SdkEngineClient` (the integration lane) it // is the REAL-GATEWAY leg — the same canonical assertions must pass against both, so // emulation-vs-gateway drift is caught in CI, not at runtime. This is what #497 lacked: // the previous parity claim was only ever exercised against the mock snapshot. const parked = await waitForValue(async () => { const found = await engine.searchElementInstanceWaitStates({ processInstanceKey, waitStateType: "USER_TASK", }); return found.length > 0 ? found : undefined; }); assert.equal(parked.length, 1, "the user task appears as a USER_TASK wait state"); const park = parked[0]; assert.equal(park.waitStateType, "USER_TASK"); assert.equal(park.elementId, "review"); assert.equal(park.processInstanceKey, processInstanceKey); // The park carries the canonical USER_TASK details — the `userTaskKey` (engine `taskKey`), // matching the identity the user-task channel reports for the same task. if (park.waitStateType === "USER_TASK") { assert.ok(park.userTaskKey, "a USER_TASK park carries its userTaskKey"); assert.equal(park.userTaskKey, tasks[0]?.userTaskKey, "the park's userTaskKey matches the user-task channel"); } // Its element-instance key resolves to the same one `searchElementInstances` reports — // the wait-state read model and the element-instance read model cannot drift. const [byElement] = await engine.searchElementInstances({ processInstanceKey, elementId: "review" }); assert.equal(park.elementInstanceKey, byElement?.elementInstanceKey); // An unfiltered wait-state search also surfaces it (no filter → every in-floor park). const unfiltered = await engine.searchElementInstanceWaitStates({ processInstanceKey }); assert.ok( unfiltered.some((w) => w.waitStateType === "USER_TASK" && w.elementId === "review"), "an unfiltered wait-state search surfaces the USER_TASK park", ); // Canonical lifecycle: the park is REMOVED when the user task completes (added on // CREATED, removed on COMPLETED/CANCELED). Poll to tolerate an eventually consistent // adapter — a live gateway projects the removal asynchronously. await engine.completeUserTask(tasks[0].userTaskKey); await waitFor(async () => (await engine.searchElementInstanceWaitStates({ processInstanceKey, waitStateType: "USER_TASK" })).length === 0 ); }); }); } /** Wrap BPMN XML content one resource for `deployResources`. */ function res(...xml: string[]): { name: string; content: string; contentType: string }[] { return xml.map((content, i) => ({ name: `fixture-${i}.bpmn`, content, contentType: "application/bpmn+xml", })); } /** Build a minimal executable BPMN document with the given process id + body. */ function bpmn(processId: string, body: string): string { return ` ${body} `; } /** Poll `predicate` until it is true or the budget is exhausted (then throw). */ async function waitFor( predicate: () => Promise, { timeoutMs = 10_000, intervalMs = 10 } = {}, ): Promise { const deadline = Date.now() + timeoutMs; for (;;) { if (await predicate()) return; if (Date.now() >= deadline) throw new Error("waitFor: condition not met before timeout"); await sleep(intervalMs); } } /** Poll `produce` until it yields a defined value or the budget is exhausted. */ async function waitForValue( produce: () => Promise, { timeoutMs = 10_000, intervalMs = 10 } = {}, ): Promise { const deadline = Date.now() + timeoutMs; for (;;) { const v = await produce(); if (v !== undefined) return v; if (Date.now() >= deadline) throw new Error("waitForValue: no value before timeout"); await sleep(intervalMs); } } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }