pcbjam/tests/kicad/pcbnew-collab.spec.ts

496 lines
20 KiB
TypeScript
Raw Normal View History

import { execSync } from "node:child_process";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
/**
* pcbnew Yjs collaborative bridge (features/yjs-bridge commit 4).
*
* pcbnew reuses the same wire contract + generic JS reconciler as pl_editor/eeschema; the new
* code is the C++ adapter — a native BOARD_LISTENER trigger + post-settle snapshot-diff emit,
* and a BOARD_COMMIT apply run inside a COROUTINE (so a freshly-built item's GAL view->Add has
jspi cleanup: remove the asyncify-era residue — dead code, conditionals, pipeline scaffolding, stale prose The runtime is JSPI-only; this removes everything that still pretended otherwise. Three exhaustive sweeps (C++/JS+build+CI/tests+docs) drove the inventory; every deletion verified by grep closure + full gates. Broken-right-now fixes: - deploy-staging.yml passed the retired opt_level input — the workflow could not even start. Removed. - env.sh carried dead exports with a live -sASYNCIFY=1 inside (WASM_LDFLAGS/PTHREAD_LDFLAGS, zero consumers). Removed; the WASM_LEGACY_EXCEPTIONS rationale rewritten to the real reason. - docker/build.sh exported PCBJAM_ASYNC_BACKEND (read nowhere). Gone. Dead weight removed: - binaryen submodule (nothing builds or invokes it), wasm-opt-bench workflow + scripts/bench/, get-wasm-opt.sh, diagnostics.js (242 lines of Asyncify-API-only code), the KICAD_PIPELINE background-postprocess scaffolding (existed to parallelize the deleted wasm-opt phase; the postprocess is a seconds-long node script and now runs inline), build-monitor's dead asyncify rows, sched-context orphan build output, dead .gitignore entries, the .jspi-assets spike dir (the two wf-result research JSONs moved to docs/features/async/migration-evidence/). - bindings: fiber_park.h + its 12 embind registrations (broken-if- called under JSPI), the kicadOpenFileStart/OPEN_JOB starter route, main_stack_runner.h + 5 includes, the always-null context-sleep weak hook in nanosleep_yield.c. - shim: the backend field (installed-flag idempotency instead), noteContextWait (dead both sides), the __wxAsyncifyDump alias (+ the WasmTool fallback and string-dump normalize branch). - web: the emscripten-6-ignored mainScriptUrlOrBlob option in boot.ts (gerber-demo keeps it: it loads the deployed CDN release, which predates emscripten 6 — noted inline). Conditionals: all 'backend === jspi' checks reduced to scheduler- presence checks; races_quiescent re-keyed from Asyncify.state (vacuous) to real backlog quiescence (resumeReady/mutatorQueue — NOT _windowLive, which is the probing activation's own window by definition). Renames (identifiers only, no file renames): ASYNC_LINK_FLAGS→ JSPI_LINK_FLAGS and Makefile ASYNC_LDFLAGS→JSPI_LDFLAGS, kicadCollabFiberBusy→kicadCollabBusy (embind + web + tests), collab_common.h fiber*→apply*/coroutine naming, asyncifySignatures→ wasmTrapSignatures (lists byte-identical). Tests: the two remaining vacuous [wx-asyncify]/fiber-resume-refused asserts re-keyed to live JSPI beacons; eeschema-load's failure message no longer sends the developer to a deleted script; wait-beacons' dead families/parser deleted; lane-0 legacy-glue guards removed (lane 0 is unconstructible); the embind test.fail re-gated with the JSPI reason (plain embind invokers cannot suspend — verified still failing); lint-determinism now scans tests/jspi (166 files clean); eeschema-collab local-move gated to chromium (~50% flaky on FF even solo; pcbnew twin covers both engines). Docs: DEBUG.md rewritten as the JSPI debugging guide; build.md describes the single-phase build; docs/features/async/README.md banner-marked historical and repointed at the NEW 23-jspi-runtime.md (current architecture: export census, turnstile, libcontext ownership + refusal contract, embind call shapes, the em-pthread service-wrapper trick, exception policy, known gaps). Gates on the cleaned tree: test:e2e 725 passed / 0 failed (after the quiescence-probe fix; the 3 other reds were verified contention flakes solo-green or the documented FF gate), web 76/0, jspi 18/18 both engines, vitest 295/295 + 17/17, all lints green, live-app census clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-14 09:25:32 +02:00
* the tool-coroutine context it needs, exactly as eeschema). Coverage:
* - snapshot (read): kicadCollabSnapshot reflects items by uuid/type/position.
* - apply (single page): kicadCollabApply moves/removes/adds tracks by uuid (deferred via
* CallAfter + coroutine, so poll for the result).
* - two-tab: a real local move propagates A→B over BroadcastChannel (skipped headless — the
* harness can't PAINT; verified in the real web app).
*
* pcbnew internal units are nanometres (1 mm = 1e6 IU), unlike eeschema (1e4 IU/mm).
*/
const SEG1 = "44444444-0000-0000-0000-000000000001";
const SEG2 = "44444444-0000-0000-0000-000000000002";
// A footprint and two of its TEXT children (a silkscreen Reference field + a user fp_text), each
// with a stable uuid so the test can move them independently of the footprint — the bug 1 case.
const FP1 = "66666666-0000-0000-0000-000000000001";
const FP1_REF = "66666666-0000-0000-0000-0000000000aa"; // Reference field (PCB_FIELD, F.SilkS)
const FP1_TXT = "66666666-0000-0000-0000-0000000000cc"; // user fp_text (PCB_TEXT, F.SilkS)
const VIA1 = "77777777-0000-0000-0000-000000000001"; // a through via (PCB_VIA)
const ZONE1 = "77777777-0000-0000-0000-000000000002"; // a copper zone (ZONE)
const TEXT1 = "77777777-0000-0000-0000-000000000003"; // a board graphic text (PCB_TEXT)
const SAMPLE_PCB = `(kicad_pcb
\t(version 20241229)
\t(generator "pcbnew")
\t(generator_version "9.0")
\t(general
\t\t(thickness 1.6)
\t)
\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"
\t\t\t(at 0 -4.2 0)
\t\t\t(layer "F.SilkS")
\t\t\t(uuid "${FP1_REF}")
\t\t\t(effects (font (size 1 1) (thickness 0.15)))
\t\t)
\t\t(property "Value" "R"
\t\t\t(at 0 4.6 0)
\t\t\t(layer "F.Fab")
\t\t\t(uuid "66666666-0000-0000-0000-0000000000bb")
\t\t\t(effects (font (size 1 1) (thickness 0.15)))
\t\t)
\t\t(fp_text user "HELLO"
\t\t\t(at 0 0 0)
\t\t\t(layer "F.SilkS")
\t\t\t(uuid "${FP1_TXT}")
\t\t\t(effects (font (size 1 1) (thickness 0.15)))
\t\t)
\t)
\t(via (at 80 80) (size 1.4) (drill 0.6) (layers "F.Cu" "B.Cu") (net 0) (uuid "${VIA1}"))
\t(zone
\t\t(net 0)
\t\t(net_name "")
\t\t(layer "F.Cu")
\t\t(uuid "${ZONE1}")
\t\t(hatch edge 0.5)
\t\t(connect_pads (clearance 0))
\t\t(min_thickness 0.25)
\t\t(polygon (pts (xy 60 110) (xy 75 110) (xy 75 125) (xy 60 125)))
\t)
\t(gr_text "BOARDTEXT"
\t\t(at 90 95 0)
\t\t(layer "F.SilkS")
\t\t(uuid "${TEXT1}")
\t\t(effects (font (size 1 1) (thickness 0.15)) (justify left bottom))
\t)
\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}"))
)
`;
type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void };
type Mod = {
kicadOpenFile(p: string): unknown;
kicadCollabSnapshot(): string;
kicadCollabApply(j: string): unknown;
kicadCollabTestMoveFirst(dx: number, dy: number): string;
kicadCollabGetPos(id: string): string;
kicadCollabTestItemBlob(id: string): string;
};
function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
}
async function bootAndOpen(page: Page, name: string): Promise<void> {
// Use the seeded collab harness (pcbnew-collab.html), which skips the first-run setup
// wizard via a kicad_common.json seed — exactly like eeschema.html. The plain pcbnew.html
// deliberately keeps the wizard (pcbnew.spec.ts tests it), which would block boot here.
await page.goto("/kicad/pcbnew-collab.html");
await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
await page.waitForFunction(
() => {
const m = (window as unknown as { Module?: Mod }).Module;
return (
typeof m?.kicadOpenFile === "function" &&
typeof m?.kicadCollabSnapshot === "function" &&
typeof m?.kicadCollabApply === "function" &&
typeof m?.kicadCollabTestMoveFirst === "function"
);
},
null,
{ timeout: 90000 },
);
await page.waitForFunction(
() =>
!!window.wxElementRegistry &&
window.wxElementRegistry
.findAll({ visible: true })
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
null,
{ timeout: 90000 },
);
await page.evaluate(
({ content, name }) => {
const w = window as unknown as { FS: FS; Module: Mod };
const dir = "/home/kicad/documents";
try {
w.FS.mkdirTree(dir);
} catch {
/* exists */
}
const p = `${dir}/${name}.kicad_pcb`;
w.FS.writeFile(p, content);
w.Module.kicadOpenFile(p);
},
{ content: SAMPLE_PCB, name },
);
await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(new RegExp(name, "i"));
}
test.beforeAll(() => {
execSync("node collab/build.mjs", { cwd: path.resolve(__dirname, ".."), stdio: "inherit" });
});
test.describe("pcbnew collab bridge — single page", () => {
test("snapshot reflects board by uuid/type/position", async ({ page, testLogger }) => {
await bootAndOpen(page, "snap");
const snap = await page.evaluate(() => JSON.parse(window.Module.kicadCollabSnapshot()));
const byId = new Map<string, { type: string; x: number; y: number }>(
snap.added.map((i: { id: string; type: string; x: number; y: number }) => [i.id, i]),
);
expect(byId.has(SEG1)).toBe(true);
expect(byId.get(SEG1)!.type).toBe("PCB_TRACK");
expect(byId.get(SEG1)!.x).toBe(50_800_000); // 50.8mm × 1e6 IU (nm)
// The footprint AND its text children are emitted individually by uuid (bug 1 fix) — that's
// what lets a silkscreen text move sync without the footprint moving.
expect(byId.has(FP1), "footprint present").toBe(true);
expect(byId.get(FP1)!.type).toBe("FOOTPRINT");
expect(byId.has(FP1_REF), "footprint Reference field present").toBe(true);
expect(byId.get(FP1_REF)!.type).toBe("PCB_FIELD");
expect(byId.has(FP1_TXT), "footprint user fp_text present").toBe(true);
expect(byId.get(FP1_TXT)!.type).toBe("PCB_TEXT");
// Via/zone carry the native geometry their `added` reconstruction needs (no blob path).
expect(byId.has(VIA1), "via present").toBe(true);
expect(byId.get(VIA1)!.type).toBe("PCB_VIA");
expect((byId.get(VIA1) as { drill?: number }).drill, "via drill emitted").toBeGreaterThan(0);
expect(byId.has(ZONE1), "zone present").toBe(true);
expect(byId.get(ZONE1)!.type).toBe("ZONE");
expect(
(byId.get(ZONE1) as { poly?: number[][] }).poly?.length,
"zone outline emitted",
).toBeGreaterThanOrEqual(3);
expect(byId.has(TEXT1), "board text present").toBe(true);
expect(byId.get(TEXT1)!.type).toBe("PCB_TEXT");
expect((byId.get(TEXT1) as { text?: string }).text, "board text string emitted").toBe(
"BOARDTEXT",
);
// The text is on F.SilkS (=5). Asserting the ACTUAL layer (not just "round-trips") catches the
// GetLayer() emit bug, where every item reported layer 0 (F.Cu) and added items landed on the
// wrong copper layer on the peer.
expect((byId.get(TEXT1) as { layer?: number }).layer, "board text on F.SilkS (not stuck at 0)").toBe(5);
expect((byId.get(SEG1) as { layer?: number }).layer, "segment on F.Cu").toBe(0);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
// `added` reconstruction of a footprint, via and zone. The emit side attaches BOTH the full
// itemToJson fields AND an s-expr clipboard blob; makeItem then reconstructs a footprint from
// the bare `(footprint …)` blob, and a via/zone NATIVELY from the geometry fields (the
jspi cleanup: remove the asyncify-era residue — dead code, conditionals, pipeline scaffolding, stale prose The runtime is JSPI-only; this removes everything that still pretended otherwise. Three exhaustive sweeps (C++/JS+build+CI/tests+docs) drove the inventory; every deletion verified by grep closure + full gates. Broken-right-now fixes: - deploy-staging.yml passed the retired opt_level input — the workflow could not even start. Removed. - env.sh carried dead exports with a live -sASYNCIFY=1 inside (WASM_LDFLAGS/PTHREAD_LDFLAGS, zero consumers). Removed; the WASM_LEGACY_EXCEPTIONS rationale rewritten to the real reason. - docker/build.sh exported PCBJAM_ASYNC_BACKEND (read nowhere). Gone. Dead weight removed: - binaryen submodule (nothing builds or invokes it), wasm-opt-bench workflow + scripts/bench/, get-wasm-opt.sh, diagnostics.js (242 lines of Asyncify-API-only code), the KICAD_PIPELINE background-postprocess scaffolding (existed to parallelize the deleted wasm-opt phase; the postprocess is a seconds-long node script and now runs inline), build-monitor's dead asyncify rows, sched-context orphan build output, dead .gitignore entries, the .jspi-assets spike dir (the two wf-result research JSONs moved to docs/features/async/migration-evidence/). - bindings: fiber_park.h + its 12 embind registrations (broken-if- called under JSPI), the kicadOpenFileStart/OPEN_JOB starter route, main_stack_runner.h + 5 includes, the always-null context-sleep weak hook in nanosleep_yield.c. - shim: the backend field (installed-flag idempotency instead), noteContextWait (dead both sides), the __wxAsyncifyDump alias (+ the WasmTool fallback and string-dump normalize branch). - web: the emscripten-6-ignored mainScriptUrlOrBlob option in boot.ts (gerber-demo keeps it: it loads the deployed CDN release, which predates emscripten 6 — noted inline). Conditionals: all 'backend === jspi' checks reduced to scheduler- presence checks; races_quiescent re-keyed from Asyncify.state (vacuous) to real backlog quiescence (resumeReady/mutatorQueue — NOT _windowLive, which is the probing activation's own window by definition). Renames (identifiers only, no file renames): ASYNC_LINK_FLAGS→ JSPI_LINK_FLAGS and Makefile ASYNC_LDFLAGS→JSPI_LDFLAGS, kicadCollabFiberBusy→kicadCollabBusy (embind + web + tests), collab_common.h fiber*→apply*/coroutine naming, asyncifySignatures→ wasmTrapSignatures (lists byte-identical). Tests: the two remaining vacuous [wx-asyncify]/fiber-resume-refused asserts re-keyed to live JSPI beacons; eeschema-load's failure message no longer sends the developer to a deleted script; wait-beacons' dead families/parser deleted; lane-0 legacy-glue guards removed (lane 0 is unconstructible); the embind test.fail re-gated with the JSPI reason (plain embind invokers cannot suspend — verified still failing); lint-determinism now scans tests/jspi (166 files clean); eeschema-collab local-move gated to chromium (~50% flaky on FF even solo; pcbnew twin covers both engines). Docs: DEBUG.md rewritten as the JSPI debugging guide; build.md describes the single-phase build; docs/features/async/README.md banner-marked historical and repointed at the NEW 23-jspi-runtime.md (current architecture: export census, turnstile, libcontext ownership + refusal contract, embind call shapes, the em-pthread service-wrapper trick, exception policy, known gaps). Gates on the cleaned tree: test:e2e 725 passed / 0 failed (after the quiescence-probe fix; the 3 other reds were verified contention flakes solo-green or the documented FF gate), web 76/0, jspi 18/18 both engines, vitest 295/295 + 17/17, all lints green, live-app census clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-14 09:25:32 +02:00
// `(kicad_pcb …)` envelope parse was asyncify-fragile in wasm for those; healthy under JSPI —
// roundtrip.spec.ts pins it — the native path stays as the lean route). Round-trip each: read
// its full snapshot item + blob, delete it, re-add, confirm it returns at the same position.
for (const [label, id, type] of [
["footprint", FP1, "FOOTPRINT"],
["via", VIA1, "PCB_VIA"],
["zone", ZONE1, "ZONE"],
["board text", TEXT1, "PCB_TEXT"],
] as const) {
test(`apply adds a ${label} (footprint via blob, via/zone/text native)`, async ({ page, testLogger }) => {
await bootAndOpen(page, `add-${label}`);
// Full emit-equivalent payload: snapshot item (native geometry fields) + the clipboard blob.
const payload = await page.evaluate((i) => {
const snap = JSON.parse(window.Module.kicadCollabSnapshot());
const item = snap.added.find((it: { id: string }) => it.id === i);
return { ...item, sexpr: window.Module.kicadCollabTestItemBlob(i) };
}, id);
expect(payload.id, `${label} in snapshot`).toBe(id);
const posBefore = await page.evaluate((i) => window.Module.kicadCollabGetPos(i), id);
expect(posBefore, `${label} resolvable before`).not.toBe("");
// delete it
await page.evaluate(
(i) => window.Module.kicadCollabApply(JSON.stringify({ added: [], changed: [], removed: [i] })),
id,
);
await expect
.poll(() => page.evaluate((i) => window.Module.kicadCollabGetPos(i), id), {
timeout: 10000,
intervals: [200],
})
.toBe("");
// re-add it
await page.evaluate(
(p) => window.Module.kicadCollabApply(JSON.stringify({ added: [p], changed: [], removed: [] })),
payload,
);
await expect
.poll(() => page.evaluate((i) => window.Module.kicadCollabGetPos(i), id), {
timeout: 10000,
intervals: [200],
})
.toBe(posBefore);
// Fidelity beyond the anchor: a text's justification anchors its glyphs, so a peer that
// dropped it would render the text visibly offset even though GetPosition matches. Confirm
// the reconstructed item round-trips its type-defining fields.
const after = await page.evaluate((i) => {
const snap = JSON.parse(window.Module.kicadCollabSnapshot());
return snap.added.find((it: { id: string }) => it.id === i);
}, id);
// The item's own layer travels in the delta and is applied explicitly by makeItem — it does
// NOT pick up the peer editor's active layer. (Footprint layer = which side it sits on.)
expect(after.layer, `${label} layer preserved`).toBe(payload.layer);
if (type === "PCB_TEXT") {
expect(after.text, "text string preserved").toBe(payload.text);
expect(after.hjust, "text horizontal justification preserved").toBe(payload.hjust);
expect(after.vjust, "text vertical justification preserved").toBe(payload.vjust);
}
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
}
feat(wasm-eh): migrate the WASM build to native wasm exceptions (+ 3D viewer default-on) Replace the legacy Emscripten JS-exceptions model with native wasm-EH (legacy encoding) across the whole build, keeping Asyncify coroutines working via a from-source Binaryen --hoist-cpp-catches pre-pass. Net result: native-EH is the only build mode, the 3D viewer is on by default, and pcbnew shrinks substantially. Highlights: - Binaryen submodule everywhere + --hoist-cpp-catches integration in apply-asyncify; post-link Asyncify covers every app wasm (not just standalone test wasm). - Build deps (incl. OpenCASCADE without OCC_CONVERT_SIGNALS) and all KiCad apps with -fwasm-exceptions; emscripten_sleep added to the post-link asyncify-imports. - libcontext fiber entry wired under native exceptions; while-loop main loop + currData shim injected into all wx apps. - Native-EH collab apply fixed: DEBUG-define the embind TU + match all out-of-CMake C++ TUs' ABI flags to the core, fixing the vtable-layout skew / mis-dispatch. - 3D viewer enabled by default (real raytracer linked, not the stub). - Retire the EH-spike scaffolding; flip the asyncify-races ablation pins to shim-redundancy pins (native-EH stays clean with the legacy shims ablated). - Fix the asyncify-races quiescence check to not require Asyncify.currData==0: under the native-EH per-frame-yield top loop the main stack is asyncify-suspended every frame, so currData legitimately churns (a freed-but-not-yet-nulled buffer, not a leak). Refresh the pcbnew toolbar screenshot baseline for the new kicad. - CI: drop the obsolete binaryen_version input/env (the build uses the binaryen submodule fork's wasm-opt, not a version download); key the wasm-output cache on the binaryen submodule SHA instead. Bumps the wxwidgets + binaryen submodules to their squashed feature commits. Validated green: all 7 apps native-EH (real 3D in pcbnew); KiCad e2e 63/63 Firefox + Chromium (3D viewer renders); wx 336; coroutine 34/34 both engines; asyncify 7/7 both engines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:50:18 +02:00
// REGRESSION (native-EH vtable-slot skew, task #54): a second consecutive collab apply must also
// take effect. Root cause: the embind TU was compiled without -DDEBUG while the core TU (CMake
// Config=Debug) had it, so a DEBUG-gated virtual (EDA_ITEM::Show) occupied a vtable slot the embind
// didn't account for, shifting every later slot by one. Embind virtual calls in the apply
// (SetWidth/GetPosition/the rebaseline snapshot getters) then read the wrong slot → call_indirect
// signature-mismatch trap, swallowed by the apply coroutine's catch_all → silent loop, so the apply
// never completed. Fixed by building the embind TU with -DDEBUG in Debug builds
// (scripts/kicad/build-kicad-target.sh). RED before the fix (move times out), GREEN after.
test("native-EH: a second consecutive collab apply also takes effect", async ({ page, testLogger }) => {
await bootAndOpen(page, "apply");
const before = await page.evaluate((id) => window.Module.kicadCollabGetPos(id), SEG1);
const [bx, by] = before.split(",").map(Number);
const moveTo = async (x: number) => {
await page.evaluate(
({ id, x, by }) =>
window.Module.kicadCollabApply(
JSON.stringify({
changed: [{ id, type: "PCB_TRACK", sx: x, sy: by, ex: x + 50_800_000, ey: by, width: 200000 }],
added: [],
removed: [],
}),
),
{ id: SEG1, x, by },
);
await expect
.poll(() => page.evaluate((id) => window.Module.kicadCollabGetPos(id), SEG1), {
timeout: 10000,
intervals: [200],
})
.toBe(`${x},${by}`);
};
await moveTo(bx + 5_000_000); // 1st apply
await moveTo(bx + 8_000_000); // 2nd apply — RED before the -DDEBUG fix (apply mis-dispatches), GREEN after
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
// Apply mutates the model headless: kicadOpenFile returns false (the incomplete-project load
// skips some late steps) but the board IS built, so BOARD_COMMIT::Push takes effect. Rendering
// still needs the real app. (Same headless reality as the eeschema apply test.)
const TRACK_ID = "55555555-0000-0000-0000-000000000001";
test("apply moves/removes/adds tracks by uuid, no echo", async ({ page, testLogger }) => {
await bootAndOpen(page, "apply");
const before = await page.evaluate((id) => window.Module.kicadCollabGetPos(id), SEG1);
const [bx, by] = before.split(",").map(Number);
const nx = bx + 5_000_000; // +5mm
await page.evaluate(() => {
(window as unknown as { __echo: string[] }).__echo = [];
(window as unknown as { kicadCollab: { onDelta: (j: string) => void } }).kicadCollab = {
onDelta: (j: string) => (window as unknown as { __echo: string[] }).__echo.push(j),
};
});
// changed: reshape SEG1's endpoints (a track moves via its two endpoints, like an eeschema
// wire — the sx/sy/ex/ey form the emit side always produces). Deferred via CallAfter → poll.
await page.evaluate(
({ id, nx, by }) =>
window.Module.kicadCollabApply(
JSON.stringify({
changed: [{ id, type: "PCB_TRACK", sx: nx, sy: by, ex: nx + 50_800_000, ey: by, width: 200000 }],
added: [],
removed: [],
}),
),
{ id: SEG1, nx, by },
);
await expect
.poll(() => page.evaluate((id) => window.Module.kicadCollabGetPos(id), SEG1), {
timeout: 10000,
intervals: [200],
})
.toBe(`${nx},${by}`);
// removed: delete SEG2.
await page.evaluate(
(seg) =>
window.Module.kicadCollabApply(JSON.stringify({ changed: [], added: [], removed: [seg] })),
SEG2,
);
await expect
.poll(() => page.evaluate((id) => window.Module.kicadCollabGetPos(id), SEG2), {
timeout: 10000,
intervals: [200],
})
.toBe("");
// added: a new track reconstructs by uuid (native PCB_TRACK build — no clipboard Parse).
await page.evaluate(
(trackId) =>
window.Module.kicadCollabApply(
JSON.stringify({
changed: [],
removed: [],
added: [
{
id: trackId,
type: "PCB_TRACK",
sx: 60_000_000,
sy: 60_000_000,
ex: 90_000_000,
ey: 60_000_000,
width: 200000,
layer: 0, // F_Cu
},
],
}),
),
TRACK_ID,
);
await expect
.poll(
async () =>
(await page.evaluate(() => window.Module.kicadCollabSnapshot())).includes(TRACK_ID),
{ timeout: 10000, intervals: [250] },
)
.toBe(true);
const echoes = await page.evaluate(() => (window as unknown as { __echo: string[] }).__echo);
expect(echoes, "apply() must not echo a local onDelta").toHaveLength(0);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
// Bug 1: a footprint's silkscreen text (a child item) can be moved independently of its
// footprint. apply resolves the child by uuid (BOARD::ResolveItem sees footprint children) and
// commits a Modify — which BOARD_COMMIT::undoLevelItem rolls up to the parent footprint — then
// SetPosition. The footprint itself does NOT move. Covers both the Reference field (PCB_FIELD)
// and a user fp_text (PCB_TEXT).
test("apply moves a footprint's silkscreen text child by uuid (footprint stays put)", async ({
page,
testLogger,
}) => {
await bootAndOpen(page, "fptext");
const fpBefore = await page.evaluate((id) => window.Module.kicadCollabGetPos(id), FP1);
for (const childId of [FP1_REF, FP1_TXT]) {
const before = await page.evaluate((id) => window.Module.kicadCollabGetPos(id), childId);
expect(before, `child ${childId} resolvable`).not.toBe("");
const [cx, cy] = before.split(",").map(Number);
const ny = cy + 3_000_000; // +3mm in Y
await page.evaluate(
({ id, cx, ny }) =>
window.Module.kicadCollabApply(
JSON.stringify({ changed: [{ id, x: cx, y: ny }], added: [], removed: [] }),
),
{ id: childId, cx, ny },
);
await expect
.poll(() => page.evaluate((id) => window.Module.kicadCollabGetPos(id), childId), {
timeout: 10000,
intervals: [200],
})
.toBe(`${cx},${ny}`);
}
// The footprint origin must be unchanged — only the child text moved.
const fpAfter = await page.evaluate((id) => window.Module.kicadCollabGetPos(id), FP1);
expect(fpAfter, "footprint did not move").toBe(fpBefore);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
});
test.describe("pcbnew collab bridge — two tabs (BroadcastChannel)", () => {
// SKIP headless for the same reason as the single-page apply test (harness can't PAINT).
// Verified working in the real web app.
tests: un-skip sweep — 26 tests revived on the JSPI build, failures re-gated with fresh evidence Empirical pass over every skip/fixme whose premise the JSPI migration could have changed. Revived (verified green): - Firefox wasm-budget guards RETIRED (drift-trio, drift-trio-fuzz, drift-trio-scenarios, ysync-two-tab, ysync-libsymbols): the JSPI build (~half the asyncify size) fits three editor tabs inside Firefox 153's per-process budget — +20 firefox collab tests. - roundtrip 'pcbnew preserves items through a yjs round trip': the asyncify-fragile envelope parse it waited on is gone — both engines. - drift-trio-scenarios S4/S4b: converge now (was KNOWN ~5-8%). - pcbnew-collab + eeschema-collab 'a local move propagates A→B'. - web eeschema-fp-selector, read-only-editor's fixme'd writer-stream test, footprint-browse-remote read path (chromium; firefox gated: FootprintEnumerate rows never appear in 60s — slow wasm tier suspected). Still broken, re-gated with re-verified reasons: - 3d-viewer raytracer engine toggle: still inert, both engines. - maximize display geometry: wxDisplay reports 0x0 in the harness. - web editor WRITE bridge (symbol/footprint × remote/spike): wedges at the New Symbol/Footprint dialog step on both engines — the web-e2e-rot 01 gap stands for writes. Verify runs: kicad+wx touched files 93 passed / 0 failed / 7 skipped (intended gates); web touched files 7 passed / 0 failed / 9 skipped. lint:determinism + lint:ci-coverage green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-13 18:43:36 +02:00
// re-enabled 2026-08-13: passes on the JSPI build (both engines)
test("a local move propagates A→B", async ({ context, testLogger }) => {
const channel = `pcb-collab-e2e-${test.info().workerIndex}`;
const bundle = path.resolve(__dirname, "../apps/kicad/collab-bundle.js");
const tabA = await context.newPage();
const tabB = await context.newPage();
await bootAndOpen(tabA, "tabA");
await bootAndOpen(tabB, "tabB");
for (const p of [tabA, tabB]) await p.addScriptTag({ path: bundle });
const startCollab = (p: Page) =>
p.evaluate(async (ch) => {
const w = window as unknown as {
KicadCollab: { start: (m: unknown, win: unknown, o: unknown) => Promise<unknown> };
Module: unknown;
};
await w.KicadCollab.start(w.Module, window, { provider: { kind: "broadcastchannel", settleMs: 500 }, room: ch });
}, channel);
await startCollab(tabA);
await startCollab(tabB);
// Read the pre-move baseline BEFORE triggering the move: TestMoveFirst
// queues the commit through CallAfter + the apply coroutine, and the drain
// can land between two consecutive page.evaluate round-trips. A GetPos
// taken after the call raced that drain (~50% under CI load) and captured
// the ALREADY-MOVED position, so the not-toBe poll waited on itself.
const preSnap = await tabA.evaluate(() => JSON.parse(window.Module.kicadCollabSnapshot()));
const prePos = new Map<string, string>(
preSnap.added.map((i: { id: string; x: number; y: number }) => [i.id, `${i.x},${i.y}`]),
);
const uuid = await tabA.evaluate(() => window.Module.kicadCollabTestMoveFirst(2_000_000, 0));
expect(uuid).toMatch(/[0-9a-f-]{36}/);
const orig = prePos.get(uuid);
expect(orig, "moved item present in pre-move snapshot").toBeTruthy();
await expect
.poll(() => tabA.evaluate((id) => window.Module.kicadCollabGetPos(id), uuid), {
timeout: 15000,
intervals: [300],
})
.not.toBe(orig);
const posA = await tabA.evaluate((id) => window.Module.kicadCollabGetPos(id), uuid);
await expect
.poll(() => tabB.evaluate((id) => window.Module.kicadCollabGetPos(id), uuid), {
timeout: 15000,
intervals: [300],
})
.toBe(posA);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await tabA.close();
await tabB.close();
});
});