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>
This commit is contained in:
parent
f94114efb8
commit
83b3418778
15 changed files with 1662 additions and 3 deletions
228
tests/kicad/pl_editor-collab.spec.ts
Normal file
228
tests/kicad/pl_editor-collab.spec.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
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/apps/frontend/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, { channel: ch, settleMs: 500 });
|
||||
}, 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue