feat(drift-trio): 3-client drift harness phase A + child-wire root-lift fix
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5cAM9M6q34n5X4dbrfVvi
This commit is contained in:
parent
fc2efeff2c
commit
84cf459e16
4 changed files with 699 additions and 7 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
247
tests/kicad/drift-trio.spec.ts
Normal file
247
tests/kicad/drift-trio.spec.ts
Normal file
|
|
@ -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<string>(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<string>(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<readonly [ToolCfg, string, S1Actions]> = [
|
||||
[
|
||||
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<string, string> = {};
|
||||
for (const u of uuids) before[u] = await getPos(trio.A, u);
|
||||
const movedId = await callHook<string>(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<boolean>(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<boolean>(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<boolean>(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<boolean>(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);
|
||||
});
|
||||
});
|
||||
435
tests/kicad/utils/trio.ts
Normal file
435
tests/kicad/utils/trio.ts
Normal file
|
|
@ -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<string, (...a: never[]) => 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<void> {
|
||||
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<void> {
|
||||
return page.evaluate(async (o) => {
|
||||
const w = window as unknown as {
|
||||
KicadCollabV2: { start: (m: unknown, win: unknown, o: unknown) => Promise<void> };
|
||||
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<readonly [label: string, page: Page]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Trio> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<DriftSummary | null> {
|
||||
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<T>(page: Page, fn: string, ...args: (string | number)[]): Promise<T> {
|
||||
return page.evaluate(
|
||||
({ fn, args }) => {
|
||||
const w = window as unknown as { Module: Record<string, (...a: unknown[]) => unknown> };
|
||||
return w.Module[fn]!(...args);
|
||||
},
|
||||
{ fn, args },
|
||||
) as Promise<T>;
|
||||
}
|
||||
|
||||
export function getPos(page: Page, uuid: string): Promise<string> {
|
||||
return callHook<string>(page, "kicadCollabGetPos", uuid);
|
||||
}
|
||||
|
||||
export function undoDepth(page: Page): Promise<number> {
|
||||
return callHook<number>(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] ?? "<missing>"}\n b: ${lb[i] ?? "<missing>"}`;
|
||||
}
|
||||
}
|
||||
return "<no line diff — length/EOL mismatch>";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 10ef7c1a0278970277cfb67c7b0360e3f1d03ce9
|
||||
Subproject commit b17af7c971f702d5e1ce2a753843160160797dc7
|
||||
Loading…
Reference in a new issue