pcbjam/tests/kicad/roundtrip.spec.ts
Viktor Vaczi 9c475a804e 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

523 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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[];
/**
* 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;
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 };
}
/**
* 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"],
};
// 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);
});
// 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);
});
// 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.
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);
},
);
});