sync robustness: per-file retry with backoff, file-granular progress, bounded config fetch timeout, honest error details (bad origin / storage unavailable), isAvailable no longer gated on navigator.onLine (isolated LAN reports offline while origin is reachable)

This commit is contained in:
Lumen Stage1 2026-10-06 10:12:04 -05:00
commit a0db9f7862
5 changed files with 156 additions and 14 deletions

View file

@ -48,10 +48,13 @@ describe("Stage 9 HttpTransport", () => {
vi.restoreAllMocks();
});
it("reports availability from the browser online hint without making a request", () => {
it("reports available even when the browser thinks it is offline (isolated LAN hub)", () => {
expect(transport.isAvailable()).toBe(true);
Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
expect(transport.isAvailable()).toBe(false);
// A festival box has no upstream internet: onLine is false while the
// origin is reachable. Availability must not gate the attempt — a truly
// dead network fails fast at fetch time instead.
expect(transport.isAvailable()).toBe(true);
expect(fetchImpl).not.toHaveBeenCalled();
});
@ -164,6 +167,49 @@ describe("Stage 9 pull and verifier boundary", () => {
).toBeGreaterThan(2);
});
it("retries a transient network failure mid-download and reports progress", async () => {
const keys = generateTestKeyPair();
const built = buildPackage(makeValidInput(), { signWith: keys });
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
const files = new Map<string, Uint8Array>();
files.set("/editions/lumen-2026/packages/1/manifest.json", manifestBytes);
files.set(
"/editions/lumen-2026/packages/1/signature.json",
new TextEncoder().encode(JSON.stringify(built.pkg.signature)),
);
for (const [name, file] of built.pkg.files)
files.set(`/editions/lumen-2026/packages/1/${name}`, file.canonicalBytes);
for (const asset of built.pkg.assets)
files.set(`/editions/lumen-2026/packages/1/${asset.file}`, asset.bytesContent);
let emergencyFailures = 1;
const seen: string[] = [];
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
const url = inputUrl(input);
if (url.endsWith("/latest.json")) return Promise.resolve(response(pointer()));
if (url.endsWith("/emergency.json") && emergencyFailures > 0) {
emergencyFailures -= 1;
return Promise.reject(new TypeError("wifi hiccup"));
}
const body = files.get(new URL(url).pathname);
return Promise.resolve(body ? response(body) : response("missing", 404));
});
const result = await pullCandidate(
new HttpTransport("https://festival.example/", { fetchImpl }),
"lumen-2026",
{
trustedKeys: new Map([[keys.fingerprint, publicKeyFromDerBase64(keys.publicKeyDerBase64)]]),
appVersion: "1.0.0",
supportedSchemaRange: [1],
},
{ onProgress: (progress) => seen.push(progress.label) },
);
if (!result || "skipped" in result) throw new Error("expected candidate");
expect(result.result.ok).toBe(true);
expect(emergencyFailures).toBe(0);
expect(seen).toContain("emergency.json");
});
it("does not retrieve protected files when the signature gate fails", async () => {
const keys = generateTestKeyPair();
const built = buildPackage(makeValidInput(), { signWith: keys });