Files
bun-src/test/cli/install/bun-install-retry.test.ts
2026-08-27 21:09:14 +00:00

442 lines
14 KiB
TypeScript

import { file, spawn } from "bun";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
import { access, writeFile } from "fs/promises";
import { bunExe, bunEnv as env, readdirSorted, tmpdirSync, toBeValidBin, toBeWorkspaceLink, toHaveBins } from "harness";
import { join } from "path";
import {
dummyAfterAll,
dummyAfterEach,
dummyBeforeAll,
dummyBeforeEach,
dummyRegistry,
package_dir,
requested,
root_url,
setHandler,
} from "./dummy.registry";
beforeAll(dummyBeforeAll);
afterAll(dummyAfterAll);
expect.extend({
toHaveBins,
toBeValidBin,
toBeWorkspaceLink,
});
let port: string;
let add_dir: string;
setDefaultTimeout(1000 * 60 * 5);
beforeAll(() => {
port = new URL(root_url).port;
});
beforeEach(async () => {
add_dir = tmpdirSync();
await dummyBeforeEach();
});
afterEach(async () => {
await dummyAfterEach();
});
// Manifest request 302-redirects and the redirect target answers a retryable
// 500 once. The install retry must restart from the original manifest URL.
it("retries a manifest whose redirect target 500s once", async () => {
const urls: string[] = [];
let redirectTargetHits = 0;
setHandler(async request => {
const { pathname } = new URL(request.url);
urls.push(pathname);
if (pathname === "/BaR") {
return new Response(null, { status: 302, headers: { Location: `${root_url}/redirected/BaR` } });
}
if (pathname === "/redirected/BaR") {
if (redirectTargetHits++ === 0) {
return new Response("transient", { status: 500 });
}
return Response.json({
name: "BaR",
versions: {
"0.0.2": { name: "BaR", version: "0.0.2", dist: { tarball: `${root_url}/BaR-0.0.2.tgz` } },
},
"dist-tags": { latest: "0.0.2" },
});
}
if (pathname === "/BaR-0.0.2.tgz") {
return new Response(file(join(import.meta.dir, "bar-0.0.2.tgz")));
}
return new Response("unexpected", { status: 404 });
});
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { BaR: "0.0.2" } }),
);
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
const [err, out, exitCode] = await Promise.all([stderr.text(), stdout.text(), exited]);
expect(err).not.toContain("error:");
expect(err).toContain("Saved lockfile");
expect(out).toContain("1 package installed");
expect(exitCode).toBe(0);
// The retry restarts from the original manifest URL, so the server sees the
// whole redirect chain a second time.
expect(urls).toEqual(["/BaR", "/redirected/BaR", "/BaR", "/redirected/BaR", "/BaR-0.0.2.tgz"]);
expect(await file(join(package_dir, "node_modules", "BaR", "package.json")).json()).toEqual({
name: "bar",
version: "0.0.2",
});
});
// A cross-origin redirect strips Authorization from the request (per the fetch
// spec). The install retry restarts from the original registry URL and must
// carry the original headers, including Authorization, again.
it("retries an authorized manifest whose cross-origin redirect target 500s once", async () => {
const token = "test-registry-token";
const registryUrls: string[] = [];
const cdnAuth: (string | null)[] = [];
let cdnHits = 0;
// A second server on its own port stands in for the CDN the registry
// redirects to; a different port makes the redirect cross-origin.
await using cdn = Bun.serve({
port: 0,
fetch(request) {
if (new URL(request.url).pathname !== "/cdn/BaR") {
return new Response("unexpected", { status: 404 });
}
cdnAuth.push(request.headers.get("authorization"));
if (cdnHits++ === 0) {
return new Response("transient", { status: 500 });
}
return Response.json({
name: "BaR",
versions: {
"0.0.2": { name: "BaR", version: "0.0.2", dist: { tarball: `${root_url}/BaR-0.0.2.tgz` } },
},
"dist-tags": { latest: "0.0.2" },
});
},
});
setHandler(async request => {
const { pathname } = new URL(request.url);
registryUrls.push(pathname);
if (pathname === "/BaR") {
// The registry requires the token on every request, including the retry.
if (request.headers.get("authorization") !== `Bearer ${token}`) {
return new Response("missing authorization", { status: 401 });
}
return new Response(null, {
status: 302,
headers: { Location: `http://localhost:${cdn.port}/cdn/BaR` },
});
}
if (pathname === "/BaR-0.0.2.tgz") {
return new Response(file(join(import.meta.dir, "bar-0.0.2.tgz")));
}
return new Response("unexpected", { status: 404 });
});
await writeFile(
join(package_dir, "bunfig.toml"),
Bun.TOML.stringify({
install: {
cache: false,
registry: { url: `${root_url}/`, token },
saveTextLockfile: false,
},
}),
);
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { BaR: "0.0.2" } }),
);
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
const [err, out, exitCode] = await Promise.all([stderr.text(), stdout.text(), exited]);
expect(err).not.toContain("error:");
expect(err).toContain("Saved lockfile");
expect(out).toContain("1 package installed");
expect(exitCode).toBe(0);
// Both registry hits carried the token (the handler 401s otherwise); the
// cross-origin CDN hops must NOT have (the spec strips it for that hop).
expect(registryUrls).toEqual(["/BaR", "/BaR", "/BaR-0.0.2.tgz"]);
expect(cdnAuth).toEqual([null, null]);
});
// Sibling retry site (tarball downloads in runTasks): the tarball URL
// 302-redirects and the target 500s once before serving the archive.
it("retries a tarball whose redirect target 500s once", async () => {
const urls: string[] = [];
let redirectTargetHits = 0;
setHandler(async request => {
const { pathname } = new URL(request.url);
urls.push(pathname);
if (pathname === "/BaR") {
return Response.json({
name: "BaR",
versions: {
"0.0.2": { name: "BaR", version: "0.0.2", dist: { tarball: `${root_url}/BaR-0.0.2.tgz` } },
},
"dist-tags": { latest: "0.0.2" },
});
}
if (pathname === "/BaR-0.0.2.tgz") {
return new Response(null, { status: 302, headers: { Location: `${root_url}/redirected/BaR-0.0.2.tgz` } });
}
if (pathname === "/redirected/BaR-0.0.2.tgz") {
if (redirectTargetHits++ === 0) {
return new Response("transient", { status: 500 });
}
return new Response(file(join(import.meta.dir, "bar-0.0.2.tgz")));
}
return new Response("unexpected", { status: 404 });
});
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { BaR: "0.0.2" } }),
);
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
const [err, out, exitCode] = await Promise.all([stderr.text(), stdout.text(), exited]);
expect(err).not.toContain("error:");
expect(err).toContain("Saved lockfile");
expect(out).toContain("1 package installed");
expect(exitCode).toBe(0);
expect(urls).toEqual([
"/BaR",
"/BaR-0.0.2.tgz",
"/redirected/BaR-0.0.2.tgz",
"/BaR-0.0.2.tgz",
"/redirected/BaR-0.0.2.tgz",
]);
expect(await file(join(package_dir, "node_modules", "BaR", "package.json")).json()).toEqual({
name: "bar",
version: "0.0.2",
});
});
it("retries on 500", async () => {
const urls: string[] = [];
setHandler(dummyRegistry(urls, undefined, 4));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "foo",
version: "0.0.1",
}),
);
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "add", "BaR", "--linker=hoisted"],
cwd: package_dir,
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
const err = await stderr.text();
expect(err).not.toContain("error:");
expect(err).toContain("Saved lockfile");
const out = await stdout.text();
expect(out.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun add v1."),
"",
"installed [email protected]",
"",
"1 package installed",
]);
expect(await exited).toBe(0);
expect(urls.sort()).toEqual([
`${root_url}/BaR`,
`${root_url}/BaR`,
`${root_url}/BaR`,
`${root_url}/BaR`,
`${root_url}/BaR`,
`${root_url}/BaR`,
`${root_url}/BaR-0.0.2.tgz`,
`${root_url}/BaR-0.0.2.tgz`,
`${root_url}/BaR-0.0.2.tgz`,
`${root_url}/BaR-0.0.2.tgz`,
`${root_url}/BaR-0.0.2.tgz`,
`${root_url}/BaR-0.0.2.tgz`,
]);
expect(requested).toBe(12);
await Promise.all([
(async () => expect(await readdirSorted(join(package_dir, "node_modules"))).toEqual([".cache", "BaR"]))(),
(async () => expect(await readdirSorted(join(package_dir, "node_modules", "BaR"))).toEqual(["package.json"]))(),
(async () =>
expect(await file(join(package_dir, "node_modules", "BaR", "package.json")).json()).toEqual({
name: "bar",
version: "0.0.2",
}))(),
(async () =>
expect(await file(join(package_dir, "package.json")).text()).toEqual(
JSON.stringify(
{
name: "foo",
version: "0.0.1",
dependencies: {
BaR: "^0.0.2",
},
},
null,
2,
),
))(),
async () => await access(join(package_dir, "bun.lockb")),
]);
});
// A tarball that fails permanently must run its download (and retry cycle)
// exactly once and be reported as exactly one error. Previously the resolve
// phase's failure dropped the dedupe entry so the install phase re-ran the
// entire download: a 500 endpoint saw 12 GETs instead of 6 and the same
// `error: GET ...` line was printed twice.
describe.each(["hoisted", "isolated"])("linker=%s", linker => {
it.each([
{ status: 404, expectedGets: 1 },
{ status: 500, expectedGets: 6 },
])("does not re-download a tarball that already failed with $status", async ({ status, expectedGets }) => {
const urls: string[] = [];
setHandler(async request => {
const { pathname } = new URL(request.url);
urls.push(pathname);
if (pathname === "/BaR") {
return Response.json({
name: "BaR",
"dist-tags": { latest: "0.0.2" },
versions: {
"0.0.2": { name: "BaR", version: "0.0.2", dist: { tarball: `${root_url}/BaR-0.0.2.tgz` } },
},
});
}
if (pathname === "/BaR-0.0.2.tgz") {
return new Response("no", { status });
}
return new Response("unexpected", { status: 404 });
});
await writeFile(
join(package_dir, "bunfig.toml"),
Bun.TOML.stringify({
install: {
cache: false,
registry: `${root_url}/`,
linker,
},
}),
);
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { BaR: "0.0.2" } }),
);
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--no-progress", "--ignore-scripts"],
cwd: package_dir,
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
const [err, out, exitCode] = await Promise.all([stderr.text(), stdout.text(), exited]);
const tarballGets = urls.filter(u => u === "/BaR-0.0.2.tgz");
const errorLines = err.split("\n").filter(l => l.startsWith("error:"));
expect({ tarballGets: tarballGets.length, errorLines }).toEqual({
tarballGets: expectedGets,
errorLines: [`error: GET ${root_url}/BaR-0.0.2.tgz - ${status}`],
});
expect(out).not.toContain("installed");
expect(exitCode).toBe(1);
});
it("does not re-download an optional dependency's tarball that already failed", async () => {
const urls: string[] = [];
setHandler(async request => {
const { pathname } = new URL(request.url);
urls.push(pathname);
if (pathname === "/BaR") {
return Response.json({
name: "BaR",
"dist-tags": { latest: "0.0.2" },
versions: {
"0.0.2": { name: "BaR", version: "0.0.2", dist: { tarball: `${root_url}/BaR-0.0.2.tgz` } },
},
});
}
if (pathname === "/BaR-0.0.2.tgz") return new Response("no", { status: 404 });
if (pathname === "/baz") {
return Response.json({
name: "baz",
"dist-tags": { latest: "0.0.3" },
versions: {
"0.0.3": { name: "baz", version: "0.0.3", dist: { tarball: `${root_url}/baz-0.0.3.tgz` } },
},
});
}
if (pathname === "/baz-0.0.3.tgz") return new Response(file(join(import.meta.dir, "baz-0.0.3.tgz")));
return new Response("unexpected", { status: 404 });
});
await writeFile(
join(package_dir, "bunfig.toml"),
Bun.TOML.stringify({
install: {
cache: false,
registry: `${root_url}/`,
linker,
},
}),
);
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "foo",
version: "0.0.1",
dependencies: { baz: "0.0.3" },
optionalDependencies: { BaR: "0.0.2" },
}),
);
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--no-progress", "--ignore-scripts"],
cwd: package_dir,
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
const [err, out, exitCode] = await Promise.all([stderr.text(), stdout.text(), exited]);
const tarballGets = urls.filter(u => u === "/BaR-0.0.2.tgz");
const warnLines = err.split("\n").filter(l => l.startsWith("warn:"));
const errorLines = err.split("\n").filter(l => l.startsWith("error:"));
expect({ tarballGets: tarballGets.length, warnLines, errorLines }).toEqual({
tarballGets: 1,
warnLines: [`warn: GET ${root_url}/BaR-0.0.2.tgz - 404`],
errorLines: [],
});
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toMatchObject({
name: "baz",
version: "0.0.3",
});
if (linker === "hoisted") {
expect(out).not.toContain("Failed to install");
expect(exitCode).toBe(0);
}
});
});