// e2e coverage for `bootTestApp` (issue #157, S2). Boots a real Urban app in-process
// against the WASM engine + a virtual clock, and drives all four surfaces the harness
// exposes: HTTP/UI, workers, SQLite, and the instance-tracking reconciler.
//
// The reconciler case is the reason this kit exists: a cancelled instance is TERMINATED,
// and the poll loop must patch the tracking row. Here that whole loop runs deterministically
// — no 15s wall-clock wait, no flaky poll — because `advanceTime` drives the virtual clock.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootTestApp } from "./boot-app.ts";
const ORDER_BPMN = `
`;
const REVIEW_BPMN = `
`;
const HANDLERS = `export const handlers = {
"order.pack": async (job, app) => {
await app.data.table("orders", "process_key").insert({
process_key: job.processInstanceKey,
status: "packed",
note: job.variables.orderId ?? null,
});
return { packed: true };
},
};`;
const MIGRATION = `CREATE TABLE orders (
id INTEGER PRIMARY KEY,
process_key TEXT,
status TEXT,
note TEXT
);`;
// A process whose single service task is served by a worker with a *real-time budget*: a poll loop
// that sources `now`/`wait` from the app clock (issue #408). The probe never becomes ready, so only
// the budget stops it — under the test kit that budget is the virtual clock, so `advanceTime` bounds
// the loop instead of it burning the real `PROBE_BUDGET_MS`.
const PROBE_BPMN = `
`;
const PROBE_HANDLERS = `export const handlers = {
"probe.poll": async (job, app) => {
// Bind the budget to the APP clock (not Date.now) and sleep on app.wait (not setTimeout), so the
// loop advances only as virtual time does. A never-ready probe: only the budget stops it.
const deadline = app.now() + 60000; // PROBE_BUDGET_MS = PT1M
let attempts = 0;
while (app.now() < deadline) {
attempts += 1;
await app.wait(10000); // PROBE_INTERVAL_MS
}
await app.data.table("probes", "process_key").insert({
process_key: job.processInstanceKey,
attempts,
});
return { readied: false, attempts };
},
};`;
const PROBE_MIGRATION = `CREATE TABLE probes (
id INTEGER PRIMARY KEY,
process_key TEXT,
attempts INTEGER
);`;
/** Build a minimal but complete Urban app on disk: two processes, a worker, a SQLite
* source with one migration, a webhook that starts `order`, and an instanceTracking
* binding that abandons a tracked row when its instance terminates. */
async function makeFixture(): Promise {
const dir = await mkdtemp(join(tmpdir(), "urban-testkit-boot-"));
await mkdir(join(dir, "processes"), { recursive: true });
await mkdir(join(dir, "workers"), { recursive: true });
await mkdir(join(dir, "db", "migrations"), { recursive: true });
await writeFile(join(dir, "processes", "order.bpmn"), ORDER_BPMN);
await writeFile(join(dir, "processes", "review.bpmn"), REVIEW_BPMN);
await writeFile(join(dir, "workers", "handlers.ts"), HANDLERS);
await writeFile(join(dir, "db", "migrations", "001_init.sql"), MIGRATION);
const manifest = {
schemaVersion: 1,
id: "testkit-boot-fixture",
name: "Testkit Boot Fixture",
models: { processes: ["processes/*.bpmn"] },
data: {
default: "app",
sources: { app: { driver: "sqlite", url: "file:./db/app.db", migrations: "db/migrations" } },
},
workers: [{ taskType: "order.pack", handler: "workers/handlers.ts" }],
triggers: [
{
id: "order-hook",
type: "webhook",
path: "/hooks/order",
action: { start: "order", variables: "= body" },
},
],
instanceTracking: [
{
table: "orders",
keyField: "process_key",
statusField: "status",
activeStatuses: ["active"],
onTerminated: { set: { status: "abandoned" } },
pollMs: 1000,
},
],
};
await writeFile(join(dir, "nano.app.json"), JSON.stringify(manifest, null, 2));
return dir;
}
test("bootTestApp drives HTTP → worker → SQLite in-process and deterministically", async () => {
const dir = await makeFixture();
const app = await bootTestApp(dir);
try {
// HTTP surface: post to the webhook trigger, which starts the `order` process.
const res = await app.ui.call({
method: "POST",
path: "/hooks/order",
headers: { "content-type": "application/json" },
body: JSON.stringify({ orderId: "ord-1" }),
});
assert.ok((res.status ?? 200) < 300, `webhook accepted (status ${res.status})`);
// No worker has run yet at this instant — settle drains the engine's queued job.
await app.settle();
// SQLite surface: the worker's insert is now visible through the data layer.
const orders = app.db.table<{ process_key: string; status: string; note: string }>(
"orders",
"process_key",
);
const rows = await orders.all();
assert.equal(rows.length, 1, "one order row written by the worker");
assert.equal(rows[0].status, "packed");
assert.equal(rows[0].note, "ord-1", "worker projected the webhook body variable");
} finally {
await app.stop();
await rm(dir, { recursive: true, force: true });
}
});
test("bootTestApp stops the in-process server so routes are no longer callable", async () => {
const dir = await makeFixture();
const app = await bootTestApp(dir);
await app.stop();
await rm(dir, { recursive: true, force: true });
await assert.rejects(
() => app.ui.call({ method: "GET", path: "/hooks/order" }),
/no router mounted/,
"a stopped app's captured handler is cleared, so a route call throws",
);
});
test("bootTestApp stops the app when the seed step throws", async () => {
const dir = await makeFixture();
const boom = new Error("seed failed");
await assert.rejects(
() => bootTestApp(dir, { seed: () => Promise.reject(boom) }),
/seed failed/,
"a throwing seed rejects the boot and rolls the app back (no leaked engine/router)",
);
await rm(dir, { recursive: true, force: true });
});
test("bootTestApp reconciles a terminated instance's tracking row on advanceTime", async () => {
const dir = await makeFixture();
const app = await bootTestApp(dir);
try {
// Start a long-running instance (parks at the user task → ACTIVE) and track it.
const { processInstanceKey } = await app.engine.createInstance({ processDefinitionId: "review" });
const orders = app.db.table<{ process_key: string; status: string }>("orders", "process_key");
await orders.insert({ process_key: processInstanceKey, status: "active" });
// Terminate it. The row is still "active" — the reconciler poll hasn't fired yet.
await app.engine.cancelInstance({ processInstanceKey });
let row = await orders.findOne({ process_key: processInstanceKey });
assert.equal(row?.status, "active", "row not yet reconciled before any poll fires");
// Advance past the poll interval: the reconciler observes TERMINATED and feeds the canonical
// projection. Since ADR 0065 (the writer→source inversion) the reconciler is a SOURCE, not a
// WRITER — it makes NO base-row write, so the base `status` STAYS "active" (the worker-owned
// business status). The terminal outcome is DERIVED, on read, by the managed VIEW.
await app.advanceTime(1000);
row = await orders.findOne({ process_key: processInstanceKey });
assert.equal(row?.status, "active", "base row status is untouched — the reconciler no longer writes it (ADR 0065)");
// The effective (derived) status is served by the managed `__tracking` VIEW, which
// re-exports `base.*` plus a `derived_status` column computed over the canonical projection. The
// terminal edge (`EXISTS urban_instance_state … TERMINATED`) now resolves to the binding's
// `onTerminated.set.status` value ("abandoned") with no stored write to tear from engine truth.
const tracking = app.db.table<{ process_key: string; status: string; derived_status: string }>(
"orders__tracking",
"process_key",
);
const derived = await tracking.findOne({ process_key: processInstanceKey });
assert.equal(derived?.status, "active", "the VIEW re-exports the untouched base status");
assert.equal(
derived?.derived_status,
"abandoned",
"the derived VIEW reports the terminal status, derived over the canonical projection (ADR 0065)",
);
} finally {
await app.stop();
await rm(dir, { recursive: true, force: true });
}
});
/** Build a minimal app whose one worker runs a time-bounded poll loop on the app clock (#408). */
async function makeProbeFixture(): Promise {
const dir = await mkdtemp(join(tmpdir(), "urban-testkit-probe-"));
await mkdir(join(dir, "processes"), { recursive: true });
await mkdir(join(dir, "workers"), { recursive: true });
await mkdir(join(dir, "db", "migrations"), { recursive: true });
await writeFile(join(dir, "processes", "probe.bpmn"), PROBE_BPMN);
await writeFile(join(dir, "workers", "handlers.ts"), PROBE_HANDLERS);
await writeFile(join(dir, "db", "migrations", "001_init.sql"), PROBE_MIGRATION);
const manifest = {
schemaVersion: 1,
id: "testkit-probe-fixture",
name: "Testkit Probe Fixture",
models: { processes: ["processes/*.bpmn"] },
data: {
default: "app",
sources: { app: { driver: "sqlite", url: "file:./db/app.db", migrations: "db/migrations" } },
},
workers: [{ taskType: "probe.poll", handler: "workers/handlers.ts" }],
};
await writeFile(join(dir, "nano.app.json"), JSON.stringify(manifest, null, 2));
return dir;
}
test("bootTestApp bounds a time-bounded worker by advanceTime (virtual time, not real wall-time) — #408", async () => {
// Regression guard for #408: before the virtual-clock scheduler was threaded into mountWorkers and
// the app clock/`wait` seam was surfaced on AppApi, a worker with a real-time budget (poll loop,
// backoff) hardwired Date.now()/setTimeout, so under the test kit it burned the FULL real budget
// (~PT1M) while virtual time had already moved on. Now the handler sources now/wait from `app`, so
// the whole loop settles over the virtual clock: `advanceTime` bounds it, and it never touches the
// real wall clock.
const dir = await makeProbeFixture();
const app = await bootTestApp(dir);
const wallStart = Date.now();
try {
// Start the instance: the poll worker is dispatched and parks on its first app.wait — the drain
// does NOT block on it (a real push worker runs autonomously), so createInstance returns at once.
const { processInstanceKey } = await app.engine.createInstance({ processDefinitionId: "probe" });
const probes = app.db.table<{ process_key: string; attempts: number }>("probes", "process_key");
// No virtual time has passed, so the loop is still parked — it has written nothing yet.
await app.settle();
assert.equal((await probes.all()).length, 0, "the never-ready loop must not finish before its budget");
// Advance the whole PT1M budget in virtual time: this — and only this — bounds the loop.
await app.advanceTime(60000);
const rows = await probes.all();
assert.equal(rows.length, 1, "the loop settled once its virtual budget elapsed");
assert.equal(rows[0].attempts, 6, "exactly one poll per 10s interval across the 60s budget");
assert.equal(app.scheduler.pending(), 0, "no virtual timers may leak past the budget");
} finally {
const wallElapsed = Date.now() - wallStart;
assert.ok(
wallElapsed < 10_000,
`the worker must settle in virtual time, not burn the real 60s budget (took ${wallElapsed}ms real)`,
);
await app.stop();
await rm(dir, { recursive: true, force: true });
}
});
test("bootTestApp stop() cancels a worker parked on app.wait instead of hanging (#446 follow-up)", async () => {
// Regression (issue #446 follow-up): a worker parked mid-`app.wait()` sits on a VIRTUAL timer that
// only `advanceTime` fires. If `stop()` (→ `engine.close()`) is called before that timer's due
// instant, the handler never settles on its own, so close()'s `#settleInflight()` used to await it
// forever and teardown hung until the test-runner timeout. The engine's shutdown signal, threaded
// into the scheduler backing `app.wait()`, now cancels that park at teardown: the wait rejects, the
// handler unwinds, and stop() returns. Proven end-to-end through the real app boot + teardown path.
const dir = await makeProbeFixture();
const app = await bootTestApp(dir);
let stopped = false;
try {
// Start the instance: the poll worker is dispatched and parks on its first app.wait(10s). No
// virtual time passes, so it stays parked in-flight — the classic virtual-timer park.
await app.engine.createInstance({ processDefinitionId: "probe" });
await app.settle();
assert.ok(app.scheduler.pending() > 0, "the worker must be parked on a virtual app.wait timer");
// stop() closes the engine mid-park; it must cancel the wait via the shutdown signal rather than
// await a virtual timer no advanceTime will ever fire. A hang would blow the runner timeout.
await app.stop();
stopped = true;
// Deterministic teardown postcondition (not a wall-clock bound): the shutdown signal must have
// cancelled the parked virtual timer, so the scheduler has no armed timers left. A stop() that
// truly hung would never reach this line — the test runner's own timeout catches that.
assert.equal(app.scheduler.pending(), 0, "stop() must cancel the parked virtual wait, leaving no armed timers");
} finally {
if (!stopped) await app.stop();
await rm(dir, { recursive: true, force: true });
}
});