From 95e0edf6a6717dee299cfd6cb1056768a99e5ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 14 Jul 2026 13:54:11 +0200 Subject: [PATCH] tasks-runner 0001 T4+T6: binding revert-marker tests + CLI contract script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T4: kicad-binding.test.ts — revertNonce → DOC_REVERTED_EVENT dispatch via a window stub (once per nonce; stale marker on open silent; observer gone after destroy). T6: tests/tools/cli-contract.ts (npm run tools:contract, skip-when-unbuilt): resave version bump + relint clean for board/schematic/ hierarchy/footprint (the CTL_FOR_LIBRARY version-header regression), exit codes 2/4/1 — the exact contract run-tools-job.ts keys off. 15 checks green. Also bumps pcbjam-shared (T3 unit tests). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HLua64PCVwkQ1hpWdaf1Gm --- tests/package.json | 3 +- tests/tools/cli-contract.ts | 148 ++++++++++++++++++ web/pcbjam-shared | 2 +- .../src/wasm/collab/kicad-binding.test.ts | 77 ++++++++- 4 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 tests/tools/cli-contract.ts diff --git a/tests/package.json b/tests/package.json index e1fc1f7..f021ee8 100644 --- a/tests/package.json +++ b/tests/package.json @@ -57,7 +57,8 @@ "3d:check:webgl": "tsx tools/screenshots/compare-dirs.ts --old 3d-regression/baseline-webgl --new 3d-regression/output/webgl --out 3d-regression/output/diff/webgl-self --floors 3d-regression/floors.json --level webgl-self --label 3d-webgl --fail-on-change", "3d:check:parity": "tsx tools/screenshots/compare-dirs.ts --old 3d-regression/baseline --new 3d-regression/output/webgl --out 3d-regression/output/diff/parity --floors 3d-regression/floors.json --level webgl-vs-native --label 3d-parity", "3d:review": "tsx tools/screenshots/compare-dirs.ts --old 3d-regression/baseline --new 3d-regression/output/webgl --out 3d-regression/output/diff/parity-review --floors 3d-regression/floors.json --level webgl-vs-native --label 3d-parity --artifacts always", - "3d:test:webgl": "playwright test e2e/3d-webgl.spec.ts" + "3d:test:webgl": "playwright test e2e/3d-webgl.spec.ts", + "tools:contract": "tsx tools/cli-contract.ts" }, "devDependencies": { "@playwright/test": "^1.40.0", diff --git a/tests/tools/cli-contract.ts b/tests/tools/cli-contract.ts new file mode 100644 index 0000000..ccb14ca --- /dev/null +++ b/tests/tools/cli-contract.ts @@ -0,0 +1,148 @@ +/** + * kicad_tools CLI contract (tasks-runner 0001 T6) — pins the exact behavior + * the backend job runner depends on (run-tools-job.ts): --resave/--lint + * semantics and the exit-code contract (0 ok / 1 lint-fail / 2 usage / + * 4 input-invalid / 5 write-failed). Only exit 4 flags a file invalid, so a + * drifting code here silently breaks the upload gate. + * + * Sibling of corpus-lint.ts: SKIPs (exit 0) when output/kicad_tools.js is + * absent; becomes a hard gate in the runner-image CI (tasks-runner 0001 R2). + * + * Run: cd tests && npm run tools:contract + */ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(here, "../.."); +const cli = path.join(repo, "output/kicad_tools.js"); + +if (!existsSync(cli)) { + console.log("cli-contract: SKIP — output/kicad_tools.js not built"); + process.exit(0); +} + +let failures = 0; + +function check(name: string, ok: boolean, detail = ""): void { + if (ok) { + console.log(`ok ${name}`); + } else { + failures++; + console.error(`FAIL ${name}${detail ? ` — ${detail}` : ""}`); + } +} + +/** Run the CLI; returns { code, stderr } (never throws on non-zero exit). */ +function run(args: string[]): { code: number; stderr: string } { + try { + execFileSync("node", [cli, ...args], { stdio: ["ignore", "ignore", "pipe"] }); + return { code: 0, stderr: "" }; + } catch (e) { + const err = e as { status?: number; stderr?: Buffer }; + return { code: err.status ?? -1, stderr: err.stderr?.toString() ?? "" }; + } +} + +function version(file: string): number { + return Number(/\(version (\d+)\)/.exec(readFileSync(file, "utf8"))?.[1] ?? 0); +} + +const tmp = mkdtempSync(path.join(tmpdir(), "cli-contract-")); +const out = (name: string) => path.join(tmp, name); + +try { + const demoPcb = path.join(repo, "tests/fixtures/demo/demo.kicad_pcb"); + const demoSch = path.join(repo, "tests/fixtures/demo/demo.kicad_sch"); + // Multi-sheet schematic from the MIT-licensed shared fixture corpus. + const hierSch = path.join( + repo, + "web/pcbjam-shared/test/fixtures/kicad", + readdirSync(path.join(repo, "web/pcbjam-shared/test/fixtures/kicad")).find( + (f) => f === "flat_hierarchy.kicad_sch", + ) ?? "flat_hierarchy.kicad_sch", + ); + const qaMod = path.join( + repo, + "kicad/qa/data/libraries/Resistor_SMD.pretty/R_0201_0603Metric_Pad0.64x0.40mm_HandSolder.kicad_mod", + ); + + // --- resave: board version bump + relint clean -------------------------- + { + const r = run(["--resave", demoPcb, out("pcb")]); + const produced = path.join(out("pcb"), "demo.kicad_pcb"); + check("resave board exits 0", r.code === 0, `exit ${r.code}`); + check( + "resave board bumps the format version", + version(produced) > version(demoPcb), + `${version(demoPcb)} → ${version(produced)}`, + ); + check("resaved board lints clean", run(["--lint", produced]).code === 0); + } + + // --- resave: schematic (single + hierarchy) ------------------------------ + { + const r = run(["--resave", demoSch, out("sch")]); + check("resave schematic exits 0", r.code === 0, `exit ${r.code}`); + const produced = readdirSync(out("sch")).filter((f) => f.endsWith(".kicad_sch")); + check("single schematic → one sheet file", produced.length === 1, `${produced.length}`); + } + if (existsSync(hierSch)) { + const r = run(["--resave", hierSch, out("hier")]); + check("resave hierarchy exits 0", r.code === 0, `exit ${r.code}`); + const produced = readdirSync(out("hier")).filter((f) => f.endsWith(".kicad_sch")); + check( + "hierarchical schematic → one file per sheet", + produced.length > 1, + `${produced.length} file(s)`, + ); + check( + "every produced sheet lints clean", + produced.every((f) => run(["--lint", path.join(out("hier"), f)]).code === 0), + ); + } + + // --- resave: footprint keeps the (version) header (CTL_FOR_LIBRARY) ------ + if (existsSync(qaMod)) { + const r = run(["--resave", qaMod, out("mod")]); + const produced = path.join(out("mod"), path.basename(qaMod)); + check("resave footprint exits 0", r.code === 0, `exit ${r.code}`); + check( + "resaved .kicad_mod carries a (version) header", + version(produced) > 20200000, + readFileSync(produced, "utf8").slice(0, 120), + ); + check("resaved footprint lints clean", run(["--lint", produced]).code === 0); + } else { + console.log("skip footprint fixtures (kicad submodule not initialized)"); + } + + // --- exit-code contract --------------------------------------------------- + { + check("usage (no args) exits 2", run(["--resave"]).code === 2); + + const garbage = out("garbage.kicad_pcb"); + writeFileSync(garbage, "not a board ("); + check("invalid input exits 4 (the upload-gate signal)", run(["--resave", garbage, out("g")]).code === 4); + check("lint of invalid input exits 1", run(["--lint", garbage]).code === 1); + + const unsupported = out("readme.txt"); + writeFileSync(unsupported, "hello"); + check("unsupported extension exits 2", run(["--resave", unsupported, out("u")]).code === 2); + } +} finally { + rmSync(tmp, { recursive: true, force: true }); +} + +console.log(failures ? `cli-contract: ${failures} FAILURE(S)` : "cli-contract: all green"); +process.exit(failures ? 1 : 0); diff --git a/web/pcbjam-shared b/web/pcbjam-shared index ed67f0f..b12cc3a 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit ed67f0f454a43c672fab8460b2d88b5a1a1ef6f9 +Subproject commit b12cc3a74dfd79787da1363f0a51349aec94ae00 diff --git a/web/standalone/src/wasm/collab/kicad-binding.test.ts b/web/standalone/src/wasm/collab/kicad-binding.test.ts index b761ead..26b5e51 100644 --- a/web/standalone/src/wasm/collab/kicad-binding.test.ts +++ b/web/standalone/src/wasm/collab/kicad-binding.test.ts @@ -14,7 +14,7 @@ import { yToDoc, type KicadItem, } from "@pcbjam/shared"; -import { bindKicadCollab, SexprVersionError, type KicadItemsBridge } from "./kicad-binding"; +import { bindKicadCollab, DOC_REVERTED_EVENT, SexprVersionError, type KicadItemsBridge } from "./kicad-binding"; /** * A fake editor implementing the v2 items bridge over an in-memory flattened @@ -442,3 +442,78 @@ describe("bindKicadCollab — read-only viewer (read-only-viewer)", () => { expect(docToFile(yToDoc(a))).toBe(docToFile(seedDoc)); }); }); + +describe("validity-revert marker → DOC_REVERTED_EVENT (kicad-validity 0001 B3)", () => { + /** Stub the browser window just enough for the binding's dispatch. */ + function withWindowSpy(): { events: CustomEvent[]; restore: () => void } { + const events: CustomEvent[] = []; + const prev = (globalThis as { window?: unknown }).window; + (globalThis as { window?: unknown }).window = { + dispatchEvent: (e: Event) => { + events.push(e as CustomEvent); + return true; + }, + }; + return { + events, + restore: () => { + (globalThis as { window?: unknown }).window = prev; + }, + }; + } + + it("dispatches once per nonce when the backend stamps a revert", () => { + const spy = withWindowSpy(); + try { + const { a, b } = pair(); + const edA = new FakeEditor(); + seedEditor(edA, FP); + bindKicadCollab(a, edA).seed(); + + // The "backend" writes the marker on the peer doc; it relays over. + const meta = b.getMap("kdoc_meta"); + b.transact(() => { + meta.set("revertNonce", "job-1"); + meta.set("revertReason", "unbalanced ("); + meta.set("revertedAt", "2026-07-14T00:00:00Z"); + }); + + expect(spy.events).toHaveLength(1); + expect(spy.events[0]!.type).toBe(DOC_REVERTED_EVENT); + expect(spy.events[0]!.detail).toMatchObject({ + reason: "unbalanced (", + at: "2026-07-14T00:00:00Z", + }); + + // Same nonce again (e.g. a reconnect replay) → silent. + b.transact(() => meta.set("revertReason", "unbalanced ( again")); + expect(spy.events).toHaveLength(1); + + // A NEW nonce → a second toast. + b.transact(() => meta.set("revertNonce", "job-2")); + expect(spy.events).toHaveLength(2); + } finally { + spy.restore(); + } + }); + + it("a nonce present BEFORE binding does not fire (stale marker on open)", () => { + const spy = withWindowSpy(); + try { + const doc = new Y.Doc(); + doc.getMap("kdoc_meta").set("revertNonce", "old-job"); + const ed = new FakeEditor(); + seedEditor(ed, FP); + const binding = bindKicadCollab(doc, ed); + binding.seed(); + expect(spy.events).toHaveLength(0); + + // …and after destroy() the observer is gone entirely. + binding.destroy(); + doc.getMap("kdoc_meta").set("revertNonce", "post-destroy"); + expect(spy.events).toHaveLength(0); + } finally { + spy.restore(); + } + }); +});