From 84cf459e16dfafcb92fba4d3893d9a786db01125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 21 Jul 2026 09:16:38 +0200 Subject: [PATCH] feat(drift-trio): 3-client drift harness phase A + child-wire root-lift fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trio harness (standalone-hardening 0008 phase A): tests/kicad/utils/trio.ts (A=seeder/B=editor/C=observer, explicit-uuid fixtures, settleConverged + oracleSweep) + drift-trio.spec.ts — pl_editor plumbing baseline, eeschema and pcbnew S1, segment change-path regression (4/4 Chromium; firefox skipped by wasm budget). browser-entry-v2 driftReport upgraded to the production comparator (driftDocDelta + compareSlots, reordered/layoutReordered). Bumps web/pcbjam-shared for the deltaToItemsWire root-lift fix the harness surfaced (doc §10 ledger). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G5cAM9M6q34n5X4dbrfVvi --- tests/collab/browser-entry-v2.ts | 22 +- tests/kicad/drift-trio.spec.ts | 247 ++++++++++++++++++ tests/kicad/utils/trio.ts | 435 +++++++++++++++++++++++++++++++ web/pcbjam-shared | 2 +- 4 files changed, 699 insertions(+), 7 deletions(-) create mode 100644 tests/kicad/drift-trio.spec.ts create mode 100644 tests/kicad/utils/trio.ts diff --git a/tests/collab/browser-entry-v2.ts b/tests/collab/browser-entry-v2.ts index 077152b..7b63676 100644 --- a/tests/collab/browser-entry-v2.ts +++ b/tests/collab/browser-entry-v2.ts @@ -17,9 +17,10 @@ // reason the standalone vitest config sets `dedupe: ["yjs"]`). import * as Y from "yjs"; import { - docDelta, + compareSlots, docToFile, docToY, + driftDocDelta, fileToDoc, isEmptyKicadDelta, yToDoc, @@ -94,16 +95,22 @@ export interface DriftSummary { added: string[]; updated: string[]; removed: string[]; + /** Order-only item churn — surfaced for triage, NOT drift (kicad-delta.ts). */ + reordered: string[]; layoutChanged: boolean; + /** Order-only layout churn — likewise not report-worthy on its own. */ + layoutReordered: boolean; metaChanged: boolean; } /** * The drift-detect convergence oracle, replicating computeDrift's core - * (web/standalone/src/wasm/collab/drift-detect.ts:94-118) from @pcbjam/shared + * (web/standalone/src/wasm/collab/drift-detect.ts) from @pcbjam/shared * primitives only — drift-detect itself pulls `@/lib/api`, so it can't be * bundled here. Serializes the live model via the tool's save fn, diffs it - * against the room doc; null means editor ≡ doc. + * against the room doc with the PRODUCTION comparator (driftDocDelta + + * compareSlots — order-only churn goes to reordered/layoutReordered and does + * not make a report, matching ysync 0010); null means editor ≡ doc. */ function driftReport(saveFn: string, scratchPath: string): DriftSummary | null { const w = window as unknown as { @@ -126,16 +133,19 @@ function driftReport(saveFn: string, scratchPath: string): DriftSummary | null { } const wasmDoc = fileToDoc(text); const ydocDoc = yToDoc(handle().doc); - const diff = docDelta(ydocDoc, wasmDoc); - const layoutChanged = - JSON.stringify(ydocDoc.layout) !== JSON.stringify(wasmDoc.layout); + const diff = driftDocDelta(ydocDoc, wasmDoc); + const layoutRelation = compareSlots(ydocDoc.layout, wasmDoc.layout); + const layoutChanged = layoutRelation === "different"; const metaChanged = ydocDoc.root !== wasmDoc.root; + // layoutReordered deliberately outside the gate — same as computeDrift. if (isEmptyKicadDelta(diff) && !layoutChanged && !metaChanged) return null; return { added: diff.added.map((i) => i.uuid), updated: diff.updated.map((i) => i.uuid), removed: diff.removed, + reordered: diff.reordered.map((i) => i.uuid), layoutChanged, + layoutReordered: layoutRelation === "reordered", metaChanged, }; } diff --git a/tests/kicad/drift-trio.spec.ts b/tests/kicad/drift-trio.spec.ts new file mode 100644 index 0000000..f8c9b5a --- /dev/null +++ b/tests/kicad/drift-trio.spec.ts @@ -0,0 +1,247 @@ +import { execSync } from "node:child_process"; +import path from "node:path"; +import { test, expect } from "./fixtures"; +import { + TRIO_PL, + TRIO_PCB, + TRIO_SCH, + type ToolCfg, + SYM1, + WIRE2, + FP1, + PAD1, + VIA1, + SEG1, + callHook, + closeTrio, + drift, + getPos, + hasAbort, + modelText, + openTrio, + oracleSweep, + settleConverged, + undoDepth, +} from "./utils/trio"; + +/** + * Drift trio harness — S1 baseline (standalone-hardening 0008, phase A). + * + * Three tabs as three users in one room: A seeds + edits, B edits, C only + * observes. After every action group the full oracle sweep runs: per-tab drift + * silence (editor ≡ Y.Doc, production comparator), byte-identical silent saves + * across all three, and identical room materialization. C is the pure + * remote-apply oracle — everything it holds arrived over the wire. + * + * S1 drives only the EXISTING embind test hooks (move/rotate/set-field/ + * set-pad-size/remove); the action-catalog hooks land in phase B. + * + * Repro convention (ysync-review): known-open gaps assert the CORRECT behavior + * and are marked test.fail() naming the tracking doc — fixing the bug flips + * them to "unexpected pass", forcing the marker's removal. + */ + +test.beforeAll(() => { + // Rebuild the v2 bundle so the harness always exercises the current stack. + execSync("node collab/build.mjs", { cwd: path.resolve(__dirname, ".."), stdio: "inherit" }); +}); + +/** Heavy editors exceed Firefox's per-content-process wasm budget at 2+ + * instances (see ysync-two-tab.spec.ts) — at 3 it is strictly worse. */ +function skipFirefox(): void { + test.skip( + test.info().project.name.includes("firefox"), + "three heavy wasm tabs exceed Firefox's per-process wasm budget", + ); +} + +// ── pl_editor: green baseline validating the trio plumbing itself ──────────── + +test.describe("drift trio — pl_editor (plumbing baseline)", () => { + test.describe.configure({ timeout: 420000 }); + + test("A and B add text, C observes: all three drift-silent and converged", async ({ + context, + testLogger, + }) => { + const room = `drift-trio-pl-${test.info().workerIndex}`; + const trio = await openTrio(context, TRIO_PL, room); + + const uuidA = await callHook(trio.A, "kicadCollabTestAddText", "From A", 40, 40); + expect(uuidA).toMatch(/[0-9a-f-]{36}/); + await expect + .poll(async () => await modelText(trio.C, TRIO_PL), { timeout: 20000, intervals: [300] }) + .toContain("From A"); + await settleConverged(trio, TRIO_PL); + await oracleSweep(trio, TRIO_PL); + + const uuidB = await callHook(trio.B, "kicadCollabTestAddText", "From B", 60, 60); + expect(uuidB).toMatch(/[0-9a-f-]{36}/); + await expect + .poll(async () => await modelText(trio.A, TRIO_PL), { timeout: 20000, intervals: [300] }) + .toContain("From B"); + await settleConverged(trio, TRIO_PL); + await oracleSweep(trio, TRIO_PL); + + expect(hasAbort(testLogger), "no WASM abort").toBe(false); + await closeTrio(trio); + }); +}); + +// ── The heavy editors: eeschema + pcbnew S1 ────────────────────────────────── + +interface S1Actions { + /** [hookName, ...args] for B's rotate target and A's property edit. */ + rotateTarget: string; + propertyEdit: { fn: string; args: (string | number)[]; expectInSave: string }; + removeTarget: string; + moveDx: number; +} + +const S1: ReadonlyArray = [ + [ + TRIO_SCH, + "eeschema", + { + rotateTarget: SYM1, + // Assign-footprint-shaped edit: symbol field text (Value). + propertyEdit: { + fn: "kicadCollabTestSetFieldText", + args: [SYM1, "trio-10k"], + expectInSave: '"trio-10k"', + }, + removeTarget: WIRE2, + moveDx: 40000, // eeschema IU = 1e4/mm → 4mm + }, + ], + [ + TRIO_PCB, + "pcbnew", + { + rotateTarget: FP1, + propertyEdit: { + fn: "kicadCollabTestSetPadSize", + args: [PAD1, 1500000, 1000000], // pcbnew IU = 1e6/mm → 1.5 × 1 mm + expectInSave: "(size 1.5 1)", + }, + removeTarget: VIA1, + moveDx: 2000000, // 2mm + }, + ], +]; + +for (const [cfg, label, act] of S1) { + test.describe(`drift trio — ${label} S1 baseline`, () => { + test.describe.configure({ timeout: 900000 }); + + test(`${label}: A+B interleave move/rotate/property/delete, C observes`, async ({ + context, + testLogger, + }) => { + skipFirefox(); + const room = `drift-trio-${label}-${test.info().workerIndex}`; + const trio = await openTrio(context, cfg, room); + + // 1. A moves the first item (the seeder's emit half — bug 01 regression + // surface: seed()'s snapshotItems registered A's listener). The hooks + // run on a fiber, so first poll A's OWN pos until the move landed + // (two-tab's green precondition), then compare the peers against it. + const uuids = [...cfg.fixture.matchAll(/\(uuid "([0-9a-f-]{36})"\)/g)].map((m) => m[1]!); + const before: Record = {}; + for (const u of uuids) before[u] = await getPos(trio.A, u); + const movedId = await callHook(trio.A, "kicadCollabTestMoveFirst", act.moveDx, 0); + expect(movedId).toMatch(/[0-9a-f-]{36}/); + await expect + .poll(() => getPos(trio.A, movedId), { timeout: 15000, intervals: [300] }) + .not.toBe(before[movedId]); + const posA = await getPos(trio.A, movedId); + for (const peer of [trio.B, trio.C]) { + await expect + .poll(() => getPos(peer, movedId), { timeout: 20000, intervals: [300] }) + .toBe(posA); + } + await settleConverged(trio, cfg); + await oracleSweep(trio, cfg); + + // 2. B rotates the symbol/footprint (joiner's emit half). + expect(await callHook(trio.B, "kicadCollabTestRotateItem", act.rotateTarget, 90)).toBe( + true, + ); + await settleConverged(trio, cfg); + await oracleSweep(trio, cfg); + + // 3. A edits a property (field text / pad size — the "assign footprint, + // change value" family). + expect( + await callHook(trio.A, act.propertyEdit.fn, ...act.propertyEdit.args), + ).toBe(true); + for (const peer of [trio.B, trio.C]) { + await expect + .poll(async () => await modelText(peer, cfg), { timeout: 20000, intervals: [300] }) + .toContain(act.propertyEdit.expectInSave); + } + await settleConverged(trio, cfg); + await oracleSweep(trio, cfg); + + // 4. B deletes a top-level item. + expect(await callHook(trio.B, "kicadCollabTestRemoveItem", act.removeTarget)).toBe( + true, + ); + for (const peer of [trio.A, trio.C]) { + await expect + .poll(async () => await modelText(peer, cfg), { timeout: 20000, intervals: [300] }) + .not.toContain(act.removeTarget); + } + await settleConverged(trio, cfg); + await oracleSweep(trio, cfg); + + // 5. Undo isolation: remote applies ride SKIP_UNDO — the observer's undo + // stack must still be empty; the editors accumulated only their own ops. + expect(await undoDepth(trio.C), "observer undo stack").toBe(0); + expect(await undoDepth(trio.A), "A undo stack").toBeGreaterThan(0); + expect(await undoDepth(trio.B), "B undo stack").toBeGreaterThan(0); + + expect(hasAbort(testLogger), "no WASM abort").toBe(false); + await closeTrio(trio); + }); + }); +} + +// ── pcbnew segment CHANGE apply (the envelope-parse path) ──────────────────── +// A changed track rides wrapInBoardEnvelope → the `(kicad_pcb …)` envelope +// parse on the receiver. ysync 0008 "known limit 1" recorded that path as +// dying silently in the commit — this trio run shows the CHANGE path works +// under the current build (the still-fixme'd roundtrip full-board case is the +// bulk-ADD rebuild, a different path). Kept green as the regression guard. + +test.describe("drift trio — pcbnew segment edit (envelope-parse change path)", () => { + test.describe.configure({ timeout: 900000 }); + + test("a segment endpoint edit reaches the peers' editors", async ({ + context, + testLogger, + }) => { + skipFirefox(); + + const room = `drift-trio-pcbseg-${test.info().workerIndex}`; + const trio = await openTrio(context, TRIO_PCB, room); + + expect(await callHook(trio.A, "kicadCollabTestMoveEndpoint", SEG1, 2000000, 0)).toBe( + true, + ); + + // B applies the changed segment blob (envelope parse) and stays + // drift-silent; all three converge on the moved endpoint. + await expect + .poll(async () => (await drift(trio.B, TRIO_PCB))?.updated ?? [], { + timeout: 20000, + intervals: [400], + }) + .toEqual([]); + await settleConverged(trio, TRIO_PCB); + await oracleSweep(trio, TRIO_PCB); + + expect(hasAbort(testLogger), "no WASM abort").toBe(false); + await closeTrio(trio); + }); +}); diff --git a/tests/kicad/utils/trio.ts b/tests/kicad/utils/trio.ts new file mode 100644 index 0000000..5c41b34 --- /dev/null +++ b/tests/kicad/utils/trio.ts @@ -0,0 +1,435 @@ +import path from "node:path"; +import type { BrowserContext, Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +/** + * Trio harness (standalone-hardening 0008): three tabs as three users in one + * room — A seeds + edits, B edits, C only observes. Runs the PRODUCTION v2 + * items stack via collab-bundle-v2.js (window.KicadCollabV2), same plumbing as + * ysync-two-tab.spec.ts, generalized to N pages with a per-step oracle sweep: + * + * 1. per-tab drift silence — driftReport(): editor model ≡ room Y.Doc + * 2. cross-tab convergence — every tab's silent save is byte-identical + * 3. room materialization — docToFile(yToDoc(doc)) renders, identically + * + * BroadcastChannel does not cross Playwright contexts, so all tabs live in ONE + * context. Heavy editors (eeschema/pcbnew) exceed Firefox's per-content-process + * wasm budget at 2+ instances — trio specs must skip firefox and run Chromium. + */ + +type FSApi = { + mkdirTree(p: string): void; + writeFile(p: string, d: string): void; + readFile(p: string, o: { encoding: "utf8" }): string; +}; +type Mod = Record unknown>; + +export interface ToolCfg { + html: string; + ext: string; + saveFn: string; + fixture: string; + /** Module fns (beyond the v2 bridge pair + saveFn) boot must wait for. */ + fns: string[]; +} + +export const BOOT_TIMEOUT = 150000; + +/** All trio tabs open the SAME document name (it is the same file). Symbol + * `(instances (project "trio" …))` entries must match this name. */ +export const TRIO_DOC = "trio"; + +export function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean { + return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted(")); +} + +// ── Fixtures ───────────────────────────────────────────────────────────────── +// Adapted from the proven roundtrip.spec.ts fixtures: the schematic carries a +// real symbol (lib_symbols + instances — required by TestSetFieldText and the +// instance-path drift class), the board's footprint carries pads (required by +// TestSetPadSize). + +export const SCH_ROOT = "11111111-1111-1111-1111-111111111111"; +export const SYM1 = "33333333-0000-0000-0000-000000000001"; +export const WIRE1 = "22222222-0000-0000-0000-000000000001"; +export const WIRE2 = "22222222-0000-0000-0000-000000000002"; + +export const TRIO_SCH: ToolCfg = { + html: "eeschema.html", + ext: "kicad_sch", + saveFn: "kicadSaveSchematic", + fns: [ + "kicadCollabTestMoveFirst", + "kicadCollabGetPos", + "kicadCollabTestRotateItem", + "kicadCollabTestRemoveItem", + "kicadCollabTestSetFieldText", + "kicadCollabTestUndoDepth", + ], + fixture: `(kicad_sch +\t(version 20250114) +\t(generator "eeschema") +\t(generator_version "9.0") +\t(uuid "${SCH_ROOT}") +\t(paper "A4") +\t(lib_symbols +\t\t(symbol "Device:R" +\t\t\t(pin_numbers (hide yes)) +\t\t\t(pin_names (offset 0)) +\t\t\t(exclude_from_sim no) +\t\t\t(in_bom yes) +\t\t\t(on_board yes) +\t\t\t(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27)))) +\t\t\t(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27)))) +\t\t\t(symbol "R_0_1" +\t\t\t\t(rectangle (start -1.016 -2.54) (end 1.016 2.54) +\t\t\t\t\t(stroke (width 0.254) (type default)) (fill (type none))) +\t\t\t) +\t\t\t(symbol "R_1_1" +\t\t\t\t(pin passive line (at 0 3.81 270) (length 1.27) +\t\t\t\t\t(name "~" (effects (font (size 1.27 1.27)))) +\t\t\t\t\t(number "1" (effects (font (size 1.27 1.27))))) +\t\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27) +\t\t\t\t\t(name "~" (effects (font (size 1.27 1.27)))) +\t\t\t\t\t(number "2" (effects (font (size 1.27 1.27))))) +\t\t\t) +\t\t) +\t) +\t(symbol +\t\t(lib_id "Device:R") +\t\t(at 100 100 0) +\t\t(unit 1) +\t\t(exclude_from_sim no) +\t\t(in_bom yes) +\t\t(on_board yes) +\t\t(dnp no) +\t\t(uuid "${SYM1}") +\t\t(property "Reference" "R1" (at 102 99 0) (effects (font (size 1.27 1.27)) (justify left))) +\t\t(property "Value" "R" (at 102 101 0) (effects (font (size 1.27 1.27)) (justify left))) +\t\t(pin "1" (uuid "33333333-0000-0000-0000-0000000000a1")) +\t\t(pin "2" (uuid "33333333-0000-0000-0000-0000000000a2")) +\t\t(instances +\t\t\t(project "${TRIO_DOC}" +\t\t\t\t(path "/${SCH_ROOT}" (reference "R1") (unit 1)) +\t\t\t) +\t\t) +\t) +\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}")) +\t(wire (pts (xy 50.8 76.2) (xy 101.6 76.2)) (stroke (width 0) (type default)) (uuid "${WIRE2}")) +\t(sheet_instances (path "/" (page "1"))) +) +`, +}; + +export const FP1 = "66666666-0000-0000-0000-000000000001"; +export const PAD1 = "66666666-0000-0000-0000-0000000000d1"; +export const PAD2 = "66666666-0000-0000-0000-0000000000d2"; +export const FP1_TXT = "66666666-0000-0000-0000-0000000000cc"; +export const VIA1 = "77777777-0000-0000-0000-000000000001"; +export const SEG1 = "88888888-0000-0000-0000-000000000001"; +export const SEG2 = "88888888-0000-0000-0000-000000000002"; + +export const TRIO_PCB: ToolCfg = { + // pcbnew-collab.html: memory-tuned + seeds kicad_common.json so the first-run + // wizard modal never blocks boot (see pcbnew-collab.spec.ts). + html: "pcbnew-collab.html", + ext: "kicad_pcb", + saveFn: "kicadSaveBoard", + fns: [ + "kicadCollabTestMoveFirst", + "kicadCollabGetPos", + "kicadCollabTestRotateItem", + "kicadCollabTestRemoveItem", + "kicadCollabTestSetPadSize", + "kicadCollabTestMoveEndpoint", + "kicadCollabTestUndoDepth", + ], + fixture: `(kicad_pcb +\t(version 20241229) +\t(generator "pcbnew") +\t(generator_version "9.0") +\t(general (thickness 1.6)) +\t(paper "A4") +\t(layers +\t\t(0 "F.Cu" signal) +\t\t(2 "B.Cu" signal) +\t\t(37 "F.SilkS" user) +\t\t(25 "Edge.Cuts" user) +\t) +\t(setup) +\t(net 0 "") +\t(footprint "TestLib:R" +\t\t(layer "F.Cu") +\t\t(uuid "${FP1}") +\t\t(at 100 100) +\t\t(attr smd) +\t\t(property "Reference" "R1" (at 0 -4.2 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15)))) +\t\t(property "Value" "R" (at 0 4.6 0) (layer "F.Fab") (uuid "66666666-0000-0000-0000-0000000000bb") (effects (font (size 1 1) (thickness 0.15)))) +\t\t(property "Datasheet" "" (at 0 0 0) (layer "F.Fab") (hide yes) (uuid "66666666-0000-0000-0000-0000000000d3") (effects (font (size 1.27 1.27)))) +\t\t(property "Description" "" (at 0 0 0) (layer "F.Fab") (hide yes) (uuid "66666666-0000-0000-0000-0000000000d4") (effects (font (size 1.27 1.27)))) +\t\t(fp_text user "HELLO" (at 0 0 0) (layer "F.SilkS") (uuid "${FP1_TXT}") (effects (font (size 1 1) (thickness 0.15)))) +\t\t(pad "1" smd rect (at -1.27 0) (size 1.2 1) (layers "F.Cu") (uuid "${PAD1}")) +\t\t(pad "2" smd rect (at 1.27 0) (size 1.2 1) (layers "F.Cu") (uuid "${PAD2}")) +\t) +\t(via (at 80 80) (size 1.4) (drill 0.6) (layers "F.Cu" "B.Cu") (net 0) (uuid "${VIA1}")) +\t(segment (start 50.8 50.8) (end 101.6 50.8) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG1}")) +\t(segment (start 50.8 76.2) (end 101.6 76.2) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG2}")) +) +`, +}; + +export const PL_TEXT1 = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; +export const PL_RECT = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + +/** pl_editor: the small tool whose emit hook is eager (OnModify) — the green + * baseline that validates the trio plumbing itself, independent of the heavy + * editors (three pl_editor tabs even fit Firefox's wasm budget). */ +export const TRIO_PL: ToolCfg = { + html: "pl_editor.html", + ext: "kicad_wks", + saveFn: "kicadSaveDrawingSheet", + // pl_editor has no undo-depth hook — the undo oracle is heavy-tools-only. + fns: ["kicadCollabTestAddText"], + fixture: `(kicad_wks (version 20220228) (generator "pl_editor") (generator_version "9.0") + (setup (textsize 1.5 1.5)(linewidth 0.15)(textlinewidth 0.15) + (left_margin 10)(right_margin 10)(top_margin 10)(bottom_margin 10)) + (rect (uuid "${PL_RECT}") (name border) (start 0 0 ltcorner) (end 0 0 rbcorner)) + (tbtext "Title" (uuid "${PL_TEXT1}") (name title) (pos 100 20 ltcorner) (font (size 2 2))) +) +`, +}; + +// ── Boot + session plumbing (bootOpen recipe from ysync-two-tab.spec.ts) ──── + +const BUNDLE = path.resolve(__dirname, "../../apps/kicad/collab-bundle-v2.js"); + +export async function bootOpen(page: Page, cfg: ToolCfg): Promise { + await page.goto(`/kicad/${cfg.html}`); + await expect(page.locator("#canvas")).toBeVisible({ timeout: BOOT_TIMEOUT }); + await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: BOOT_TIMEOUT }); + await page.waitForFunction( + (fns) => { + const m = (window as unknown as { Module?: Mod }).Module; + return !!m && fns.every((f) => typeof m[f] === "function"); + }, + ["kicadOpenFile", "kicadCollabSnapshotItems", "kicadCollabApplyItems", cfg.saveFn, ...cfg.fns], + { timeout: BOOT_TIMEOUT }, + ); + await page.waitForFunction( + () => + !!window.wxElementRegistry && + window.wxElementRegistry + .findAll({ visible: true }) + .some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")), + null, + { timeout: BOOT_TIMEOUT }, + ); + await page.evaluate( + ({ content, ext, name }) => { + const w = window as unknown as { FS: FSApi; Module: { kicadOpenFile(p: string): unknown } }; + try { + w.FS.mkdirTree("/home/kicad/documents"); + } catch { + /* exists */ + } + const p = `/home/kicad/documents/${name}.${ext}`; + w.FS.writeFile(p, content); + w.Module.kicadOpenFile(p); + }, + { content: cfg.fixture, ext: cfg.ext, name: TRIO_DOC }, + ); + await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(new RegExp(TRIO_DOC, "i")); + await page.addScriptTag({ path: BUNDLE }); +} + +function startV2( + page: Page, + opts: { room: string; settleMs?: number; seedText?: string; editorMatchesDoc?: boolean }, +): Promise { + return page.evaluate(async (o) => { + const w = window as unknown as { + KicadCollabV2: { start: (m: unknown, win: unknown, o: unknown) => Promise }; + Module: unknown; + }; + await w.KicadCollabV2.start(w.Module, window, o); + }, opts); +} + +export interface Trio { + A: Page; + B: Page; + C: Page; + /** Sweep/settle order: seeder first, observer last. */ + tabs: ReadonlyArray; +} + +/** + * Boot three tabs on the same fixture + room. A file-seeds (the production + * first-tab path — its listener registers via seed's snapshotItems, ysync bug + * 01's regression surface); B and C open the identical file and join with + * editorMatchesDoc (the ydoc-load style baseline-only path). Returns after all + * three converge on the seeded state. + */ +export async function openTrio(context: BrowserContext, cfg: ToolCfg, room: string): Promise { + const [A, B, C] = [await context.newPage(), await context.newPage(), await context.newPage()]; + // Sequential boots: three concurrent wasm compiles thrash; the two-tab spec + // boots serially for the same reason. + await bootOpen(A, cfg); + await bootOpen(B, cfg); + await bootOpen(C, cfg); + await startV2(A, { room, seedText: cfg.fixture }); + await startV2(B, { room, editorMatchesDoc: true }); + await startV2(C, { room, editorMatchesDoc: true }); + const trio: Trio = { + A, + B, + C, + tabs: [ + ["A", A], + ["B", B], + ["C", C], + ], + }; + await settleConverged(trio, cfg); + return trio; +} + +export async function closeTrio(trio: Trio): Promise { + for (const [, page] of trio.tabs) await page.close(); +} + +// ── Per-tab probes ─────────────────────────────────────────────────────────── + +/** Silent save-to-MEMFS + read back — no onSave side effects. */ +export function modelText(page: Page, cfg: ToolCfg): Promise { + return page.evaluate( + ({ saveFn, ext }) => { + const w = window as unknown as { FS: FSApi; Module: Mod }; + const out = `/home/kicad/documents/_dump.${ext}`; + (w.Module[saveFn] as (p: string) => unknown)(out); + return w.FS.readFile(out, { encoding: "utf8" }); + }, + { saveFn: cfg.saveFn, ext: cfg.ext }, + ); +} + +/** docToFile(yToDoc(room doc)) in-page; { err } instead of throwing. */ +export function renderDoc(page: Page): Promise<{ ok?: string; err?: string }> { + return page.evaluate(() => { + const w = window as unknown as { KicadCollabV2: { renderActiveDoc(): string } }; + try { + return { ok: w.KicadCollabV2.renderActiveDoc() }; + } catch (e) { + return { err: String(e) }; + } + }); +} + +export interface DriftSummary { + added: string[]; + updated: string[]; + removed: string[]; + reordered: string[]; + layoutChanged: boolean; + layoutReordered: boolean; + metaChanged: boolean; +} + +/** Item-level drift summary via the production comparator (browser-entry-v2). */ +export function drift(page: Page, cfg: ToolCfg): Promise { + return page.evaluate( + ({ saveFn, ext }) => { + const w = window as unknown as { + KicadCollabV2: { driftReport(f: string, p: string): DriftSummary | null }; + }; + return w.KicadCollabV2.driftReport(saveFn, `/home/kicad/documents/_drift.${ext}`); + }, + { saveFn: cfg.saveFn, ext: cfg.ext }, + ); +} + +/** Invoke a Module.* test hook with plain args and return its result. */ +export function callHook(page: Page, fn: string, ...args: (string | number)[]): Promise { + return page.evaluate( + ({ fn, args }) => { + const w = window as unknown as { Module: Record unknown> }; + return w.Module[fn]!(...args); + }, + { fn, args }, + ) as Promise; +} + +export function getPos(page: Page, uuid: string): Promise { + return callHook(page, "kicadCollabGetPos", uuid); +} + +export function undoDepth(page: Page): Promise { + return callHook(page, "kicadCollabTestUndoDepth"); +} + +// ── Oracles ────────────────────────────────────────────────────────────────── + +/** First differing line between two texts, for readable failure messages. */ +function firstDiff(a: string, b: string): string { + const la = a.split("\n"); + const lb = b.split("\n"); + for (let i = 0; i < Math.max(la.length, lb.length); i++) { + if (la[i] !== lb[i]) { + return `line ${i + 1}:\n a: ${la[i] ?? ""}\n b: ${lb[i] ?? ""}`; + } + } + return ""; +} + +/** + * Poll until every tab's silent save is byte-identical. The convergence gate + * between scenario steps — bounded poll, no blind sleeps (tests/TESTING.md). + */ +export async function settleConverged(trio: Trio, cfg: ToolCfg, timeout = 30000): Promise { + await expect + .poll( + async () => { + const texts = await Promise.all(trio.tabs.map(([, p]) => modelText(p, cfg))); + return texts.every((t) => t === texts[0]); + }, + { timeout, intervals: [400] }, + ) + .toBe(true); +} + +/** + * The full oracle sweep, run at every settle point: + * 1. every tab is drift-silent at the ITEM level (added/updated/removed — + * layoutChanged/metaChanged are NOT asserted: non-item state only syncs at + * seed, and the writer may normalize preamble formatting; reordered / + * layoutReordered are not drift by definition), + * 2. all three silent saves are byte-identical, + * 3. the room doc materializes, identically, on every tab. + */ +export async function oracleSweep(trio: Trio, cfg: ToolCfg): Promise { + for (const [label, page] of trio.tabs) { + const d = await drift(page, cfg); + expect(d?.added ?? [], `${label} drift added`).toEqual([]); + expect(d?.updated ?? [], `${label} drift updated`).toEqual([]); + expect(d?.removed ?? [], `${label} drift removed`).toEqual([]); + } + + const texts = await Promise.all(trio.tabs.map(([, p]) => modelText(p, cfg))); + for (let i = 1; i < texts.length; i++) { + expect( + texts[i] === texts[0], + `model ${trio.tabs[i]![0]} diverged from A — ${firstDiff(texts[0]!, texts[i]!)}`, + ).toBe(true); + } + + const renders = await Promise.all(trio.tabs.map(([, p]) => renderDoc(p))); + for (let i = 0; i < renders.length; i++) { + expect(renders[i]!.err, `${trio.tabs[i]![0]} room doc must materialize`).toBeUndefined(); + } + for (let i = 1; i < renders.length; i++) { + expect( + renders[i]!.ok === renders[0]!.ok, + `room doc render ${trio.tabs[i]![0]} diverged from A`, + ).toBe(true); + } +} diff --git a/web/pcbjam-shared b/web/pcbjam-shared index 10ef7c1..b17af7c 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit 10ef7c1a0278970277cfb67c7b0360e3f1d03ce9 +Subproject commit b17af7c971f702d5e1ce2a753843160160797dc7