Files
bun.sh/test/bundler/bun-build-api.test.ts
Alistair Smith 300f486125 Bundler changes to bring us closer to esbuild's api (#22076)
### What does this PR do?

- Implements .onEnd

Fixes #22061

Once #22144 is merged, this also fixes:
Fixes #9862
Fixes #20806

### How did you verify your code works?

Tests

---

TODO in a followup (#22144)
> ~~Make all entrypoints be called in onResolve~~
> ~~Fixes # 9862~~
> ~~Fixes # 20806~~

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-26 01:50:32 -07:00

1122 lines
33 KiB
TypeScript

import assert from "assert";
import { afterEach, describe, expect, test } from "bun:test";
import { readFileSync, writeFileSync } from "fs";
import { bunEnv, bunExe, tempDirWithFiles, tempDirWithFilesAnon } from "harness";
import path, { join } from "path";
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([build.outputs[0].path]).toRun("world\n");
});
test("passing undefined doesnt segfault", () => {
try {
// @ts-ignore
Bun.build();
} catch (error) {
return;
}
throw new Error("should have thrown");
});
// 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("rebuilding busts the directory entries cache", () => {
Bun.gc(true);
const tmpdir = tempDirWithFiles("rebuild-bust-dirent-cache", {
"package.json": `{}`,
});
const { exitCode, stderr } = Bun.spawnSync({
cmd: [bunExe(), join(import.meta.dir, "fixtures", "bundler-reloader-script.ts")],
env: { ...bunEnv, BUNDLER_RELOADER_SCRIPT_TMP_DIR: tmpdir },
stderr: "pipe",
stdout: "inherit",
});
if (stderr.byteLength > 0) {
throw new Error(stderr.toString());
}
expect(exitCode).toBe(0);
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 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("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("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("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("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("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("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("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("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("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("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("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("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");
});
});
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();
});
});