Expose sync simulation testing as plain test declarations from the testing entry: scenario() boots a real local sync server, deploys the app's schema and permissions, and hands the body real synced peers (alice/bob) that drive the app's own tables through offline windows, concurrent edits, and restarts. converge() settles and diffs replica views (counter columns by presence; g-set columns order-insensitively); assertRow/assertNoRow/assertRowCount check one peer's local view. scenario.fuzz generates seeded, replayable op sequences across both peers and asserts convergence against restarted peers and a fresh canonical reader. scenario.browser carries the same peer controls over the Playwright two-client fixture; the reference convergence e2e now dogfoods it. The scenario sync test pins the live-writer half of update-versus- delete adjudication: the delete wins for every fresh view while the session that made the doomed edit keeps it until restart. New check-gated task test:scenario owns the FFI-needing suite; the pure fuzz-generator tests run in test:unit.
- commit
- 879fb711851577b692b233b6de0f1afe8d4ed380
- parent
- 0add918b719cb4564ae5781e60d45ba264e51762
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | apps/ | 26 | ++- |
| M | apps/ | 156 | +++++------- |
| M | deno.json | 3 | +- |
| M | docs/ | 88 | +++++++- |
| A | package/ | 57 | +++++ |
| M | package/ | 2 | +- |
| M | package/ | 45 | ++++ |
| A | package/ | 289 | +++++++++++++++++++++++ |
| A | package/ | 176 | ++++++++++++++ |
| A | package/ | 46 | ++++ |
| A | package/ | 379 | ++++++++++++++++++++++++++++++ |
| A | package/ | 89 | +++++++ |
| A | package/ | 249 | ++++++++++++++++++++ |
| A | package/ | 122 | ++++++++++ |
| A | package/ | 91 | +++++++ |
| A | package/ | 84 | +++++++ |
@@ -8,6 +8,7 @@
88 "npm:@noble/hashes@1.8.0": "1.8.0",99 "npm:astro@7.0.9": "7.0.9_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1",1010 "npm:jazz-tools@2.0.0-alpha.53": "2.0.0-alpha.53",11+ "npm:playwright@1.61.1": "1.61.1",1112 "npm:preact-render-to-string@6.7.0": "6.7.0_preact@10.29.7",1213 "npm:preact@10.29.7": "10.29.7_preact-render-to-string@6.7.0",1314 "npm:typescript@6.0.3": "6.0.3",@@ -1950,6 +1951,11 @@
19501951 "tiny-inflate"19511952 ]19521953 },1954+ "fsevents@2.3.2": {1955+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",1956+ "os": ["darwin"],1957+ "scripts": true1958+ },19531959 "fsevents@2.3.3": {19541960 "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",19551961 "os": ["darwin"],@@ -2406,6 +2412,20 @@
24062412 "picomatch@4.0.5": {24072413 "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="24082414 },2415+ "playwright-core@1.61.1": {2416+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",2417+ "bin": true2418+ },2419+ "playwright@1.61.1": {2420+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",2421+ "dependencies": [2422+ "playwright-core"2423+ ],2424+ "optionalDependencies": [2425+ "fsevents@2.3.2"2426+ ],2427+ "bin": true2428+ },24092429 "pluralize-esm@9.0.5": {24102430 "integrity": "sha512-Kb2dcpMsIutFw2hYrN0EhsAXOUJTd6FVMIxvNAkZCMQLVt9NGZqQczvGpYDxNWCZeCWLHUPxQIBudWzt1h7VVA=="24112431 },@@ -2868,7 +2888,7 @@
28682888 "tinyglobby"28692889 ],28702890 "optionalDependencies": [2871- "fsevents"2891+ "fsevents@2.3.3"28722892 ],28732893 "bin": true28742894 },@@ -2883,7 +2903,7 @@
28832903 "tinyglobby"28842904 ],28852905 "optionalDependencies": [2886- "fsevents"2906+ "fsevents@2.3.3"28872907 ],28882908 "optionalPeers": [28892909 "esbuild@0.28.1"@@ -3130,7 +3150,7 @@
31303150 "npm:vite@8.0.1"31313151 ],31323152 "links": {3133- "jsr:@nzip/lofi@0.7.0": {3153+ "jsr:@nzip/lofi@0.8.1": {31343154 "dependencies": [31353155 "npm:@astrojs/check@0.9.9",31363156 "npm:@astrojs/preact@6.0.1",@@ -1,12 +1,7 @@
1-import type {2- BrowserTestClient,3- ConcurrentOfflineScenario,4- TwoClientFixture,5-} from "@nzip/lofi/testing";6-71 /**8- * A real, worked example of the `@nzip/lofi/testing` toolkit: two browser9- * clients each add a task while offline, then reconnect and converge on both.2+ * A real, worked example of the `@nzip/lofi/testing` browser scenario: two3+ * browser clients each add a task while offline, then reconnect and converge4+ * on both.105 *116 * This is an opt-in browser gate, not part of `deno task test`:127 * 1. Configure managed sync (JAZZ_APP_ID + JAZZ_SERVER_URL in .env) with the@@ -26,6 +21,8 @@ import type {
2621 * default suite stays fast and never launches a browser.2722 */2823 24+import type { BrowserScenarioPeer } from "@nzip/lofi/testing";25+2926 function e2eBaseURL(): string | undefined {3027 // The default suite runs without --allow-env; treat a denied read as "skip".3128 try {@@ -42,98 +39,75 @@ const taskListReady = () => {
4239 return status !== null && /item\(s\)/.test(status.textContent ?? "");4340 };4441 45-Deno.test("two clients converge on concurrent offline task edits", async () => {46- const baseURL = e2eBaseURL();47- if (!baseURL) {42+const baseURL = e2eBaseURL();43+if (baseURL === undefined) {44+ Deno.test("two clients converge on concurrent offline task edits", () => {4845 console.log(4946 "skipping convergence example; set LOFI_E2E_BASE_URL to a synced deployment to run it",5047 );51- return;52- }53-48+ });49+} else {5450 // Imported lazily so the default suite never loads Playwright.55- const {56- BrowserUnavailableError,57- createTwoClientFixture,58- runConcurrentOfflineConvergence,59- waitForReady,60- withVirtualAuthenticator,61- } = await import("@nzip/lofi/testing");51+ const { scenario, waitForReady, withVirtualAuthenticator } = await import("@nzip/lofi/testing");6252 63- const ready = (client: BrowserTestClient) =>64- waitForReady(client.page, taskListReady, undefined, { description: `${client.name} ready` });65- // The backup-and-sync election, driven through the real account UI. Cloning66- // afterwards carries the elected, synced account into the second client.67- const electSync = async (client: BrowserTestClient) => {68- await withVirtualAuthenticator(client.page);69- await client.page.getByRole("button", { name: "Back up & enable sync" }).click();70- await client.page.locator('[aria-label="Recovery phrase"] li').first().waitFor({71- state: "visible",72- timeout: 30_000,73- });74- await client.page.getByRole("button", { name: "I saved my phrase — enable sync" }).click();75- await client.page.getByRole("heading", { name: "Backed up & syncing" }).waitFor({76- state: "visible",77- timeout: 30_000,78- });79- await ready(client);80- };81- const addTask = async (client: BrowserTestClient, text: string) => {82- await client.page.fill("#new-task", text);83- await client.page.press("#new-task", "Enter");53+ const ready = (peer: BrowserScenarioPeer) =>54+ waitForReady(peer.page, taskListReady, undefined, { description: `${peer.name} ready` });55+ const addTask = async (peer: BrowserScenarioPeer, text: string) => {56+ await peer.page.fill("#new-task", text);57+ await peer.page.press("#new-task", "Enter");8458 };85- const seeTask = (client: BrowserTestClient, text: string) =>86- client.page.locator(".task", { hasText: text }).first().waitFor({59+ const seeTask = (peer: BrowserScenarioPeer, text: string) =>60+ peer.page.locator(".task", { hasText: text }).first().waitFor({8761 state: "visible",8862 timeout: 15_000,8963 });9064 91- let fixture: TwoClientFixture | undefined;92- try {93- fixture = await createTwoClientFixture({94- baseURL,95- // Shared identity clones the first client's state in memory after the96- // election, so both browser contexts act as the same synced account.97- identity: {98- mode: "shared",99- preparePrimary: async (client) => {100- await ready(client);101- await electSync(client);102- },65+ scenario.browser("two clients converge on concurrent offline task edits", {66+ baseURL,67+ // Shared identity clones the first client's state in memory after the68+ // election, so both browser contexts act as the same synced account. The69+ // election runs through the real account UI.70+ identity: {71+ mode: "shared",72+ preparePrimary: async (client) => {73+ const readyPage = () =>74+ waitForReady(client.page, taskListReady, undefined, { description: "primary ready" });75+ await readyPage();76+ await withVirtualAuthenticator(client.page);77+ await client.page.getByRole("button", { name: "Back up & enable sync" }).click();78+ await client.page.locator('[aria-label="Recovery phrase"] li').first().waitFor({79+ state: "visible",80+ timeout: 30_000,81+ });82+ await client.page.getByRole("button", { name: "I saved my phrase — enable sync" }).click();83+ await client.page.getByRole("heading", { name: "Backed up & syncing" }).waitFor({84+ state: "visible",85+ timeout: 30_000,86+ });87+ await readyPage();10388 },104- artifacts: { directory: "test-results" },105- });106- } catch (error) {107- if (error instanceof BrowserUnavailableError) {108- console.log(error.message);109- return;110- }111- throw error;112- }89+ },90+ ready,91+ // Failure artifacts stay value-free: counts and booleans only, no task text.92+ snapshot: async (peer) => ({93+ online: !peer.isOffline,94+ items: await peer.page.locator(".task").count(),95+ }),96+ artifacts: { directory: "test-results" },97+ }, async ({ alice, bob }) => {98+ await alice.offline();99+ await bob.offline();100+ await addTask(alice, "first client task");101+ await addTask(bob, "second client task");102+ await seeTask(alice, "first client task");103+ await seeTask(bob, "second client task");104+ await alice.online();105+ await bob.online();113106 114- try {115- const scenario: ConcurrentOfflineScenario<string> = {116- edits: ["first client task", "second client task"],117- ready,118- apply: addTask,119- locallyApplied: seeTask,120- converged: async ({ clients }) => {121- await Promise.all(clients.map((client) =>122- Promise.all([123- seeTask(client, "first client task"),124- seeTask(client, "second client task"),125- ])126- ));127- },128- // Failure artifacts stay value-free: counts and booleans only, no task text.129- snapshot: async (client) => ({130- online: !client.offline,131- items: await client.page.locator(".task").count(),132- }),133- failureLabel: "task-convergence",134- };135- await runConcurrentOfflineConvergence(fixture, scenario);136- } finally {137- await fixture.close();138- }139-});107+ // App-owned convergence: both tasks visible on both peers.108+ for (const peer of [alice, bob]) {109+ await seeTask(peer, "first client task");110+ await seeTask(peer, "second client task");111+ }112+ });113+}@@ -61,7 +61,7 @@
6161 },6262 "tasks": {6363 "build": "deno task --cwd apps/reference build",64- "check": "deno fmt --check . && deno lint && deno task doc && deno task doc:closure && deno task docs:links && deno task test:unit && deno task test:access && deno task test:effects && deno task test:conformance",64+ "check": "deno fmt --check . && deno lint && deno task doc && deno task doc:closure && deno task docs:links && deno task test:unit && deno task test:access && deno task test:effects && deno task test:conformance && deno task test:scenario",6565 "doc": "deno run --allow-run tools/doc_lint.ts",6666 "doc:closure": "deno run --allow-run --allow-read tools/type_closure_lint.ts",6767 "docs:links": "deno run --allow-read tools/docs_links.ts",@@ -69,6 +69,7 @@
6969 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",7070 "test:effects": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/effects_sync_test.ts",7171 "test:conformance": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/merge_sync_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/migration_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/sliceable_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/nested_conformance_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/store_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/conformance_test.ts",72+ "test:scenario": "deno test --allow-env --allow-sys --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/scenario_sync_test.ts",7273 "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",7374 "test": "deno task test:unit",7475 "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",@@ -25,6 +25,83 @@ The default suite covers application tests without launching a browser. Framewor
2525 run once in the `@nzip/lofi` package suite instead of being copied into every application. Keep2626 domain logic and permission-shape checks in this fast path when possible.2727 28+## Simulation-test sync with scenarios29+30+The `@nzip/lofi/testing` scenario surface turns sync simulation testing into plain test31+declarations: named peers make concurrent and offline edits through your app's own schema, and the32+test asserts they converge.33+34+```ts35+import { assertNoRow, converge, scenario } from "@nzip/lofi/testing";36+import { app, permissions } from "../src/lofi/schema.ts";37+38+scenario("offline rename versus remote delete", { app, permissions }, async ({ alice, bob }) => {39+ const doc = await alice.db.documents.insert({ title: "Untitled" });40+ await converge(alice, bob);41+42+ await alice.offline();43+ await alice.db.documents.update(doc.id, { title: "Draft" });44+ await bob.db.documents.remove(doc.id);45+ await alice.online();46+ await alice.settle();47+48+ await alice.restart();49+ await converge(alice, bob);50+ await assertNoRow(alice, app.documents, doc.id);51+ await assertNoRow(bob, app.documents, doc.id);52+});53+```54+55+Each `scenario` call registers one test that boots a real local sync server, deploys the app's56+schema and permissions, creates the peers as real synced clients on a memory driver, and tears57+everything down afterwards. What converges in a scenario is what converges under production sync;58+nothing is mocked. The local server needs FFI, so run scenario tests explicitly with `deno test -A`59+rather than inside the default fast suite.60+61+Each peer offers:62+63+- `alice.db.<table>.insert / update / remove / all / get` — the app's own tables, applied to the64+ peer's local view immediately, online or offline;65+- `alice.offline()` and `alice.online()` — an offline window: edits keep applying locally and upload66+ on reconnect;67+- `alice.settle()` — wait until every write the peer issued has been durably accepted or adjudicated68+ by the sync node;69+- `alice.restart()` — close and reboot the peer with the same identity, dropping session-local state70+ and re-syncing from the server;71+- `addPeer("carol")` (on the scenario context) — boot a fresh reader, useful for asserting the72+ canonical state a new device would see.73+74+`converge(alice, bob)` settles the peers, then polls until every peer reads identical rows in every75+table, and fails with a per-table, per-peer diff at its deadline. `assertRow`, `assertNoRow`, and76+`assertRowCount` check one peer's local view. Two merge behaviors are built into the comparison:77+78+- counter-merged columns are compared by row presence, not value, because a live replica that79+ watched a counter's history reads a different total than a fresh boot of the same account;80+- a live replica that edited a row another peer concurrently deleted keeps showing its own write for81+ the rest of its session, even though the delete wins for every other view. Restart that peer to82+ observe the adjudicated outcome, as the example above does.83+84+Scenarios use real sync with scripted offline windows: op sequences are exactly reproducible, but85+network timing is not, so assertions poll within deadlines rather than assuming schedules.86+Transport-level fault injection (latency, dropped or reordered messages) and a virtual clock are not87+part of this surface.88+89+### Fuzz scenarios90+91+`scenario.fuzz` generates a seeded sequence of inserts, updates, removes, and offline windows across92+both peers, executes it, restarts the peers, and asserts that they and a fresh reader converge on93+identical state:94+95+```ts96+scenario.fuzz("fuzzed edits converge", { app, permissions, seed: 7, steps: 40 });97+```98+99+Omit `seed` to explore: each run picks a random seed, and every failure reports the seed and the100+full op trace. Replay a failure exactly by passing the reported seed (or setting `LOFI_FUZZ_SEED`).101+The generator drives tables whose required columns it can fill with scalar values and skips the102+rest; writes that the sync node legitimately rejects, such as updating a row the other peer already103+deleted, count as outcomes rather than failures — the invariant under test is convergence.104+28105 ## Test the production build manually29106 30107 ```sh@@ -97,12 +174,15 @@ deno run -A npm:playwright@1.61.1 install chromium
97174 98175 ## Adapt the example to your UI99176 100-Update these four pieces in `tests/convergence_e2e_test.ts`:177+The example is a `scenario.browser` declaration: the same scenario controls as the headless surface,178+driving two real browser clients against your served app. Update these pieces in179+`tests/convergence_e2e_test.ts`:101180 102181 - `ready` — a DOM condition proving the local store has hydrated;103-- `apply` — the user action that makes one local mutation;104-- `locallyApplied` — proof that the offline client rendered its own mutation;105-- `converged` — proof that both clients eventually render all expected results.182+- `snapshot` — a value-free view of the peer's state (counts and booleans, never user data),183+ compared when settling and captured in failure artifacts;184+- the body — page-mediated edits inside the offline window, then proof that both peers eventually185+ render all expected results.106186 107187 Use stable accessible roles, labels, and application-owned `data-*` attributes. Avoid arbitrary108188 sleeps; readiness helpers retry observable conditions until their timeout.@@ -0,0 +1,57 @@
1+// The scenario DSL against a real local Jazz server: the worked2+// offline-rename-versus-remote-delete example, cross-peer visibility through3+// converge, and a fixed-seed fuzz smoke run. Runs in its own task4+// (test:scenario) because the server needs FFI.5+import { schema as s } from "jazz-tools";6+import { assertNoRow, assertRow, assertRowCount, converge, scenario } from "./testing/mod.ts";7+8+const app = s.defineApp({9+ documents: s.table({ title: s.string() }),10+});11+const permissions = s.definePermissions(app, ({ policy }) => {12+ policy.documents.allowInsert.always();13+ policy.documents.allowRead.always();14+ policy.documents.allowUpdate.always();15+ policy.documents.allowDelete.always();16+});17+18+scenario("offline rename versus remote delete", { app, permissions }, async ({ alice, bob }) => {19+ const doc = await alice.db.documents.insert({ title: "Untitled" });20+ await converge(alice, bob);21+ await assertRow(bob, app.documents, doc.id, { title: "Untitled" });22+23+ await alice.offline();24+ await alice.db.documents.update(doc.id, { title: "Draft" });25+ await bob.db.documents.remove(doc.id);26+ await alice.online();27+ await alice.settle();28+29+ // PINNED (alpha.53): the delete wins everywhere except in the live session30+ // that made the doomed rename — that replica keeps showing its own write31+ // until it restarts. If the first assert fails, upstream started applying32+ // remote deletes to live writers; drop the restart from this scenario.33+ await assertRow(alice, app.documents, doc.id, { title: "Draft" });34+ await alice.restart();35+36+ await converge(alice, bob);37+ await assertNoRow(alice, app.documents, doc.id);38+ await assertNoRow(bob, app.documents, doc.id);39+});40+41+scenario("a fresh reader sees the converged state", { app, permissions }, async (ctx) => {42+ const { alice, bob, addPeer } = ctx;43+ await alice.db.documents.insert({ title: "one" });44+ await bob.db.documents.insert({ title: "two" });45+ await converge(alice, bob);46+47+ const carol = await addPeer("carol");48+ await converge(alice, bob, carol);49+ await assertRowCount(carol, app.documents, 2);50+});51+52+scenario.fuzz("fuzzed edits converge", {53+ app,54+ permissions,55+ seed: 7,56+ steps: 15,57+});@@ -25,7 +25,7 @@
2525 "tests/auth_e2e_test.ts": "87dc3eb441eeaa96fc82b15df90bf4108096506217e5bfae907b483a3e75b4bf",2626 "tests/author-boundary_test.ts": "8340fda5d2764ed3c57e72e72d36974efca00c1c0ae126adfbdc4b394963d81f",2727 "tests/backup_migration_e2e_test.ts": "7ad87dad687641ba937ab20b0c5d06a34478c7c08527ed6e8c6861ad6c97d622",28- "tests/convergence_e2e_test.ts": "191bdcda1dc6bffba354c0d53cdeb7b5b892e2c7face3010a29a61d3cb0569a5",28+ "tests/convergence_e2e_test.ts": "217c15456251cf5ed2f44d1ab5a7a1076cad8eeb5f46a33e4dfead9beea770ba",2929 "tests/testing-contract_test.ts": "4257306060d42566b879d92b3a6c395805b20f53717bee55d670c0ef7a117eec",3030 "tsconfig.json": "c632473513c623f3cc69d7476159fb9421624521220029755695defa265c6cab"3131 }@@ -8,6 +8,14 @@
88 * workspace import map. Install a matching Chromium with99 * `deno run -A npm:playwright@1.61.1 install chromium`.1010 *11+ * The scenario surface (`scenario`, `converge`, and the row assertions) turns12+ * sync simulation testing into plain test declarations: named peers make13+ * concurrent and offline edits through the app's own schema, and the test14+ * asserts they converge. Headless scenarios drive real synced clients against15+ * a local sync server and need FFI permissions; browser scenarios drive the16+ * two-client Playwright fixture against a served app; `scenario.fuzz` runs17+ * seeded, replayable operation sequences for property-style coverage.18+ *1119 * Alongside the browser fixtures, this entry re-exports the deterministic1220 * seams of the schema and runtime registries — key installation, registry1321 * clears, and fake runtime installation — so a test can isolate schema@@ -51,6 +59,43 @@ export {
5159 withVirtualAuthenticator,5260 } from "./webauthn.ts";5361 62+// Scenario simulation testing.63+export { scenario, type ScenarioApi } from "./scenario/scenario.ts";64+export type { ScenarioConfig, ScenarioContext } from "./scenario/run.ts";65+export type { ScenarioApp } from "./scenario/headless.ts";66+export type {67+ ScenarioDb,68+ ScenarioPeer,69+ ScenarioPeerControls,70+ ScenarioReadOptions,71+ ScenarioSettleOptions,72+ ScenarioTable,73+ ScenarioTableKey,74+} from "./scenario/peer.ts";75+export { ScenarioError, type ScenarioErrorOptions, type ScenarioStage } from "./scenario/errors.ts";76+export {77+ assertNoRow,78+ assertRow,79+ assertRowCount,80+ converge,81+ type ConvergeOptions,82+ type ScenarioTableRef,83+} from "./scenario/assertions.ts";84+export {85+ type FuzzColumn,86+ type FuzzOp,87+ type FuzzOpKind,88+ type FuzzPlan,89+ type FuzzPlanInput,90+ type FuzzScenarioOptions,91+ generateFuzzPlan,92+} from "./scenario/fuzz.ts";93+export type {94+ BrowserScenarioContext,95+ BrowserScenarioOptions,96+ BrowserScenarioPeer,97+} from "./scenario/browser.ts";98+5499 // Deterministic seams over the schema-side registries.55100 export {56101 clearEffectDeclarations,@@ -0,0 +1,289 @@
1+import { ScenarioError } from "./errors.ts";2+import {3+ type ConvergeSource,4+ convergeSourceOf,5+ type ScenarioPeer,6+ type ScenarioPeerControls,7+ type ScenarioTable,8+ type ScenarioTableKey,9+} from "./peer.ts";10+11+/** Options accepted by {@link converge} alongside the peers. */12+export interface ConvergeOptions<A> {13+ /** Tables to compare. Defaults to every table of the app. */14+ tables?: readonly ScenarioTableKey<A>[];15+ /** Deadline for the peers to reach identical views. Defaults to 20 seconds. */16+ timeoutMs?: number;17+ /** Interval between comparison rounds. Defaults to 250 milliseconds. */18+ pollMs?: number;19+ /**20+ * How to compare counter-merged columns. `"presence"` (the default) checks21+ * only that the row and column exist: a replica that watched a counter's22+ * history legitimately reads a different total than a fresh boot of the same23+ * account, so value equality across live replicas is not part of the24+ * convergence contract. `"value"` opts into strict equality anyway.25+ */26+ counterColumns?: "presence" | "value";27+}28+29+type NormalizedTable = Map<string, string>;30+31+function stableSerialize(value: unknown): string {32+ return JSON.stringify(value, function (key, cooked: unknown) {33+ const raw: unknown = (this as Record<string, unknown>)[key];34+ if (raw instanceof Date) return `date:${raw.getTime()}`;35+ if (Array.isArray(cooked)) return cooked;36+ if (typeof cooked === "object" && cooked !== null) {37+ const record = cooked as Record<string, unknown>;38+ return Object.fromEntries(Object.keys(record).sort().map((k) => [k, record[k]]));39+ }40+ return cooked;41+ });42+}43+44+function normalizeRows(45+ rows: readonly Record<string, unknown>[],46+ skipColumns: ReadonlySet<string>,47+ setColumns: ReadonlySet<string>,48+): NormalizedTable {49+ const normalized: NormalizedTable = new Map();50+ for (const row of rows) {51+ const id = String(row.id);52+ const shaped: Record<string, unknown> = {};53+ for (const [column, value] of Object.entries(row)) {54+ if (skipColumns.has(column)) {55+ shaped[column] = value === undefined ? "absent" : "present";56+ } else if (setColumns.has(column) && Array.isArray(value)) {57+ shaped[column] = value.map((element) => stableSerialize(element)).sort();58+ } else {59+ shaped[column] = value;60+ }61+ }62+ normalized.set(id, stableSerialize(shaped));63+ }64+ return normalized;65+}66+67+function diffTables(68+ table: string,69+ views: readonly { peer: string; rows: NormalizedTable }[],70+): string[] {71+ const lines: string[] = [];72+ const reference = views[0];73+ for (const view of views.slice(1)) {74+ for (const [id, serialized] of reference.rows) {75+ const other = view.rows.get(id);76+ if (other === undefined) {77+ lines.push(`${table}: row ${id} present on ${reference.peer}, missing on ${view.peer}`);78+ } else if (other !== serialized) {79+ lines.push(80+ `${table}: row ${id} differs\n ${reference.peer}: ${serialized}\n ${view.peer}: ${other}`,81+ );82+ }83+ }84+ for (const id of view.rows.keys()) {85+ if (!reference.rows.has(id)) {86+ lines.push(`${table}: row ${id} present on ${view.peer}, missing on ${reference.peer}`);87+ }88+ }89+ }90+ return lines;91+}92+93+function isPeer(candidate: unknown): candidate is ScenarioPeerControls {94+ return typeof candidate === "object" && candidate !== null &&95+ typeof (candidate as ScenarioPeerControls).settle === "function";96+}97+98+async function tableRound(99+ sources: readonly { name: string; source: Extract<ConvergeSource, { kind: "tables" }> }[],100+ tables: readonly string[],101+ counterColumns: "presence" | "value" | undefined,102+): Promise<string[]> {103+ const diff: string[] = [];104+ for (const table of tables) {105+ const skip = counterColumns === "value"106+ ? new Set<string>()107+ : sources[0].source.counterColumns(table);108+ const sets = sources[0].source.setColumns(table);109+ const views = await Promise.all(sources.map(async ({ name, source }) => ({110+ peer: name,111+ rows: normalizeRows(await source.readTable(table, "global"), skip, sets),112+ })));113+ diff.push(...diffTables(table, views));114+ }115+ return diff;116+}117+118+async function snapshotRound(119+ sources: readonly { name: string; source: Extract<ConvergeSource, { kind: "snapshot" }> }[],120+): Promise<string[]> {121+ const views = await Promise.all(sources.map(async ({ name, source }) => ({122+ peer: name,123+ view: stableSerialize(await source.snapshot()),124+ })));125+ const reference = views[0];126+ return views.slice(1)127+ .filter((view) => view.view !== reference.view)128+ .map((view) =>129+ `snapshot differs\n ${reference.peer}: ${reference.view}\n ${view.peer}: ${view.view}`130+ );131+}132+133+/**134+ * Assert that all peers converge to identical views. Settles every peer's135+ * issued writes, then polls until every peer reads the same state, or fails136+ * at the deadline with a per-peer diff. All peers must be online. Headless137+ * peers compare their tables row by row; browser peers compare their138+ * app-supplied value-free snapshots. Accepts an options object after the139+ * peers: `converge(alice, bob, { timeoutMs: 30_000 })`.140+ */141+export async function converge<A>(142+ ...args: readonly (ScenarioPeer<A> | ScenarioPeerControls | ConvergeOptions<A>)[]143+): Promise<void> {144+ const peers = args.filter(isPeer);145+ const options = (args.find((arg) => !isPeer(arg)) ?? {}) as ConvergeOptions<A>;146+ if (peers.length < 2) {147+ throw new ScenarioError("convergence", "converge needs at least two peers");148+ }149+ for (const peer of peers) {150+ if (peer.isOffline) {151+ throw new ScenarioError(152+ "convergence",153+ `${peer.name} is offline; call ${peer.name}.online() before asserting convergence`,154+ { peer: peer.name },155+ );156+ }157+ }158+ const resolved = peers.map((peer) => {159+ const source = convergeSourceOf(peer);160+ if (source === undefined) {161+ throw new ScenarioError(162+ "convergence",163+ `${peer.name} does not expose a converge source`,164+ { peer: peer.name },165+ );166+ }167+ return { name: peer.name, source };168+ });169+ const tableSources = resolved.filter(170+ (entry): entry is { name: string; source: Extract<ConvergeSource, { kind: "tables" }> } =>171+ entry.source.kind === "tables",172+ );173+ const snapshotSources = resolved.filter(174+ (entry): entry is { name: string; source: Extract<ConvergeSource, { kind: "snapshot" }> } =>175+ entry.source.kind === "snapshot",176+ );177+ if (tableSources.length > 0 && snapshotSources.length > 0) {178+ throw new ScenarioError(179+ "convergence",180+ "cannot converge headless and browser peers against each other",181+ );182+ }183+184+ const timeoutMs = options.timeoutMs ?? 20_000;185+ const pollMs = options.pollMs ?? 250;186+ const deadline = Date.now() + timeoutMs;187+ await Promise.all(peers.map((peer) => peer.settle({ timeoutMs })));188+189+ const tables = (options.tables as readonly string[] | undefined) ??190+ tableSources[0]?.source.tableKeys ?? [];191+ let lastDiff: string[] = [];192+ while (true) {193+ lastDiff = tableSources.length > 0194+ ? await tableRound(tableSources, tables, options.counterColumns)195+ : await snapshotRound(snapshotSources);196+ if (lastDiff.length === 0) return;197+ if (Date.now() >= deadline) break;198+ await new Promise((resolve) => setTimeout(resolve, pollMs));199+ }200+ throw new ScenarioError(201+ "convergence",202+ `peers did not converge within ${timeoutMs}ms`,203+ { details: lastDiff.join("\n") },204+ );205+}206+207+/**208+ * A reference to one of the app's tables, as the row assertions accept it:209+ * any of the app object's own table handles (e.g. `app.documents`) matches.210+ */211+export type ScenarioTableRef<Row> = { readonly _table: string; readonly _rowType: Row };212+213+function tableFacade<Row>(214+ peer: ScenarioPeer<unknown>,215+ table: ScenarioTableRef<Row>,216+): ScenarioTable<Row, unknown, unknown> {217+ const facade = (peer.db as Record<string, ScenarioTable<Row, unknown, unknown>>)[table._table];218+ if (facade === undefined) {219+ throw new ScenarioError(220+ "assertion",221+ `table ${table._table} is not part of ${peer.name}'s app`,222+ { peer: peer.name },223+ );224+ }225+ return facade;226+}227+228+/**229+ * Assert that a peer's local view contains the row, optionally matching a230+ * subset of its columns.231+ */232+export async function assertRow<Row>(233+ peer: ScenarioPeer<unknown>,234+ table: ScenarioTableRef<Row>,235+ id: string,236+ partial?: Partial<Row>,237+): Promise<void> {238+ const row = await tableFacade(peer, table).get(id);239+ if (row === undefined) {240+ throw new ScenarioError(241+ "assertion",242+ `expected ${peer.name} to see row ${id} in ${table._table}, but it is absent`,243+ { peer: peer.name },244+ );245+ }246+ for (const [column, expected] of Object.entries(partial ?? {})) {247+ const actual = (row as Record<string, unknown>)[column];248+ if (stableSerialize(actual) !== stableSerialize(expected)) {249+ throw new ScenarioError(250+ "assertion",251+ `expected ${table._table} row ${id} on ${peer.name} to have ` +252+ `${column} = ${stableSerialize(expected)}, found ${stableSerialize(actual)}`,253+ { peer: peer.name },254+ );255+ }256+ }257+}258+259+/** Assert that a peer's local view does not contain the row. */260+export async function assertNoRow(261+ peer: ScenarioPeer<unknown>,262+ table: ScenarioTableRef<unknown>,263+ id: string,264+): Promise<void> {265+ const row = await tableFacade(peer, table).get(id);266+ if (row !== undefined) {267+ throw new ScenarioError(268+ "assertion",269+ `expected ${peer.name} to no longer see row ${id} in ${table._table}, but it is present`,270+ { peer: peer.name },271+ );272+ }273+}274+275+/** Assert the number of rows a peer's local view of a table contains. */276+export async function assertRowCount(277+ peer: ScenarioPeer<unknown>,278+ table: ScenarioTableRef<unknown>,279+ expected: number,280+): Promise<void> {281+ const rows = await tableFacade(peer, table).all();282+ if (rows.length !== expected) {283+ throw new ScenarioError(284+ "assertion",285+ `expected ${table._table} on ${peer.name} to hold ${expected} row(s), found ${rows.length}`,286+ { peer: peer.name },287+ );288+ }289+}@@ -0,0 +1,176 @@
1+// The fixture module value-loads Playwright, which touches system APIs at2+// import time; it is loaded lazily inside the run path so declaring (and3+// skipping) a browser scenario never pays that cost.4+import type { Page } from "playwright";5+import type {6+ BrowserTestClient,7+ FailureArtifactOptions,8+ IdentityOptions,9+ TwoClientFixture,10+} from "../fixture.ts";11+import type { ValueFreeState } from "../safety.ts";12+import { ScenarioError, type ScenarioStage } from "./errors.ts";13+import { registerConvergeSource, type ScenarioPeerControls } from "./peer.ts";14+15+/**16+ * A browser scenario peer: the scenario controls over a real browser client.17+ * There is no typed `db` here — the app's data lives inside the page, so18+ * edits go through the page and state is observed through the scenario's19+ * value-free snapshot hook.20+ */21+export interface BrowserScenarioPeer extends ScenarioPeerControls {22+ /** The underlying browser client, for artifacts and diagnostics. */23+ readonly client: BrowserTestClient;24+ /** The peer's page, for page-mediated edits and assertions. */25+ readonly page: Page;26+}27+28+/** The peers a browser scenario body receives. */29+export interface BrowserScenarioContext {30+ /** The first peer — the primary identity when identity mode is `shared`. */31+ readonly alice: BrowserScenarioPeer;32+ /** The second peer. */33+ readonly bob: BrowserScenarioPeer;34+}35+36+/** Configuration for a browser scenario. */37+export interface BrowserScenarioOptions {38+ /**39+ * The served app under test. When `undefined` the scenario logs a skip40+ * notice and passes, so browser scenarios stay opt-in — the conventional41+ * source is the `LOFI_E2E_BASE_URL` environment variable.42+ */43+ baseURL: string | undefined;44+ /** Identity mode for the two clients, forwarded to the two-client fixture. */45+ identity: IdentityOptions;46+ /** App-owned readiness assertion, awaited on both peers before the body runs. */47+ ready: (peer: BrowserScenarioPeer) => Promise<void>;48+ /**49+ * App-owned, value-free view of the peer's state — counts and booleans,50+ * never user data. Convergence and settling compare these snapshots, and a51+ * failure's artifacts include them.52+ */53+ snapshot: (peer: BrowserScenarioPeer) => Promise<ValueFreeState>;54+ /** Failure artifact capture options, forwarded to the two-client fixture. */55+ artifacts?: FailureArtifactOptions;56+ /** Overall defensive deadline for the whole scenario. Defaults to 120 seconds. */57+ timeoutMs?: number;58+}59+60+const SNAPSHOT_POLL_MS = 250;61+62+function makePeer(63+ name: string,64+ client: BrowserTestClient,65+ snapshot: (peer: BrowserScenarioPeer) => Promise<ValueFreeState>,66+): BrowserScenarioPeer {67+ const peer: BrowserScenarioPeer = {68+ name,69+ client,70+ get page() {71+ return client.page;72+ },73+ get isOffline() {74+ return client.offline;75+ },76+ async offline() {77+ await client.goOffline();78+ },79+ async online() {80+ await client.goOnline();81+ },82+ async settle(options) {83+ const timeoutMs = options?.timeoutMs ?? 20_000;84+ const deadline = Date.now() + timeoutMs;85+ let previous: string | undefined;86+ while (Date.now() < deadline) {87+ const current = JSON.stringify(await snapshot(peer));88+ if (previous !== undefined && current === previous) return;89+ previous = current;90+ await new Promise((resolve) => setTimeout(resolve, SNAPSHOT_POLL_MS));91+ }92+ throw new ScenarioError(93+ "convergence",94+ `${name}'s snapshot did not stabilize within ${timeoutMs}ms`,95+ { peer: name },96+ );97+ },98+ };99+ registerConvergeSource(peer, { kind: "snapshot", snapshot: () => snapshot(peer) });100+ return peer;101+}102+103+/**104+ * The browser scenario core: boots the two-client fixture against the served105+ * app, readies both peers, runs the body, and captures value-free failure106+ * artifacts before tearing down. Skips (resolving cleanly, with a notice)107+ * when no `baseURL` is configured or no browser is installed.108+ */109+export async function runBrowserScenario(110+ options: BrowserScenarioOptions,111+ body: (context: BrowserScenarioContext) => Promise<void>,112+): Promise<void> {113+ if (!options.baseURL) {114+ console.log("skipping browser scenario; set LOFI_E2E_BASE_URL to a served app to run it");115+ return;116+ }117+ const timeoutMs = options.timeoutMs ?? 120_000;118+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {119+ throw new TypeError("scenario timeoutMs must be a positive finite number");120+ }121+122+ const { BrowserUnavailableError, createTwoClientFixture } = await import("../fixture.ts");123+ let fixture: TwoClientFixture;124+ try {125+ fixture = await createTwoClientFixture({126+ baseURL: options.baseURL,127+ identity: options.identity,128+ artifacts: options.artifacts,129+ });130+ } catch (error) {131+ if (error instanceof BrowserUnavailableError) {132+ console.log(error.message);133+ return;134+ }135+ throw new ScenarioError("boot", "browser fixture creation failed", { cause: error });136+ }137+138+ const alice = makePeer("alice", fixture.first, options.snapshot);139+ const bob = makePeer("bob", fixture.second, options.snapshot);140+ const peers = [alice, bob];141+ const deadline = setTimeout(() => {142+ // The deadline is defensive: close() interrupts a hung page wait.143+ void fixture.close().catch(() => undefined);144+ }, timeoutMs);145+146+ let stage: ScenarioStage = "peers";147+ try {148+ await Promise.all(peers.map((peer) => options.ready(peer)));149+ stage = "body";150+ await body({ alice, bob });151+ } catch (error) {152+ if (error instanceof ScenarioError) throw error;153+ let captureError: unknown;154+ try {155+ await fixture.captureFailure(156+ `scenario-${stage}`,157+ (client) => options.snapshot(client === fixture.first ? alice : bob),158+ );159+ } catch (failure) {160+ captureError = failure ?? new Error("failure-artifact capture rejected without a reason");161+ }162+ const message = error instanceof Error ? error.message : String(error);163+ throw new ScenarioError(stage, message, {164+ cause: error,165+ details: captureError !== undefined166+ ? "failure-artifact capture also failed (see cause chain)"167+ : undefined,168+ });169+ } finally {170+ clearTimeout(deadline);171+ await Promise.allSettled(172+ peers.filter((peer) => peer.isOffline).map((peer) => peer.online()),173+ );174+ await fixture.close();175+ }176+}@@ -0,0 +1,46 @@
1+/** The lifecycle stages a scenario moves through, in order. */2+export type ScenarioStage =3+ | "boot"4+ | "deploy"5+ | "peers"6+ | "body"7+ | "convergence"8+ | "assertion"9+ | "teardown";10+11+/** Options for {@link ScenarioError}, extending `ErrorOptions` with scenario context. */12+export interface ScenarioErrorOptions extends ErrorOptions {13+ /** The peer whose action failed, when the failure is attributable to one peer. */14+ peer?: string;15+ /**16+ * A preformatted multi-line report — e.g. the per-table, per-peer diff a17+ * failed convergence produces — appended to the error message.18+ */19+ details?: string;20+}21+22+/** Thrown when a scenario fails, naming the lifecycle stage that failed via {@link stage}. */23+export class ScenarioError extends Error {24+ /** Always `"ScenarioError"`. */25+ override readonly name = "ScenarioError";26+27+ /** The peer whose action failed, or `undefined` when no single peer is at fault. */28+ readonly peer?: string;29+30+ /** The preformatted report passed via {@link ScenarioErrorOptions.details}, if any. */31+ readonly details?: string;32+33+ /**34+ * Builds the error, recording which lifecycle stage was in progress.35+ * @param stage the lifecycle stage that was in progress when the scenario failed36+ * @param message what went wrong, in one line37+ * @param options standard error options plus the failing peer and a detail report38+ */39+ constructor(readonly stage: ScenarioStage, message: string, options?: ScenarioErrorOptions) {40+ const peerNote = options?.peer !== undefined ? ` (peer ${options.peer})` : "";41+ const detailNote = options?.details !== undefined ? `\n${options.details}` : "";42+ super(`Scenario failed during ${stage}${peerNote}: ${message}${detailNote}`, options);43+ this.peer = options?.peer;44+ this.details = options?.details;45+ }46+}@@ -0,0 +1,379 @@
1+import { converge } from "./assertions.ts";2+import { ScenarioError } from "./errors.ts";3+import type { ScenarioApp } from "./headless.ts";4+import type { ScenarioTable } from "./peer.ts";5+import { runHeadlessScenario, type ScenarioConfig } from "./run.ts";6+7+/** The kinds of operations a fuzz plan is built from. */8+export type FuzzOpKind = "insert" | "update" | "remove" | "offline" | "online" | "sync";9+10+/**11+ * One step of a fuzz plan. Row-targeting steps reference the insert that12+ * created the row by its `ref` — the index of that insert in the plan — since13+ * row ids are only assigned when the plan runs.14+ */15+export type FuzzOp =16+ | { kind: "insert"; peer: string; table: string; values: Record<string, unknown>; ref: number }17+ | { kind: "update"; peer: string; table: string; ref: number; patch: Record<string, unknown> }18+ | { kind: "remove"; peer: string; table: string; ref: number }19+ | { kind: "offline"; peer: string }20+ | { kind: "online"; peer: string }21+ | { kind: "sync" };22+23+/** One column of a table, as the fuzz generator sees it. */24+export interface FuzzColumn {25+ /** Column name. */26+ name: string;27+ /** The compiled column type tag, e.g. `"Text"` or `"Integer"`. */28+ type: string;29+ /** Whether the column accepts null. */30+ nullable: boolean;31+ /** Whether the column has a declared default and can be omitted on insert. */32+ hasDefault: boolean;33+ /** The column's merge strategy, when it is not last-write-wins. */34+ mergeStrategy?: string;35+ /** The referenced table, for foreign-key columns. */36+ references?: string;37+}38+39+/** Input to the pure fuzz-plan generator. */40+export interface FuzzPlanInput {41+ /** PRNG seed; the same seed always yields the same plan. */42+ seed: number;43+ /** Number of operations to generate. */44+ steps: number;45+ /** Peer names participating in the plan. */46+ peers: readonly string[];47+ /** Column descriptions per table. */48+ tables: { readonly [table: string]: readonly FuzzColumn[] };49+ /** Relative op-kind weights; omitted kinds use their defaults. */50+ weights?: Partial<Record<FuzzOpKind, number>>;51+}52+53+/** A generated fuzz plan: the operations plus what the generator left out. */54+export interface FuzzPlan {55+ /** The seed the plan was generated from. */56+ seed: number;57+ /** The operations, in execution order. */58+ ops: readonly FuzzOp[];59+ /**60+ * Tables the generator cannot insert into — e.g. a required foreign-key or61+ * merge-strategy column with no default — and therefore skipped entirely.62+ */63+ skippedTables: readonly string[];64+}65+66+const DEFAULT_WEIGHTS: Record<FuzzOpKind, number> = {67+ insert: 4,68+ update: 4,69+ remove: 2,70+ offline: 1.5,71+ online: 1.5,72+ sync: 1,73+};74+75+const SCALAR_TYPES = new Set(["Text", "Integer", "Float", "Boolean", "Timestamp"]);76+const MAX_OFFLINE_OPS = 6;77+78+function mulberry32(seed: number): () => number {79+ let state = seed >>> 0;80+ return () => {81+ state = (state + 0x6D2B79F5) >>> 0;82+ let t = state;83+ t = Math.imul(t ^ (t >>> 15), t | 1);84+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);85+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;86+ };87+}88+89+function scalarValue(random: () => number, type: string, sequence: number): unknown {90+ switch (type) {91+ case "Text":92+ return `v${sequence}-${Math.floor(random() * 1000)}`;93+ case "Integer":94+ return Math.floor(random() * 100);95+ case "Float":96+ return Math.floor(random() * 1000) / 10;97+ case "Boolean":98+ return random() < 0.5;99+ case "Timestamp":100+ return new Date(1_700_000_000_000 + Math.floor(random() * 1_000_000) * 1000);101+ default:102+ return null;103+ }104+}105+106+function insertableColumns(columns: readonly FuzzColumn[]): readonly FuzzColumn[] | undefined {107+ const generated: FuzzColumn[] = [];108+ for (const column of columns) {109+ if (column.name === "id") continue;110+ const generatable = SCALAR_TYPES.has(column.type) &&111+ column.mergeStrategy === undefined && column.references === undefined;112+ if (generatable) {113+ generated.push(column);114+ } else if (!column.hasDefault && !column.nullable) {115+ return undefined;116+ }117+ }118+ return generated;119+}120+121+/**122+ * Generate a fuzz plan: a deterministic, seed-replayable sequence of inserts,123+ * updates, removes, offline windows, and sync barriers across the peers.124+ * Updates and removes only target rows their peer has locally created or has125+ * observed through a sync barrier; offline windows are bounded; the plan ends126+ * with every peer back online.127+ */128+export function generateFuzzPlan(input: FuzzPlanInput): FuzzPlan {129+ const random = mulberry32(input.seed);130+ const weights = { ...DEFAULT_WEIGHTS, ...input.weights };131+ const tables: { name: string; columns: readonly FuzzColumn[] }[] = [];132+ const skippedTables: string[] = [];133+ for (const [name, columns] of Object.entries(input.tables)) {134+ const insertable = insertableColumns(columns);135+ if (insertable === undefined) skippedTables.push(name);136+ else tables.push({ name, columns: insertable });137+ }138+139+ const ops: FuzzOp[] = [];140+ const inserts: { ref: number; table: string; peer: string }[] = [];141+ const barrierKnown = new Set<number>();142+ const offlinePeers = new Set<string>();143+ const offlineOps = new Map<string, number>();144+145+ const pick = <T>(items: readonly T[]): T => items[Math.floor(random() * items.length)];146+147+ const insertValues = (columns: readonly FuzzColumn[]): Record<string, unknown> => {148+ const values: Record<string, unknown> = {};149+ for (const column of columns) {150+ if (column.hasDefault && random() < 0.3) continue;151+ if (column.nullable && random() < 0.1) {152+ values[column.name] = null;153+ continue;154+ }155+ values[column.name] = scalarValue(random, column.type, ops.length);156+ }157+ return values;158+ };159+160+ const targetsFor = (peer: string, table: string): readonly number[] =>161+ inserts162+ .filter((entry) =>163+ entry.table === table && (entry.peer === peer || barrierKnown.has(entry.ref))164+ )165+ .map((entry) => entry.ref);166+167+ const kindsAvailable = (): FuzzOpKind[] => {168+ const kinds: FuzzOpKind[] = [];169+ if (tables.length > 0) kinds.push("insert");170+ if (inserts.length > 0) kinds.push("update", "remove");171+ if (offlinePeers.size < input.peers.length) kinds.push("offline");172+ if (offlinePeers.size > 0) kinds.push("online");173+ if (offlinePeers.size === 0 && inserts.length > 0) kinds.push("sync");174+ return kinds;175+ };176+177+ const weightedKind = (kinds: readonly FuzzOpKind[]): FuzzOpKind => {178+ const total = kinds.reduce((sum, kind) => sum + weights[kind], 0);179+ let roll = random() * total;180+ for (const kind of kinds) {181+ roll -= weights[kind];182+ if (roll <= 0) return kind;183+ }184+ return kinds[kinds.length - 1];185+ };186+187+ for (let step = 0; step < input.steps; step++) {188+ // Close any offline window that has run its course before choosing an op.189+ const overdue = input.peers.find((peer) =>190+ offlinePeers.has(peer) && (offlineOps.get(peer) ?? 0) >= MAX_OFFLINE_OPS191+ );192+ if (overdue !== undefined) {193+ offlinePeers.delete(overdue);194+ offlineOps.delete(overdue);195+ ops.push({ kind: "online", peer: overdue });196+ continue;197+ }198+199+ const kinds = kindsAvailable();200+ if (kinds.length === 0) break;201+ const kind = weightedKind(kinds);202+ for (const peer of offlinePeers) offlineOps.set(peer, (offlineOps.get(peer) ?? 0) + 1);203+204+ if (kind === "insert") {205+ const table = pick(tables);206+ const peer = pick(input.peers);207+ const ref = ops.length;208+ ops.push({ kind, peer, table: table.name, values: insertValues(table.columns), ref });209+ inserts.push({ ref, table: table.name, peer });210+ } else if (kind === "update" || kind === "remove") {211+ const peer = pick(input.peers);212+ const entry = pick(inserts);213+ const targets = targetsFor(peer, entry.table);214+ if (targets.length === 0) continue;215+ const ref = pick(targets);216+ if (kind === "update") {217+ const columns = tables.find((table) => table.name === entry.table)?.columns ?? [];218+ if (columns.length === 0) continue;219+ const column = pick(columns);220+ ops.push({221+ kind,222+ peer,223+ table: entry.table,224+ ref,225+ patch: { [column.name]: scalarValue(random, column.type, ops.length) },226+ });227+ } else {228+ ops.push({ kind, peer, table: entry.table, ref });229+ }230+ } else if (kind === "offline") {231+ const online = input.peers.filter((peer) => !offlinePeers.has(peer));232+ const peer = pick(online);233+ offlinePeers.add(peer);234+ offlineOps.set(peer, 0);235+ ops.push({ kind, peer });236+ } else if (kind === "online") {237+ const peer = pick([...offlinePeers]);238+ offlinePeers.delete(peer);239+ offlineOps.delete(peer);240+ ops.push({ kind, peer });241+ } else {242+ ops.push({ kind: "sync" });243+ for (const entry of inserts) barrierKnown.add(entry.ref);244+ }245+ }246+247+ for (const peer of input.peers) {248+ if (offlinePeers.has(peer)) ops.push({ kind: "online", peer });249+ }250+ return { seed: input.seed, ops, skippedTables };251+}252+253+/** Options for a fuzz scenario, extending the scenario config with fuzz knobs. */254+export interface FuzzScenarioOptions<A extends ScenarioApp> extends ScenarioConfig<A> {255+ /**256+ * PRNG seed. Defaults to the `LOFI_FUZZ_SEED` environment variable when257+ * set, otherwise a random seed. The seed in use is always part of any258+ * failure, so a failing run can be replayed exactly.259+ */260+ seed?: number;261+ /** Number of operations to generate. Defaults to 40. */262+ steps?: number;263+ /** Tables to fuzz. Defaults to every table the generator can insert into. */264+ tables?: readonly string[];265+ /** Relative op-kind weights; omitted kinds use their defaults. */266+ weights?: Partial<Record<FuzzOpKind, number>>;267+}268+269+function fuzzColumnsOf(270+ app: ScenarioApp,271+ tables?: readonly string[],272+): { [table: string]: readonly FuzzColumn[] } {273+ const described: { [table: string]: readonly FuzzColumn[] } = {};274+ for (const [name, table] of Object.entries(app.wasmSchema)) {275+ if (tables !== undefined && !tables.includes(name)) continue;276+ described[name] = table.columns.map((column) => ({277+ name: column.name,278+ type: column.column_type.type,279+ nullable: column.nullable,280+ hasDefault: column.default !== undefined,281+ mergeStrategy: column.merge_strategy,282+ references: column.references,283+ }));284+ }285+ return described;286+}287+288+function resolveSeed(explicit?: number): number {289+ if (explicit !== undefined) return explicit;290+ try {291+ const fromEnv = Deno.env.get("LOFI_FUZZ_SEED");292+ if (fromEnv !== undefined && fromEnv !== "") return Number(fromEnv) >>> 0;293+ } catch {294+ // Env permission not granted: fall through to a random seed.295+ }296+ return Math.floor(Math.random() * 0xFFFFFFFF);297+}298+299+/**300+ * Run one fuzz scenario: generate a plan, execute it through real peers, and301+ * assert convergence — across the participating peers and against a fresh302+ * canonical reader. Failures carry the seed and the full op trace.303+ */304+export async function runFuzzScenario<A extends ScenarioApp>(305+ options: FuzzScenarioOptions<A>,306+): Promise<void> {307+ const seed = resolveSeed(options.seed);308+ const plan = generateFuzzPlan({309+ seed,310+ steps: options.steps ?? 40,311+ peers: ["alice", "bob"],312+ tables: fuzzColumnsOf(options.app, options.tables),313+ weights: options.weights,314+ });315+ const trace = plan.ops.map((op, index) => `${index}: ${JSON.stringify(op)}`).join("\n");316+ const report = `seed ${seed} (replay with LOFI_FUZZ_SEED=${seed})\n${trace}`;317+318+ try {319+ await runHeadlessScenario(options, async ({ alice, bob, addPeer }) => {320+ const peers = { alice, bob };321+ const refToId = new Map<number, string>();322+ const facadeFor = (323+ peer: typeof alice,324+ table: string,325+ ): ScenarioTable<Record<string, unknown>, Record<string, unknown>, unknown> =>326+ (peer.db as Record<327+ string,328+ ScenarioTable<Record<string, unknown>, Record<string, unknown>, unknown>329+ >)[table];330+331+ for (const op of plan.ops) {332+ if (op.kind === "sync") {333+ // A mid-plan barrier settles durability so later ops can target334+ // rows across peers; it deliberately does not assert convergence,335+ // because a live replica that offline-edited a remotely deleted row336+ // legitimately diverges until it restarts.337+ await Promise.all([alice.settle(), bob.settle()]);338+ continue;339+ }340+ const peer = peers[op.peer as keyof typeof peers];341+ if (op.kind === "offline") {342+ await peer.offline();343+ } else if (op.kind === "online") {344+ await peer.online();345+ } else if (op.kind === "insert") {346+ const row = await facadeFor(peer, op.table).insert(op.values);347+ refToId.set(op.ref, String(row.id));348+ } else {349+ const id = refToId.get(op.ref);350+ if (id === undefined) continue;351+ // A write against a row another peer already deleted may be352+ // rejected at adjudication; that is a legitimate outcome, and the353+ // invariant under test is convergence, not per-op success.354+ if (op.kind === "update") {355+ await facadeFor(peer, op.table).update(id, op.patch).catch(() => undefined);356+ } else {357+ await facadeFor(peer, op.table).remove(id).catch(() => undefined);358+ }359+ }360+ }361+362+ // Restart both peers before the final comparison: what must converge is363+ // the durable, adjudicated state every fresh view agrees on, not the364+ // session-lifetime echoes of each live replica.365+ await Promise.all([alice.settle(), bob.settle()]);366+ await Promise.all([alice.restart(), bob.restart()]);367+ const canonical = await addPeer("canonical");368+ await converge(alice, bob, canonical);369+ });370+ } catch (error) {371+ if (error instanceof ScenarioError) {372+ throw new ScenarioError(error.stage, `fuzz run failed: ${error.message}`, {373+ cause: error,374+ details: report,375+ });376+ }377+ throw new ScenarioError("body", "fuzz run failed", { cause: error, details: report });378+ }379+}@@ -0,0 +1,89 @@
1+// The fuzz generator is pure: these tests pin seed determinism and the2+// structural invariants the executor relies on, without booting a server.3+import { assert } from "../../runtime/test-assert.ts";4+import { type FuzzColumn, type FuzzPlanInput, generateFuzzPlan } from "./fuzz.ts";5+6+const columns: readonly FuzzColumn[] = [7+ { name: "id", type: "Uuid", nullable: false, hasDefault: true },8+ { name: "title", type: "Text", nullable: false, hasDefault: false },9+ { name: "done", type: "Boolean", nullable: false, hasDefault: true },10+];11+12+const input: FuzzPlanInput = {13+ seed: 42,14+ steps: 60,15+ peers: ["alice", "bob"],16+ tables: { documents: columns },17+};18+19+Deno.test("same seed yields an identical plan", () => {20+ const first = generateFuzzPlan(input);21+ const second = generateFuzzPlan(input);22+ assert(23+ JSON.stringify(first) === JSON.stringify(second),24+ "two runs of the generator with one seed diverged",25+ );26+ assert(first.ops.length > 0, "the plan generated no operations");27+});28+29+Deno.test("plans keep their structural invariants", () => {30+ for (const seed of [1, 7, 99, 12345]) {31+ const plan = generateFuzzPlan({ ...input, seed });32+ const insertsByRef = new Map<number, { table: string; peer: string }>();33+ const barrierKnown = new Set<number>();34+ const offline = new Set<string>();35+ for (const op of plan.ops) {36+ if (op.kind === "insert") {37+ insertsByRef.set(op.ref, { table: op.table, peer: op.peer });38+ } else if (op.kind === "update" || op.kind === "remove") {39+ const origin = insertsByRef.get(op.ref);40+ assert(origin !== undefined, `seed ${seed}: op targets ref ${op.ref} before its insert`);41+ assert(origin.table === op.table, `seed ${seed}: op targets ref ${op.ref} across tables`);42+ assert(43+ origin.peer === op.peer || barrierKnown.has(op.ref),44+ `seed ${seed}: ${op.peer} targets a row it cannot have observed`,45+ );46+ } else if (op.kind === "offline") {47+ assert(!offline.has(op.peer), `seed ${seed}: ${op.peer} went offline twice`);48+ offline.add(op.peer);49+ } else if (op.kind === "online") {50+ assert(offline.has(op.peer), `seed ${seed}: ${op.peer} came online while online`);51+ offline.delete(op.peer);52+ } else {53+ assert(offline.size === 0, `seed ${seed}: sync barrier while a peer is offline`);54+ for (const ref of insertsByRef.keys()) barrierKnown.add(ref);55+ }56+ }57+ assert(offline.size === 0, `seed ${seed}: the plan ends with a peer offline`);58+ }59+});60+61+Deno.test("tables the generator cannot insert into are skipped", () => {62+ const plan = generateFuzzPlan({63+ ...input,64+ tables: {65+ documents: columns,66+ attachments: [67+ { name: "id", type: "Uuid", nullable: false, hasDefault: true },68+ {69+ name: "document",70+ type: "Uuid",71+ nullable: false,72+ hasDefault: false,73+ references: "documents",74+ },75+ ],76+ },77+ });78+ assert(79+ plan.skippedTables.length === 1 && plan.skippedTables[0] === "attachments",80+ "a required foreign-key table was not skipped",81+ );82+ assert(83+ plan.ops.every((op) =>84+ op.kind === "offline" || op.kind === "online" || op.kind === "sync" ||85+ op.table === "documents"86+ ),87+ "an op targeted a skipped table",88+ );89+});@@ -0,0 +1,249 @@
1+// Headless scenario adapter: real `createDb` clients on a memory driver2+// syncing through a local Jazz server. `jazz-tools` and `jazz-tools/testing`3+// are imported dynamically inside the boot path only — this module is loaded4+// by the `./testing` entry under the FFI-free unit-test task, so nothing5+// FFI-adjacent may load at import time.6+import type { CompiledPermissions } from "jazz-tools";7+import { ScenarioError } from "./errors.ts";8+import {9+ registerConvergeSource,10+ type ScenarioDb,11+ type ScenarioPeer,12+ type ScenarioReadOptions,13+ type ScenarioSettleOptions,14+} from "./peer.ts";15+16+type JazzTesting = typeof import("jazz-tools/testing");17+type JazzRoot = typeof import("jazz-tools");18+type Db = Awaited<ReturnType<JazzRoot["createDb"]>>;19+20+/** The app-object shape the headless adapter needs: table handles plus the compiled schema. */21+export type ScenarioApp = {22+ readonly wasmSchema: {23+ readonly [table: string]: {24+ readonly columns: readonly {25+ readonly name: string;26+ readonly nullable: boolean;27+ readonly default?: unknown;28+ readonly references?: string;29+ readonly column_type: { readonly type: string };30+ readonly merge_strategy?: string;31+ }[];32+ };33+ };34+};35+36+type TableHandle = {37+ readonly _table: string;38+ where(conditions: Record<string, unknown>): unknown;39+};40+41+type WriteWaiter = { wait(options: { tier: "local" | "global" }): Promise<unknown> };42+43+function mergeColumns(44+ schema: ScenarioApp["wasmSchema"],45+ table: string,46+ strategy: string,47+): ReadonlySet<string> {48+ const names = new Set<string>();49+ for (const column of schema[table]?.columns ?? []) {50+ if (column.merge_strategy === strategy) names.add(column.name);51+ }52+ return names;53+}54+55+async function within<T>(operation: Promise<T>, label: string, milliseconds: number): Promise<T> {56+ let timer: ReturnType<typeof setTimeout> | undefined;57+ try {58+ return await Promise.race([59+ operation,60+ new Promise<never>((_resolve, reject) => {61+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);62+ }),63+ ]);64+ } finally {65+ if (timer !== undefined) clearTimeout(timer);66+ }67+}68+69+function fillSecret(fill: number): string {70+ let binary = "";71+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);72+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");73+}74+75+function tableKeysOf(app: object): readonly string[] {76+ return Object.entries(app)77+ .filter(([, value]) =>78+ typeof value === "object" && value !== null &&79+ typeof (value as { _table?: unknown })._table === "string"80+ )81+ .map(([key]) => key);82+}83+84+/** A booted headless scenario: a local Jazz server with a deployed app and peer factory. */85+export interface HeadlessScenarioHarness<A> {86+ /** Boot a new named peer synced to the harness's server. */87+ peer(name: string): Promise<ScenarioPeer<A>>;88+ /** Tear down every peer and the server, with bounded waits. */89+ stop(): Promise<void>;90+}91+92+/**93+ * Boot a local Jazz server, deploy the app, and return a peer factory.94+ * Requires FFI (the local server is native), so callers live in the95+ * scenario test task, never in the unit-test task.96+ */97+export async function createHeadlessScenarioHarness<A extends ScenarioApp>(98+ app: A,99+ permissions: CompiledPermissions,100+): Promise<HeadlessScenarioHarness<A>> {101+ const testing: JazzTesting = await import("jazz-tools/testing");102+ const root: JazzRoot = await import("jazz-tools");103+ const server = await testing.startLocalJazzServer({ allowLocalFirstAuth: true });104+ try {105+ await testing.deploy({106+ appId: server.appId,107+ serverUrl: server.url,108+ adminSecret: server.adminSecret,109+ schema: app as never,110+ permissions,111+ });112+ } catch (error) {113+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);114+ throw new ScenarioError("deploy", "deploying the app schema failed", { cause: error });115+ }116+117+ const clients: Db[] = [];118+ let nextFill = 1;119+120+ const bootClient = (fill: number): Promise<Db> =>121+ root.createDb({122+ appId: server.appId,123+ serverUrl: server.url,124+ secret: fillSecret(fill),125+ userBranch: "main",126+ driver: { type: "memory" },127+ });128+129+ const makePeer = async (name: string): Promise<ScenarioPeer<A>> => {130+ const fill = nextFill++;131+ let db = await bootClient(fill);132+ clients.push(db);133+ let offline = false;134+ let outstanding: WriteWaiter[] = [];135+136+ const track = (handle: WriteWaiter): void => {137+ outstanding.push(handle);138+ };139+140+ const readTable = async (141+ key: string,142+ tier: "local" | "global",143+ ): Promise<readonly Record<string, unknown>[]> => {144+ const handle = (app as Record<string, unknown>)[key] as TableHandle;145+ const rows = await db.all(handle as never, { tier });146+ return rows as readonly Record<string, unknown>[];147+ };148+149+ const facade: Record<string, unknown> = {};150+ for (const key of tableKeysOf(app)) {151+ const handle = (app as Record<string, unknown>)[key] as TableHandle;152+ // Writes apply to the local view immediately; durability waits are left153+ // to settle()/converge because local-tier waits do not resolve while154+ // the peer is disconnected, and offline writes must not block.155+ // The async wrappers turn the vendor's synchronous write errors (e.g.156+ // updating a deleted row) into rejections, so callers can uniformly157+ // `.catch` them.158+ facade[key] = {159+ insert: async (values: Record<string, unknown>) => {160+ const result = db.insert(handle as never, values as never);161+ track(result);162+ return await Promise.resolve(result.value);163+ },164+ update: async (id: string, patch: Record<string, unknown>) => {165+ const result = db.update(handle as never, id, patch as never);166+ track(result);167+ await Promise.resolve();168+ },169+ remove: async (id: string) => {170+ const result = db.delete(handle as never, id);171+ track(result);172+ await Promise.resolve();173+ },174+ all: async (where?: Record<string, unknown>, options?: ScenarioReadOptions) => {175+ const query = where === undefined ? handle : handle.where(where);176+ return await db.all(query as never, { tier: options?.tier ?? "local" });177+ },178+ get: async (id: string, options?: ScenarioReadOptions) => {179+ const row = await db.one(180+ handle.where({ id }) as never,181+ { tier: options?.tier ?? "local" },182+ );183+ return row ?? undefined;184+ },185+ };186+ }187+188+ const peer: ScenarioPeer<A> = {189+ name,190+ get isOffline() {191+ return offline;192+ },193+ db: facade as ScenarioDb<A>,194+ async offline() {195+ if (offline) return;196+ offline = true;197+ await db.disconnect();198+ },199+ async online() {200+ if (!offline) return;201+ offline = false;202+ await db.reconnect();203+ },204+ async settle(options?: ScenarioSettleOptions) {205+ const timeoutMs = options?.timeoutMs ?? 20_000;206+ const pending = outstanding;207+ outstanding = [];208+ const hint = offline ? ` — ${name} is offline; call ${name}.online() before settling` : "";209+ // A rejected write is settled: the sync node adjudicated it. Only the210+ // durability wait itself failing (e.g. an offline peer) is an error.211+ await within(212+ Promise.all(213+ pending.map((handle) => handle.wait({ tier: "global" }).catch(() => undefined)),214+ ),215+ `settle of ${pending.length} write(s) on ${name}${hint}`,216+ timeoutMs,217+ );218+ },219+ async restart() {220+ outstanding = [];221+ await within(db.logout(), `${name} logout`, 5_000).catch(() => undefined);222+ const index = clients.indexOf(db);223+ if (index !== -1) clients.splice(index, 1);224+ db = await bootClient(fill);225+ clients.push(db);226+ offline = false;227+ },228+ };229+230+ registerConvergeSource(peer, {231+ kind: "tables",232+ tableKeys: tableKeysOf(app),233+ counterColumns: (table) => mergeColumns(app.wasmSchema, table, "Counter"),234+ setColumns: (table) => mergeColumns(app.wasmSchema, table, "GSet"),235+ readTable,236+ });237+ return peer;238+ };239+240+ return {241+ peer: makePeer,242+ async stop() {243+ await Promise.allSettled(244+ clients.map((client, index) => within(client.logout(), `client ${index} cleanup`, 3_000)),245+ );246+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);247+ },248+ };249+}@@ -0,0 +1,122 @@
1+import type { InsertOf, RowOf, WhereOf } from "jazz-tools";2+3+/**4+ * How a peer's view is read for convergence comparison. Headless peers expose5+ * their tables; browser peers expose the app-supplied value-free snapshot.6+ * Registered by the adapters; not part of the public surface.7+ */8+export type ConvergeSource =9+ | {10+ kind: "tables";11+ tableKeys: readonly string[];12+ counterColumns(table: string): ReadonlySet<string>;13+ setColumns(table: string): ReadonlySet<string>;14+ readTable(key: string, tier: "local" | "global"): Promise<readonly Record<string, unknown>[]>;15+ }16+ | {17+ kind: "snapshot";18+ snapshot(): Promise<unknown>;19+ };20+21+const convergeSources = new WeakMap<object, ConvergeSource>();22+23+/** Attach a peer's converge source. Called by the adapters when a peer boots. */24+export function registerConvergeSource(peer: object, source: ConvergeSource): void {25+ convergeSources.set(peer, source);26+}27+28+/** Look up a peer's converge source, or `undefined` for foreign objects. */29+export function convergeSourceOf(peer: object): ConvergeSource | undefined {30+ return convergeSources.get(peer);31+}32+33+/**34+ * The table names of an app: every key whose value is a table handle. The35+ * app object's non-table members (`union`, `wasmSchema`) are excluded by36+ * shape, not by name, so future non-table members stay excluded too.37+ */38+export type ScenarioTableKey<A> =39+ & {40+ [K in keyof A]: A[K] extends { readonly _table: string } ? K : never;41+ }[keyof A]42+ & string;43+44+/** Options for reads through a {@link ScenarioTable}. */45+export interface ScenarioReadOptions {46+ /**47+ * Durability tier to read at. `"local"` (the default) returns the peer's48+ * current local view and works offline; `"global"` waits until the read49+ * reflects globally durable data and requires the peer to be online.50+ */51+ tier?: "local" | "global";52+}53+54+/**55+ * One table of a headless peer's typed facade: the app's insert/update/remove56+ * writes, applied to the peer's local view immediately (online or offline),57+ * plus local-view reads. Writes issued here are tracked by the owning peer so58+ * {@link ScenarioPeerControls.settle} can wait for their global durability.59+ */60+export interface ScenarioTable<Row, Init, Where> {61+ /** Insert a row and resolve with it as applied to the local view. */62+ insert(values: Init): Promise<Row>;63+ /** Update a row by id in the local view. */64+ update(id: string, patch: Partial<Init>): Promise<void>;65+ /** Delete a row by id from the local view. */66+ remove(id: string): Promise<void>;67+ /** Read every row this peer can currently see. */68+ all(where?: Where, options?: ScenarioReadOptions): Promise<readonly Row[]>;69+ /** Read one row by id, or `undefined` when this peer cannot see it. */70+ get(id: string, options?: ScenarioReadOptions): Promise<Row | undefined>;71+}72+73+/** The app's tables as scenario facades, keyed by the app's own table names. */74+export type ScenarioDb<A> = {75+ readonly [K in ScenarioTableKey<A>]: ScenarioTable<RowOf<A[K]>, InsertOf<A[K]>, WhereOf<A[K]>>;76+};77+78+/** Options for {@link ScenarioPeerControls.settle}. */79+export interface ScenarioSettleOptions {80+ /** Defensive deadline for the settle wait. Defaults to 20 seconds. */81+ timeoutMs?: number;82+}83+84+/**85+ * The controls every scenario peer implements, headless or browser: a stable86+ * name, an offline window toggle, and a durability barrier for issued writes.87+ */88+export interface ScenarioPeerControls {89+ /** The peer's name in the scenario, e.g. `"alice"`. */90+ readonly name: string;91+ /** Whether the peer is currently inside an offline window. */92+ readonly isOffline: boolean;93+ /** Open an offline window: the peer keeps working locally without syncing. */94+ offline(): Promise<void>;95+ /** Close the offline window and resume syncing. */96+ online(): Promise<void>;97+ /**98+ * Resolve once every write issued through this peer has reached global99+ * durability. The peer must be online; offline peers cannot settle and the100+ * wait fails at its deadline with a reminder to call101+ * {@link ScenarioPeerControls.online} first.102+ */103+ settle(options?: ScenarioSettleOptions): Promise<void>;104+}105+106+/**107+ * A headless scenario peer: a real synced client with the scenario controls108+ * plus the app's typed table API. Writes issued through {@link db} apply to109+ * this peer's local view immediately — pair them with110+ * {@link ScenarioPeerControls.settle} or a convergence assertion when the111+ * scenario needs them visible elsewhere.112+ */113+export interface ScenarioPeer<A> extends ScenarioPeerControls {114+ /** The app's tables, scoped to this peer's replica. */115+ readonly db: ScenarioDb<A>;116+ /**117+ * Restart the peer: tear the client down and boot a fresh one with the same118+ * identity. Headless peers run on a memory driver, so unsynced local state119+ * is lost — what survives a restart is what had already synced.120+ */121+ restart(): Promise<void>;122+}@@ -0,0 +1,91 @@
1+import type { CompiledPermissions } from "jazz-tools";2+import { ScenarioError, type ScenarioStage } from "./errors.ts";3+import { createHeadlessScenarioHarness, type ScenarioApp } from "./headless.ts";4+import type { ScenarioPeer } from "./peer.ts";5+6+/** The app under test plus the scenario's own settings. */7+export interface ScenarioConfig<A extends ScenarioApp> {8+ /** The app object, as returned by `s.defineApp`. */9+ app: A;10+ /** The compiled permission bundle, as returned by `s.definePermissions`. */11+ permissions: CompiledPermissions;12+ /** Overall defensive deadline for the whole scenario. Defaults to 60 seconds. */13+ timeoutMs?: number;14+}15+16+/** The peers a scenario body receives, plus a factory for additional ones. */17+export interface ScenarioContext<A> {18+ /** The first peer, online and synced at scenario start. */19+ readonly alice: ScenarioPeer<A>;20+ /** The second peer, online and synced at scenario start. */21+ readonly bob: ScenarioPeer<A>;22+ /**23+ * Boot another named peer — e.g. a fresh reader that joins after the edits24+ * to observe the server-canonical state.25+ */26+ addPeer(name: string): Promise<ScenarioPeer<A>>;27+}28+29+/**30+ * The headless scenario core: boots a local sync server, deploys the app,31+ * creates the alice and bob peers, runs the body, and tears everything down32+ * with bounded waits. Thrown failures are wrapped in {@link ScenarioError}33+ * naming the lifecycle stage that failed.34+ */35+export async function runHeadlessScenario<A extends ScenarioApp>(36+ config: ScenarioConfig<A>,37+ body: (context: ScenarioContext<A>) => Promise<void>,38+): Promise<void> {39+ const timeoutMs = config.timeoutMs ?? 60_000;40+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {41+ throw new TypeError("scenario timeoutMs must be a positive finite number");42+ }43+ const controller = new AbortController();44+ const timeout = setTimeout(45+ () => controller.abort(new Error(`scenario exceeded its ${timeoutMs}ms deadline`)),46+ timeoutMs,47+ );48+ const withinDeadline = async <Result>(operation: Promise<Result>): Promise<Result> => {49+ if (controller.signal.aborted) throw controller.signal.reason;50+ let onAbort!: () => void;51+ const aborted = new Promise<never>((_resolve, reject) => {52+ onAbort = () => reject(controller.signal.reason);53+ controller.signal.addEventListener("abort", onAbort, { once: true });54+ });55+ try {56+ return await Promise.race([operation, aborted]);57+ } finally {58+ controller.signal.removeEventListener("abort", onAbort);59+ }60+ };61+62+ let stage: ScenarioStage = "boot";63+ const peers: ScenarioPeer<A>[] = [];64+ let harness: Awaited<ReturnType<typeof createHeadlessScenarioHarness<A>>> | undefined;65+ try {66+ harness = await withinDeadline(67+ createHeadlessScenarioHarness(config.app, config.permissions),68+ );69+ stage = "peers";70+ const booted = harness;71+ const addPeer = async (name: string): Promise<ScenarioPeer<A>> => {72+ const peer = await withinDeadline(booted.peer(name));73+ peers.push(peer);74+ return peer;75+ };76+ const alice = await addPeer("alice");77+ const bob = await addPeer("bob");78+ stage = "body";79+ await withinDeadline(body({ alice, bob, addPeer }));80+ } catch (error) {81+ if (error instanceof ScenarioError) throw error;82+ const message = error instanceof Error ? error.message : String(error);83+ throw new ScenarioError(stage, message, { cause: error });84+ } finally {85+ clearTimeout(timeout);86+ await Promise.allSettled(87+ peers.filter((peer) => peer.isOffline).map((peer) => peer.online()),88+ );89+ if (harness !== undefined) await harness.stop();90+ }91+}@@ -0,0 +1,84 @@
1+import {2+ type BrowserScenarioContext,3+ type BrowserScenarioOptions,4+ runBrowserScenario,5+} from "./browser.ts";6+import { type FuzzScenarioOptions, runFuzzScenario } from "./fuzz.ts";7+import type { ScenarioApp } from "./headless.ts";8+import { runHeadlessScenario, type ScenarioConfig, type ScenarioContext } from "./run.ts";9+10+/**11+ * The scenario entry points. The call signature registers a headless12+ * scenario; {@link ScenarioApi.fuzz} and {@link ScenarioApi.browser} register13+ * the fuzz and browser flavors. Each call registers one `Deno.test`, so14+ * scenarios are declared at the top level of a test module.15+ */16+export interface ScenarioApi {17+ /**18+ * Declare a headless scenario: two peers (alice and bob) on real synced19+ * clients against a local sync server, driving the app's own tables.20+ */21+ <A extends ScenarioApp>(22+ name: string,23+ config: ScenarioConfig<A>,24+ body: (context: ScenarioContext<A>) => Promise<void>,25+ ): void;26+ /**27+ * Declare a fuzz scenario: a seeded, replayable operation sequence across28+ * both peers, checked for convergence — including against a fresh reader.29+ */30+ fuzz<A extends ScenarioApp>(name: string, options: FuzzScenarioOptions<A>): void;31+ /**32+ * Declare a browser scenario: two Playwright clients against a served app,33+ * with page-mediated edits and value-free snapshots. Skips cleanly when no34+ * `baseURL` is configured or no browser is installed.35+ */36+ browser(37+ name: string,38+ options: BrowserScenarioOptions,39+ body: (context: BrowserScenarioContext) => Promise<void>,40+ ): void;41+}42+43+/**44+ * Declare simulation-test scenarios: named peers make concurrent and offline45+ * edits through the app's own schema, and the test asserts they converge.46+ *47+ * ```ts48+ * scenario("offline rename vs remote delete", { app, permissions }, async ({ alice, bob }) => {49+ * const doc = await alice.db.documents.insert({ title: "Untitled" });50+ * await converge(alice, bob);51+ *52+ * await alice.offline();53+ * await alice.db.documents.update(doc.id, { title: "Draft" });54+ * await bob.db.documents.remove(doc.id);55+ * await alice.online();56+ * await alice.settle();57+ *58+ * await alice.restart(); // the live writer keeps its doomed rename until it restarts59+ * await converge(alice, bob);60+ * await assertNoRow(alice, app.documents, doc.id);61+ * });62+ * ```63+ */64+export const scenario: ScenarioApi = Object.assign(65+ function scenario<A extends ScenarioApp>(66+ name: string,67+ config: ScenarioConfig<A>,68+ body: (context: ScenarioContext<A>) => Promise<void>,69+ ): void {70+ Deno.test(name, () => runHeadlessScenario(config, body));71+ },72+ {73+ fuzz<A extends ScenarioApp>(name: string, options: FuzzScenarioOptions<A>): void {74+ Deno.test(name, () => runFuzzScenario(options));75+ },76+ browser(77+ name: string,78+ options: BrowserScenarioOptions,79+ body: (context: BrowserScenarioContext) => Promise<void>,80+ ): void {81+ Deno.test(name, () => runBrowserScenario(options, body));82+ },83+ },84+);