tasks-runner 0001 T4+T6: binding revert-marker tests + CLI contract script
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HLua64PCVwkQ1hpWdaf1Gm
This commit is contained in:
parent
fb2aa2759d
commit
95e0edf6a6
4 changed files with 227 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
148
tests/tools/cli-contract.ts
Normal file
148
tests/tools/cli-contract.ts
Normal file
|
|
@ -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);
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit ed67f0f454a43c672fab8460b2d88b5a1a1ef6f9
|
||||
Subproject commit b12cc3a74dfd79787da1363f0a51349aec94ae00
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue