import { execSync } from "node:child_process"; import path from "node:path"; import type { Page } from "@playwright/test"; import { test, expect } from "./fixtures"; import { TRIO_PCB, TRIO_SCH, type TabSet, type ToolCfg, type Trio, SYM1, FP1, VIA1, WIRE1, bootOpen, callHook, closeTrio, getPos, hasAbort, modelText, openTrio, oracleSweep, settleConverged, startV2, undoDepth, } from "./utils/trio"; /** * Drift trio harness — scenarios S2–S8 (standalone-hardening 0008 §6, phase C). * * S1 (per-action baseline + full catalogs) lives in drift-trio.spec.ts; this * file drives the CONCURRENT shapes: interleaved bursts with no settle between * them, conflicting edits on the same item, undo storms, churn, late joiners, * and save interplay. Assertions for conflicts are convergence + drift silence * — the winner is whatever the CRDT resolves, never a specific outcome. * * Concurrency pattern: fire both actors' sequences via Promise.all (each * actor's hooks run sequentially on its own tab), then wait for MARKER content * to land on every tab before settling — settleConverged alone can pass on the * pre-action state (finding #7), and in concurrent scenarios there is no * single actor save to gate on. */ test.beforeAll(() => { execSync("node collab/build.mjs", { cwd: path.resolve(__dirname, ".."), stdio: "inherit" }); }); function skipFirefox(): void { // 2026-08-13: guard retired — the JSPI build (~half the asyncify size) fits // three editor tabs inside Firefox 153's per-process wasm budget; the // whole file passes on kicad-firefox. Kept as a hook in case the wall // returns with future growth. } /** Poll until every tab's silent save contains `marker`. */ async function waitAllContain(set: TabSet, cfg: ToolCfg, marker: string, timeout = 90000): Promise { for (const [label, page] of set.tabs) { await expect .poll(async () => (await modelText(page, cfg)).includes(marker), { timeout, intervals: [400], message: `${label} must receive "${marker}"`, }) .toBe(true); } } /** Per-tool adapter for the scenario scripts. */ interface ToolOps { cfg: ToolCfg; label: string; /** The symbol / footprint — the property-carrying primary item. */ primary: string; /** A second fixture item (wire / via) for disjoint + move-move conflicts. */ secondary: string; mm: number; // IU per mm move(page: Page, uuid: string, dxIU: number): Promise; setValue(page: Page, text: string): Promise; /** Add a text-ish marker item carrying `text`; returns its uuid. */ addMarker(page: Page, text: string, slot: number): Promise; } const SCH_OPS: ToolOps = { cfg: TRIO_SCH, label: "eeschema", primary: SYM1, secondary: WIRE1, mm: 10000, move: (p, uuid, dx) => callHook(p, "kicadCollabTestMoveSchItem", uuid, dx, 0), setValue: (p, text) => callHook(p, "kicadCollabTestSetFieldText", SYM1, text), addMarker: (p, text, slot) => callHook(p, "kicadCollabTestAddLabel", "label", text, 400000 + slot * 60000, 400000), }; const PCB_OPS: ToolOps = { cfg: TRIO_PCB, label: "pcbnew", primary: FP1, secondary: VIA1, mm: 1000000, move: (p, uuid, dx) => callHook(p, "kicadCollabTestMoveBoardItem", uuid, dx, 0), setValue: (p, text) => callHook(p, "kicadCollabTestSetFootprintField", FP1, "Value", text), addMarker: (p, text, slot) => callHook(p, "kicadCollabTestAddBoardText", text, 20000000 + slot * 15000000, 130000000, "F.SilkS"), }; for (const ops of [SCH_OPS, PCB_OPS]) { const { cfg, label } = ops; test.describe(`drift trio scenarios — ${label}`, () => { test.describe.configure({ timeout: 900000 }); // ── S2: disjoint ping-pong ─────────────────────────────────────────────── test(`${label} S2: A and B alternate disjoint edits, 3 rounds`, async ({ context, testLogger, }) => { skipFirefox(); const trio = await openTrio(context, cfg, `s2-${label}-${test.info().workerIndex}`); for (let round = 0; round < 3; round++) { await test.step(`round ${round + 1}`, async () => { // Landed-gate per actor (finding #7), then settle covers cross-tab. const beforeA = await getPos(trio.A, ops.primary); const beforeB = await getPos(trio.B, ops.secondary); await Promise.all([ ops.move(trio.A, ops.primary, ops.mm), ops.move(trio.B, ops.secondary, ops.mm), ]); await expect .poll(() => getPos(trio.A, ops.primary), { timeout: 15000, intervals: [300] }) .not.toBe(beforeA); await expect .poll(() => getPos(trio.B, ops.secondary), { timeout: 15000, intervals: [300] }) .not.toBe(beforeB); await settleConverged(trio, cfg); await oracleSweep(trio, cfg); }); } expect(hasAbort(testLogger), "no WASM abort").toBe(false); await closeTrio(trio); }); // ── S3: same-item interleave (the 0008 headline scenario) ──────────────── test(`${label} S3: A creates an item; B moves+renames it while A keeps editing around it`, async ({ context, testLogger, }) => { skipFirefox(); const trio = await openTrio(context, cfg, `s3-${label}-${test.info().workerIndex}`); // A creates the contested item and everyone sees it. let target: string; if (label === "eeschema") { target = await callHook(trio.A, "kicadCollabTestAddSymbol", "Device:R", 800000, 800000, "R9"); } else { target = await callHook(trio.A, "kicadCollabTestDuplicateBoardItem", FP1, 15000000, 0); } expect(target).toMatch(/[0-9a-f-]{36}/); await waitAllContain(trio, cfg, target); // Interleaved bursts, NO settle in between: B moves the new item and // renames the primary's value while A wires/tracks around the new item. await Promise.all([ (async () => { if (label === "eeschema") { await callHook(trio.A, "kicadCollabTestAddWire", 760000, 800000, 800000, 800000); await callHook(trio.A, "kicadCollabTestAddWire", 800000, 838100, 800000, 880000); } else { await callHook(trio.A, "kicadCollabTestAddTrack", 110000000, 100000000, 115000000, 100000000, 300000, "F.Cu"); await callHook(trio.A, "kicadCollabTestAddVia", 113000000, 100000000, 800000, 400000); } })(), (async () => { await ops.move(trio.B, target, 2 * ops.mm); await ops.setValue(trio.B, "s3-renamed"); })(), ]); // Marker-wait on the rename (last B op) + settle covers A's adds. await waitAllContain(trio, cfg, "s3-renamed"); await settleConverged(trio, cfg); await oracleSweep(trio, cfg); expect(hasAbort(testLogger), "no WASM abort").toBe(false); await closeTrio(trio); }); // ── S4: conflict pairs on the SAME item ────────────────────────────────── // QUARANTINED 2026-08-01 (whole conflict class, not just value-vs-value): // racing edits on the SAME item genuinely fail to converge in a small // percentage of runs — across this week's CI + local stress, THREE // different steps have each diverged (move-vs-delete, value-vs-value, // move-vs-move; settleConverged 90s timeout, byte equality never reached). // Not poll timing, not the asyncify guards (zero beacons in failing // runs). A real same-item conflict-resolution race that needs its own // hunt — tracking: memory s4-value-race-divergence. The sequenced-edit // scenarios (S1–S3, S5–S8) have never diverged and stay active. // re-enabled 2026-08-13: converges on the JSPI build test(`${label} S4: move-vs-delete, value-vs-value, move-vs-move`, async ({ context, testLogger, }) => { skipFirefox(); const trio = await openTrio(context, cfg, `s4-${label}-${test.info().workerIndex}`); // Victim for move-vs-delete: a marker item A adds and everyone holds. const victim = await ops.addMarker(trio.A, "s4-victim", 0); expect(victim).toMatch(/[0-9a-f-]{36}/); await waitAllContain(trio, cfg, victim); await test.step("move-vs-delete", async () => { // Winner is CRDT policy, not asserted; both hooks may race the item // away from under each other, so their return values are not asserted // either — convergence + silence is the contract. await Promise.all([ ops.move(trio.A, victim, ops.mm), callHook(trio.B, "kicadCollabTestRemoveItem", victim), ]); await settleConverged(trio, cfg); await oracleSweep(trio, cfg); }); await test.step("move-vs-move", async () => { await Promise.all([ ops.move(trio.A, ops.secondary, 2 * ops.mm), ops.move(trio.B, ops.secondary, -2 * ops.mm), ]); await settleConverged(trio, cfg); await oracleSweep(trio, cfg); }); expect(hasAbort(testLogger), "no WASM abort").toBe(false); await closeTrio(trio); }); // QUARANTINED 2026-08-01: two clients racing setValue GENUINELY diverge in // ~5-8% of runs — settleConverged times out at 90s with the trio never // reaching byte equality. NOT a timing flake (windows already widened) and // NOT the asyncify guards (zero beacons in failing runs; reproduces // locally at single-worker). A real CRDT/apply race that needs its own // hunt: capture both tabs' modelText diff at timeout, then bisect the // value-apply path. Tracking: memory s4-value-race-divergence. // re-enabled 2026-08-13: converges on the JSPI build test(`${label} S4b: value-vs-value converges (KNOWN ~5-8% divergence)`, async ({ context, testLogger, }) => { skipFirefox(); const trio = await openTrio(context, cfg, `s4b-${label}-${test.info().workerIndex}`); await Promise.all([ops.setValue(trio.A, "s4-from-A"), ops.setValue(trio.B, "s4-from-B")]); // One of the two values won everywhere; poll until every tab carries // SOME s4 value, then settle on byte equality. for (const [tabLabel, p] of trio.tabs) { await expect .poll(async () => /s4-from-[AB]/.test(await modelText(p, cfg)), { timeout: 60000, intervals: [400], message: `${tabLabel} must receive one of the racing values`, }) .toBe(true); } await settleConverged(trio, cfg); await oracleSweep(trio, cfg); expect(hasAbort(testLogger), "no WASM abort").toBe(false); await closeTrio(trio); }); // ── S5: undo storm ─────────────────────────────────────────────────────── test(`${label} S5: A undoes its own ops while B keeps editing`, async ({ context, testLogger, }) => { skipFirefox(); const trio = await openTrio(context, cfg, `s5-${label}-${test.info().workerIndex}`); // A's undoable ops, settled so peers hold them. expect(await ops.move(trio.A, ops.primary, ops.mm)).toBe(true); expect(await ops.setValue(trio.A, "s5-tmp")).toBe(true); await waitAllContain(trio, cfg, "s5-tmp"); await settleConverged(trio, cfg); // A undoes both while B lands fresh edits. await Promise.all([ (async () => { expect(await callHook(trio.A, "kicadCollabTestUndo")).toBe(true); expect(await callHook(trio.A, "kicadCollabTestUndo")).toBe(true); })(), (async () => { await ops.move(trio.B, ops.secondary, ops.mm); await ops.addMarker(trio.B, "s5-b-survives", 1); })(), ]); // B's edits survive A's undos; the undone value is gone everywhere. await waitAllContain(trio, cfg, "s5-b-survives"); for (const [tabLabel, p] of trio.tabs) { await expect .poll(async () => (await modelText(p, cfg)).includes("s5-tmp"), { timeout: 60000, intervals: [400], message: `${tabLabel} must lose the undone value`, }) .toBe(false); } await settleConverged(trio, cfg); await oracleSweep(trio, cfg); expect(await undoDepth(trio.C), "observer undo stack").toBe(0); expect(hasAbort(testLogger), "no WASM abort").toBe(false); await closeTrio(trio); }); // ── S6: burst churn ────────────────────────────────────────────────────── test(`${label} S6: 12-edit bursts from both sides, one settle at the end`, async ({ context, testLogger, }) => { skipFirefox(); const trio = await openTrio(context, cfg, `s6-${label}-${test.info().workerIndex}`); await Promise.all([ (async () => { for (let i = 0; i < 12; i++) await ops.move(trio.A, ops.primary, Math.round(ops.mm / 5)); await ops.addMarker(trio.A, "s6-done-a", 2); })(), (async () => { for (let i = 0; i < 12; i++) await ops.move(trio.B, ops.secondary, Math.round(ops.mm / 5)); await ops.addMarker(trio.B, "s6-done-b", 3); })(), ]); await waitAllContain(trio, cfg, "s6-done-a", 45000); await waitAllContain(trio, cfg, "s6-done-b", 45000); await settleConverged(trio, cfg, 45000); await oracleSweep(trio, cfg); expect(hasAbort(testLogger), "no WASM abort").toBe(false); await closeTrio(trio); }); // ── S7: late joiner adopts a room that evolved without it ──────────────── test(`${label} S7: C joins after A+B edited; adopt converges drift-silent`, async ({ context, testLogger, }) => { skipFirefox(); const room = `s7-${label}-${test.info().workerIndex}`; const A = await context.newPage(); const B = await context.newPage(); await bootOpen(A, cfg); await bootOpen(B, cfg); await startV2(A, { room, seedText: cfg.fixture }); await startV2(B, { room, editorMatchesDoc: true }); const duo: TabSet = { tabs: [["A", A], ["B", B]] }; await settleConverged(duo, cfg); // The room evolves before C exists. await ops.move(A, ops.primary, ops.mm); await ops.addMarker(B, "s7-early", 4); await waitAllContain(duo, cfg, "s7-early"); await settleConverged(duo, cfg); // C opened the ORIGINAL fixture file, so its editor does NOT match the // doc — it must take the ADOPT branch (no editorMatchesDoc, no seed). const C = await context.newPage(); await bootOpen(C, cfg); await startV2(C, { room }); const trio: Trio = { A, B, C, tabs: [["A", A], ["B", B], ["C", C]] }; await waitAllContain(trio, cfg, "s7-early"); await settleConverged(trio, cfg); await oracleSweep(trio, cfg); expect(hasAbort(testLogger), "no WASM abort").toBe(false); await closeTrio(trio); }); }); } // ── S8: user-save during a peer's burst (pcbnew) ───────────────────────────── // Ctrl+S drives the FULL save flow (the real writer + the C++→JS onSave // notification chokepoint) while remote applies land — suspension contention // between the save coroutine and the apply coroutines is exactly the surface. test.describe("drift trio scenarios — pcbnew S8 save interplay", () => { test.describe.configure({ timeout: 900000 }); test("pcbnew S8: A saves (Ctrl+S) mid B-burst", async ({ context, testLogger }) => { skipFirefox(); const trio = await openTrio(context, TRIO_PCB, `s8-pcb-${test.info().workerIndex}`); await Promise.all([ (async () => { for (let i = 0; i < 6; i++) await callHook(trio.B, "kicadCollabTestMoveBoardItem", VIA1, 300000, 0); await callHook(trio.B, "kicadCollabTestAddBoardText", "s8-done", 20000000, 140000000, "F.SilkS"); })(), (async () => { // Focus the canvas on an empty margin, then user-save twice mid-burst. await trio.A.locator("#canvas").click({ position: { x: 30, y: 300 } }); await trio.A.keyboard.press("Control+s"); await trio.A.keyboard.press("Control+s"); })(), ]); await waitAllContain(trio, TRIO_PCB, "s8-done"); await settleConverged(trio, TRIO_PCB); await oracleSweep(trio, TRIO_PCB); expect(hasAbort(testLogger), "no WASM abort").toBe(false); await closeTrio(trio); }); });