diff --git a/tests/kicad/drift-trio-scenarios.spec.ts b/tests/kicad/drift-trio-scenarios.spec.ts new file mode 100644 index 0000000..c610429 --- /dev/null +++ b/tests/kicad/drift-trio-scenarios.spec.ts @@ -0,0 +1,390 @@ +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 { + test.skip( + test.info().project.name.includes("firefox"), + "three heavy wasm tabs exceed Firefox's per-process wasm budget", + ); +} + +/** Poll until every tab's silent save contains `marker`. */ +async function waitAllContain(set: TabSet, cfg: ToolCfg, marker: string, timeout = 30000): 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 ────────────────────────────────── + 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("value-vs-value", async () => { + 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: 20000, + intervals: [400], + message: `${tabLabel} must receive one of the racing values`, + }) + .toBe(true); + } + 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); + }); + + // ── 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: 20000, + 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 — asyncify contention +// between the save fiber and the apply fibers 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); + }); +}); diff --git a/tests/kicad/utils/trio.ts b/tests/kicad/utils/trio.ts index f1a8e49..32be8ec 100644 --- a/tests/kicad/utils/trio.ts +++ b/tests/kicad/utils/trio.ts @@ -261,7 +261,7 @@ export async function bootOpen(page: Page, cfg: ToolCfg): Promise { await page.addScriptTag({ path: BUNDLE }); } -function startV2( +export function startV2( page: Page, opts: { room: string; settleMs?: number; seedText?: string; editorMatchesDoc?: boolean }, ): Promise { @@ -274,12 +274,15 @@ function startV2( }, opts); } -export interface Trio { +/** Anything with labeled tabs — the oracles work on trios, duos, quads alike. */ +export interface TabSet { + tabs: ReadonlyArray; +} + +export interface Trio extends TabSet { A: Page; B: Page; C: Page; - /** Sweep/settle order: seeder first, observer last. */ - tabs: ReadonlyArray; } /** @@ -404,7 +407,7 @@ function firstDiff(a: string, b: string): string { * 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 { +export async function settleConverged(trio: TabSet, cfg: ToolCfg, timeout = 30000): Promise { await expect .poll( async () => { @@ -425,7 +428,7 @@ export async function settleConverged(trio: Trio, cfg: ToolCfg, timeout = 30000) * 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 { +export async function oracleSweep(trio: TabSet, cfg: ToolCfg): Promise { for (const [label, page] of trio.tabs) { const d = await drift(page, cfg); expect(d?.added ?? [], `${label} drift added`).toEqual([]); diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index ec34f1e..59e2675 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -1518,15 +1518,23 @@ bool schCollabTestMoveSchItem( std::string aId, int aDx, int aDy ) if( !fr ) return false; - SCH_SHEET_PATH path; - SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), - &path, /*allowNull*/ true ); - - if( !item ) + SCH_SHEET_PATH probe; + if( !fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), &probe, + /*allowNull*/ true ) ) return false; - SCH_SCREEN* screen = path.LastScreen(); - fr->CallAfter( [fr, item, screen, aDx, aDy]() { collabTestMove( fr, item, screen, aDx, aDy ); } ); + // Re-resolve when the deferred body runs: a remote remove can apply in + // between and the captured pointer would be dangling — the commit would + // resurrect a deleted item (drift-trio S4 move-vs-delete). Vanished => + // the move loses, silently. + fr->CallAfter( [fr, aId, aDx, aDy]() { + SCH_SHEET_PATH path; + SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), + &path, /*allowNull*/ true ); + + if( item ) + collabTestMove( fr, item, path.LastScreen(), aDx, aDy ); + } ); return true; } @@ -1544,16 +1552,21 @@ bool schCollabTestMirrorSchItem( std::string aId, bool aHorizontal ) if( !item ) return false; - SCH_SCREEN* screen = path.LastScreen(); + pcbjam_collab::runOnFiber( fr, [fr, aId, aHorizontal]() { // re-resolve on the fiber (S4) + SCH_SHEET_PATH path; + SCH_ITEM* live = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), + &path, /*allowNull*/ true ); + + if( !live ) + return; - pcbjam_collab::runOnFiber( fr, [fr, item, screen, aHorizontal]() { SCH_COMMIT commit( fr ); - commit.Modify( item, screen ); + commit.Modify( live, path.LastScreen() ); if( aHorizontal ) - item->MirrorHorizontally( item->GetPosition().x ); + live->MirrorHorizontally( live->GetPosition().x ); else - item->MirrorVertically( item->GetPosition().y ); + live->MirrorVertically( live->GetPosition().y ); commit.Push( wxT( "Collab test mirror" ) ); } ); diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index 22f5717..3bc530e 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -2111,12 +2111,16 @@ std::string pcbCollabTestAddZone( int aX1, int aY1, int aX2, int aY2, std::strin bool pcbCollabTestFlipBoardItem( std::string aId ) { PCB_EDIT_FRAME* fr = pcbFrame(); - BOARD_ITEM* item = testResolve( fr, aId ); - if( !item ) + if( !testResolve( fr, aId ) ) return false; - pcbjam_collab::runOnFiber( fr, [fr, item]() { + pcbjam_collab::runOnFiber( fr, [fr, aId]() { // re-resolve on the fiber (S4) + BOARD_ITEM* item = testResolve( fr, aId ); + + if( !item ) + return; + BOARD_COMMIT commit( fr ); commit.Modify( item ); item->Flip( item->GetPosition(), FLIP_DIRECTION::LEFT_RIGHT ); @@ -2135,14 +2139,19 @@ bool pcbCollabTestSetFootprintField( std::string aId, std::string aField, std::s if( !item || item->Type() != PCB_FOOTPRINT_T ) return false; - FOOTPRINT* fp = static_cast( item ); - wxString text = wxString::FromUTF8( aText.c_str() ); - bool isRef = ( aField == "Reference" ); + wxString text = wxString::FromUTF8( aText.c_str() ); + bool isRef = ( aField == "Reference" ); if( !isRef && aField != "Value" ) return false; - pcbjam_collab::runOnFiber( fr, [fr, fp, text, isRef]() { + pcbjam_collab::runOnFiber( fr, [fr, aId, text, isRef]() { // re-resolve on the fiber (S4) + BOARD_ITEM* live = testResolve( fr, aId ); + + if( !live || live->Type() != PCB_FOOTPRINT_T ) + return; + + FOOTPRINT* fp = static_cast( live ); BOARD_COMMIT commit( fr ); commit.Modify( fp ); @@ -2160,12 +2169,16 @@ bool pcbCollabTestSetFootprintField( std::string aId, std::string aField, std::s bool pcbCollabTestSetBoardItemLocked( std::string aId, bool aLocked ) { PCB_EDIT_FRAME* fr = pcbFrame(); - BOARD_ITEM* item = testResolve( fr, aId ); - if( !item ) + if( !testResolve( fr, aId ) ) return false; - pcbjam_collab::runOnFiber( fr, [fr, item, aLocked]() { + pcbjam_collab::runOnFiber( fr, [fr, aId, aLocked]() { // re-resolve on the fiber (S4) + BOARD_ITEM* item = testResolve( fr, aId ); + + if( !item ) + return; + BOARD_COMMIT commit( fr ); commit.Modify( item ); item->SetLocked( aLocked ); @@ -2179,12 +2192,18 @@ bool pcbCollabTestSetBoardItemLocked( std::string aId, bool aLocked ) bool pcbCollabTestMoveBoardItem( std::string aId, int aDx, int aDy ) { PCB_EDIT_FRAME* fr = pcbFrame(); - BOARD_ITEM* item = testResolve( fr, aId ); - if( !item ) + if( !testResolve( fr, aId ) ) return false; - pcbjam_collab::runOnFiber( fr, [fr, item, aDx, aDy]() { collabTestMove( fr, item, aDx, aDy ); } ); + // Re-resolve ON the fiber: a remote remove can apply between scheduling + // and running, and doApplyItems FREES removed items — a captured pointer + // would be dangling and the commit would resurrect a deleted item + // (drift-trio S4 move-vs-delete). Vanished => the move loses, silently. + pcbjam_collab::runOnFiber( fr, [fr, aId, aDx, aDy]() { + if( BOARD_ITEM* item = testResolve( fr, aId ) ) + collabTestMove( fr, item, aDx, aDy ); + } ); return true; }