import { execSync } from "node:child_process"; import path from "node:path"; import type { Page } from "@playwright/test"; import { test, expect } from "./fixtures"; /** * pl_editor Yjs collaborative bridge (features/yjs-bridge commit 2). * * Two layers of coverage: * 1. single-page — the C++ bridge contract in isolation: kicadCollabSnapshot reflects * the model; kicadCollabApply mutates it by uuid (changed / removed / added scalar); * applying a remote delta does NOT echo a local onDelta (s_applyingRemote guard). * 2. two-tab — the full loop through the generic reconciler + Yjs + BroadcastChannel: * a local text insert in tab A appears in tab B, and vice-versa. * * The collab reconciler/transport are bundled from web/standalone/src/wasm/collab via * esbuild into apps/kicad/collab-bundle.js (rebuilt in beforeAll for freshness). */ const CHANNEL_BASE = "pl-collab-e2e"; // A drawing sheet with explicit uuids so both tabs load identical item identities. const U_TITLE = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; const SHEET = `(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 "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") (name border) (start 0 0 ltcorner) (end 0 0 rbcorner)) (tbtext "Title" (uuid "${U_TITLE}") (name title) (pos 100 20 ltcorner) (font (size 2 2))) ) `; type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void; readFile(p: string, o: { encoding: "utf8" }): string }; type Mod = { kicadOpenFile(p: string): unknown; kicadSaveDrawingSheet(p: string): unknown; kicadCollabSnapshot(): string; kicadCollabApply(j: string): unknown; kicadCollabTestAddText(text: string, x: number, y: number): string; }; function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean { return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted(")); } /** Bring a pl_editor tab fully up and open SHEET in its MEMFS. */ async function bootAndOpen(page: Page, name: string): Promise { await page.goto("/kicad/pl_editor.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?.kicadCollabTestAddText === "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_wks`; w.FS.writeFile(p, content); w.Module.kicadOpenFile(p); }, { content: SHEET, name }, ); await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(new RegExp(name, "i")); } /** Read the tab's current model back as text via save-to-MEMFS. */ async function modelText(page: Page): Promise { return page.evaluate(() => { const w = window as unknown as { FS: FS; Module: Mod }; const out = "/home/kicad/documents/_dump.kicad_wks"; w.Module.kicadSaveDrawingSheet(out); return w.FS.readFile(out, { encoding: "utf8" }); }); } test.beforeAll(() => { // Rebuild the collab bundle so the test always exercises the current reconciler. execSync("node collab/build.mjs", { cwd: path.resolve(__dirname, ".."), stdio: "inherit", }); }); test.describe("pl_editor collab bridge — single page (C++ contract)", () => { test("snapshot reflects model; apply changes/removes/adds by uuid; no echo", async ({ page, testLogger, }) => { await bootAndOpen(page, "single"); // snapshot: both seeded items present, with decomposed fields. const snap = await page.evaluate(() => JSON.parse(window.Module.kicadCollabSnapshot())); const ids: string[] = snap.added.map((i: { id: string }) => i.id); expect(ids).toContain(U_TITLE); const title = snap.added.find((i: { id: string }) => i.id === U_TITLE); expect(title.type).toBe("text"); expect(title.text).toBe("Title"); // Install an onDelta capture to prove apply() does NOT echo (s_applyingRemote). 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: move the title text. added (scalar): a new line. removed: the border rect. await page.evaluate((titleId) => { window.Module.kicadCollabApply( JSON.stringify({ changed: [{ id: titleId, type: "text", x: 123, y: 45 }], added: [ { id: "cccccccc-cccc-cccc-cccc-cccccccccccc", type: "segment", name: "seg", x: 5, y: 5, anchor: 3, ex: 25, ey: 5, eanchor: 3, linewidth: 0.2, }, ], removed: ["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"], }), ); }, U_TITLE); const out = await modelText(page); // title moved expect(out).toContain(`(uuid "${U_TITLE}")`); expect(out).toMatch(/\(pos 123 45/); // new segment added with its uuid expect(out).toContain(`(uuid "cccccccc-cccc-cccc-cccc-cccccccccccc")`); expect(out).toContain("(line"); // border rect removed expect(out).not.toContain("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); // echo suppression: applying a remote delta must not have produced a local delta. 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); }); }); test.describe("pl_editor collab bridge — two tabs (BroadcastChannel)", () => { test("a local text insert propagates A→B and B→A", async ({ context, testLogger }) => { const channel = `${CHANNEL_BASE}-${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"); // Load the reconciler bundle into both tabs. for (const p of [tabA, tabB]) await p.addScriptTag({ path: bundle }); // Start collab: A first (seeds the doc), then B (adopts via state query). const startCollab = async (p: Page) => p.evaluate(async (ch) => { const w = window as unknown as { KicadCollab: { start: (m: unknown, win: unknown, o: unknown) => Promise }; Module: unknown; __collab?: unknown; }; w.__collab = await w.KicadCollab.start(w.Module, window, { provider: { kind: "broadcastchannel", settleMs: 500 }, room: ch }); }, channel); await startCollab(tabA); await startCollab(tabB); // Tab A inserts text locally (the real PlaceItem model path + OnModify → emit). const uuidA = await tabA.evaluate(() => window.Module.kicadCollabTestAddText("Hello from A", 40, 40), ); expect(uuidA).toMatch(/[0-9a-f-]{36}/); // Tab B should receive it through Y.Doc + BroadcastChannel + kicadCollabApply. await expect .poll(async () => await modelText(tabB), { timeout: 15000, intervals: [300] }) .toContain("Hello from A"); expect(await modelText(tabB)).toContain(`(uuid "${uuidA}")`); // Reverse direction: B → A. const uuidB = await tabB.evaluate(() => window.Module.kicadCollabTestAddText("Hello from B", 60, 60), ); await expect .poll(async () => await modelText(tabA), { timeout: 15000, intervals: [300] }) .toContain("Hello from B"); expect(await modelText(tabA)).toContain(`(uuid "${uuidB}")`); expect(hasAbort(testLogger), "no WASM abort").toBe(false); await tabA.close(); await tabB.close(); }); });