Files
bun-src/test/bundler/bun-build-api.test.ts
T

1649 lines
57 KiB
TypeScript
Raw Normal View History

2026-08-27 21:09:14 +00:00
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,
);