findings Q-2: a non-target sibling fetch failure no longer aborts the open
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9KFksoViNPYYs1ygkcAoQ
This commit is contained in:
parent
9e757c5396
commit
d069b7be80
3 changed files with 127 additions and 11 deletions
95
web/standalone/src/wasm/kicad-runner.findings-q.test.ts
Normal file
95
web/standalone/src/wasm/kicad-runner.findings-q.test.ts
Normal file
|
|
@ -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<string, Uint8Array>;
|
||||
}
|
||||
|
||||
function fakeWin(): { win: Parameters<typeof driveProjectIntoTool>[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<typeof driveProjectIntoTool>[0];
|
||||
return { win, fs };
|
||||
}
|
||||
|
||||
function opts(
|
||||
files: string[],
|
||||
fetchBytes: DriveOptions["fetchBytes"],
|
||||
extra: Partial<DriveOptions> = {},
|
||||
): 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
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(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue