Files
bun-src/test/bundler/compile-asset-bunfs.test.ts
2026-08-27 21:09:14 +00:00

341 lines
13 KiB
TypeScript

// https://github.com/oven-sh/bun/issues/15734
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { join, sep } from "path";
// `bun build --compile` copies + rewrites the whole bun binary (~1GB under
// debug+ASAN), which blows the 5s default.
const TIMEOUT = 60_000;
const exe = process.platform === "win32" ? ".exe" : "";
async function compile(dir: string, extraArgs: string[] = []) {
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "--compile", "./index.ts", "--outfile", "app", ...extraArgs],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (code !== 0) throw new Error(`compile failed (exit ${code})\n${stdout}\n${stderr}`);
}
async function run(dir: string) {
// cwd outside the build dir so the binary cannot accidentally find real files on disk.
await using proc = Bun.spawn({
cmd: [join(dir, "app" + exe)],
cwd: process.platform === "win32" ? process.env.TEMP || "C:\\Windows\\Temp" : "/tmp",
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, code };
}
describe.concurrent("compile --asset and /$bunfs/ directory semantics", () => {
// One compiled binary exercises every CLI-side /$bunfs/ path we care about:
// a file-loader asset's parent directory, an --asset directory tree, an
// --asset single file, and the ENOENT/ENOTDIR/EISDIR/EACCES error paths.
// These used to be four separate `bun build --compile` invocations.
test(
"CLI: file-loader asset, --asset dir + file, and /$bunfs/ fs semantics",
async () => {
using dir = tempDir("bunfs-cli", {
"index.ts": /* ts */ `
import asset from "./data.txt" with { type: "file" };
import fs from "node:fs";
import path from "node:path";
function errcode(fn: () => unknown): string {
try { fn(); return ""; } catch (e: any) { return e.code; }
}
// file-loader asset: parent-directory semantics
const assetDir = path.dirname(asset);
const fileLoader = {
assetExists: fs.existsSync(asset),
dirExists: fs.existsSync(assetDir),
dirExistsTrailingSlash: fs.existsSync(assetDir + "/"),
dirStatIsDir: fs.statSync(assetDir).isDirectory(),
dirLstatIsDir: fs.lstatSync(assetDir).isDirectory(),
accessOk: errcode(() => fs.accessSync(assetDir)) === "",
accessWriteErr: errcode(() => fs.accessSync(asset, fs.constants.W_OK)),
readdir: fs.readdirSync(assetDir).sort(),
readdirHasAsset: fs.readdirSync(assetDir).includes(path.basename(asset)),
};
// --asset directory tree (mirrors svelte-adapter-bun: walk a directory
// relative to the bundled entry, stat each entry, serve via Bun.file())
const root = path.join(import.meta.dir, "client");
if (!fs.existsSync(root)) throw new Error("client dir missing: " + root);
const entries = fs.readdirSync(root, { withFileTypes: true });
const byName: Record<string, { isDir: boolean; isFile: boolean }> = {};
for (const e of entries) byName[e.name] = { isDir: e.isDirectory(), isFile: e.isFile() };
const indexHtml = path.join(root, "index.html");
const nestedCss = path.join(root, "_app", "immutable", "app.css");
const client = {
root,
entries: Object.keys(byName).sort(),
byName,
indexHtmlExists: fs.existsSync(indexHtml),
indexHtmlSize: fs.statSync(indexHtml).size,
indexHtmlContent: await Bun.file(indexHtml).text(),
nestedCssExists: fs.existsSync(nestedCss),
nestedCssContent: await Bun.file(nestedCss).text(),
nestedCssViaReadFile: fs.readFileSync(nestedCss, "utf8"),
nestedDirIsDir: fs.statSync(path.join(root, "_app", "immutable")).isDirectory(),
readFileDirErr: errcode(() => fs.readFileSync(root)),
recursive: fs.readdirSync(root, { recursive: true }).map(String).sort(),
recursiveAsync: (await fs.promises.readdir(root, { recursive: true })).map(String).sort(),
embeddedFileCount: Bun.embeddedFiles.length,
};
// readdir on a non-existent /$bunfs/ path
const missing = path.join(import.meta.dir, "does-not-exist");
const enoent = {
code: errcode(() => fs.readdirSync(missing)),
exists: fs.existsSync(missing),
};
// --asset single file
const cfg = path.join(import.meta.dir, "config.json");
const singleFile = {
exists: fs.existsSync(cfg),
content: fs.readFileSync(cfg, "utf8"),
readdirCode: errcode(() => fs.readdirSync(cfg)),
};
console.log(JSON.stringify({ fileLoader, client, enoent, singleFile }));
`,
"data.txt": "hello",
"client/index.html": "<!doctype html><h1>hi</h1>",
"client/favicon.svg": "<svg/>",
"client/_app/immutable/app.css": "body{margin:0}",
"client/_app/immutable/chunks/entry.js": "export default 1;",
"config.json": `{"ok":true}`,
});
await compile(String(dir), ["--asset", "./client", "--asset", "./config.json"]);
const { stdout, stderr, code } = await run(String(dir));
expect(stderr.trim()).toBe("");
const r = JSON.parse(stdout.trim());
const expectedRecursive = [
"_app",
join("_app", "immutable"),
join("_app", "immutable", "app.css"),
join("_app", "immutable", "chunks"),
join("_app", "immutable", "chunks", "entry.js"),
"favicon.svg",
"index.html",
].sort();
expect(r).toEqual({
fileLoader: {
assetExists: true,
dirExists: true,
dirExistsTrailingSlash: true,
dirStatIsDir: true,
dirLstatIsDir: true,
accessOk: true,
accessWriteErr: "EACCES",
// the hashed file-loader name is covered by readdirHasAsset
readdir: expect.arrayContaining(["client", "config.json"]),
readdirHasAsset: true,
},
client: {
root: expect.stringMatching(/[/\\]root[/\\]client$/),
entries: ["_app", "favicon.svg", "index.html"],
byName: {
_app: { isDir: true, isFile: false },
"favicon.svg": { isDir: false, isFile: true },
"index.html": { isDir: false, isFile: true },
},
indexHtmlExists: true,
indexHtmlSize: "<!doctype html><h1>hi</h1>".length,
indexHtmlContent: "<!doctype html><h1>hi</h1>",
nestedCssExists: true,
nestedCssContent: "body{margin:0}",
nestedCssViaReadFile: "body{margin:0}",
nestedDirIsDir: true,
readFileDirErr: "EISDIR",
recursive: expectedRecursive,
recursiveAsync: expectedRecursive,
embeddedFileCount: expect.any(Number),
},
enoent: { code: "ENOENT", exists: false },
singleFile: { exists: true, content: `{"ok":true}`, readdirCode: "ENOTDIR" },
});
// recursive uses the platform path separator (same as Node's real-fs recursive readdir)
expect(r.client.recursive.join("\n")).not.toContain(sep === "/" ? "\\" : "/");
// data.txt + config.json + 4 under client/
expect(r.client.embeddedFileCount).toBeGreaterThanOrEqual(6);
expect(code).toBe(0);
},
TIMEOUT,
);
// One Bun.build() covers both the directory-asset and the single-file-asset
// JS-API paths, including the index.js-does-not-collide-with-entry case.
// These used to be two separate Bun.build() calls (each writes a ~1GB binary).
test(
"Bun.build({compile: {assets}}): directory + file assets, entry keyed at basename(outfile)",
async () => {
using dir = tempDir("bunfs-jsapi", {
"index.ts": /* ts */ `
import fs from "node:fs";
import path from "node:path";
const root = path.join(import.meta.dir, "public");
console.log(JSON.stringify({
entries: fs.readdirSync(root).sort(),
content: fs.readFileSync(path.join(root, "index.html"), "utf8"),
subCss: fs.readFileSync(path.join(root, "sub", "a.css"), "utf8"),
// entry is keyed at basename(outfile), so an index.js asset is readable as itself
indexJs: fs.readFileSync(path.join(import.meta.dir, "index.js"), "utf8"),
}));
`,
"public/index.html": "<h1>js-api</h1>",
"public/sub/a.css": "body{}",
"cfg/index.js": `ASSET_CONTENT`,
});
const result = await Bun.build({
entrypoints: [join(String(dir), "index.ts")],
compile: {
outfile: join(String(dir), "app"),
assets: [join(String(dir), "public"), join(String(dir), "cfg", "index.js")],
},
});
expect(result.success).toBe(true);
const { stdout, stderr, code } = await run(String(dir));
expect(stderr.trim()).toBe("");
expect(JSON.parse(stdout.trim())).toEqual({
entries: ["index.html", "sub"],
content: "<h1>js-api</h1>",
subCss: "body{}",
indexJs: "ASSET_CONTENT",
});
expect(code).toBe(0);
},
TIMEOUT,
);
test(
"Bun.build({compile: {assets}}) rejects colliding paths",
async () => {
using dir = tempDir("bunfs-asset-jsapi-err", {
"index.ts": `console.log("x");`,
"a/data.json": `1`,
"b/data.json": `2`,
});
const result = await Bun.build({
entrypoints: [join(String(dir), "index.ts")],
throw: false,
compile: {
outfile: join(String(dir), "app"),
assets: [join(String(dir), "a", "data.json"), join(String(dir), "b", "data.json")],
},
});
expect(result.success).toBe(false);
const logs = result.logs.map(String).join("\n");
expect(logs).toContain("collides");
expect(logs).toContain("data.json");
},
TIMEOUT,
);
test(
"--asset errors on colliding embedded paths",
async () => {
using dir = tempDir("bunfs-asset-collide", {
"index.ts": `console.log("unreachable");`,
"a/config.json": `1`,
"b/config.json": `2`,
});
await using proc = Bun.spawn({
cmd: [
bunExe(),
"build",
"--compile",
"./index.ts",
"--outfile",
"app",
"--asset",
"./a/config.json",
"--asset",
"./b/config.json",
],
cwd: String(dir),
env: bunEnv,
stdout: "ignore",
stderr: "pipe",
});
const [stderr, code] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toContain("collides");
expect(stderr).toContain("config.json");
expect(code).not.toBe(0);
},
TIMEOUT,
);
test(
"--asset errors when its basename matches --outfile",
async () => {
using dir = tempDir("bunfs-asset-entry", {
"index.ts": `console.log("unreachable");`,
"client/index.html": `x`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "--compile", "./index.ts", "--outfile", "./dist/client", "--asset", "./client"],
cwd: String(dir),
env: bunEnv,
stdout: "ignore",
stderr: "pipe",
});
const [stderr, code] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toContain("same path as the entry point");
expect(code).not.toBe(0);
},
TIMEOUT,
);
test.each([
[["build", "./index.ts", "--asset", "./public"], "--asset requires --compile"],
[
["build", "--compile", "--target=browser", "./index.html", "--asset", "./public"],
"--target browser with --asset",
],
[["build", "--compile", "./index.ts", "--outfile", "app", "--asset", "./does-not-exist"], "failed to read asset"],
...(process.platform === "win32"
? []
: [
[
["build", "--compile", "./index.ts", "--outfile", "app", "--asset", "/dev/null"],
"is not a regular file or directory",
] as const,
]),
])(
"rejects %j",
async (args, expected) => {
using dir = tempDir("bunfs-asset-reject", {
"index.ts": `console.log("x");`,
"index.html": `<!doctype html>`,
"public/a.txt": `a`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
cwd: String(dir),
env: bunEnv,
stdout: "ignore",
stderr: "pipe",
});
const [stderr, code] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).toContain(expected);
expect(code).not.toBe(0);
},
TIMEOUT,
);
});