From d069b7be80eb8323a1fd67153a4bf8b79ce667bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Fri, 28 Aug 2026 15:15:08 +0200 Subject: [PATCH] findings Q-2: a non-target sibling fetch failure no longer aborts the open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit syncProjectToMemfs rethrew the first rejected fetchBytes whatever file it was, so one missing/unreadable sibling (a .kicad_sch body gone, a Q-1 phantom row, a transient 5xx) took the whole board open down behind "download failed (404)". Now only the TARGET's failure rejects; siblings are logged, counted and reported through onStatus, and KiCad reports a missing sheet itself. kicad-runner.test.ts pinned the old contract with a non-target file — corrected to a target; new kicad-runner.findings-q.test gates both branches. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D9KFksoViNPYYs1ygkcAoQ --- .../src/wasm/kicad-runner.findings-q.test.ts | 95 +++++++++++++++++++ web/standalone/src/wasm/kicad-runner.test.ts | 14 +-- web/standalone/src/wasm/kicad-runner.ts | 29 +++++- 3 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 web/standalone/src/wasm/kicad-runner.findings-q.test.ts diff --git a/web/standalone/src/wasm/kicad-runner.findings-q.test.ts b/web/standalone/src/wasm/kicad-runner.findings-q.test.ts new file mode 100644 index 0000000..f25cd19 --- /dev/null +++ b/web/standalone/src/wasm/kicad-runner.findings-q.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { driveProjectIntoTool, type DriveOptions } from "./kicad-runner"; +import { memfsFilePath } from "./constants"; + +/** + * Findings Q-2 (docs/features/findings/groups/Q-…): a 404 on a NON-TARGET + * sibling must degrade, not abort the open. Today `syncProjectToMemfs` + * rethrows the first rejected fetch, so one missing `.kicad_sch` beside the + * board (or a Q-1 phantom row the listing invented) takes the whole editor + * boot down behind a "download failed (404)" that reads as corruption. + * The TARGET file failing must still reject — there is nothing to open. + */ + +interface FakeFS { + files: Map; +} + +function fakeWin(): { win: Parameters[0]; fs: FakeFS } { + const fs: FakeFS = { files: new Map() }; + const win = { + FS: { + writeFile: (path: string, data: Uint8Array) => { + fs.files.set(path, data); + }, + mkdirTree: () => {}, + readFile: () => new Uint8Array(), + analyzePath: () => ({ exists: false }), + }, + } as unknown as Parameters[0]; + return { win, fs }; +} + +function opts( + files: string[], + fetchBytes: DriveOptions["fetchBytes"], + extra: Partial = {}, +): DriveOptions { + return { + tool: "pcbnew", + slug: "proj", + files: files.map((path) => ({ path })), + fetchBytes, + log: () => {}, + onStatus: () => {}, + ...extra, + }; +} + +describe("Q-2 · sibling fetch failures during MEMFS staging", () => { + it("a 404 on a non-target sibling is logged and skipped; every other file still stages", async () => { + const { win, fs } = fakeWin(); + const files = ["board.kicad_pcb", "board.kicad_sch", "sub sheet.kicad_sch"]; + const logs: string[] = []; + // No targetPath → the run stops after staging (no open flow), like the + // sibling tests in kicad-runner.test.ts. The failing file is NOT the + // target here either way. + await driveProjectIntoTool( + win, + opts( + files, + async (p) => { + if (p === "sub sheet.kicad_sch") { + throw new Error(`download failed (404): ${p}`); + } + return new TextEncoder().encode(`body:${p}`); + }, + { log: (m) => logs.push(m) }, + ), + ); + + expect(fs.files.has(memfsFilePath("proj", "board.kicad_pcb"))).toBe(true); + expect(fs.files.has(memfsFilePath("proj", "board.kicad_sch"))).toBe(true); + expect(fs.files.has(memfsFilePath("proj", "sub sheet.kicad_sch"))).toBe(false); + expect(logs.some((l) => l.includes("sub sheet.kicad_sch") && l.includes("404"))).toBe( + true, + ); + }); + + it("the target file failing still rejects (nothing to open)", async () => { + const { win } = fakeWin(); + await expect( + driveProjectIntoTool( + win, + opts( + ["board.kicad_pcb", "board.kicad_sch"], + async (p) => { + if (p === "board.kicad_pcb") throw new Error(`download failed (404): ${p}`); + return new TextEncoder().encode(`body:${p}`); + }, + { targetPath: "board.kicad_pcb" }, + ), + ), + ).rejects.toThrow("board.kicad_pcb"); + }); +}); diff --git a/web/standalone/src/wasm/kicad-runner.test.ts b/web/standalone/src/wasm/kicad-runner.test.ts index 01a5da7..f14e8b1 100644 --- a/web/standalone/src/wasm/kicad-runner.test.ts +++ b/web/standalone/src/wasm/kicad-runner.test.ts @@ -112,18 +112,18 @@ describe("MEMFS project staging", () => { expect(seen.at(-1)?.[1]).toBe(files.length); }); - it("rejects when a fetch fails", async () => { + it("rejects when the TARGET's fetch fails (siblings: see kicad-runner.findings-q.test)", async () => { const { win } = fakeWin(); - const files = ["ok1.kicad_sym", "bad.kicad_sym", "ok2.kicad_sym"]; + const files = ["ok1.kicad_sym", "bad.kicad_pcb", "ok2.kicad_sym"]; await expect( - driveProjectIntoTool( - win, - opts(files, async (p) => { - if (p === "bad.kicad_sym") throw new Error("fetch exploded"); + driveProjectIntoTool(win, { + ...opts(files, async (p) => { + if (p === "bad.kicad_pcb") throw new Error("fetch exploded"); return new Uint8Array([1]); }), - ), + targetPath: "bad.kicad_pcb", + }), ).rejects.toThrow("fetch exploded"); }); }); diff --git a/web/standalone/src/wasm/kicad-runner.ts b/web/standalone/src/wasm/kicad-runner.ts index 2280d2e..32c03f9 100644 --- a/web/standalone/src/wasm/kicad-runner.ts +++ b/web/standalone/src/wasm/kicad-runner.ts @@ -191,20 +191,41 @@ async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise< : Promise.resolve(opts.files)); const queue = [...perFile]; + const skipped: string[] = []; const worker = async (): Promise => { for (let file = queue.shift(); file; file = queue.shift()) { - const bytes = await opts.fetchBytes(file.path); + let bytes: Uint8Array; + try { + bytes = await opts.fetchBytes(file.path); + } catch (err) { + // Findings Q-2: only the TARGET is load-bearing — without its bytes + // there is nothing to open, so that failure still rejects below. A + // sibling that cannot be fetched (a schematic whose body is gone, a + // stale listing row, a transient 5xx) must NOT abort the open of an + // unrelated board: log it, count it, and let KiCad report the missing + // sheet through its own dialog if it ever needs it. + if (file.path === opts.targetPath) throw err; + skipped.push(file.path); + opts.log(`[stage] skipped ${file.path}: ${String(err)}`); + opts.onFileProgress?.(++staged, opts.files.length); + continue; + } stageOne(file.path, bytes); } }; - // One rejection fails the stage (same as the serial loop did), but let the - // in-flight siblings settle first so a failure can't leave a fetch writing - // into MEMFS after the caller has moved on. + // A target rejection fails the stage, but let the in-flight siblings settle + // first so a failure can't leave a fetch writing into MEMFS after the caller + // has moved on. const results = await Promise.allSettled( Array.from({ length: Math.min(STAGE_CONCURRENCY, queue.length) }, worker), ); const failed = results.find((r) => r.status === "rejected"); if (failed) throw (failed as PromiseRejectedResult).reason; + if (skipped.length) { + opts.onStatus( + `${skipped.length} project file(s) could not be loaded: ${skipped.join(", ")}`, + ); + } } /**