feat(pl_editor): Yjs collaborative bridge — differ/apply + generic reconciler (yjs-bridge commit 2)
Bidirectional bridge between pl_editor's DS_DATA_MODEL and a Yjs doc, two same-origin
tabs syncing over BroadcastChannel. Full architecture in features/yjs-bridge/0001-0002.
C++ (wasm layer, wasm/bindings/pl_editor_embind.cpp — public DS_DATA_MODEL API only,
zero added fork divergence beyond the OnModify hook):
- snapshot-differ ChangeSource: diff model vs last-emitted snapshot on OnModify,
emit per-item delta JSON via EM_ASM window.kicadCollab.onDelta
- kicadCollabApply(json): apply remote delta by uuid — scalars (text/segment/rect)
by field, polygon/bitmap via SetPageLayout-append blob; reseed snapshot + HardRedraw
- kicadCollabSnapshot() (seed/baseline), s_applyingRemote echo guard, and a
kicadCollabTestAddText() PoC local-edit hook
- wire format: {added:[item],changed:[item],removed:[uuid]}, item = {id,type,...fields}
JS (web/apps/frontend/src/wasm/collab/, generic + schema-agnostic):
- reconciler: uuid-keyed Y.Map of per-item Y.Map; down = onDelta→Y, up = observe→apply,
origin-tagged echo suppression; seed-once join adopts the doc authoritatively
- broadcast-transport: minimal BroadcastChannel Yjs provider (query/state catch-up)
- WasmTool wiring behind ?collab=1 (pl_editor only); gated debug logging
Tests: tests/kicad/pl_editor-collab.spec.ts — single-page C++ contract (snapshot/apply
changed+removed+added/echo-suppression) + two-tab BroadcastChannel A<->B propagation.
Reconciler+yjs bundled via esbuild (tests/collab/build.mjs, npm run build:collab).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:25:22 +02:00
|
|
|
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.
|
|
|
|
|
*
|
2026-06-10 17:37:58 +02:00
|
|
|
* The collab reconciler/transport are bundled from web/standalone/src/wasm/collab via
|
feat(pl_editor): Yjs collaborative bridge — differ/apply + generic reconciler (yjs-bridge commit 2)
Bidirectional bridge between pl_editor's DS_DATA_MODEL and a Yjs doc, two same-origin
tabs syncing over BroadcastChannel. Full architecture in features/yjs-bridge/0001-0002.
C++ (wasm layer, wasm/bindings/pl_editor_embind.cpp — public DS_DATA_MODEL API only,
zero added fork divergence beyond the OnModify hook):
- snapshot-differ ChangeSource: diff model vs last-emitted snapshot on OnModify,
emit per-item delta JSON via EM_ASM window.kicadCollab.onDelta
- kicadCollabApply(json): apply remote delta by uuid — scalars (text/segment/rect)
by field, polygon/bitmap via SetPageLayout-append blob; reseed snapshot + HardRedraw
- kicadCollabSnapshot() (seed/baseline), s_applyingRemote echo guard, and a
kicadCollabTestAddText() PoC local-edit hook
- wire format: {added:[item],changed:[item],removed:[uuid]}, item = {id,type,...fields}
JS (web/apps/frontend/src/wasm/collab/, generic + schema-agnostic):
- reconciler: uuid-keyed Y.Map of per-item Y.Map; down = onDelta→Y, up = observe→apply,
origin-tagged echo suppression; seed-once join adopts the doc authoritatively
- broadcast-transport: minimal BroadcastChannel Yjs provider (query/state catch-up)
- WasmTool wiring behind ?collab=1 (pl_editor only); gated debug logging
Tests: tests/kicad/pl_editor-collab.spec.ts — single-page C++ contract (snapshot/apply
changed+removed+added/echo-suppression) + two-tab BroadcastChannel A<->B propagation.
Reconciler+yjs bundled via esbuild (tests/collab/build.mjs, npm run build:collab).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:25:22 +02:00
|
|
|
* 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;
|
|
|
|
|
};
|
2026-06-11 10:48:02 +02:00
|
|
|
w.__collab = await w.KicadCollab.start(w.Module, window, { provider: { kind: "broadcastchannel", settleMs: 500 }, room: ch });
|
feat(pl_editor): Yjs collaborative bridge — differ/apply + generic reconciler (yjs-bridge commit 2)
Bidirectional bridge between pl_editor's DS_DATA_MODEL and a Yjs doc, two same-origin
tabs syncing over BroadcastChannel. Full architecture in features/yjs-bridge/0001-0002.
C++ (wasm layer, wasm/bindings/pl_editor_embind.cpp — public DS_DATA_MODEL API only,
zero added fork divergence beyond the OnModify hook):
- snapshot-differ ChangeSource: diff model vs last-emitted snapshot on OnModify,
emit per-item delta JSON via EM_ASM window.kicadCollab.onDelta
- kicadCollabApply(json): apply remote delta by uuid — scalars (text/segment/rect)
by field, polygon/bitmap via SetPageLayout-append blob; reseed snapshot + HardRedraw
- kicadCollabSnapshot() (seed/baseline), s_applyingRemote echo guard, and a
kicadCollabTestAddText() PoC local-edit hook
- wire format: {added:[item],changed:[item],removed:[uuid]}, item = {id,type,...fields}
JS (web/apps/frontend/src/wasm/collab/, generic + schema-agnostic):
- reconciler: uuid-keyed Y.Map of per-item Y.Map; down = onDelta→Y, up = observe→apply,
origin-tagged echo suppression; seed-once join adopts the doc authoritatively
- broadcast-transport: minimal BroadcastChannel Yjs provider (query/state catch-up)
- WasmTool wiring behind ?collab=1 (pl_editor only); gated debug logging
Tests: tests/kicad/pl_editor-collab.spec.ts — single-page C++ contract (snapshot/apply
changed+removed+added/echo-suppression) + two-tab BroadcastChannel A<->B propagation.
Reconciler+yjs bundled via esbuild (tests/collab/build.mjs, npm run build:collab).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:25:22 +02:00
|
|
|
}, 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();
|
|
|
|
|
});
|
|
|
|
|
});
|