pcbjam/tests/kicad/roundtrip.spec.ts

523 lines
21 KiB
TypeScript
Raw Normal View History

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; v2 items wire since ysync 0008
* Stage D): 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)
* kicadCollabSnapshotItems (per-item s-expr blobs, the Slot-model wire)
* RELOAD the page (fresh process-global wasm; boot.ts) open an EMPTY doc
* kicadCollabApplyItems(snapshot) (rebuild the model purely from the blobs)
* 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[];
fix(ysync): wire dialect == file dialect, uuid churn, drift noise The Y.Doc is the source of truth for the FILE, but both live wires wrote KiCad's CLIPBOARD dialect — a lossy, paste-oriented format. Every difference was permanent, unfixable drift. - pcbnew: serialize footprint blobs with CTL_FOR_BOARD, not CLIPBOARD_IO's CTL_FOR_CLIPBOARD, which emitted (version)(generator)(generator_version) inside every (footprint …). Keep (locked yes). - eeschema: aForClipboard=false — clipboard mode collapsed every symbol's (instances … (path "/sheet")) to (path ""). - Re-supply (version) at PARSE time only (withFootprintVersion): the token is invalid file content but load-bearing on decode — without it the parser starts at m_requiredVersion=0 and stamps (hide yes) on every mandatory field. - FOOTPRINT copy ctor: restore mandatory-field uuids (EDA_ITEM::operator= keeps the target's const m_Uuid, so Clone() rerolled all four). - drift: classify order-only diffs as `reordered` — y-sexpr v2 reorders legitimately; excluded from counts, report-worthiness and dedupe hashes. Migration 0017. - fpedit from eeschema: AsyncLoad()+BlockUntilLoaded() in initLibraryTree — FACE_PCB starts lazily there and never preloaded its libraries. Guards: wire-vs-file round-trip tests (pcbnew + eeschema), fpedit-from-eeschema (verified red without the fix), symedit-from-eeschema. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016p9kjdGBdcpwUSjJ3q5xg2
2026-07-20 18:29:52 +02:00
/**
* uuids that live in the FILE but are legitimately absent from the items wire,
* because they identify the document rather than an item e.g. a schematic's
* root `(uuid …)`, which travels in kdoc_meta/kdoc_layout, not kdoc_items.
* Listed explicitly per fixture so `expectWireMatchesFile` can subtract them
* without blanket-ignoring "the wire dropped an item", which is a real defect.
*/
wireOmitsUuids?: string[];
}
type Mod = {
kicadOpenFile(p: string): unknown;
kicadCollabSnapshotItems(): string;
kicadCollabApplyItems(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?.kicadCollabSnapshotItems === "function" &&
typeof m?.kicadCollabApplyItems === "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 }> {
// 13: 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.kicadCollabSnapshotItems());
await extract.close();
// v2 wire: { added: [{ sexpr, parent }] } — uuids live inside the blobs.
const blobs: string[] = JSON.parse(snap).added.map((w: { sexpr: string }) => w.sexpr);
const ids = [...new Set(blobs.flatMap((s) => [...s.matchAll(/\(uuid "([^"]+)"\)/g)].map((m) => m[1]!)))];
expect(ids.length, "fixture must contain uuid items").toBeGreaterThan(0);
// 45: 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.kicadCollabApplyItems(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;
test(determinism): deterministic waits + stableShot screenshots; drop blind sleeps/ifs/retries Make the Playwright e2e + kicad suites deterministic so screenshot flake stops tracing to timing races. - Blind page.waitForTimeout -> condition waits (expect.poll, web-first assertions, waitUntil) + readiness helpers (waitForWxApp, waitForCanvasApp). Remaining sleeps are documented interaction dwells (annotated). - Defensive "if element exists" branches -> loud asserts; label-fallback chains -> normalized clickMenuItemByText. First-run wizard for/if loops removed by seeding calculator/gerbview/pcbnew HTMLs. - Screenshots: new stableShot(page, name) settles the render in-page (canvas hash over rAF) then writes a raw PNG to test-results/ for the existing offline gate (tools/screenshots vs baseline-screenshots). Replaces toHaveScreenshot, which did inline compare + its own baselines and had decoupled the specs from the real gate. scale:'css' pinned. - retries: 0 in both configs. - Guard: tests/tools/lint-determinism.ts (npm run lint:determinism) bans blind sleeps / toHaveScreenshot / inline retries / swallowed catches in specs; documented exceptions carry a marker. Rules in tests/TESTING.md. Assertions, coverage, and renders unchanged (semantic-equivalence reviewed; captures pixel-identical modulo inherent timer/timestamp/3d-raytrace variance). Both suites green at retries:0 (e2e 340, kicad 92); ~35-61% faster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVX1pHMvRPYHdp6ZfEawrk
2026-07-07 10:50:24 +02:00
await rebuild.waitForTimeout(300); // eslint-disable-line -- deliberate best-effort poll (a hard wait would hide which items failed)
}
const regen = await saveRead(rebuild, cfg, "regen_dump");
await rebuild.close();
return { orig, regen };
}
fix(ysync): wire dialect == file dialect, uuid churn, drift noise The Y.Doc is the source of truth for the FILE, but both live wires wrote KiCad's CLIPBOARD dialect — a lossy, paste-oriented format. Every difference was permanent, unfixable drift. - pcbnew: serialize footprint blobs with CTL_FOR_BOARD, not CLIPBOARD_IO's CTL_FOR_CLIPBOARD, which emitted (version)(generator)(generator_version) inside every (footprint …). Keep (locked yes). - eeschema: aForClipboard=false — clipboard mode collapsed every symbol's (instances … (path "/sheet")) to (path ""). - Re-supply (version) at PARSE time only (withFootprintVersion): the token is invalid file content but load-bearing on decode — without it the parser starts at m_requiredVersion=0 and stamps (hide yes) on every mandatory field. - FOOTPRINT copy ctor: restore mandatory-field uuids (EDA_ITEM::operator= keeps the target's const m_Uuid, so Clone() rerolled all four). - drift: classify order-only diffs as `reordered` — y-sexpr v2 reorders legitimately; excluded from counts, report-worthiness and dedupe hashes. Migration 0017. - fpedit from eeschema: AsyncLoad()+BlockUntilLoaded() in initLibraryTree — FACE_PCB starts lazily there and never preloaded its libraries. Guards: wire-vs-file round-trip tests (pcbnew + eeschema), fpedit-from-eeschema (verified red without the fix), symedit-from-eeschema. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016p9kjdGBdcpwUSjJ3q5xg2
2026-07-20 18:29:52 +02:00
/**
* One boot: the file the tool would SAVE, and the per-item blobs it would put on
* the collab WIRE, taken from the same model at the same moment.
*/
async function fileAndWire(
context: BrowserContext,
cfg: ToolCfg,
): Promise<{ file: string; wire: string }> {
const page = await context.newPage();
await bootOpen(page, cfg, cfg.fixture, "rt");
const file = await saveRead(page, cfg, "orig_dump");
const snap = await page.evaluate(() => window.Module.kicadCollabSnapshotItems());
await page.close();
// Splice the blobs into one synthetic document so sexprDiff can index them by
// uuid. Non-footprint blobs already arrive wrapped in their own `(kicad_pcb …)`
// envelope; nesting is harmless — only uuid-bearing forms are compared, and the
// envelope's layers/version carry none.
const blobs: string[] = JSON.parse(snap).added.map((w: { sexpr: string }) => w.sexpr);
return { file, wire: `(${cfg.tool}_wire ${blobs.join("\n")})` };
}
/**
* THE invariant this suite was missing. `roundTrip` compares two FILE saves, so a
* wire blob written in a different dialect than the file writer round-trips
* perfectly and still corrupts the Y.Doc which is exactly what happened:
*
* - pcbnew serialized wire footprints through CLIPBOARD_IO (CTL_FOR_CLIPBOARD),
* which unlike CTL_FOR_BOARD emits `(version …) (generator …)
* (generator_version )` INSIDE every `(footprint )`. Every footprint of
* every board drifted, permanently.
* - eeschema serialized symbols with `aForClipboard=true`, whose
* `MakeRelativeTo(currentSheet)` collapses `(instances … (path "/<sheet>" …))`
* to `(path "")` so materializing the Y.Doc would strip every symbol's sheet
* path, reference and unit.
*
* The Y.Doc is the source of truth for the FILE, so the two must agree token for
* token. NOTE: no `ignoreTokens` here on purpose `generator_version` is exactly
* one of the tokens the pcbnew bug leaked, and ignoring it would re-mask it. (The
* fixture-vs-build version mismatch that `ignoreTokens` exists for lives on the
* ROOT form, which carries no uuid and is therefore never compared.)
*/
async function expectWireMatchesFile(context: BrowserContext, cfg: ToolCfg): Promise<void> {
const { file, wire } = await fileAndWire(context, cfg);
const raw = sexprDiff(file, wire);
const omitted = new Set(cfg.wireOmitsUuids ?? []);
const diff = {
...raw,
removed: raw.removed.filter((u) => !omitted.has(u)),
};
expect(
diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0,
`wire blobs disagree with the file save:\n${JSON.stringify(diff, null, 2)}`,
).toBe(true);
}
// ── 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"],
};
fix(ysync): wire dialect == file dialect, uuid churn, drift noise The Y.Doc is the source of truth for the FILE, but both live wires wrote KiCad's CLIPBOARD dialect — a lossy, paste-oriented format. Every difference was permanent, unfixable drift. - pcbnew: serialize footprint blobs with CTL_FOR_BOARD, not CLIPBOARD_IO's CTL_FOR_CLIPBOARD, which emitted (version)(generator)(generator_version) inside every (footprint …). Keep (locked yes). - eeschema: aForClipboard=false — clipboard mode collapsed every symbol's (instances … (path "/sheet")) to (path ""). - Re-supply (version) at PARSE time only (withFootprintVersion): the token is invalid file content but load-bearing on decode — without it the parser starts at m_requiredVersion=0 and stamps (hide yes) on every mandatory field. - FOOTPRINT copy ctor: restore mandatory-field uuids (EDA_ITEM::operator= keeps the target's const m_Uuid, so Clone() rerolled all four). - drift: classify order-only diffs as `reordered` — y-sexpr v2 reorders legitimately; excluded from counts, report-worthiness and dedupe hashes. Migration 0017. - fpedit from eeschema: AsyncLoad()+BlockUntilLoaded() in initLibraryTree — FACE_PCB starts lazily there and never preloaded its libraries. Guards: wire-vs-file round-trip tests (pcbnew + eeschema), fpedit-from-eeschema (verified red without the fix), symedit-from-eeschema. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016p9kjdGBdcpwUSjJ3q5xg2
2026-07-20 18:29:52 +02:00
// A schematic carrying a real SYMBOL with `(instances …)`. The wire-vs-file test
// below needs one: the eeschema clipboard dialect rewrote the instance path
// relative to the current sheet, collapsing it to `(path "")`. A wire-only
// fixture (SCH above) cannot catch that — only symbols have instances.
const SCH_SYM: ToolCfg = {
...SCH,
// The schematic's own root uuid is document identity (kdoc_meta/kdoc_layout),
// never an item on the items wire — so the file has it and the wire does not.
wireOmitsUuids: ["11111111-1111-1111-1111-111111111111"],
fixture: `(kicad_sch
(version 20250114)
(generator "eeschema")
(generator_version "9.0")
(uuid "11111111-1111-1111-1111-111111111111")
(paper "A4")
(lib_symbols
(symbol "Device:R"
(pin_numbers (hide yes))
(pin_names (offset 0))
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
(symbol "R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
(stroke (width 0.254) (type default)) (fill (type none)))
)
(symbol "R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27)))))
)
)
)
(symbol
(lib_id "Device:R")
(at 100 100 0)
(unit 1)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(dnp no)
(uuid "33333333-0000-0000-0000-000000000001")
(property "Reference" "R1" (at 102 99 0) (effects (font (size 1.27 1.27)) (justify left)))
(property "Value" "R" (at 102 101 0) (effects (font (size 1.27 1.27)) (justify left)))
(pin "1" (uuid "33333333-0000-0000-0000-0000000000a1"))
(pin "2" (uuid "33333333-0000-0000-0000-0000000000a2"))
(instances
(project "rt"
(path "/11111111-1111-1111-1111-111111111111" (reference "R1") (unit 1))
)
)
)
(sheet_instances (path "/" (page "1")))
)
`,
};
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"],
};
// Footprint-only board: the v2 items wire's PROVEN pcbnew apply path (bare
// footprint blob parse + children — the 0004 containment win). Kept separate
// from PCB so the fragile-envelope types (via/text/segment, see the fixme
// below) don't mask the passing footprint coverage.
const PCB_FP: ToolCfg = {
...PCB,
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))))
)
(footprint "TestLib:C"
(layer "F.Cu")
(uuid "99999999-0000-0000-0000-000000000009")
(at 50 50)
(attr smd)
(property "Reference" "C1" (at 0 -2 0) (layer "F.SilkS") (uuid "99999999-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15))))
)
)
`,
};
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);
});
// THE 0004 CONTAINMENT WIN (ysync 0008 Stage C/D): footprints round-trip via the
// items wire — the bare-footprint blob parse recreates the footprint WITH its
// children (properties, fp_text) in place, which the scalar wire never could.
test("pcbnew preserves footprint containment through a yjs round trip", async ({
context,
testLogger,
}) => {
const { orig, regen } = await roundTrip(context, PCB_FP);
const diff = sexprDiff(orig, regen, { ignoreTokens: PCB_FP.ignoreTokens });
expect(
diff.equal,
`round trip lost data:\n${JSON.stringify(diff, null, 2)}`,
).toBe(true);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
fix(ysync): wire dialect == file dialect, uuid churn, drift noise The Y.Doc is the source of truth for the FILE, but both live wires wrote KiCad's CLIPBOARD dialect — a lossy, paste-oriented format. Every difference was permanent, unfixable drift. - pcbnew: serialize footprint blobs with CTL_FOR_BOARD, not CLIPBOARD_IO's CTL_FOR_CLIPBOARD, which emitted (version)(generator)(generator_version) inside every (footprint …). Keep (locked yes). - eeschema: aForClipboard=false — clipboard mode collapsed every symbol's (instances … (path "/sheet")) to (path ""). - Re-supply (version) at PARSE time only (withFootprintVersion): the token is invalid file content but load-bearing on decode — without it the parser starts at m_requiredVersion=0 and stamps (hide yes) on every mandatory field. - FOOTPRINT copy ctor: restore mandatory-field uuids (EDA_ITEM::operator= keeps the target's const m_Uuid, so Clone() rerolled all four). - drift: classify order-only diffs as `reordered` — y-sexpr v2 reorders legitimately; excluded from counts, report-worthiness and dedupe hashes. Migration 0017. - fpedit from eeschema: AsyncLoad()+BlockUntilLoaded() in initLibraryTree — FACE_PCB starts lazily there and never preloaded its libraries. Guards: wire-vs-file round-trip tests (pcbnew + eeschema), fpedit-from-eeschema (verified red without the fix), symedit-from-eeschema. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016p9kjdGBdcpwUSjJ3q5xg2
2026-07-20 18:29:52 +02:00
});
// The wire dialect must BE the file dialect — see expectWireMatchesFile. These
// two would both have failed before the CTL_FOR_BOARD / aForClipboard=false fix:
// pcbnew on the `(version)(generator)(generator_version)` triple inside every
// footprint, eeschema on `(instances … (path ""))`.
test("pcbnew wire blobs match the file save token for token", async ({
context,
testLogger,
}) => {
await expectWireMatchesFile(context, PCB_FP);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
test("eeschema wire blobs keep the symbol's instance path", async ({
context,
testLogger,
}) => {
await expectWireMatchesFile(context, SCH_SYM);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
jspi cleanup: remove the asyncify-era residue — dead code, conditionals, pipeline scaffolding, stale prose The runtime is JSPI-only; this removes everything that still pretended otherwise. Three exhaustive sweeps (C++/JS+build+CI/tests+docs) drove the inventory; every deletion verified by grep closure + full gates. Broken-right-now fixes: - deploy-staging.yml passed the retired opt_level input — the workflow could not even start. Removed. - env.sh carried dead exports with a live -sASYNCIFY=1 inside (WASM_LDFLAGS/PTHREAD_LDFLAGS, zero consumers). Removed; the WASM_LEGACY_EXCEPTIONS rationale rewritten to the real reason. - docker/build.sh exported PCBJAM_ASYNC_BACKEND (read nowhere). Gone. Dead weight removed: - binaryen submodule (nothing builds or invokes it), wasm-opt-bench workflow + scripts/bench/, get-wasm-opt.sh, diagnostics.js (242 lines of Asyncify-API-only code), the KICAD_PIPELINE background-postprocess scaffolding (existed to parallelize the deleted wasm-opt phase; the postprocess is a seconds-long node script and now runs inline), build-monitor's dead asyncify rows, sched-context orphan build output, dead .gitignore entries, the .jspi-assets spike dir (the two wf-result research JSONs moved to docs/features/async/migration-evidence/). - bindings: fiber_park.h + its 12 embind registrations (broken-if- called under JSPI), the kicadOpenFileStart/OPEN_JOB starter route, main_stack_runner.h + 5 includes, the always-null context-sleep weak hook in nanosleep_yield.c. - shim: the backend field (installed-flag idempotency instead), noteContextWait (dead both sides), the __wxAsyncifyDump alias (+ the WasmTool fallback and string-dump normalize branch). - web: the emscripten-6-ignored mainScriptUrlOrBlob option in boot.ts (gerber-demo keeps it: it loads the deployed CDN release, which predates emscripten 6 — noted inline). Conditionals: all 'backend === jspi' checks reduced to scheduler- presence checks; races_quiescent re-keyed from Asyncify.state (vacuous) to real backlog quiescence (resumeReady/mutatorQueue — NOT _windowLive, which is the probing activation's own window by definition). Renames (identifiers only, no file renames): ASYNC_LINK_FLAGS→ JSPI_LINK_FLAGS and Makefile ASYNC_LDFLAGS→JSPI_LDFLAGS, kicadCollabFiberBusy→kicadCollabBusy (embind + web + tests), collab_common.h fiber*→apply*/coroutine naming, asyncifySignatures→ wasmTrapSignatures (lists byte-identical). Tests: the two remaining vacuous [wx-asyncify]/fiber-resume-refused asserts re-keyed to live JSPI beacons; eeschema-load's failure message no longer sends the developer to a deleted script; wait-beacons' dead families/parser deleted; lane-0 legacy-glue guards removed (lane 0 is unconstructible); the embind test.fail re-gated with the JSPI reason (plain embind invokers cannot suspend — verified still failing); lint-determinism now scans tests/jspi (166 files clean); eeschema-collab local-move gated to chromium (~50% flaky on FF even solo; pcbnew twin covers both engines). Docs: DEBUG.md rewritten as the JSPI debugging guide; build.md describes the single-phase build; docs/features/async/README.md banner-marked historical and repointed at the NEW 23-jspi-runtime.md (current architecture: export census, turnstile, libcontext ownership + refusal contract, embind call shapes, the em-pthread service-wrapper trick, exception policy, known gaps). Gates on the cleaned tree: test:e2e 725 passed / 0 failed (after the quiescence-probe fix; the 3 other reds were verified contention flakes solo-green or the documented FF gate), web 76/0, jspi 18/18 both engines, vitest 295/295 + 17/17, all lints green, live-app census clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-14 09:25:32 +02:00
// FORMER KNOWN GAP (ysync 0008 status, known limit 1): pcbnew
// track/via/zone/text APPLY rides the `(kicad_pcb …)` envelope parse, which
// was asyncify-fragile in wasm (even a verbatim SaveSelection envelope for a
// segment died silently in the commit) and lost those items on the rebuild
// side. Healthy under JSPI — this test is the pin.
tests: un-skip sweep — 26 tests revived on the JSPI build, failures re-gated with fresh evidence Empirical pass over every skip/fixme whose premise the JSPI migration could have changed. Revived (verified green): - Firefox wasm-budget guards RETIRED (drift-trio, drift-trio-fuzz, drift-trio-scenarios, ysync-two-tab, ysync-libsymbols): the JSPI build (~half the asyncify size) fits three editor tabs inside Firefox 153's per-process budget — +20 firefox collab tests. - roundtrip 'pcbnew preserves items through a yjs round trip': the asyncify-fragile envelope parse it waited on is gone — both engines. - drift-trio-scenarios S4/S4b: converge now (was KNOWN ~5-8%). - pcbnew-collab + eeschema-collab 'a local move propagates A→B'. - web eeschema-fp-selector, read-only-editor's fixme'd writer-stream test, footprint-browse-remote read path (chromium; firefox gated: FootprintEnumerate rows never appear in 60s — slow wasm tier suspected). Still broken, re-gated with re-verified reasons: - 3d-viewer raytracer engine toggle: still inert, both engines. - maximize display geometry: wxDisplay reports 0x0 in the harness. - web editor WRITE bridge (symbol/footprint × remote/spike): wedges at the New Symbol/Footprint dialog step on both engines — the web-e2e-rot 01 gap stands for writes. Verify runs: kicad+wx touched files 93 passed / 0 failed / 7 skipped (intended gates); web touched files 7 passed / 0 failed / 9 skipped. lint:determinism + lint:ci-coverage green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-13 18:43:36 +02:00
test( // re-enabled 2026-08-13: the asyncify-fragile envelope parse is gone with JSPI — passes both engines
"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);
},
);
});