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);
|
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 { 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(
|
await expect(
|
||||||
driveProjectIntoTool(
|
driveProjectIntoTool(win, {
|
||||||
win,
|
...opts(files, async (p) => {
|
||||||
opts(files, async (p) => {
|
if (p === "bad.kicad_pcb") throw new Error("fetch exploded");
|
||||||
if (p === "bad.kicad_sym") throw new Error("fetch exploded");
|
|
||||||
return new Uint8Array([1]);
|
return new Uint8Array([1]);
|
||||||
}),
|
}),
|
||||||
),
|
targetPath: "bad.kicad_pcb",
|
||||||
|
}),
|
||||||
).rejects.toThrow("fetch exploded");
|
).rejects.toThrow("fetch exploded");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -191,20 +191,41 @@ async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<
|
||||||
: Promise.resolve(opts.files));
|
: Promise.resolve(opts.files));
|
||||||
|
|
||||||
const queue = [...perFile];
|
const queue = [...perFile];
|
||||||
|
const skipped: string[] = [];
|
||||||
const worker = async (): Promise<void> => {
|
const worker = async (): Promise<void> => {
|
||||||
for (let file = queue.shift(); file; file = queue.shift()) {
|
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);
|
stageOne(file.path, bytes);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// One rejection fails the stage (same as the serial loop did), but let the
|
// A target rejection fails the stage, but let the in-flight siblings settle
|
||||||
// in-flight siblings settle first so a failure can't leave a fetch writing
|
// first so a failure can't leave a fetch writing into MEMFS after the caller
|
||||||
// into MEMFS after the caller has moved on.
|
// has moved on.
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
Array.from({ length: Math.min(STAGE_CONCURRENCY, queue.length) }, worker),
|
Array.from({ length: Math.min(STAGE_CONCURRENCY, queue.length) }, worker),
|
||||||
);
|
);
|
||||||
const failed = results.find((r) => r.status === "rejected");
|
const failed = results.find((r) => r.status === "rejected");
|
||||||
if (failed) throw (failed as PromiseRejectedResult).reason;
|
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