pcbjam/tests/kicad/pl_editor-collab.spec.ts
Gergő Törcsvári b69ba09ebd
feat: adopt shared wasm wire schemas; resurrect collab e2e bundle (ysync 0008 A)
- standalone wasm/collab/types.ts: the CollabItem/CollabDelta data shapes now
  come from MIT @pcbjam/shared (collab-wire zod schemas) via re-export; the
  RUNTIME adapter interface (CollabBridge) stays here. emptyDelta/isEmptyDelta
  alias the shared helpers — reconciler untouched.
- tests/collab/browser-entry.ts: fix the import path stale since the repo
  restructure (web/apps/frontend → web/standalone) — the collab bundle had been
  silently unbuildable; its opts type now tracks startCollab's real signature.
- tests/kicad/*-collab.spec.ts: update KicadCollab.start calls from the
  pre-ysync-0004 { channel, settleMs } API to { provider: { kind:
  "broadcastchannel", settleMs }, room } — the drift the broken bundle hid.

Verified: pl_editor 2/2, eeschema 2 passed, pcbnew 7 passed (skips pre-existing);
shared 47 unit tests; both typechecks clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:05:40 +02:00

228 lines
8.6 KiB
TypeScript

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<void> {
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<string> {
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<unknown> };
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();
});
});