Files
bun-src/test/bundler/bun-build-api.test.ts
T
2026-08-27 21:09:14 +00:00

1649 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "assert";
import { afterEach, describe, expect, test } from "bun:test";
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from "fs";
import {
bunEnv,
bunExe,
bunRun,
isASAN,
isDebug,
isWindows,
tempDir,
tempDirWithFiles,
tempDirWithFilesAnon,
} from "harness";
import path, { join } from "path";
import { SourceMapConsumer } from "source-map";
import { buildNoThrow } from "./buildNoThrow";
describe("Bun.build", () => {
test("css works", async () => {
const dir = tempDirWithFiles("bun-build-api-css", {
"a.css": `
@import "./b.css";
.hi {
color: red;
}
`,
"b.css": `
.hello {
color: blue;
}
`,
});
const build = await Bun.build({
entrypoints: [join(dir, "a.css")],
minify: true,
});
expect(build.outputs).toHaveLength(1);
expect(build.outputs[0].kind).toBe("asset");
expect(await build.outputs[0].text()).toEqualIgnoringWhitespace(".hello{color:#00f}.hi{color:red}\n");
});
test("bytecode works", async () => {
const dir = tempDirWithFiles("bun-build-api-bytecode", {
"package.json": `{}`,
"index.ts": `
export function hello() {
return "world";
}
console.log(hello());
`,
out: {
"hmm.js": "hmm",
},
});
const build = await Bun.build({
entrypoints: [join(dir, "index.ts")],
outdir: join(dir, "out"),
target: "bun",
bytecode: true,
});
expect(build.outputs).toHaveLength(2);
expect(build.outputs[0].kind).toBe("entry-point");
expect(build.outputs[1].kind).toBe("bytecode");
expect(await bunRun(build.outputs[0].path)).toSpawn("world");
});
test("passing undefined doesnt segfault", () => {
try {
// @ts-ignore
Bun.build();
} catch (error) {
return;
}
throw new Error("should have thrown");
});
// A `define:` value that isn't valid JSON or a JS identifier is auto-quoted
// (treated as a string literal). The JSON lexer must not error eagerly on the
// first character — a raw minified CSS string starts with `*{...}`, which
// src/codegen/bake-codegen.ts passes verbatim as `OVERLAY_CSS`.
describe.each([
"*{box-sizing:border-box}.root{all:initial}",
"?foo",
"(parenthesized)",
")close",
"abc{not json}",
// Leading-operator chars must `step()` before falling back to auto-quote so
// `parse_string_literal`'s leading `step()` lands past index 1 (matching the
// reference lexer). Otherwise a LF at index 1 truncates the value to `"("`.
"(\nrest",
"*\nrest",
])("define value %j is auto-quoted when not valid JSON", value => {
test("emits a quoted string literal", async () => {
const dir = tempDirWithFiles("bun-build-define-auto-quote", {
"entry.ts": `declare const X: string; console.log(X);`,
});
const result = await Bun.build({
entrypoints: [join(dir, "entry.ts")],
define: { X: value },
});
expect(result.success).toBe(true);
const out = await result.outputs[0].text();
// The printer emits the define as a `"..."` string literal, or as a
// `` `...` `` template literal when the value contains a literal newline.
// Either way the full value — not a truncated prefix — must round-trip.
if (!out.includes("`" + value + "`")) {
expect(out).toContain(JSON.stringify(value));
}
});
});
// https://github.com/oven-sh/bun/issues/12818
test("sourcemap + build error crash case", async () => {
const dir = tempDirWithFiles("build", {
"/src/file1.ts": `
import { A } from './dir';
console.log(A);
`,
"/src/dir/index.ts": `
import { B } from "./file3";
export const A = [B]
`,
"/src/dir/file3.ts": `
import { C } from "../file1"; // error
export const B = C;
`,
"/src/package.json": `
{ "type": "module" }
`,
"/src/tsconfig.json": `
{
"extends": "../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"types": []
}
}
`,
});
const y = await buildNoThrow({
entrypoints: [join(dir, "src/file1.ts")],
outdir: join(dir, "out"),
sourcemap: "external",
external: ["@minecraft"],
});
});
test("invalid options throws", async () => {
expect(() => Bun.build({} as any)).toThrow();
expect(() =>
Bun.build({
entrypoints: [],
} as any),
).toThrow();
expect(() =>
Bun.build({
entrypoints: ["hello"],
format: "invalid",
} as any),
).toThrow();
expect(() =>
Bun.build({
entrypoints: ["hello"],
target: "invalid",
} as any),
).toThrow();
expect(() =>
Bun.build({
entrypoints: ["hello"],
sourcemap: "invalid",
} as any),
).toThrow();
});
test("returns errors properly", async () => {
Bun.gc(true);
const build = await buildNoThrow({
entrypoints: [join(import.meta.dir, "does-not-exist.ts")],
});
expect(build.outputs).toHaveLength(0);
expect(build.logs).toHaveLength(1);
expect(build.logs[0]).toBeInstanceOf(BuildMessage);
expect(build.logs[0].message).toMatch(/ModuleNotFound/);
expect(build.logs[0].name).toBe("BuildMessage");
expect(build.logs[0].position).toEqual(null);
expect(build.logs[0].level).toEqual("error");
Bun.gc(true);
});
test("errors are thrown", async () => {
Bun.gc(true);
try {
await Bun.build({
entrypoints: [join(import.meta.dir, "does-not-exist.ts")],
});
expect.unreachable();
} catch (e) {
assert(e instanceof AggregateError);
expect(e.errors).toHaveLength(1);
expect(e.errors[0]).toBeInstanceOf(BuildMessage);
expect(e.errors[0].message).toMatch(/ModuleNotFound/);
expect(e.errors[0].name).toBe("BuildMessage");
expect(e.errors[0].position).toEqual(null);
expect(e.errors[0].level).toEqual("error");
Bun.gc(true);
}
});
test("returns output files", async () => {
Bun.gc(true);
const build = await Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/index.js")],
});
expect(build.outputs).toHaveLength(1);
expect(build.logs).toHaveLength(0);
Bun.gc(true);
});
test("Bun.write(BuildArtifact)", async () => {
Bun.gc(true);
const tmpdir = tempDirWithFiles("bun-build-api-write", {
"package.json": `{}`,
});
const x = await Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/index.js")],
});
await Bun.write(path.join(tmpdir, "index.js"), x.outputs[0]);
expect(readFileSync(path.join(tmpdir, "index.js"), "utf-8")).toMatchSnapshot();
Bun.gc(true);
});
test("outdir + reading out blobs works", async () => {
Bun.gc(true);
const fixture = tempDirWithFiles("build-outdir", {
"package.json": `{}`,
});
const x = await Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/index.js")],
outdir: fixture,
});
expect(await x.outputs.values().next().value?.text()).toMatchSnapshot();
Bun.gc(true);
});
test("BuildArtifact properties", async () => {
Bun.gc(true);
const outdir = tempDirWithFiles("build-artifact-properties", {
"package.json": `{}`,
});
const x = await Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/index.js")],
outdir,
});
console.log(await x.outputs[0].text());
const [blob] = x.outputs;
expect(blob).toBeTruthy();
expect(blob.type).toBe("text/javascript;charset=utf-8");
expect(blob.size).toBeGreaterThan(1);
expect(path.relative(outdir, blob.path)).toBe("index.js");
expect(blob.hash).toBeTruthy();
expect(blob.hash).toMatchSnapshot("hash");
expect(blob.kind).toBe("entry-point");
expect(blob.loader).toBe("jsx");
expect(blob.sourcemap).toBe(null);
Bun.gc(true);
});
test("BuildArtifact properties + entry.naming", async () => {
Bun.gc(true);
const outdir = tempDirWithFiles("build-artifact-properties-entry-naming", {
"package.json": `{}`,
});
const x = await Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/index.js")],
naming: {
entry: "hello",
},
outdir,
});
const [blob] = x.outputs;
expect(blob).toBeTruthy();
expect(blob.type).toBe("text/javascript;charset=utf-8");
expect(blob.size).toBeGreaterThan(1);
expect(path.relative(outdir, blob.path)).toBe("hello");
expect(blob.hash).toBeTruthy();
expect(blob.hash).toMatchSnapshot("hash");
expect(blob.kind).toBe("entry-point");
expect(blob.loader).toBe("jsx");
expect(blob.sourcemap).toBe(null);
Bun.gc(true);
});
test("BuildArtifact properties sourcemap", async () => {
Bun.gc(true);
const outdir = tempDirWithFiles("build-artifact-properties-sourcemap", {
"package.json": `{}`,
});
const x = await Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/index.js")],
sourcemap: "external",
outdir,
});
const [blob, map] = x.outputs;
expect(blob.type).toBe("text/javascript;charset=utf-8");
expect(blob.size).toBeGreaterThan(1);
expect(path.relative(outdir, blob.path)).toBe("index.js");
expect(blob.hash).toBeTruthy();
expect(blob.hash).toMatchSnapshot("hash index.js");
expect(blob.kind).toBe("entry-point");
expect(blob.loader).toBe("jsx");
expect(blob.sourcemap).toBe(map);
expect(map.type).toBe("application/json;charset=utf-8");
expect(map.size).toBeGreaterThan(1);
expect(path.relative(outdir, map.path)).toBe("index.js.map");
expect(map.hash).toBeTruthy();
expect(map.hash).toMatchSnapshot("hash index.js.map");
expect(map.kind).toBe("sourcemap");
expect(map.loader).toBe("file");
expect(map.sourcemap).toBe(null);
Bun.gc(true);
});
test("BuildArtifact sourcemap is traced from the owner, not rooted separately", async () => {
// `.sourcemap` is the wrapper's `m_sourcemap` WriteBarrier slot (visited in
// visitChildren); it must not also be held by a Strong root.
using dir = tempDir("build-artifact-sourcemap-gc", {
"index.js": "export const x = 1;\n",
"run.js": `
const { heapStats } = require("bun:jsc");
const result = await Bun.build({
entrypoints: ["./index.js"],
sourcemap: "external",
outdir: ".",
});
const entry = result.outputs[0];
const map = result.outputs[1];
console.log(JSON.stringify({
sourcemapIsMap: entry.sourcemap === map,
inspectShowsSourcemap: Bun.inspect(entry).includes("sourcemap: BuildArtifact (sourcemap)"),
protectedBuildArtifact: heapStats().protectedObjectTypeCounts.BuildArtifact ?? 0,
}));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(JSON.parse(stdout)).toEqual({
sourcemapIsMap: true,
inspectShowsSourcemap: true,
protectedBuildArtifact: 0,
});
expect(exitCode).toBe(0);
});
// test("BuildArtifact properties splitting", async () => {
// Bun.gc(true);
// const x = await Bun.build({
// entrypoints: [join(import.meta.dir, "./fixtures/trivial/index.js")],
// splitting: true,
// });
// expect(x.outputs).toHaveLength(2);
// const [indexBlob, chunkBlob] = x.outputs;
// expect(indexBlob).toBeTruthy();
// expect(indexBlob.type).toBe("text/javascript;charset=utf-8");
// expect(indexBlob.size).toBeGreaterThan(1);
// expect(indexBlob.path).toBe("/index.js");
// expect(indexBlob.hash).toBeTruthy();
// expect(indexBlob.hash).toMatchSnapshot("hash index.js");
// expect(indexBlob.kind).toBe("entry-point");
// expect(indexBlob.loader).toBe("jsx");
// expect(indexBlob.sourcemap).toBe(null);
// expect(chunkBlob).toBeTruthy();
// expect(chunkBlob.type).toBe("text/javascript;charset=utf-8");
// expect(chunkBlob.size).toBeGreaterThan(1);
// expect(chunkBlob.path).toBe(`/foo-${chunkBlob.hash}.js`);
// expect(chunkBlob.hash).toBeTruthy();
// expect(chunkBlob.hash).toMatchSnapshot("hash foo.js");
// expect(chunkBlob.kind).toBe("chunk");
// expect(chunkBlob.loader).toBe("jsx");
// expect(chunkBlob.sourcemap).toBe(null);
// Bun.gc(true);
// });
test("new Response(BuildArtifact) sets content type", async () => {
const x = await Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/index.js")],
outdir: tempDirWithFiles("response-buildartifact", {}),
});
const response = new Response(x.outputs[0]);
expect(response.headers.get("content-type")).toBe("text/javascript;charset=utf-8");
expect(await response.text()).toMatchSnapshot("response text");
});
test.todo("new Response(BuildArtifact) sets etag", async () => {
const x = await Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/index.js")],
outdir: tempDirWithFiles("response-buildartifact-etag", {}),
});
const response = new Response(x.outputs[0]);
expect(response.headers.get("etag")).toBeTruthy();
expect(response.headers.get("etag")).toMatchSnapshot("content-etag");
});
// test("BuildArtifact with assets", async () => {
// const x = await Bun.build({
// entrypoints: [join(import.meta.dir, "./fixtures/with-assets/index.js")],
// loader: {
// ".blob": "file",
// ".png": "file",
// },
// });
// console.log(x);
// const [blob, asset] = x.outputs;
// expect(blob).toBeTruthy();
// expect(blob instanceof Blob).toBe(true);
// expect(blob.type).toBe("text/javascript;charset=utf-8");
// expect(blob.size).toBeGreaterThan(1);
// expect(blob.path).toBe("/index.js");
// expect(blob.hash).toBeTruthy();
// expect(blob.hash).toMatchSnapshot();
// expect(blob.kind).toBe("entry-point");
// expect(blob.loader).toBe("jsx");
// expect(blob.sourcemap).toBe(null);
// throw new Error("test was not fully written");
// });
test.concurrent("loader map with an empty-string key is ignored without leaving uninitialized slots", async () => {
// `JSPropertyIterator` skips empty-name properties, but `loader_names` was being
// indexed by the property position instead of a dense counter, leaving garbage in
// the skipped slot that was later read/freed. Run in a subprocess so a crash in the
// bundler thread surfaces as a test failure instead of taking down the test runner.
const dir = tempDirWithFiles("bun-build-loader-empty-key", {
"entry.ts": `export const x: number = 42;\n`,
});
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const result = await Bun.build({
entrypoints: [${JSON.stringify(join(dir, "entry.ts"))}],
loader: { "": "js", ".ts": "ts", ".js": "js" },
});
if (!result.success) throw new AggregateError(result.logs, "build failed");
console.log(JSON.stringify({ success: result.success, outputs: result.outputs.length }));
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(JSON.parse(stdout.trim())).toEqual({ success: true, outputs: 1 });
expect(exitCode).toBe(0);
});
test.concurrent("rebuilding busts the directory entries cache", async () => {
Bun.gc(true);
const tmpdir = tempDirWithFiles("rebuild-bust-dirent-cache", {
"package.json": `{}`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), join(import.meta.dir, "fixtures", "bundler-reloader-script.ts")],
env: { ...bunEnv, BUNDLER_RELOADER_SCRIPT_TMP_DIR: tmpdir },
stderr: "pipe",
stdout: "inherit",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
if (stderr.length > 0) {
throw new Error(stderr);
}
expect(exitCode).toBe(0);
Bun.gc(true);
});
// https://github.com/oven-sh/bun/issues/33099
// A package reached through a symlinked node_modules entry caches its file
// descriptor in the resolver. A second in-process Bun.build() used to reuse a
// descriptor the first build had already closed, failing with EBADF. The test
// host must also import the package so its fd is cached before the builds run.
test.concurrent.skipIf(isWindows)(
"repeated in-process builds of a symlinked package do not reuse a closed fd",
async () => {
using dir = tempDir("build-symlink-fd-cache", {
"vendor/pkg/package.json": `{"name":"pkg","version":"1.0.0","type":"module","exports":"./index.js"}`,
"vendor/pkg/index.js": `export const value = 1;\n`,
"entry.ts": `import { value } from "pkg";\nconsole.log(value);\n`,
"repro.test.ts": `
import { it } from "bun:test";
import { value } from "pkg";
void value;
it("builds", async () => {
for (let i = 1; i <= 3; i++) {
const result = await Bun.build({ entrypoints: ["./entry.ts"] });
if (!result.success) {
throw new AggregateError(result.logs, "build " + i + " failed");
}
}
console.log("ALL_BUILDS_OK");
});
`,
});
mkdirSync(join(String(dir), "node_modules"), { recursive: true });
symlinkSync("../vendor/pkg", join(String(dir), "node_modules", "pkg"));
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "repro.test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout + stderr).toContain("ALL_BUILDS_OK");
expect(exitCode).toBe(0);
},
);
test.concurrent("errors are returned as an array", async () => {
const x = await buildNoThrow({
entrypoints: [join(import.meta.dir, "does-not-exist.ts")],
outdir: tempDirWithFiles("errors-are-returned-as-an-array", {}),
});
expect(x.success).toBe(false);
expect(x.logs).toHaveLength(1);
expect(x.logs[0].message).toMatch(/ModuleNotFound/);
expect(x.logs[0].name).toBe("BuildMessage");
expect(x.logs[0].position).toEqual(null);
});
test.concurrent("fails instead of truncating when a module is too deeply nested to print", async () => {
// A TOML dotted header builds an object nested arbitrarily deep without
// recursing in the parser, so the printer's recursion guard is the first
// thing to hit it. The printed part used to be silently dropped, leaving
// a corrupt bundle and success: true.
using dir = tempDir("build-api-deep-toml", {
"deep.toml": "[" + Buffer.alloc(200_000, "a.").toString() + "a]\nd = 1\n",
});
const x = await buildNoThrow({
entrypoints: [join(String(dir), "deep.toml")],
outdir: join(String(dir), "out"),
});
expect(x.success).toBe(false);
expect(x.outputs).toHaveLength(0);
expect(x.logs).toHaveLength(1);
expect(x.logs[0].message).toContain("Maximum call stack size exceeded while generating code for this file");
expect(x.logs[0].name).toBe("BuildMessage");
});
test.concurrent("reports a module that fails to print once across entrypoints", async () => {
// Without code splitting the failing file is printed once per entry
// chunk; the error is still per-file, like parse errors.
using dir = tempDir("build-api-deep-toml-multi", {
"deep.toml": "[" + Buffer.alloc(200_000, "a.").toString() + "a]\nd = 1\n",
"a.js": `import d from "./deep.toml"; console.log(d);`,
"b.js": `import d from "./deep.toml"; console.log(Object.keys(d));`,
});
const x = await buildNoThrow({
entrypoints: [join(String(dir), "a.js"), join(String(dir), "b.js")],
outdir: join(String(dir), "out"),
});
expect(x.success).toBe(false);
expect(x.logs).toHaveLength(1);
expect(x.logs[0].message).toContain("Maximum call stack size exceeded while generating code for this file");
});
test.concurrent("warnings do not fail a build", async () => {
const x = await Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/jsx-warning/index.jsx")],
outdir: tempDirWithFiles("warnings-do-not-fail-a-build", {}),
});
expect(x.success).toBe(true);
expect(x.logs).toHaveLength(1);
expect(x.logs[0].message).toBe(
'"key" prop after a {...spread} is deprecated in JSX. Falling back to classic runtime.',
);
expect(x.logs[0].name).toBe("BuildMessage");
expect(x.logs[0].position).toBeTruthy();
});
test.concurrent("module() throws error", async () => {
expect(() =>
Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/bundle-ws.ts")],
plugins: [
{
name: "test",
setup: b => {
b.module("ad", () => {
return {
exports: {
hello: "world",
},
loader: "object",
};
});
},
},
],
}),
).toThrow();
});
test.concurrent("non-object plugins throw invalid argument errors", () => {
for (const plugin of [null, undefined, 1, "hello", true, false, Symbol.for("hello")]) {
expect(() => {
Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/bundle-ws.ts")],
plugins: [
// @ts-expect-error
plugin,
],
});
}).toThrow("Expected plugin to be an object");
}
});
test.concurrent("hash considers cross chunk imports", async () => {
Bun.gc(true);
const fixture = tempDirWithFiles("build-hash-cross-chunk-imports", {
"entry1.ts": `
import { bar } from './bar'
export const entry1 = () => {
console.log('FOO')
bar()
}
`,
"entry2.ts": `
import { bar } from './bar'
export const entry1 = () => {
console.log('FOO')
bar()
}
`,
"bar.ts": `
export const bar = () => {
console.log('BAR')
}
`,
});
const first = await Bun.build({
entrypoints: [join(fixture, "entry1.ts"), join(fixture, "entry2.ts")],
outdir: join(fixture, "out"),
target: "browser",
splitting: true,
minify: false,
naming: "[dir]/[name]-[hash].[ext]",
});
if (!first.success) throw new AggregateError(first.logs);
expect(first.outputs.length).toBe(3);
writeFileSync(join(fixture, "bar.ts"), readFileSync(join(fixture, "bar.ts"), "utf8").replace("BAR", "BAZ"));
const second = await Bun.build({
entrypoints: [join(fixture, "entry1.ts"), join(fixture, "entry2.ts")],
outdir: join(fixture, "out2"),
target: "browser",
splitting: true,
minify: false,
naming: "[dir]/[name]-[hash].[ext]",
});
if (!second.success) throw new AggregateError(second.logs);
expect(second.outputs.length).toBe(3);
const totalUniqueHashes = new Set();
const allFiles = [...first.outputs, ...second.outputs];
for (const out of allFiles) totalUniqueHashes.add(out.hash);
expect(
totalUniqueHashes.size,
"number of unique hashes should be 6: three per bundle. the changed foo.ts affects all chunks",
).toBe(6);
// ensure that the hashes are in the path
for (const out of allFiles) {
expect(out.path).toInclude(out.hash!);
}
Bun.gc(true);
});
test.concurrent("ignoreDCEAnnotations works", async () => {
const fixture = tempDirWithFiles("build-ignore-dce-annotations", {
"package.json": `{}`,
"entry.ts": `
/* @__PURE__ */ console.log(1)
`,
});
const bundle = await Bun.build({
entrypoints: [join(fixture, "entry.ts")],
ignoreDCEAnnotations: true,
minify: true,
outdir: path.join(fixture, "out"),
});
if (!bundle.success) throw new AggregateError(bundle.logs);
expect(await bundle.outputs[0].text()).toBe("console.log(1);\n");
});
test.concurrent("emitDCEAnnotations works", async () => {
const fixture = tempDirWithFiles("build-emit-dce-annotations", {
"package.json": `{}`,
"entry.ts": `
export const OUT = /* @__PURE__ */ console.log(1)
`,
});
const bundle = await Bun.build({
entrypoints: [join(fixture, "entry.ts")],
emitDCEAnnotations: true,
minify: true,
outdir: path.join(fixture, "out"),
});
if (!bundle.success) throw new AggregateError(bundle.logs);
expect(await bundle.outputs[0].text()).toBe("var o=/*@__PURE__*/console.log(1);export{o as OUT};\n");
});
test.concurrent(
"you can write onLoad and onResolve plugins using the 'html' loader, and it includes script and link tags as bundled entrypoints",
async () => {
const fixture = tempDirWithFiles("build-html-plugins", {
"index.html": `
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="./style.css">
<script src="./script.js"></script>
</head>
</html>
`,
"style.css": ".foo { color: red; }",
// Check we actually do bundle the script
"script.js": "console.log(1 + 2)",
});
let onLoadCalled = false;
let onResolveCalled = false;
const build = await Bun.build({
entrypoints: [join(fixture, "index.html")],
minify: {
syntax: true,
},
plugins: [
{
name: "test-plugin",
setup(build) {
build.onLoad({ filter: /\.html$/ }, async args => {
onLoadCalled = true;
const contents = await Bun.file(args.path).text();
return {
contents: contents.replace("</head>", "<meta name='injected-by-plugin' content='true'></head>"),
loader: "html",
};
});
build.onResolve({ filter: /\.(js|css)$/ }, args => {
onResolveCalled = true;
return {
path: join(fixture, args.path),
namespace: "file",
};
});
},
},
],
});
expect(build.success).toBe(true);
expect(onLoadCalled).toBe(true);
expect(onResolveCalled).toBe(true);
// Should have 3 outputs - HTML, JS and CSS
expect(build.outputs).toHaveLength(3);
// Verify we have one of each type
const types = build.outputs.map(o => o.type);
expect(types).toContain("text/html;charset=utf-8");
expect(types).toContain("text/javascript;charset=utf-8");
expect(types).toContain("text/css;charset=utf-8");
// Verify the JS output contains the __dirname
const js = build.outputs.find(o => o.type === "text/javascript;charset=utf-8");
expect(await js?.text()).toContain("console.log(3)");
// Verify our plugin modified the HTML
const html = build.outputs.find(o => o.type === "text/html;charset=utf-8");
expect(await html?.text()).toContain("<meta name='injected-by-plugin' content='true'>");
},
);
});
test.concurrent("macro with nested object", async () => {
const dir = tempDirWithFilesAnon({
"index.ts": `
import { testMacro } from "./macro" assert { type: "macro" };
export const testConfig = testMacro({
borderRadius: {
1: "4px",
2: "8px",
},
});
`,
"macro.ts": `
export function testMacro(val: any) {
return val;
}
`,
});
const build = await Bun.build({
entrypoints: [join(dir, "index.ts")],
minify: true,
});
expect(build.outputs).toHaveLength(1);
expect(build.outputs[0].kind).toBe("entry-point");
expect(await build.outputs[0].text()).toEqualIgnoringWhitespace(
`var t={borderRadius:{"1":"4px","2":"8px"}};export{t as testConfig};\n`,
);
});
// Since NODE_PATH has to be set, we need to run this test outside the bundler tests.
test.concurrent("regression/NODE_PATHBuild api", async () => {
const dir = tempDirWithFiles("node-path-build", {
"entry.js": `
import MyClass from 'MyClass';
console.log(new MyClass().constructor.name);
`,
"src/MyClass.js": `
export default class MyClass {}
`,
"build.js": `
import { join } from "path";
const build = await Bun.build({
entrypoints: [join(import.meta.dir, "entry.js")],
outdir: join(import.meta.dir, "out"),
});
if (!build.success) {
console.error("Build failed:", build.logs);
process.exit(1);
}
// Run the built file
const runProc = Bun.spawn({
cmd: [process.argv[0], join(import.meta.dir, "out", "entry.js")],
stdout: "pipe",
stderr: "pipe",
});
await runProc.exited;
const runOutput = await new Response(runProc.stdout).text();
const runError = await new Response(runProc.stderr).text();
if (runError) {
console.error("Run error:", runError);
process.exit(1);
}
console.log(runOutput.trim());
`,
});
// Run the build script with NODE_PATH set
const proc = Bun.spawn({
cmd: [bunExe(), join(dir, "build.js")],
env: {
...bunEnv,
NODE_PATH: join(dir, "src"),
},
stdout: "pipe",
stderr: "pipe",
cwd: dir,
});
await proc.exited;
const output = await proc.stdout.text();
const error = await proc.stderr.text();
expect(error).toBe("");
expect(output.trim()).toBe("MyClass");
});
test.concurrent("regression/GlobalThis", async () => {
const dir = tempDirWithFiles("global-this-regression", {
"entry.js": `
function identity(x) {
return x;
}
import * as mod1 from 'assert';
identity(mod1);
import * as mod2 from 'buffer';
identity(mod2);
import * as mod3 from 'console';
identity(mod3);
import * as mod4 from 'constants';
identity(mod4);
import * as mod5 from 'crypto';
identity(mod5);
import * as mod6 from 'domain';
identity(mod6);
import * as mod7 from 'events';
identity(mod7);
import * as mod8 from 'http';
identity(mod8);
import * as mod9 from 'https';
identity(mod9);
import * as mod10 from 'net';
identity(mod10);
import * as mod11 from 'os';
identity(mod11);
import * as mod12 from 'path';
identity(mod12);
import * as mod13 from 'process';
identity(mod13);
import * as mod14 from 'punycode';
identity(mod14);
import * as mod15 from 'stream';
identity(mod15);
import * as mod16 from 'string_decoder';
identity(mod16);
import * as mod17 from 'sys';
identity(mod17);
import * as mod18 from 'timers';
identity(mod18);
import * as mod20 from 'tty';
identity(mod20);
import * as mod21 from 'url';
identity(mod21);
import * as mod22 from 'util';
identity(mod22);
import * as mod23 from 'zlib';
identity(mod23);
`,
});
const build = await Bun.build({
entrypoints: [join(dir, "entry.js")],
target: "browser",
});
expect(build.success).toBe(true);
const text = await build.outputs[0].text();
expect(text).not.toContain("process.env.");
expect(text).not.toContain(" global.");
expect(text).toContain(" globalThis.");
});
describe.concurrent("sourcemap boolean values", () => {
test("sourcemap: true should work (boolean)", async () => {
const dir = tempDirWithFiles("sourcemap-true-boolean", {
"index.js": `console.log("hello");`,
});
const build = await Bun.build({
entrypoints: [join(dir, "index.js")],
sourcemap: true,
});
expect(build.success).toBe(true);
expect(build.outputs).toHaveLength(1);
expect(build.outputs[0].kind).toBe("entry-point");
const output = await build.outputs[0].text();
expect(output).toContain("//# sourceMappingURL=data:application/json;base64,");
});
test("sourcemap: false should work (boolean)", async () => {
const dir = tempDirWithFiles("sourcemap-false-boolean", {
"index.js": `console.log("hello");`,
});
const build = await Bun.build({
entrypoints: [join(dir, "index.js")],
sourcemap: false,
});
expect(build.success).toBe(true);
expect(build.outputs).toHaveLength(1);
expect(build.outputs[0].kind).toBe("entry-point");
const output = await build.outputs[0].text();
expect(output).not.toContain("//# sourceMappingURL=");
});
test("sourcemap: true with outdir should create linked sourcemap", async () => {
const dir = tempDirWithFiles("sourcemap-true-outdir", {
"index.js": `console.log("hello");`,
});
const build = await Bun.build({
entrypoints: [join(dir, "index.js")],
outdir: join(dir, "out"),
sourcemap: true,
});
expect(build.success).toBe(true);
expect(build.outputs).toHaveLength(2);
const jsOutput = build.outputs.find(o => o.kind === "entry-point");
const mapOutput = build.outputs.find(o => o.kind === "sourcemap");
expect(jsOutput).toBeTruthy();
expect(mapOutput).toBeTruthy();
expect(jsOutput!.sourcemap).toBe(mapOutput!);
const jsText = await jsOutput!.text();
expect(jsText).toContain("//# sourceMappingURL=index.js.map");
});
});
describe.concurrent("sourcemap positions", () => {
// Source-map columns count UTF-16 code units. Tokens after the first
// non-ASCII character on a line (Latin-1, astral, CJK) must still map to
// their exact original column.
test("original columns after non-ASCII characters on the same line", async () => {
const source = [
`export function a1() { throw new Error("A"); } export const za = "e";`,
`export const zb = "é"; export function b1() { throw new Error("B"); }`,
`export const zc = "🎉"; export function c1() { throw new Error("C"); }`,
`export const zd = "汉字 wörld"; export function d1() { throw new Error("D"); }`,
``,
].join("\n");
const dir = tempDirWithFiles("build-sourcemap-unicode-columns", { "in.ts": source });
const build = await Bun.build({
entrypoints: [join(dir, "in.ts")],
outdir: join(dir, "out"),
sourcemap: "external",
});
expect(build.success).toBe(true);
const generated = await build.outputs.find(o => o.kind === "entry-point")!.text();
const map = await build.outputs.find(o => o.kind === "sourcemap")!.json();
// 1-based line, 0-based UTF-16 column: the convention `source-map` uses on
// both sides of originalPositionFor.
const lineColumn = (text: string, index: number) => {
const before = text.slice(0, index);
return { line: before.split("\n").length, column: index - (before.lastIndexOf("\n") + 1) };
};
await SourceMapConsumer.with(map, null, consumer => {
for (const token of ['new Error("A")', 'new Error("B")', 'new Error("C")', 'new Error("D")']) {
const { line, column } = consumer.originalPositionFor(lineColumn(generated, generated.indexOf(token)));
expect({ token, line, column }).toEqual({ token, ...lineColumn(source, source.indexOf(token)) });
}
});
});
});
const originalCwd = process.cwd() + "";
describe("tsconfig option", () => {
afterEach(() => {
process.chdir(originalCwd);
});
test("should resolve path mappings", async () => {
const dir = tempDirWithFiles("tsconfig-api-basic", {
"tsconfig.json": `{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}`,
"src/utils.ts": `export const greeting = "Hello World";`,
"index.ts": `import { greeting } from "@/utils";
export { greeting };`,
});
try {
process.chdir(dir);
const result = await Bun.build({
entrypoints: ["./index.ts"],
tsconfig: "./tsconfig.json",
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("Hello World");
} finally {
process.chdir(originalCwd);
}
});
test("should work from nested directories", async () => {
const dir = tempDirWithFiles("tsconfig-api-nested", {
"tsconfig.json": `{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}`,
"src/utils.ts": `export const greeting = "Hello World";`,
"src/nested/index.ts": `import { greeting } from "@/utils";
export { greeting };`,
});
try {
process.chdir(join(dir, "src/nested"));
const result = await Bun.build({
entrypoints: ["./index.ts"],
tsconfig: "../../tsconfig.json",
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("Hello World");
} finally {
process.chdir(originalCwd);
}
});
test("should handle relative tsconfig paths", async () => {
const dir = tempDirWithFiles("tsconfig-api-relative", {
"tsconfig.json": `{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}`,
"configs/build-tsconfig.json": `{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".."
}
}`,
"src/utils.ts": `export const greeting = "Hello World";`,
"index.ts": `import { greeting } from "@/utils";
export { greeting };`,
});
try {
process.chdir(dir);
const result = await Bun.build({
entrypoints: ["./index.ts"],
tsconfig: "./configs/build-tsconfig.json",
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("Hello World");
} finally {
process.chdir(originalCwd);
}
});
test("onEnd fires before promise resolves with throw: true", async () => {
const dir = tempDirWithFiles("onend-throwonerror-true", {
"index.ts": `
// This will cause a build error
import { missing } from "./does-not-exist";
console.log(missing);
`,
});
let onEndCalled = false;
let onEndCalledBeforeReject = false;
let promiseRejected = false;
try {
await Bun.build({
entrypoints: [join(dir, "index.ts")],
throw: true,
plugins: [
{
name: "test-plugin",
setup(builder) {
builder.onEnd(result => {
onEndCalled = true;
onEndCalledBeforeReject = !promiseRejected;
// Result should contain error information
expect(result.success).toBe(false);
expect(result.logs).toBeDefined();
expect(result.logs.length).toBeGreaterThan(0);
});
},
},
],
});
// Should not reach here
expect(false).toBe(true);
} catch (error) {
promiseRejected = true;
// Verify onEnd was called before promise rejected
expect(onEndCalled).toBe(true);
expect(onEndCalledBeforeReject).toBe(true);
}
});
test("onEnd fires before promise resolves with throw: false", async () => {
const dir = tempDirWithFiles("onend-throwonerror-false", {
"index.ts": `
// This will cause a build error
import { missing } from "./does-not-exist";
console.log(missing);
`,
});
let onEndCalled = false;
let onEndCalledBeforeResolve = false;
let promiseResolved = false;
const result = await Bun.build({
entrypoints: [join(dir, "index.ts")],
throw: false,
plugins: [
{
name: "test-plugin",
setup(builder) {
builder.onEnd(result => {
onEndCalled = true;
onEndCalledBeforeResolve = !promiseResolved;
// Result should contain error information
expect(result.success).toBe(false);
expect(result.logs).toBeDefined();
expect(result.logs.length).toBeGreaterThan(0);
});
},
},
],
});
promiseResolved = true;
// Verify onEnd was called before promise resolved
expect(onEndCalled).toBe(true);
expect(onEndCalledBeforeResolve).toBe(true);
expect(result.success).toBe(false);
expect(result.logs.length).toBeGreaterThan(0);
});
test("onEnd always fires on successful build", async () => {
const dir = tempDirWithFiles("onend-success", {
"index.ts": `
export const message = "Build successful";
console.log(message);
`,
});
let onEndCalled = false;
let onEndCalledBeforeResolve = false;
let promiseResolved = false;
const result = await Bun.build({
entrypoints: [join(dir, "index.ts")],
throw: true, // Should not matter for successful build
plugins: [
{
name: "test-plugin",
setup(builder) {
builder.onEnd(result => {
onEndCalled = true;
onEndCalledBeforeResolve = !promiseResolved;
// Result should indicate success
expect(result.success).toBe(true);
expect(result.outputs).toBeDefined();
expect(result.outputs.length).toBeGreaterThan(0);
});
},
},
],
});
promiseResolved = true;
// Verify onEnd was called before promise resolved
expect(onEndCalled).toBe(true);
expect(onEndCalledBeforeResolve).toBe(true);
expect(result.success).toBe(true);
const output = await result.outputs[0].text();
expect(output).toContain("Build successful");
});
test("multiple onEnd callbacks fire in order before promise settles", async () => {
const dir = tempDirWithFiles("onend-multiple", {
"index.ts": `
// This will cause a build error
import { missing } from "./not-found";
`,
});
const callOrder: string[] = [];
let promiseSettled = false;
const result = await Bun.build({
entrypoints: [join(dir, "index.ts")],
throw: false,
plugins: [
{
name: "plugin-1",
setup(builder) {
builder.onEnd(() => {
callOrder.push("first");
expect(promiseSettled).toBe(false);
});
},
},
{
name: "plugin-2",
setup(builder) {
builder.onEnd(() => {
callOrder.push("second");
expect(promiseSettled).toBe(false);
});
},
},
{
name: "plugin-3",
setup(builder) {
builder.onEnd(() => {
callOrder.push("third");
expect(promiseSettled).toBe(false);
});
},
},
],
});
promiseSettled = true;
// All callbacks should have fired in order before promise resolved
expect(callOrder).toEqual(["first", "second", "third"]);
// The build actually succeeds because the import is being resolved to nothing
// What matters is that callbacks fired before promise settled
expect(result.success).toBeDefined();
});
});
// On release builds mimalloc's large-allocation arenas make RSS growth too
// non-deterministic to draw a clean line between "leaking" and "not leaking"
// for this path. Under debug/ASAN the allocator behaviour is stable enough to
// measure reliably, so we only assert there.
test.skipIf(!isDebug && !isASAN)(
"Bun.build sourcemap: 'inline' with no outdir does not leak sourcemap JSON",
async () => {
// The in-memory build path used to leak the intermediate sourcemap JSON
// buffer: it is base64-encoded into the output and then dropped without a
// free. To make the leak observable we make the sourcemap JSON huge —
// "sourcesContent" embeds the full input source, so a ~30MB comment in the
// entry produces a ~30MB sourcemap JSON while keeping the actual bundle
// work trivial. 8 leaked builds ≈ ~240MB that can never be reclaimed.
//
// RSS is noisy between builds, so we settle with several GC+sleep cycles
// before each sample to let JSC collect the output blobs and mimalloc
// purge freed pages.
const dir = tempDirWithFiles("bun-build-inline-sourcemap-leak", {
"entry.ts": "export const a = 1;\n/* " + Buffer.alloc(30 * 1024 * 1024, "x").toString() + " */\n",
"run.ts": `
const rss = process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint : process.memoryUsage.rss;
const entry = process.argv[2];
async function build() {
const res = await Bun.build({ entrypoints: [entry], sourcemap: "inline" });
if (!res.success) throw new AggregateError(res.logs, "build failed");
}
async function settle() {
for (let i = 0; i < 4; i++) { Bun.gc(true); await Bun.sleep(10); }
}
for (let i = 0; i < 2; i++) await build();
await settle();
const before = rss();
for (let i = 0; i < 8; i++) await build();
await settle();
const after = rss();
console.log(JSON.stringify({ before, after, growth: after - before }));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "--smol", join(dir, "run.ts"), join(dir, "entry.ts")],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
const { growth } = JSON.parse(stdout.trim());
// Observed (2 warmup + 8 measured, settled): ~220-250MB with the free,
// ~590-650MB without it.
expect(growth).toBeLessThan(400 * 1024 * 1024);
},
120_000,
);
// Regression: src/js_printer/renamer.zig:592 `assignNamesRecursiveWithNumberScope`
// walks a linear single-child scope chain in a `while(true)` loop, allocating a
// fresh `NumberScope` from `number_scope_pool` for every level that declares
// symbols. The trailing `defer if (s != initial_scope) { s.deinit; pool.put(s) }`
// only returns the FINAL `s` to the pool — every intermediate NumberScope (and its
// `name_counts` map) is abandoned. In Zig this is harmless: `name_counts` is backed
// by the per-chunk worker arena (renamer.zig:533 `number_scope_pool = .init(arena)`,
// findUnusedName puts via `r.allocator` = worker MimallocArena) and is bulk-freed
// when the build completes. A port that drops the arena and backs `name_counts`
// with the global heap leaks one HashMap per intermediate nested scope, per build,
// forever — watch-mode / dev-server rebuilds grow unbounded.
//
// This test asserts the Zig invariant: repeated builds of a file with many deep
// linear `{ let ...; { ... } }` chains must not grow RSS proportionally to
// (chain depth × build count). Gated to debug/ASAN like the sourcemap-leak test
// above because release mimalloc page retention makes RSS too noisy to threshold.
// TODO(zig-rust-divergence): currently times out on the Rust debug build (the
// per-chunk arena backing for NumberScope.name_counts was dropped — see
// docs/ZIG_RUST_DIVERGENCE_AUDIT.md). Skipped instead of `.todo` because the
// body never reaches its assertion before the 120s timeout, so `.todo` would
// just burn two minutes of CI per run without exercising the check.
test.skip("Bun.build NumberRenamer does not leak intermediate NumberScope.name_counts across builds", async () => {
// 8 independent linear chains, each 150 blocks deep, 80 `let` bindings per
// block. Every block has exactly one child block → renamer takes the linear
// fast-path and allocates a NumberScope per level; 149 of 150 are the
// "intermediate" ones the Zig defer never puts back. 80 bindings/level means
// each leaked `name_counts` holds 80 boxed-key entries.
const CHAINS = 8;
const DEPTH = 150;
const VARS_PER_SCOPE = 80;
let entry = "";
for (let c = 0; c < CHAINS; c++) {
for (let d = 0; d < DEPTH; d++) {
let decls = "";
for (let v = 0; v < VARS_PER_SCOPE; v++) decls += `c${c}_d${d}_v${v}=${v},`;
entry += `{let ${decls.slice(0, -1)};\n`;
}
entry += "}\n".repeat(DEPTH);
}
const dir = tempDirWithFiles("bun-build-number-renamer-leak", {
"entry.js": entry,
"run.ts": `
const rss = process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint : process.memoryUsage.rss;
const entry = process.argv[2];
async function build() {
// No identifier minification → NumberRenamer path (not MinifyRenamer).
const res = await Bun.build({ entrypoints: [entry], minify: false });
if (!res.success) throw new AggregateError(res.logs, "build failed");
}
async function settle() {
for (let i = 0; i < 4; i++) { Bun.gc(true); await Bun.sleep(10); }
}
// Warm up: fill any one-shot caches and let the worker arenas reach
// steady-state so the measured window only reflects per-build retention.
for (let i = 0; i < 2; i++) await build();
await settle();
const before = rss();
for (let i = 0; i < 20; i++) await build();
await settle();
const after = rss();
console.log(JSON.stringify({ before, after, growth: after - before }));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "--smol", join(dir, "run.ts"), join(dir, "entry.js")],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const { growth } = JSON.parse(stdout.trim());
// With arena-backed scopes (Zig spec) the 20 measured builds reuse the same
// worker heap and settle near zero net growth. With global-heap name_counts
// and intermediate scopes never returned to the pool, each build abandons
// ~8×149 maps × 80 entries — roughly 4-5 MB/build, ~90-100 MB over 20
// iterations. 48 MB sits comfortably between the two with headroom for
// ASAN/LSan metadata noise.
expect(growth).toBeLessThan(48 * 1024 * 1024);
expect(exitCode).toBe(0);
}, 120_000);
// Regression: repeated in-process `Bun.build()` calls panicked with
// `index out of bounds: the len is 4095 but the index is 4095` (SIGTRAP) after
// a couple thousand builds. `Path.dupeAlloc` interns every module path into the
// process-lifetime `FilenameStore`. The Rust port had dropped two things the
// Zig original does: (1) the `isSliceInBuffer` short-circuit that returns an
// already-interned path unchanged, and (2) routing the disjoint `text`/`pretty`
// case (a freshly-relativized display path, recomputed every build) into the
// per-build arena instead of the store. Without them, each build re-appended
// every path, and once the store's overflow blocks filled
// (`OVERFLOW_GROUP_MAX` = 4095 blocks), the next append indexed one past the
// fixed-capacity pointer array and panicked.
//
// Many modules per build reaches the cap in far fewer builds: with 500 modules
// the broken binary panics roughly a third of the way through this loop, while
// the fixed binary keeps the store bounded and exits cleanly after all 400.
// (MODULES stays well under the ~550 where the unrelated recursive tree-shaker
// overflows its thread stack.) Not gated to debug/ASAN — the panic reproduces
// on release builds too.
//
// An explicit timeout is required (not optional): this runs hundreds of real
// bundles, far past bun:test's 5s default. The sibling leak tests above do the
// same. 180s matches the CI runner's own per-test ceiling.
test("Bun.build can be called thousands of times in one process without crashing", async () => {
const MODULES = 500;
const BUILDS = 400;
const files: Record<string, string> = {};
for (let i = 0; i < MODULES; i++) {
files[`m${i}.js`] =
`import { f${(i + 1) % MODULES} } from "./m${(i + 1) % MODULES}.js";\n` +
`export const v${i} = ${i};\n` +
`export function f${i}() { return v${i}; }\n`;
}
files["entry.js"] = Array.from(
{ length: MODULES },
(_, i) => `import { f${i} } from "./m${i}.js"; console.log(f${i}());`,
).join("\n");
files["run.ts"] = `
const entry = process.argv[2];
const BUILDS = ${BUILDS};
for (let i = 1; i <= BUILDS; i++) {
const res = await Bun.build({ entrypoints: [entry], minify: true, sourcemap: "external" });
if (!res.success) throw new AggregateError(res.logs, "build failed");
for (const o of res.outputs) await o.arrayBuffer();
}
console.log("OK " + BUILDS);
`;
const dir = tempDirWithFiles("bun-build-filename-store-overflow", files);
await using proc = Bun.spawn({
cmd: [bunExe(), join(dir, "run.ts"), join(dir, "entry.js")],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// A crash surfaces as a non-zero (signal) exit and a panic on stderr; assert
// the run completed cleanly instead.
expect(stderr).toBe("");
expect(stdout.trim()).toBe("OK 400");
expect(exitCode).toBe(0);
}, 180_000);
test("sourcemap sourcesContent is valid JSON when source contains C0 control chars", async () => {
// RFC 8259 only allows \" \\ \/ \b \f \n \r \t and six-char \u escapes; \v
// and \xNN are JavaScript-only. A VT (0x0B) or BEL (0x07) in the input used
// to leak through as \v / \x07 and break JSON.parse on the .map file.
const controls = Array.from({ length: 0x20 }, (_, i) => String.fromCharCode(i)).join("");
const source = `/* ctrl: [${controls}] */\nexport const x = 1;\n`;
using dir = tempDir("sourcemap-json-ctrl", { "in.js": source });
const res = await Bun.build({
entrypoints: [join(String(dir), "in.js")],
sourcemap: "external",
outdir: String(dir),
});
expect(res.success).toBe(true);
const map = res.outputs.find(o => o.kind === "sourcemap")!;
const text = await map.text();
expect(text).not.toMatch(/\\v|\\x[0-9A-Fa-f]{2}/);
const parsed = JSON.parse(text);
expect(parsed.sourcesContent[0]).toBe(source);
});
// Bun.build's link step waited for the shared thread pool to go *idle* rather than for its
// own tasks, so any unrelated pool work extended the build by its full duration — a
// node:fs read parked on a FIFO nobody writes made every later build hang forever.
test.skipIf(isWindows)(
"Bun.build does not wait for unrelated thread-pool work",
async () => {
using dir = tempDir("build-pool-wait", {
"a.ts": `import { b } from "./b"; import "./s.css"; console.log(b);`,
"b.ts": `export const b = 1;`,
"s.css": `body { color: red }`,
"run.js": `
const { join } = require("path");
const dir = process.argv[2];
const fs = require("fs");
const fifo = join(dir, "fifo");
require("child_process").execFileSync("mkfifo", [fifo]);
await Bun.build({ entrypoints: [join(dir, "a.ts")], outdir: join(dir, "out") });
let readDone = false, readErr;
// a pool thread blocks opening/reading the FIFO
const readFinished = new Promise((resolve) => fs.readFile(fifo, (err) => { readErr = err; readDone = true; resolve(); }));
// A non-blocking write-open of a FIFO only succeeds once a reader has it open, so this both
// waits for the pool thread to be in there and, by staying open without writing, keeps it
// parked in read() until we close it below.
let writer;
for (const deadline = Date.now() + 10_000; ; ) {
try { writer = fs.openSync(fifo, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK); break; } catch (e) {
if (e.code !== "ENXIO" || Date.now() > deadline) throw e;
await Bun.sleep(5);
}
}
// Release the pool thread after 5 s regardless, so a regression shows up as readDone
// being true below rather than as the whole test hanging until its timeout.
let closed = false;
const closeWriter = () => { if (!closed) { closed = true; fs.closeSync(writer); } };
setTimeout(closeWriter, 5000).unref();
const t = performance.now();
const result = await Bun.build({ entrypoints: [join(dir, "a.ts")], outdir: join(dir, "out") });
const elapsed = performance.now() - t;
// readDone must still be false: the build finished before the release of the FIFO reader,
// i.e. it did not wait for that unrelated pool task. elapsed is diagnostic only.
console.error("second build took " + Math.round(elapsed) + " ms");
console.log(result.success, result.outputs.length > 0, readDone);
closeWriter(); // EOF for the reader: let the pool thread go before exiting
await readFinished;
console.log(readDone, readErr ? readErr.code : "ok");
process.exit(0);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run.js", String(dir)],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toStartWith("second build took ");
expect(stdout).toBe("true true false\ntrue ok\n");
expect(exitCode).toBe(0);
},
30_000,
);