feat: per-tool save exports + file⇄yjs round-trip tests (0004 §A, phases 2–4)

Block A save embind exports + the round-trip integration harness.

Save exports (wasm/bindings, GPL):
- pcbnew: kicadSaveBoard(path) via PCB_IO_KICAD_SEXPR::SaveBoard(GetBoard()).
- eeschema: kicadSaveSchematic(path) via SCH_IO_KICAD_SEXPR::SaveSchematicFile.
  Saves GetCurrentSheet().Last() (NOT Schematic().Root()): the wasm open-flow
  nests the opened doc under an auto-created project root, so Root()'s screen
  holds only a child-sheet symbol, not the loaded items.
  (pl_editor already had kicadSaveDrawingSheet.)

Round-trip harness (tests/kicad/roundtrip.spec.ts):
- load fixture → save (ORIG) → kicadCollabSnapshot → reload (fresh wasm) →
  open empty → kicadCollabApply → save (REGEN) → assert sexprDiff(ORIG,REGEN).equal.
- Two separate pages (extract closed before rebuild opens) so the process-global
  wasm heap frees between boots (pcbnew ~190MB). Open both sides from the same
  filename (eeschema embeds it as the root Sheetfile property). pcbnew boots the
  seeded pcbnew-collab.html (plain pcbnew.html's first-run wizard blocks boot).
- pl_editor + eeschema round trips PASS (lossless). pcbnew is test.fixme with
  tracked apply-coverage findings (footprints/zones not reconstructed; segment
  width + via size lost; fp_text→gr_text) — bridge gaps for follow-up, not test bugs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-10 18:06:33 +02:00
commit 2163c36f28
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 397 additions and 0 deletions

View file

@ -0,0 +1,322 @@
import type { BrowserContext, Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { sexprDiff } from "../../web/standalone/src/wasm/collab/sexpr-diff";
/**
* Round-trip integration tests (feature 0004): for each collab-capable, file-
* bearing tool prove the Y.Doc representation is lossless —
*
* load a fixture file → save it (ORIG, normalized through the serializer)
* → kicadCollabSnapshot (the yjs item representation)
* → RELOAD the page (fresh process-global wasm; boot.ts) → open an EMPTY doc
* → kicadCollabApply(snapshot) (rebuild the model purely from the yjs items)
* → save (REGEN)
* → assert sexprDiff(ORIG, REGEN).equal
*
* Both ORIG and REGEN come out of the SAME tool serializer, so `sexprDiff` (which
* compares uuid-keyed items as multisets of normalized children) is false-positive
* free by construction — non-item structure (setup, layers, paper) carries no uuid
* and is intentionally not compared.
*
* The empty fixture shares the full fixture's top-level identity uuid(s) (where a
* tool has one, e.g. the schematic root) so only the synced ITEMS differ between
* ORIG and REGEN.
*/
interface ToolCfg {
tool: string;
html: string;
ext: string;
/** The per-tool save embind export (README §A). */
saveFn: "kicadSaveDrawingSheet" | "kicadSaveSchematic" | "kicadSaveBoard";
/** Full document with explicit uuid items to round-trip. */
fixture: string;
/** Valid, openable document of the same type carrying NO synced items (but the
* same top-level identity uuids as `fixture`). */
empty: string;
/** Known-volatile top-level tokens to ignore (serializer nondeterminism). */
ignoreTokens?: string[];
}
type Mod = {
kicadOpenFile(p: string): unknown;
kicadCollabSnapshot(): string;
kicadCollabApply(j: string): unknown;
} & Record<string, (p: string) => unknown>;
type FS = {
mkdirTree(p: string): void;
writeFile(p: string, d: string): void;
readFile(p: string, o: { encoding: "utf8" }): string;
};
// Heavy tools (pcbnew ~190MB wasm) can take well over a minute to download +
// compile + boot, especially the SECOND instance of a run; keep this generous.
const BOOT_TIMEOUT = 150000;
function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
}
/** Boot a fresh tool page and open `content` from MEMFS. */
async function bootOpen(page: Page, cfg: ToolCfg, content: string, name: string): Promise<void> {
await page.goto(`/kicad/${cfg.html}`);
await expect(page.locator("#canvas")).toBeVisible({ timeout: BOOT_TIMEOUT });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: BOOT_TIMEOUT });
await page.waitForFunction(
(saveFn) => {
const m = (window as unknown as { Module?: Mod }).Module;
return (
typeof m?.kicadOpenFile === "function" &&
typeof m?.kicadCollabSnapshot === "function" &&
typeof m?.kicadCollabApply === "function" &&
typeof m?.[saveFn] === "function"
);
},
cfg.saveFn,
{ timeout: BOOT_TIMEOUT },
);
await page.waitForFunction(
() =>
!!window.wxElementRegistry &&
window.wxElementRegistry
.findAll({ visible: true })
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
null,
{ timeout: BOOT_TIMEOUT },
);
await page.evaluate(
({ content, name, ext }) => {
const w = window as unknown as { FS: FS; Module: Mod };
try {
w.FS.mkdirTree("/home/kicad/documents");
} catch {
/* exists */
}
const p = `/home/kicad/documents/${name}.${ext}`;
w.FS.writeFile(p, content);
w.Module.kicadOpenFile(p);
},
{ content, name, ext: cfg.ext },
);
}
/** Save the current model to MEMFS via the tool's save export and read it back. */
async function saveRead(page: Page, cfg: ToolCfg, name: string): Promise<string> {
return page.evaluate(
({ saveFn, ext, name }) => {
const w = window as unknown as { FS: FS; Module: Mod };
const out = `/home/kicad/documents/${name}.${ext}`;
w.Module[saveFn](out);
return w.FS.readFile(out, { encoding: "utf8" });
},
{ saveFn: cfg.saveFn, ext: cfg.ext, name },
);
}
/**
* The full load → yjs → reload → apply → save loop. Uses TWO separate pages — the
* extract page is CLOSED before the rebuild page opens — so the process-global wasm
* heap of the first instance is freed before the second boots (otherwise the heavy
* tools, e.g. pcbnew at ~190MB, boot the second instance under memory pressure and
* stall). `snap` crosses the boundary as a plain JS string in test scope.
*/
async function roundTrip(
context: BrowserContext,
cfg: ToolCfg,
): Promise<{ orig: string; regen: string }> {
// 1–3: load the fixture, normalize via a save, extract the yjs snapshot.
// Open from a FIXED filename for both sides: some serializers embed the opened
// file name (e.g. eeschema's root "Sheetfile" property), so ORIG and REGEN must
// share it or that artifact reads as a (spurious) diff.
const extract = await context.newPage();
await bootOpen(extract, cfg, cfg.fixture, "rt");
const orig = await saveRead(extract, cfg, "orig_dump");
const snap = await extract.evaluate(() => window.Module.kicadCollabSnapshot());
await extract.close();
const ids: string[] = JSON.parse(snap).added.map((i: { id: string }) => i.id);
expect(ids.length, "fixture must contain uuid items").toBeGreaterThan(0);
// 4–5: fresh page (fresh wasm), open an empty doc, rebuild the model from the snapshot.
const rebuild = await context.newPage();
await bootOpen(rebuild, cfg, cfg.empty, "rt");
await rebuild.evaluate((s) => window.Module.kicadCollabApply(s), snap);
// apply() runs async for eeschema/pcbnew (CallAfter + coroutine). Best-effort wait
// for the first applied item to materialize — but DON'T fail here: proceed to save
// REGEN regardless, so sexprDiff reports exactly which items failed to round-trip
// (a poll timeout would hide that signal). pl_editor settles within a tick.
const deadline = Date.now() + 15000;
for (;;) {
const probe = await saveRead(rebuild, cfg, "regen_probe");
if (probe.includes(`(uuid "${ids[0]}")`) || Date.now() >= deadline) break;
await rebuild.waitForTimeout(300);
}
const regen = await saveRead(rebuild, cfg, "regen_dump");
await rebuild.close();
return { orig, regen };
}
// ── Fixtures ────────────────────────────────────────────────────────────────
const PL: ToolCfg = {
tool: "pl_editor",
html: "pl_editor.html",
ext: "kicad_wks",
saveFn: "kicadSaveDrawingSheet",
// No root uuid in a .kicad_wks; rect + tbtext carry the uuids we compare.
fixture: `(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 "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") (name title) (pos 100 20 ltcorner) (font (size 2 2)))
)
`,
empty: `(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))
)
`,
ignoreTokens: ["generator_version"],
};
const SCH: ToolCfg = {
tool: "eeschema",
html: "eeschema.html",
ext: "kicad_sch",
saveFn: "kicadSaveSchematic",
// Root (uuid …) shared with the empty doc so only the wires differ.
fixture: `(kicad_sch
(version 20250114)
(generator "eeschema")
(generator_version "9.0")
(uuid "11111111-1111-1111-1111-111111111111")
(paper "A4")
(lib_symbols)
(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000001"))
(wire (pts (xy 50.8 76.2) (xy 101.6 76.2)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000002"))
(sheet_instances (path "/" (page "1")))
)
`,
empty: `(kicad_sch
(version 20250114)
(generator "eeschema")
(generator_version "9.0")
(uuid "11111111-1111-1111-1111-111111111111")
(paper "A4")
(lib_symbols)
(sheet_instances (path "/" (page "1")))
)
`,
ignoreTokens: ["generator_version"],
};
const PCB: ToolCfg = {
tool: "pcbnew",
// pcbnew-collab.html seeds kicad_common.json to skip the first-run setup wizard;
// plain pcbnew.html keeps the wizard, whose modal blocks boot (see pcbnew-collab.spec).
html: "pcbnew-collab.html",
ext: "kicad_pcb",
saveFn: "kicadSaveBoard",
// No root uuid in a .kicad_pcb; footprint / via / zone / gr_text / segments carry the uuids.
fixture: `(kicad_pcb
(version 20241229)
(generator "pcbnew")
(generator_version "9.0")
(general (thickness 1.6))
(paper "A4")
(layers
(0 "F.Cu" signal)
(2 "B.Cu" signal)
(37 "F.SilkS" user)
(25 "Edge.Cuts" user)
)
(setup)
(net 0 "")
(footprint "TestLib:R"
(layer "F.Cu")
(uuid "66666666-0000-0000-0000-000000000001")
(at 100 100)
(attr smd)
(property "Reference" "R1" (at 0 -4.2 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15))))
(property "Value" "R" (at 0 4.6 0) (layer "F.Fab") (uuid "66666666-0000-0000-0000-0000000000bb") (effects (font (size 1 1) (thickness 0.15))))
(fp_text user "HELLO" (at 0 0 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000cc") (effects (font (size 1 1) (thickness 0.15))))
)
(via (at 80 80) (size 1.4) (drill 0.6) (layers "F.Cu" "B.Cu") (net 0) (uuid "77777777-0000-0000-0000-000000000001"))
(gr_text "BOARDTEXT" (at 90 95 0) (layer "F.SilkS") (uuid "77777777-0000-0000-0000-000000000003") (effects (font (size 1 1) (thickness 0.15)) (justify left bottom)))
(segment (start 50.8 50.8) (end 101.6 50.8) (width 0.2) (layer "F.Cu") (net 0) (uuid "88888888-0000-0000-0000-000000000001"))
(segment (start 50.8 76.2) (end 101.6 76.2) (width 0.2) (layer "F.Cu") (net 0) (uuid "88888888-0000-0000-0000-000000000002"))
)
`,
empty: `(kicad_pcb
(version 20241229)
(generator "pcbnew")
(generator_version "9.0")
(general (thickness 1.6))
(paper "A4")
(layers
(0 "F.Cu" signal)
(2 "B.Cu" signal)
(37 "F.SilkS" user)
(25 "Edge.Cuts" user)
)
(setup)
(net 0 "")
)
`,
ignoreTokens: ["generator_version"],
};
test.beforeAll(() => {
// No reconciler bundle needed — the round trip drives only the C++ Module.*
// exports (open / snapshot / apply / save).
});
test.describe("round trip: file → yjs → file", () => {
// Each test boots the tool TWICE (extract + rebuild); the heavy tools need well
// beyond the config's 180s per-test budget.
test.describe.configure({ timeout: 420000 });
test("pl_editor preserves items through a yjs round trip", async ({ context, testLogger }) => {
const { orig, regen } = await roundTrip(context, PL);
const diff = sexprDiff(orig, regen, { ignoreTokens: PL.ignoreTokens });
expect(
diff.equal,
`round trip lost data:\n${JSON.stringify(diff, null, 2)}`,
).toBe(true);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
test("eeschema preserves items through a yjs round trip", async ({ context, testLogger }) => {
const { orig, regen } = await roundTrip(context, SCH);
const diff = sexprDiff(orig, regen, { ignoreTokens: SCH.ignoreTokens });
expect(
diff.equal,
`round trip lost data:\n${JSON.stringify(diff, null, 2)}`,
).toBe(true);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
// KNOWN BRIDGE GAPS (0004 phase-4 findings — tracked, not test bugs). The harness
// boots, round-trips, and the comparator reports precisely what pcbnew's
// kicadCollabApply does NOT yet reconstruct from a snapshot:
// - footprints + their fields are not recreated (FP removed entirely);
// - a footprint-child fp_text comes back as a board gr_text at absolute position;
// - segment `width` and via `size` are lost (default 0);
// - zones are not reconstructed.
// Un-fixme as each apply converter lands. Run with --grep-invert skipped to see the
// live diff.
test.fixme(
"pcbnew preserves items through a yjs round trip",
async ({ context, testLogger }) => {
const { orig, regen } = await roundTrip(context, PCB);
const diff = sexprDiff(orig, regen, { ignoreTokens: PCB.ignoreTokens });
expect(
diff.equal,
`round trip lost data:\n${JSON.stringify(diff, null, 2)}`,
).toBe(true);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
},
);
});

View file

@ -23,6 +23,8 @@
#include <layer_ids.h>
#include <schematic.h>
#include <sch_edit_frame.h>
#include <sch_io/kicad_sexpr/sch_io_kicad_sexpr.h>
#include <sch_sheet.h>
#include <sch_commit.h>
#include <sch_item.h>
#include <sch_line.h>
@ -682,9 +684,49 @@ std::string kicadCollabGetPos( std::string aId )
}
// Programmatically save the in-memory schematic to a .kicad_sch file, without
// driving the Save As dialog — eeschema's analogue of pl_editor's
// kicadSaveDrawingSheet. Serializes the root sheet via the same SCH_IO_KICAD_SEXPR
// writer eeschema uses, so a test can read the file back from MEMFS and assert the
// file ⇄ Y.Doc round trip (README §A; feature 0004). Single-sheet scope: the round-
// trip fixtures are flat schematics; saving the root sheet writes the whole model.
void kicadSaveSchematic( std::string path )
{
SCH_EDIT_FRAME* fr = schFrame();
if( !fr )
return;
SCHEMATIC& sch = fr->Schematic();
// Save the CURRENT sheet, not Schematic().Root(): in the wasm open flow the
// opened document is displayed as the current sheet but can sit under an
// auto-created project root, so Root()'s own screen holds only a child-sheet
// symbol (not the loaded items). GetCurrentSheet() is the screen the editor is
// actually showing — the one whose items the snapshot/round-trip care about.
SCH_SHEET* sheet = fr->GetCurrentSheet().Last();
if( !sheet )
sheet = &sch.Root();
try
{
SCH_IO_KICAD_SEXPR io;
io.SaveSchematicFile( wxString::FromUTF8( path.c_str() ), sheet, &sch );
}
catch( ... )
{
// Don't abort the wasm runtime on a save failure; the JS caller detects it
// by the file being absent / empty.
}
}
EMSCRIPTEN_BINDINGS(eeschema) {
// Programmatic file open (preferred over UI automation from the web app).
function("kicadOpenFile", &kicadOpenFile);
// Programmatic save of the in-memory schematic (round-trip tests, README §A).
function("kicadSaveSchematic", &kicadSaveSchematic);
// Yjs collaborative bridge entry points (same contract as pl_editor).
function("kicadCollabApply", &kicadCollabApply);
function("kicadCollabSnapshot", &kicadCollabSnapshot);

View file

@ -14,6 +14,7 @@
#include <emscripten/bind.h>
#include <board.h>
#include <board_commit.h>
#include <pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.h>
#include <board_item.h>
#include <footprint.h>
#include <pad.h>
@ -743,6 +744,36 @@ std::string kicadCollabSnapshot()
}
// Programmatically save the in-memory board to a .kicad_pcb file, without driving
// the Save As dialog — pcbnew's analogue of pl_editor's kicadSaveDrawingSheet.
// Serializes exactly what the editor has loaded via the same writer eeschema/pcbnew
// use, so a test can read the file back from MEMFS and assert the file ⇄ Y.Doc
// round trip (README §A; feature 0004). Uses only public PCB_IO_KICAD_SEXPR API.
void kicadSaveBoard( std::string path )
{
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return;
BOARD* board = fr->GetBoard();
if( !board )
return;
try
{
PCB_IO_KICAD_SEXPR io;
io.SaveBoard( wxString::FromUTF8( path.c_str() ), board );
}
catch( ... )
{
// Don't abort the wasm runtime on a save failure; the JS caller detects it
// by the file being absent / empty.
}
}
// Test/PoC helper: move the first top-level board item by (dx,dy) IU via a real BOARD_COMMIT,
// firing the listener — a deterministic local edit for the two-tab demo / e2e. Returns the
// moved item's uuid.
@ -885,6 +916,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
// Programmatic file open (preferred over UI automation from the web app).
function("kicadOpenFile", &kicadOpenFile);
// Programmatic save of the in-memory board (round-trip tests, README §A).
function("kicadSaveBoard", &kicadSaveBoard);
// Yjs collaborative bridge entry points (same contract as pl_editor / eeschema).
function("kicadCollabApply", &kicadCollabApply);