mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/230/changes/paperclip/230 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The master Docker image contains a runner provider pack, and the pack contains the Pi agent runtime > - The Pi materializer downloads a hash-pinned Node archive from nodejs.org during the image build > - That download has one 60 s timeout and no retry, so one stalled transfer stops the full amd64 image build > - Master Docker builds failed this way two times in two days, and the failure did not identify the request > - This pull request retries only transport failures of that download and keeps every security gate > - The benefit is fewer red master image builds from short nodejs.org stalls, and a clear error when all attempts fail ## Linked Issues or Issue Description No public issue exists. This is a bug report. **What happened?** The `Docker` workflow on `master` failed in the `build-and-push (linux/amd64)` job at `Dockerfile:163` (`runner-provider-pack`). `build-provider-pack.mjs` stopped with an uncaught `DOMException [TimeoutError]: The operation was aborted due to timeout`. The arm64 job of the same commit passed. The next master build passed without a code change. - https://github.com/paperclipai/paperclip/actions/runs/40686298026 (2026-10-28) - https://github.com/paperclipai/paperclip/actions/runs/40808852599 (2026-10-29) **Steps to reproduce** 1. Run `node packages/paperclip-runner/scripts/materialize-pi-distribution.mjs /tmp/pi-out` on linux-x64. 2. Make the transfer from nodejs.org stall for more than 60 s. 3. The script stops at once with an anonymous `TimeoutError`. **Expected behavior** A short network stall does not stop the build. If all attempts fail, the error identifies the Pi Node download. **Evidence** In both failed jobs, the uncaught timeout came about 60 s after the last provider-pack output line (61.18 s → 121.6 s, and 38.72 s → 99.15 s). The Pi Node archive fetch is the only provider download with a 60 s timeout. The Cursor and Copilot downloads use 120–180 s. ## What Changed - `materialize-pi-distribution.mjs`: add `downloadPinnedPiNodeArchive`. It tries the download up to 3 times. Each attempt has a new 60 s timeout. The timeout covers the headers and the full body. The backoff is 5 s, then 10 s. - Only transport failures cause a retry: timeout, network `TypeError`, HTTP 5xx and HTTP 429. - These failures stop at once, with no retry: SHA-256 pin mismatch, the 128 MiB size bound, HTTP 4xx, a missing body, and cancellation. The SHA-256 pin still checks every byte. - The body of each rejected (non-2xx) response is canceled before the next attempt, so a 5xx/429 reply does not hold its connection. - Each failed attempt writes a warning to the build log, so a later successful retry does not hide the failure. When all attempts fail, the error is `Pinned Pi Node download failed after 3 attempts: <cause>`. - `materialize-pi-distribution.test.mjs`: add tests for stalled headers and a stalled body that end only when the per-attempt deadline signal aborts, release of rejected responses before the next attempt, exhausted attempts, HTTP 503, and the fail-closed cases. - `package.json`: add `scripts/materialize-pi-distribution.test.mjs` to the `test:typescript:prep` node:test list. Before this change, no CI lane ran this test file. ## Verification - `node --test packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs` → 13 pass, 0 fail. - Red checks: with the old materializer, the new tests fail. When the body cancel is removed, the release test fails. When the deadline signal is removed from the fetch call, the deadline tests fail. - `cd packages/paperclip-runner && node ../../scripts/ci-profile/node-test.mjs scripts/candidate-provider-pack.test.mjs scripts/materialize-pi-distribution.test.mjs` → 20 pass, 0 fail. - `node --check` passes on the changed script. - I did not run the full `pnpm -r typecheck`, `pnpm test:run`, or `pnpm build` locally, because the sandbox has no installed workspace dependencies. The changed files are `.mjs` scripts and are not in the runner `tsconfig.json`. CI runs the full checks. - A reviewer can read the `Build and push by digest` log of the next master Docker run. A transient stall now shows `Pinned Pi Node download attempt 1/3 failed: ...; retrying`. ## Risks - Low risk. The change affects only the Pi Node archive download. Two callers use it: the Docker provider-pack build (`candidate-provider-pack.mjs`) and `paperclipai runtime setup pi` (`provision-pi.mjs`). - The worst-case time before failure increases from 60 s to about 195 s (3 × 60 s + 15 s of backoff). The Docker job has more time than this. `provision-pi.mjs` has a 900 s setup deadline that it enforces through `checkCancelled`. The download calls `checkCancelled` before each attempt, so the deadline and SIGINT/SIGTERM still stop setup. A cancel can wait for one backoff of at most 10 s. - For `runtime setup pi`, a stall now prints one warning line on stderr for each failed attempt. Normal operation prints nothing new. - The retry does not weaken integrity. The same SHA-256 pin, size bound, and `redirect: "error"` apply to each attempt. A mismatch does not cause a retry. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edit, GitHub connector). It read the failed CI logs, wrote the change and tests, and ran the tests locally. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
16 KiB
JavaScript
232 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import test from "node:test";
|
|
import { assertPiNodeSystemDependencies, downloadPinnedPiNodeArchive, PI_BRACE_SECURITY_PATCH_SHA256, PI_DISTRIBUTION_PINS, materializePiDistribution, resolvePiBundledNpm, piDistributionInstallCommand, verifyLockedPiPackageGraph, writePiDistributionManifest } from "./materialize-pi-distribution.mjs";
|
|
import { verifyPiRuntimeManifest } from "../src/drivers/acpx/pi-verified-runtime.ts";
|
|
|
|
async function fixture(t) {
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-distribution-test-"));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const write = async (path, data) => { await mkdir(dirname(join(root, path)), { recursive: true }); await writeFile(join(root, path), data); };
|
|
return { root, write };
|
|
}
|
|
|
|
test("plain Node loads the source materializer without a TypeScript import resolver", () => {
|
|
const materializer = new URL("./materialize-pi-distribution.mjs", import.meta.url).href;
|
|
const result = spawnSync(process.execPath, ["--input-type=module", "-e", `
|
|
const { materializePiDistribution } = await import(${JSON.stringify(materializer)});
|
|
const assert = await import("node:assert/strict");
|
|
await assert.rejects(materializePiDistribution({ outputRoot: "relative" }), /must be absolute/);
|
|
`], { env: { PATH: process.env.PATH ?? "", LANG: "en_US.UTF-8" }, encoding: "utf8", timeout: 10_000 });
|
|
assert.ifError(result.error);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
});
|
|
|
|
test("distribution lock closes exact wrapper, SDK and upstream Pi graph with integrity", async () => {
|
|
const pkg = JSON.parse(await readFile(new URL("./pi-distribution/package.json", import.meta.url), "utf8"));
|
|
const lock = JSON.parse(await readFile(new URL("./pi-distribution/package-lock.json", import.meta.url), "utf8"));
|
|
assert.deepEqual(lock.packages[""].dependencies, pkg.dependencies);
|
|
assert.equal(pkg.overrides, undefined);
|
|
assert.equal(lock.packages["node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion"].version, "5.0.12");
|
|
const patch = await readFile(new URL("../../../patches/brace-expansion@5.0.9.patch", import.meta.url));
|
|
assert.equal(createHash("sha256").update(patch).digest("hex"), PI_BRACE_SECURITY_PATCH_SHA256);
|
|
assert.equal(lock.packages["node_modules/@earendil-works/pi-coding-agent/node_modules/undici"].version, "8.10.2");
|
|
assert.equal(PI_DISTRIBUTION_PINS.nodeVersion, "24.21.0");
|
|
assert.equal(PI_DISTRIBUTION_PINS.nodeBundledUndici, "7.29.1");
|
|
assert.deepEqual(pkg.dependencies, { "@agentclientprotocol/sdk": "0.26.0", "@earendil-works/pi-coding-agent": "1.0.0", "pi-acp": "0.0.33", zod: "3.25.76" });
|
|
for (const [path, entry] of Object.entries(lock.packages)) {
|
|
if (!path) continue;
|
|
assert.match(entry.integrity, /^sha512-[A-Za-z0-9+/]+=*$/);
|
|
assert.match(entry.resolved, /^https:\/\/registry\.npmjs\.org\//);
|
|
if (/node_modules\/@earendil-works\/pi-[^/]+$/.test(path)) assert.equal(entry.version, PI_DISTRIBUTION_PINS.runtime);
|
|
}
|
|
assert.ok(lock.packages["node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry"]);
|
|
assert.ok(piDistributionInstallCommand().includes("--ignore-scripts"));
|
|
assert.ok(piDistributionInstallCommand().includes("--include=optional"));
|
|
assert.equal(piDistributionInstallCommand()[0], "ci");
|
|
});
|
|
|
|
test("only the exact brace-expansion security correction may differ from Pi's shrinkwrap", async (t) => {
|
|
const { root, write } = await fixture(t);
|
|
const pi = "node_modules/@earendil-works/pi-coding-agent";
|
|
const old = { version: "5.0.9", resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", integrity: "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==" };
|
|
const lock = JSON.parse(await readFile(new URL("./pi-distribution/package-lock.json", import.meta.url), "utf8"));
|
|
const brace = lock.packages[`${pi}/node_modules/brace-expansion`];
|
|
const minimal = { lockfileVersion: 3, packages: { [pi]: lock.packages[pi], [`${pi}/node_modules/brace-expansion`]: brace } };
|
|
await write(`${pi}/package.json`, JSON.stringify({ version: "1.0.0" }));
|
|
await write(`${pi}/npm-shrinkwrap.json`, JSON.stringify({ version: "1.0.0", lockfileVersion: 3, packages: { "node_modules/brace-expansion": old } }));
|
|
await write(`${pi}/node_modules/brace-expansion/package.json`, JSON.stringify({ version: "5.0.9" }));
|
|
await assert.rejects(verifyLockedPiPackageGraph(root, minimal), /differs from its lock/);
|
|
await write(`${pi}/node_modules/brace-expansion/package.json`, JSON.stringify({ version: "5.0.12" }));
|
|
assert.equal(await verifyLockedPiPackageGraph(root, minimal), 2);
|
|
for (const field of ["version", "resolved", "integrity"]) {
|
|
const tampered = structuredClone(minimal); tampered.packages[`${pi}/node_modules/brace-expansion`][field] += "-changed";
|
|
await assert.rejects(verifyLockedPiPackageGraph(root, tampered), /unpin|differs|dropped or changed/);
|
|
}
|
|
});
|
|
|
|
test("package verification rejects missing, version-shifted and incomplete shrinkwrap graphs", async (t) => {
|
|
const { root, write } = await fixture(t);
|
|
const pi = "node_modules/@earendil-works/pi-coding-agent";
|
|
const nested = "node_modules/fixture-package";
|
|
const entry = { version: "1.0.0", resolved: "https://registry.npmjs.org/fixture-package/-/fixture-package-1.0.0.tgz", integrity: "sha512-YWJjZA==" };
|
|
const lock = { lockfileVersion: 3, packages: {
|
|
[pi]: { ...entry, version: "1.0.0" }, [`${pi}/${nested}`]: entry,
|
|
} };
|
|
await write(`${pi}/package.json`, JSON.stringify({ version: "1.0.0" }));
|
|
await write(`${pi}/npm-shrinkwrap.json`, JSON.stringify({ version: "1.0.0", lockfileVersion: 3, packages: { [nested]: entry } }));
|
|
await assert.rejects(verifyLockedPiPackageGraph(root, lock), /missing locked package/);
|
|
await write(`${pi}/${nested}/package.json`, JSON.stringify({ version: "1.0.0" }));
|
|
assert.equal(await verifyLockedPiPackageGraph(root, lock), 2);
|
|
await write(`${pi}/${nested}/package.json`, JSON.stringify({ version: "1.0.1" }));
|
|
await assert.rejects(verifyLockedPiPackageGraph(root, lock), /differs from its lock/);
|
|
await write(`${pi}/${nested}/package.json`, JSON.stringify({ version: "1.0.0" }));
|
|
const missing = structuredClone(lock); delete missing.packages[`${pi}/${nested}`];
|
|
await assert.rejects(verifyLockedPiPackageGraph(root, missing), /dropped or changed shrinkwrapped/);
|
|
});
|
|
|
|
test("manifest includes resources and emitted extension and rejects byte changes or links escaping the graph", async (t) => {
|
|
const { root, write } = await fixture(t);
|
|
await write("node/bin/node", "verified-node");
|
|
await write("node_modules/@earendil-works/pi-coding-agent/dist/cli.js", "verified-pi");
|
|
await write("node_modules/pi-acp/dist/index.js", "verified-wrapper");
|
|
await write("node_modules/pi-acp/dist/paperclip-runtime.js", "verified-helper");
|
|
await write("extensions/paperclip.js", "verified-extension");
|
|
await write("node_modules/native/runtime.node", Buffer.from([0, 1, 2]));
|
|
await write("node_modules/native/image.wasm", Buffer.from([4, 5, 6]));
|
|
await write("node_modules/native/theme.json", '{"name":"theme"}');
|
|
const result = await writePiDistributionManifest(root);
|
|
assert.equal(result.manifest.files.length, 8);
|
|
assert.match(result.environment.PAPERCLIP_PI_EXTENSION_PATH, /extensions\/paperclip\.js$/);
|
|
await write("node_modules/native/image.wasm", "tampered");
|
|
await assert.rejects(verifyPiRuntimeManifest(root, result.manifest), /differs from its qualified manifest/);
|
|
await symlink("/outside", join(root, "escape"));
|
|
await assert.rejects(writePiDistributionManifest(root), /escapes its pack/);
|
|
});
|
|
|
|
test("materialization refuses relative, root and existing outputs before installation", async (t) => {
|
|
const { root } = await fixture(t);
|
|
await assert.rejects(materializePiDistribution({ outputRoot: "relative" }), /must be absolute/);
|
|
await assert.rejects(materializePiDistribution({ outputRoot: "/" }), /unsafe/);
|
|
await assert.rejects(materializePiDistribution({ outputRoot: root }), /already exists/);
|
|
});
|
|
|
|
test("Node dependency inspection rejects Homebrew and non-system Linux libraries", () => {
|
|
assert.throws(() => assertPiNodeSystemDependencies("node:\n\t@rpath/libnode.147.dylib (version 0)\n", "darwin"), /unbundled/);
|
|
assert.doesNotThrow(() => assertPiNodeSystemDependencies("node:\n\t/usr/lib/libSystem.B.dylib (version 0)\n", "darwin"));
|
|
assert.throws(() => assertPiNodeSystemDependencies("libnode.so => /opt/lib/libnode.so (0x000)\n", "linux"), /unbundled/);
|
|
assert.doesNotThrow(() => assertPiNodeSystemDependencies("linux-vdso.so.1 (0x000)\nlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000)\n/lib64/ld-linux-x86-64.so.2 (0x000)\n", "linux"));
|
|
});
|
|
|
|
|
|
test("setup selects the exact archive npm and rejects wrong versions or linked entrypoints", async (t) => {
|
|
const fixtureRoot = await fixture(t);
|
|
const root = await realpath(fixtureRoot.root), write = fixtureRoot.write;
|
|
const prefix = "lib/node_modules/npm/";
|
|
await write(prefix + "package.json", JSON.stringify({ name: "npm", version: "11.19.0" }));
|
|
await write(prefix + "bin/npm-cli.js", "// pinned archive fixture");
|
|
assert.equal(await resolvePiBundledNpm(root), join(root, prefix, "bin/npm-cli.js"));
|
|
await write(prefix + "package.json", JSON.stringify({ name: "npm", version: "10.9.7" }));
|
|
await assert.rejects(resolvePiBundledNpm(root), /unexpected npm version/);
|
|
await write(prefix + "package.json", JSON.stringify({ name: "npm", version: "11.19.0" }));
|
|
await rm(join(root, prefix, "bin/npm-cli.js"));
|
|
await symlink(join(root, prefix, "package.json"), join(root, prefix, "bin/npm-cli.js"));
|
|
await assert.rejects(resolvePiBundledNpm(root), /invalid archive entry/);
|
|
});
|
|
|
|
test("Node archive download retries transport stalls and records each failed attempt", async () => {
|
|
const archive = Buffer.from("pinned node archive");
|
|
const archiveSha256 = createHash("sha256").update(archive).digest("hex");
|
|
const stalledBody = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([1])); controller.error(new DOMException("The operation was aborted due to timeout", "TimeoutError")); } });
|
|
const replies = [
|
|
() => Promise.reject(new DOMException("The operation was aborted due to timeout", "TimeoutError")),
|
|
() => Promise.resolve(new Response(stalledBody)),
|
|
() => Promise.resolve(new Response(archive)),
|
|
];
|
|
const signals = []; const warnings = [];
|
|
const bytes = await downloadPinnedPiNodeArchive({
|
|
url: "https://nodejs.org/dist/fixture.tar.gz", archiveSha256, retryDelayMs: 0, warn: (message) => warnings.push(message),
|
|
fetchImpl: (url, init) => { signals.push(init.signal); assert.equal(init.redirect, "error"); return replies.shift()(); },
|
|
});
|
|
assert.deepEqual(bytes, archive);
|
|
assert.equal(signals.length, 3);
|
|
assert.equal(new Set(signals).size, 3, "each attempt needs a fresh timeout");
|
|
assert.equal(warnings.length, 2);
|
|
assert.match(warnings[0], /attempt 1\/3 failed: The operation was aborted due to timeout; retrying/);
|
|
});
|
|
|
|
test("Node archive download deadline aborts stalled headers and stalled bodies", async () => {
|
|
const archive = Buffer.from("pinned node archive");
|
|
const archiveSha256 = createHash("sha256").update(archive).digest("hex");
|
|
const abortWith = (signal, fail) => signal.addEventListener("abort", () => fail(signal.reason), { once: true });
|
|
const replies = [
|
|
// Headers never arrive; only the supplied deadline can end this attempt.
|
|
(signal) => new Promise((_, reject) => abortWith(signal, reject)),
|
|
// Headers arrive, then the body stalls after one chunk until the same deadline fires.
|
|
(signal) => Promise.resolve(new Response(new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([1])); abortWith(signal, (reason) => controller.error(reason)); } }))),
|
|
() => Promise.resolve(new Response(archive)),
|
|
];
|
|
const warnings = [];
|
|
const bytes = await downloadPinnedPiNodeArchive({
|
|
url: "https://nodejs.org/dist/fixture.tar.gz", archiveSha256, timeoutMs: 20, retryDelayMs: 0, warn: (message) => warnings.push(message),
|
|
fetchImpl: (url, init) => replies.shift()(init.signal),
|
|
});
|
|
assert.deepEqual(bytes, archive);
|
|
assert.equal(warnings.length, 2);
|
|
for (const warning of warnings) assert.match(warning, /The operation was aborted due to timeout; retrying/);
|
|
});
|
|
|
|
test("Node archive download releases a rejected response before the next attempt", async () => {
|
|
const archive = Buffer.from("pinned node archive");
|
|
const archiveSha256 = createHash("sha256").update(archive).digest("hex");
|
|
const events = [];
|
|
const busy = () => new Response(new ReadableStream({ cancel() { events.push("cancel"); } }), { status: 503 });
|
|
const replies = [busy, () => new Response(archive)];
|
|
await downloadPinnedPiNodeArchive({
|
|
url: "https://nodejs.org/dist/fixture.tar.gz", archiveSha256, retryDelayMs: 0, warn: () => {},
|
|
fetchImpl: () => { events.push("fetch"); return Promise.resolve(replies.shift()()); },
|
|
});
|
|
assert.deepEqual(events, ["fetch", "cancel", "fetch"]);
|
|
events.length = 0;
|
|
await assert.rejects(downloadPinnedPiNodeArchive({
|
|
url: "https://nodejs.org/dist/fixture.tar.gz", archiveSha256, retryDelayMs: 0, warn: () => assert.fail("must not retry"),
|
|
fetchImpl: () => { events.push("fetch"); return Promise.resolve(new Response(new ReadableStream({ cancel() { events.push("cancel"); } }), { status: 404 })); },
|
|
}), /returned HTTP 404/);
|
|
assert.deepEqual(events, ["fetch", "cancel"]);
|
|
});
|
|
|
|
test("Node archive download names the exhausted request instead of an anonymous timeout", async () => {
|
|
let calls = 0;
|
|
await assert.rejects(downloadPinnedPiNodeArchive({
|
|
url: "https://nodejs.org/dist/fixture.tar.gz", archiveSha256: "0".repeat(64), retryDelayMs: 0, warn: () => {},
|
|
fetchImpl: () => { calls++; return Promise.reject(new TypeError("fetch failed")); },
|
|
}), /^Error: Pinned Pi Node download failed after 3 attempts: fetch failed$/);
|
|
assert.equal(calls, 3);
|
|
calls = 0;
|
|
await assert.rejects(downloadPinnedPiNodeArchive({
|
|
url: "https://nodejs.org/dist/fixture.tar.gz", archiveSha256: "0".repeat(64), retryDelayMs: 0, warn: () => {},
|
|
fetchImpl: () => { calls++; return Promise.resolve(new Response("busy", { status: 503 })); },
|
|
}), /failed after 3 attempts: Pinned Pi Node download returned HTTP 503/);
|
|
assert.equal(calls, 3);
|
|
});
|
|
|
|
test("Node archive download fails closed without retrying pin, bound, client or cancellation failures", async () => {
|
|
const options = { url: "https://nodejs.org/dist/fixture.tar.gz", archiveSha256: "0".repeat(64), retryDelayMs: 0, warn: () => assert.fail("must not retry") };
|
|
let calls = 0;
|
|
const once = (response) => () => { calls++; return Promise.resolve(response()); };
|
|
await assert.rejects(downloadPinnedPiNodeArchive({ ...options, fetchImpl: once(() => new Response("tampered")) }), /does not match its release pin/);
|
|
await assert.rejects(downloadPinnedPiNodeArchive({ ...options, fetchImpl: once(() => new Response("missing", { status: 404 })) }), /returned HTTP 404/);
|
|
const oversized = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(128 * 1024 * 1024 + 1)); controller.close(); } });
|
|
await assert.rejects(downloadPinnedPiNodeArchive({ ...options, fetchImpl: once(() => new Response(oversized)) }), /exceeds its bound/);
|
|
assert.equal(calls, 3);
|
|
let checks = 0;
|
|
await assert.rejects(downloadPinnedPiNodeArchive({
|
|
...options, warn: () => {}, checkCancelled: () => { if (++checks > 1) throw new Error("cancelled"); },
|
|
fetchImpl: () => Promise.reject(new DOMException("The operation was aborted due to timeout", "TimeoutError")),
|
|
}), /cancelled/);
|
|
});
|