// e2e coverage for job-worker mocking (epic #296, S1+S2). Drives the in-process WASM // adapter directly: registers a small BPMN fixture, mocks a task type, and asserts the // outcome through the engine's observable state (process-instance state + snapshot // incidents/variables). Red/Green per outcome (AGENTS.md). // // Note on observing completion variables: the engine snapshot drops a COMPLETED instance's // variables, so fixtures that assert a mock's `completeWith` output park the instance on a // trailing user task (still ACTIVE) after the mocked service task — the merged variables are // then visible in `snapshot().instances[].variables`. import { test } from "node:test"; import assert from "node:assert/strict"; import type { EngineJob } from "@nanobpm/urban/runtime"; import { createWasmEngineClient, type WasmEngineClient } from "./wasm-engine.ts"; import { applyOutcome, MockWorkerBuilder, type MockOutcome, type OutcomeEngine, } from "./worker-mock.ts"; /** A single service task (`work`) between start and end — completes the whole instance. */ const SVC_BPMN = ` `; /** A service task (`work`) then a user task (`review`): the instance parks ACTIVE on `review` * after `work` completes, so the completion variables are observable in the snapshot. */ const WAIT_BPMN = ` `; /** A service task (`work`) with an error boundary catching errorCode BOOM → alternate end. */ const BOUNDARY_BPMN = ` `; /** Two sequential service tasks (`a` then `b`) then a user task — used to prove per-type * isolation while keeping the instance ACTIVE so its merged variables stay observable. */ const TWO_BPMN = ` `; function res(name: string, content: string): { name: string; content: string; contentType: string }[] { return [{ name, content, contentType: "text/xml" }]; } /** Boot an engine, deploy `bpmn`, run `body`, and always close. */ async function withEngine( bpmn: { name: string; xml: string }, body: (engine: WasmEngineClient) => Promise, ): Promise { const engine = await createWasmEngineClient(); try { await engine.deployResources(res(bpmn.name, bpmn.xml)); await body(engine); } finally { await engine.close(); } } /** The `incidents` array from a snapshot as `reason` strings. */ function incidentReasons(engine: WasmEngineClient): string[] { const raw = engine.snapshot().incidents; if (!Array.isArray(raw)) return []; const out: string[] = []; for (const inc of raw) { if (inc && typeof inc === "object") { const reason = Reflect.get(inc, "reason"); if (typeof reason === "string") out.push(reason); } } return out; } /** A single instance's variables from the snapshot. */ function instanceVariables(engine: WasmEngineClient, key: string): Record { const raw = engine.snapshot().instances; if (!Array.isArray(raw)) return {}; for (const inst of raw) { if (inst && typeof inst === "object" && String(Reflect.get(inst, "key")) === key) { const vars = Reflect.get(inst, "variables"); if (vars && typeof vars === "object" && !Array.isArray(vars)) return { ...vars }; } } return {}; } test("mock: completeWith completes the job, driving the instance forward with the given variables", async () => { await withEngine({ name: "wait.bpmn", xml: WAIT_BPMN }, async (engine) => { // No real worker is registered for `work` at all — the mock fully stands in for it. engine.mockWorker("work").completeWith({ mocked: true, answer: 42 }); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "wait" }); const [inst] = await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey] }); // `work` completed (mock stood in) so the token advanced to the user task — the instance is // ACTIVE on `review`, NOT parked on `work` with an incident. assert.equal(inst?.state, "ACTIVE", "the mock completed `work`; the instance advanced to the user task"); const vars = instanceVariables(engine, processInstanceKey); assert.equal(vars.mocked, true, "mocked completion variables merged onto the instance"); assert.equal(vars.answer, 42); assert.equal(incidentReasons(engine).length, 0, "a completion raises no incident"); }); }); test("mock: completeWith drives a single-task process all the way to COMPLETED", async () => { await withEngine({ name: "svc.bpmn", xml: SVC_BPMN }, async (engine) => { engine.mockWorker("work").completeWith({ done: true }); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "svc" }); const [inst] = await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey] }); assert.equal(inst?.state, "COMPLETED", "the mock completed the only task, so the instance finished"); }); }); test("mock: failWith default (retries 0) parks the instance ACTIVE on an incident", async () => { await withEngine({ name: "svc.bpmn", xml: SVC_BPMN }, async (engine) => { engine.mockWorker("work").failWith({ message: "mock said no" }); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "svc" }); const [inst] = await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey] }); assert.equal(inst?.state, "ACTIVE", "a zero-retry failure must not complete the instance"); assert.ok(incidentReasons(engine).includes("mock said no"), "the failure raised an incident carrying the message"); }); }); test("mock: raiseIncident yields an incident visible in the engine snapshot", async () => { await withEngine({ name: "svc.bpmn", xml: SVC_BPMN }, async (engine) => { engine.mockWorker("work").raiseIncident({ message: "manual incident" }); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "svc" }); const [inst] = await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey] }); assert.equal(inst?.state, "ACTIVE"); assert.ok(incidentReasons(engine).includes("manual incident"), "raiseIncident surfaces an incident with its message"); }); }); test("mock: throwBpmnError takes the modelled error-boundary flow (instance completes via the boundary)", async () => { await withEngine({ name: "boundary.bpmn", xml: BOUNDARY_BPMN }, async (engine) => { engine.mockWorker("work").throwBpmnError("BOOM", "mocked boom"); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "boundary" }); const [inst] = await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey] }); // The BPMN error was caught by the boundary → COMPLETED (a plain failure would leave it ACTIVE on an incident). assert.equal(inst?.state, "COMPLETED", "the mocked BPMN error drove the error boundary, not an incident"); }); }); test("mock: when(predicate) is first-match-wins in registration order, falling through to the real handler", async () => { await withEngine({ name: "wait.bpmn", xml: WAIT_BPMN }, async (engine) => { // A real handler stamps who handled the job; the mock only intercepts specific tiers. await engine.registerWorker("work", (job) => ({ handledBy: "real", who: job.variables.who })); engine .mockWorker("work") .when((job) => job.variables.tier === "gold").completeWith({ handledBy: "mock", tier: "gold" }) .when((job) => job.variables.tier === "silver").completeWith({ handledBy: "mock", tier: "silver" }); const gold = await engine.createInstance({ processDefinitionId: "wait", variables: { tier: "gold" } }); assert.equal(instanceVariables(engine, gold.processInstanceKey).handledBy, "mock", "gold matched the first clause"); assert.equal(instanceVariables(engine, gold.processInstanceKey).tier, "gold"); const silver = await engine.createInstance({ processDefinitionId: "wait", variables: { tier: "silver" } }); assert.equal(instanceVariables(engine, silver.processInstanceKey).tier, "silver", "silver matched the second clause"); // A job matching no clause falls through to the real handler. const bronze = await engine.createInstance({ processDefinitionId: "wait", variables: { tier: "bronze", who: "b" } }); const bronzeVars = instanceVariables(engine, bronze.processInstanceKey); assert.equal(bronzeVars.handledBy, "real", "an unmatched job ran the real handler"); assert.equal(bronzeVars.who, "b"); }); }); test("mock: an unconditional default matches every job (a preceding when(...) can still win)", async () => { await withEngine({ name: "wait.bpmn", xml: WAIT_BPMN }, async (engine) => { engine .mockWorker("work") .when((job) => job.variables.special === true).completeWith({ path: "special" }) .completeWith({ path: "default" }); const special = await engine.createInstance({ processDefinitionId: "wait", variables: { special: true } }); assert.equal(instanceVariables(engine, special.processInstanceKey).path, "special"); const plain = await engine.createInstance({ processDefinitionId: "wait", variables: { special: false } }); assert.equal(instanceVariables(engine, plain.processInstanceKey).path, "default", "the unconditional default caught the rest"); }); }); test("mock: a mock on type A leaves un-mocked type B running its real handler", async () => { await withEngine({ name: "two.bpmn", xml: TWO_BPMN }, async (engine) => { let bRan = false; await engine.registerWorker("a", () => { throw new Error("real A must NOT run — it is mocked"); }); await engine.registerWorker("b", () => { bRan = true; return { bDone: true }; }); engine.mockWorker("a").completeWith({ aMocked: true }); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "two" }); const [inst] = await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey] }); assert.equal(inst?.state, "ACTIVE", "A mocked + B real both ran; the instance advanced to the user task"); assert.equal(bRan, true, "the un-mocked type B executed real code"); const vars = instanceVariables(engine, processInstanceKey); assert.equal(vars.aMocked, true); assert.equal(vars.bDone, true); }); }); test("mock: reset() removes the mock and restores the real handler", async () => { await withEngine({ name: "wait.bpmn", xml: WAIT_BPMN }, async (engine) => { let realRuns = 0; await engine.registerWorker("work", () => { realRuns += 1; return { handledBy: "real" }; }); const mock = engine.mockWorker("work").completeWith({ handledBy: "mock" }); const first = await engine.createInstance({ processDefinitionId: "wait" }); assert.equal(instanceVariables(engine, first.processInstanceKey).handledBy, "mock"); assert.equal(realRuns, 0, "the real handler was shadowed while mocked"); mock.reset(); const second = await engine.createInstance({ processDefinitionId: "wait" }); assert.equal(instanceVariables(engine, second.processInstanceKey).handledBy, "real", "reset restored real behaviour"); assert.equal(realRuns, 1, "the real handler ran once after reset"); // clearWorkerMock is the equivalent removal path. engine.mockWorker("work").completeWith({ handledBy: "mock-again" }); engine.clearWorkerMock("work"); const third = await engine.createInstance({ processDefinitionId: "wait" }); assert.equal(instanceVariables(engine, third.processInstanceKey).handledBy, "real", "clearWorkerMock also restores real behaviour"); }); }); test("mock: the deterministic drain still reaches a fixpoint under a mock (no hang)", async () => { await withEngine({ name: "two.bpmn", xml: TWO_BPMN }, async (engine) => { // Both task types mocked to complete: the whole process drains to the user task synchronously. engine.mockWorker("a").completeWith({ a: 1 }); engine.mockWorker("b").completeWith({ b: 2 }); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "two" }); // drain() returning (createInstance awaited it) without throwing the "did not quiesce" guard // proves the mock path is a fixpoint. A second explicit drain must also be a no-op. await engine.drain(); const vars = instanceVariables(engine, processInstanceKey); assert.equal(vars.a, 1, "both mocked tasks drained deterministically"); assert.equal(vars.b, 2); }); }); test("mock: mockWorker(type) is idempotent — repeated calls return the same builder and accumulate clauses", async () => { await withEngine({ name: "wait.bpmn", xml: WAIT_BPMN }, async (engine) => { const a = engine.mockWorker("work"); const b = engine.mockWorker("work"); assert.equal(a, b, "the same builder instance is returned for a type"); a.when((job) => job.variables.n === 1).completeWith({ clause: "one" }); b.when((job) => job.variables.n === 2).completeWith({ clause: "two" }); const one = await engine.createInstance({ processDefinitionId: "wait", variables: { n: 1 } }); const two = await engine.createInstance({ processDefinitionId: "wait", variables: { n: 2 } }); assert.equal(instanceVariables(engine, one.processInstanceKey).clause, "one"); assert.equal(instanceVariables(engine, two.processInstanceKey).clause, "two"); }); }); test("applyOutcome: an unknown outcome kind is rejected (the S5 exhaustiveness seam is real at runtime too)", () => { // The exhaustiveness guard is primarily a COMPILE-time seam (a new engine completion method // ⇒ a new MockOutcome variant ⇒ a non-exhaustive switch ⇒ a type error). This runtime probe // proves the same guard fails loudly rather than silently falling through, so a malformed // outcome that slips past the type system (here fabricated via JSON.parse to dodge the `as` // ban) cannot leave a job silently un-resolved. Before the `never` guard the switch simply // returned, silently dropping the job. const calls: string[] = []; const engine: OutcomeEngine = { completeJob: () => calls.push("complete"), failJob: () => calls.push("fail"), throwError: () => calls.push("throw"), }; const bogus: MockOutcome = JSON.parse('{"kind":"__nope__"}'); assert.throws(() => applyOutcome(engine, "job-1", bogus), /unhandled mock outcome|__nope__/i); assert.deepEqual(calls, [], "an unknown outcome must resolve through no engine call"); }); test("mock: a completeWith outcome the engine can't serialize fails the job instead of aborting the drain", async () => { await withEngine({ name: "svc.bpmn", xml: SVC_BPMN }, async (engine) => { // A BigInt is not JSON-serializable, so `applyOutcome`'s `JSON.stringify(variables)` throws — // exactly like the real handler path's `JSON.stringify(out)` would. That throw must be caught // and turned into a failJob/incident (mirroring the real path), NOT escape `#runJob` and abort // the whole drain. Before the fix the drain threw the raw TypeError out of createInstance. engine.mockWorker("work").completeWith({ big: 1n }); const { processInstanceKey } = await engine.createInstance({ processDefinitionId: "svc" }); const [inst] = await engine.searchProcessInstances({ processInstanceKeys: [processInstanceKey] }); assert.equal(inst?.state, "ACTIVE", "an unserializable completion must not complete the instance"); assert.ok( incidentReasons(engine).some((r) => /bigint/i.test(r)), "the serialization failure surfaced as an incident carrying the error message", ); }); }); test("mock+coverage: a mock-only type whose clauses don't match is NOT recorded as exercised (no fabricated coverage)", async () => { await withEngine({ name: "wait.bpmn", xml: WAIT_BPMN }, async (engine) => { // A mock-only type (`work` has a mock but no registered real worker) whose sole clause never // matches: `resolve()` returns undefined, so NOTHING runs for the dispatched job — it is left // locked. The coverage observer must therefore NOT be told the type was exercised; otherwise a // job that no mock and no handler serviced would fabricate coverage and hide a genuine gap. const seen: { jobType: string; mocked: boolean }[] = []; engine.observeJobs((jobType, mocked) => seen.push({ jobType, mocked })); engine.mockWorker("work").when(() => false).completeWith({ never: true }); await engine.createInstance({ processDefinitionId: "wait" }); assert.deepEqual( seen, [], "an unsatisfied mock-only dispatch (no matching clause, no real handler) must not count as exercised", ); }); }); test("mock: failWith clamps retries to a finite, non-negative integer (negative, NaN and fractional inputs)", () => { const job: EngineJob = { jobKey: "k", jobType: "work", variables: {} }; const retriesFor = (retries: number): number => { const outcome = new MockWorkerBuilder(() => {}).failWith({ retries }).resolve(job); assert.ok(outcome !== undefined && outcome.kind === "fail", "failWith yields a fail outcome"); return outcome.retries; }; assert.equal(retriesFor(-3), 0, "a negative retry count clamps to 0"); assert.equal(retriesFor(Number.NaN), 0, "NaN clamps to 0"); assert.equal(retriesFor(Number.POSITIVE_INFINITY), 0, "a non-finite retry count clamps to 0"); assert.equal(retriesFor(2.7), 2, "a fractional retry count truncates to an integer"); assert.equal(retriesFor(4), 4, "a valid non-negative integer is preserved"); }); test("mock: a second consecutive when(...) with no intervening outcome fails fast", () => { const armed = new MockWorkerBuilder(() => {}); assert.throws( () => armed.when(() => true).when(() => false), /when\(\)/i, "overwriting an already-armed predicate silently is a footgun — the second when() must throw", ); // A when() consumed by an outcome re-arms cleanly for the next clause. const chained = new MockWorkerBuilder(() => {}); assert.doesNotThrow( () => chained .when(() => true) .completeWith({ ok: 1 }) .when(() => false) .completeWith({ ok: 2 }), "when()->outcome->when()->outcome is the supported chaining shape", ); }); test("mock: a reset() builder is tombstoned — re-arming a removed builder fails fast", () => { const b = new MockWorkerBuilder(() => {}).completeWith({ ok: 1 }); assert.equal(b.hasClauses, true, "the builder is armed before reset"); b.reset(); assert.equal(b.hasClauses, false, "reset drops every clause"); // A removed builder must not be silently re-armed: clauses added after reset never affect // dispatch (it is deregistered), so mutating it is a footgun that must fail fast. assert.throws( () => b.completeWith({ ok: 2 }), /reset/i, "adding an outcome to a reset() builder must throw, not silently accumulate a dead clause", ); assert.throws( () => b.when(() => true), /reset/i, "arming a predicate on a reset() builder must throw", ); assert.equal(b.hasClauses, false, "a rejected mutation leaves the builder inert"); // reset() is idempotent — a second call on an already-removed builder is a no-op. assert.doesNotThrow(() => b.reset(), "reset() is idempotent on an already-removed builder"); }); test("mock: clearWorkerMock clears a still-held builder's state, not just the registry entry", async () => { await withEngine({ name: "wait.bpmn", xml: WAIT_BPMN }, async (engine) => { const mock = engine.mockWorker("work").completeWith({ handledBy: "mock" }); assert.equal(mock.hasClauses, true, "the builder is armed while mocked"); engine.clearWorkerMock("work"); assert.equal( mock.hasClauses, false, "clearWorkerMock must also clear a caller-held builder's clauses (equivalent to reset())", ); }); });