initial commit

This commit is contained in:
i2p
2026-08-27 21:09:14 +00:00
commit a5b6d59437
12681 changed files with 3253832 additions and 0 deletions
+867
View File
@@ -0,0 +1,867 @@
// Bundle tests are tests concerning bundling bugs that only occur in DevServer.
import { expect } from "bun:test";
import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness";
devTest("import identifier doesnt get renamed", {
framework: minimalFramework,
files: {
"db.ts": `export const abc = "123";`,
"routes/index.ts": `
import { abc } from '../db';
export default function (req, meta) {
let v1 = "";
const v2 = v1
? abc.toFixed(2)
: abc.toString();
return new Response('Hello, ' + v2 + '!');
}
`,
},
async test(dev) {
await dev.fetch("/").equals("Hello, 123!");
await dev.write("db.ts", `export const abc = "456";`);
await dev.fetch("/").equals("Hello, 456!");
await dev.patch("routes/index.ts", {
find: "Hello",
replace: "Bun",
});
await dev.fetch("/").equals("Bun, 456!");
},
});
devTest("symbol collision with import identifier", {
framework: minimalFramework,
files: {
"db.ts": `export const abc = "123";`,
"routes/index.ts": `
let import_db = 987;
import { abc } from '../db';
export default function (req, meta) {
let v1 = "";
const v2 = v1
? abc.toFixed(2)
: abc.toString();
return new Response('Hello, ' + v2 + ', ' + import_db + '!');
}
`,
},
async test(dev) {
await dev.fetch("/").equals("Hello, 123, 987!");
await dev.write("db.ts", `export const abc = "456";`);
await dev.fetch("/").equals("Hello, 456, 987!");
},
});
devTest('uses "development" condition', {
framework: minimalFramework,
files: {
"node_modules/example/package.json": JSON.stringify({
name: "example",
version: "1.0.0",
exports: {
".": {
development: "./development.js",
default: "./production.js",
},
},
}),
"node_modules/example/development.js": `export default "development";`,
"node_modules/example/production.js": `export default "production";`,
"routes/index.ts": `
import environment from 'example';
export default function (req, meta) {
return new Response('Environment: ' + environment);
}
`,
},
async test(dev) {
await dev.fetch("/").equals("Environment: development");
},
});
devTest("importing a file before it is created", {
files: {
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.ts"],
}),
"index.ts": `
import { abc } from './second';
console.log('value: ' + abc);
`,
},
async test(dev) {
await using c = await dev.client("/", {
errors: [`index.ts:1:21: error: Could not resolve: "./second"`],
});
await c.expectReload(async () => {
await dev.write("second.ts", `export const abc = "456";`);
});
await c.expectMessage("value: 456");
},
});
devTest("default export same-scope handling", {
files: {
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.ts"],
}),
"index.ts": `
import.meta.hot.accept();
await import("./fixture1.ts");
console.log((new ((await import("./fixture2.ts")).default)).a);
await import("./fixture3.ts");
console.log((new ((await import("./fixture4.ts")).default)).result);
console.log((await import("./fixture5.ts")).default);
console.log((await import("./fixture6.ts")).default);
console.log((await import("./fixture7.ts")).default());
console.log((await import("./fixture8.ts")).default());
console.log((await import("./fixture9.ts")).default(false));
`,
"fixture1.ts": `
const sideEffect = () => "a";
export default class A {
[sideEffect()] = "ONE";
}
console.log(new A().a);
`,
"fixture2.ts": `
const sideEffect = () => "a";
export default class A {
[sideEffect()] = "TWO";
}
`,
"fixture3.ts": `
export default class A {
result = "THREE"
}
console.log(new A().result);
`,
"fixture4.ts": `
import.meta.hot.accept();
export default class MOVE {
result = "FOUR"
}
`,
"fixture5.ts": `
const default_export = "FIVE";
export default default_export;
`,
"fixture6.ts": `
const default_export = "S";
function sideEffect() {
return default_export + "EVEN";
}
export default sideEffect();
console.log(default_export + "IX");
`,
"fixture7.ts": `
export default function() { return "EIGHT" };
`,
"fixture8.ts": `
import.meta.hot.accept();
export default function MOVE() { return "NINE" };
`,
"fixture9.ts": `
export default function named(flag = true) { return flag ? "TEN" : "ELEVEN" };
console.log(named());
`,
},
async test(dev) {
await using c = await dev.client("/", { storeHotChunks: true });
c.expectMessage(
//
"ONE",
"TWO",
"THREE",
"FOUR",
"FIVE",
"SIX",
"SEVEN",
"EIGHT",
"NINE",
"TEN",
"ELEVEN",
);
const filesExpectingMove = Object.entries(dev.options.files)
.filter(([, content]) => content.includes("MOVE"))
.map(([path]) => path);
for (const file of filesExpectingMove) {
await dev.writeNoChanges(file);
const chunk = await c.getMostRecentHmrChunk();
expect(chunk).toMatch(/default:\s*(function|class)\s*MOVE/);
}
await dev.writeNoChanges("fixture7.ts");
const chunk = await c.getMostRecentHmrChunk();
expect(chunk).toMatch(/default:\s*function/);
// Since fixture7.ts is not marked as accepting, it will bubble the update
// to `index.ts`, re-evaluate it and some of the dependencies.
c.expectMessage("TWO", "FOUR", "FIVE", "SEVEN", "EIGHT", "NINE", "ELEVEN");
},
});
devTest("directory cache bust case #17576", {
files: {
"web/index.html": emptyHtmlFile({
styles: [],
scripts: ["index.ts"],
}),
"web/index.ts": `
console.log(123);
import.meta.hot.accept();
`,
},
mainDir: "server",
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage(123);
await c.expectNoWebSocketActivity(async () => {
await dev.write(
"web/Test.ts",
`
export const abc = 456;
`,
);
});
await dev.write(
"web/index.ts",
`
import { abc } from "./Test.ts";
console.log(abc);
`,
);
await c.expectMessage(456);
},
});
devTest("deleting imported file shows error then recovers", {
skip: [
"win32", // unlinkSync is having weird behavior
],
files: {
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.ts"],
}),
"index.ts": `
import { value } from "./other";
console.log(value);
`,
"other.ts": `
export const value = 123;
`,
"unrelated.ts": `
export const value = 123;
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage(123);
await dev.delete("other.ts", {
errors: ['index.ts:1:23: error: Could not resolve: "./other"'],
});
await c.expectReload(async () => {
await dev.write(
"other.ts",
`
export const value = 456;
`,
);
});
await c.expectMessage(456);
await c.expectNoWebSocketActivity(async () => {
await dev.delete("unrelated.ts");
});
},
});
// Regression test: DirectoryWatchStore.Dep.source_file_path borrows the key
// string from IncrementalGraph.bundled_files. When a client-component boundary
// is demoted (its "use client" directive is removed) the server graph calls
// client_graph.disconnectAndDeleteFile which frees that key. Previously the
// Dep was left pointing at freed memory, and the next directory-watch event
// that re-resolved that Dep read it (use-after-free, caught by ASAN).
devTest("removing 'use client' from a component with a pending resolution failure", {
// separateSSRGraph is required so the "use client" file is parsed with the
// browser target; otherwise the resolution failure is attributed to the
// server graph and the client-graph key is never borrowed.
framework: {
...minimalFramework,
serverComponents: {
...minimalFramework.serverComponents!,
separateSSRGraph: true,
},
},
files: {
"routes/index.ts": `
import * as Comp from '../components/Comp';
import '../components/Sibling';
export default function (req, meta) {
return new Response('page: ' + (typeof Comp.marker));
}
`,
"components/Comp.ts": `
"use client";
export const marker = "initial";
`,
// Sibling.ts keeps a second, stable client-graph Dep on the
// components/ directory watch so the watch survives after the
// Comp.ts-owned Dep is cleaned up.
"components/Sibling.ts": `
"use client";
import './sibling-missing';
export const sibling = 1;
`,
},
async test(dev) {
// Initial bundle: Comp.ts compiles cleanly as a client-component
// boundary, so the server graph records is_client_component_boundary
// and the client graph owns the key string for Comp.ts. Sibling.ts
// fails to resolve './sibling-missing' under the browser target,
// leaving a client-graph Dep on the components/ directory watch.
await dev.fetch("/");
// Re-bundle Comp.ts with a failing import while it is still a CCB.
// With separateSSRGraph the re-parse runs under the browser target,
// so trackResolutionFailure inserts a second Dep whose
// source_file_path is the client graph's key for Comp.ts.
await dev.write(
"components/Comp.ts",
`
"use client";
import { value } from './missing';
export const marker = value;
`,
{ errors: null },
);
// Drop the directive and the failing import so the server parse
// succeeds. server_graph.receiveChunk now sees scb=false with
// was_ccb=true and calls client_graph.disconnectAndDeleteFile, which
// frees the key string that the Comp.ts Dep still references.
await dev.write(
"components/Comp.ts",
`
export const marker = "no-client";
`,
{ errors: null },
);
// Create the previously-missing file. The components/ directory watch
// is still alive (Sibling's Dep), so HotReloadEvent.processFileList
// walks every Dep for components/ and dereferences each
// source_file_path. Under ASAN the stale Comp.ts client-graph Dep is
// a heap-use-after-free here and the dev server aborts.
await dev.write("components/missing.ts", `export const value = "ok";`, { errors: null });
// The server must still be alive and responding.
const res = await dev.fetch("/");
expect(res).toBeInstanceOf(Response);
},
});
devTest("deinit with a free-list slot in DirectoryWatchStore.dependencies", {
files: {
"index.html": emptyHtmlFile({ scripts: ["index.ts"] }),
// Import-record order is source order, so trackResolutionFailure is
// called for ./sub/a first (dep index 0) and ./sub/b second (dep index 1).
"index.ts": `
import './sub/a';
import './sub/b';
export {};
`,
// sub/ must exist for the directory watch to be opened.
"sub/placeholder.ts": `export {};`,
},
async test(dev) {
// Initial bundle: both imports fail and attach deps to the sub/ watch.
await dev.fetch("/");
{
await using _ = await dev.batchChanges({ errors: null });
// Rewrite index.ts so the rebuild has no failing imports and therefore
// does not re-track anything (which would consume the free-list slot).
await dev.write("index.ts", `export {};`);
// Creating sub/a.ts fires the sub/ directory watch. Walking the dep
// chain (LIFO: 1 then 0), dep 1 (./sub/b) still fails and is kept;
// dep 0 (./sub/a) now resolves, so freeDependencyIndex(0) frees its
// specifier and, because 0 != len-1, pushes index 0 onto
// dependencies_free_list.
await dev.write("sub/a.ts", `export {};`);
}
// The server should still respond.
const res = await dev.fetch("/");
expect(res).toBeInstanceOf(Response);
// Test teardown sends graceful-exit, which calls DevServer.deinit.
// Before the fix, deinit iterated every dependencies.items slot and
// freed .specifier again for the free-list slot at index 0, tripping
// AllocationScope's invalid-free panic.
},
});
devTest("importing html file", {
files: {
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.ts"],
}),
"index.ts": `
import html from "./index.html";
console.log(html);
`,
},
async test(dev) {
await using c = await dev.client("/", {
errors: ["index.ts:1:18: error: Browser builds cannot import HTML files."],
});
},
});
devTest("importing html file with text loader (#18154)", {
files: {
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.ts"],
}),
"index.ts": `
import html from "./app.html" with { type: "text" };
console.log(html);
`,
"app.html": "<div>hello world</div>",
},
htmlFiles: ["index.html"],
async test(dev) {
await using c = await dev.client("/", {});
await c.expectMessage("<div>hello world</div>");
},
});
devTest("importing bun on the client", {
files: {
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.ts"],
}),
"index.ts": `
import bun from "bun";
console.log(bun);
`,
},
async test(dev) {
await using c = await dev.client("/", {
errors: ['index.ts:1:17: error: Browser build cannot import Bun builtin: "bun"'],
});
},
});
devTest("import.meta.main", {
files: {
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.ts"],
}),
"index.ts": `
console.log(import.meta.main);
import.meta.hot.accept();
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage(false); // import.meta.main is always false because there is no single entry point
await dev.write(
"index.ts",
`
require;
console.log(import.meta.main);
`,
);
await c.expectMessage(false);
},
});
devTest("commonjs forms", {
timeoutMultiplier: 2,
files: {
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.ts"],
}),
"index.ts": `
import cjs from "./cjs.js";
console.log(cjs);
`,
"cjs.js": `
module.exports.field = {};
`,
},
async test(dev) {
console.log("Initial");
await using c = await dev.client("/");
console.log(" expecting message");
await c.expectMessage({ field: {} });
console.log(" expecting reload");
await c.expectReload(async () => {
console.log(" writing");
await dev.write("cjs.js", `exports.field = "1";`);
console.log(" now reloading");
});
console.log(" expecting message");
await c.expectMessage({ field: "1" });
console.log("Second");
console.log(" expecting reload");
await c.expectReload(async () => {
console.log(" writing");
await dev.write("cjs.js", `let theExports = exports; theExports.field = "2";`);
});
console.log(" expecting message");
await c.expectMessage({ field: "2" });
console.log("Third");
console.log(" expecting reload");
await c.expectReload(async () => {
console.log(" writing");
await dev.write("cjs.js", `let theModule = module; theModule.exports.field = "3";`);
});
console.log(" expecting message");
await c.expectMessage({ field: "3" });
console.log("Fourth");
await c.expectReload(async () => {
await dev.write("cjs.js", `let { exports } = module; exports.field = "4";`);
});
await c.expectMessage({ field: "4" });
console.log("Fifth");
await c.expectReload(async () => {
await dev.write("cjs.js", `var { exports } = module; exports.field = "4.5";`);
});
await c.expectMessage({ field: "4.5" });
console.log("Sixth");
await c.expectReload(async () => {
await dev.write("cjs.js", `let theExports = module.exports; theExports.field = "5";`);
});
await c.expectMessage({ field: "5" });
console.log("Seventh");
await c.expectReload(async () => {
await dev.write("cjs.js", `require; eval("module.exports.field = '6'");`);
});
await c.expectMessage({ field: "6" });
},
});
// --- Barrel optimization tests ---
devTest("barrel optimization skips unused submodules", {
files: {
"index.html": emptyHtmlFile({ scripts: ["index.ts"] }),
"index.ts": `
import { Alpha } from 'barrel-lib';
console.log('got: ' + Alpha);
`,
"node_modules/barrel-lib/package.json": JSON.stringify({
name: "barrel-lib",
version: "1.0.0",
main: "./index.js",
sideEffects: false,
}),
"node_modules/barrel-lib/index.js": `
export { Alpha } from './alpha.js';
export { Beta } from './beta.js';
export { Gamma } from './gamma.js';
`,
"node_modules/barrel-lib/alpha.js": `export const Alpha = "ALPHA";`,
"node_modules/barrel-lib/beta.js": `export const Beta = <<<SYNTAX_ERROR>>>;`,
"node_modules/barrel-lib/gamma.js": `export const Gamma = <<<SYNTAX_ERROR>>>;`,
},
async test(dev) {
// Beta.js and Gamma.js have syntax errors.
// If barrel optimization works, they are never parsed, so no error.
await using c = await dev.client("/");
await c.expectMessage("got: ALPHA");
},
});
devTest("barrel optimization: adding a new import triggers reload", {
files: {
"index.html": emptyHtmlFile({ scripts: ["index.ts"] }),
"index.ts": `
import { Alpha } from 'barrel-lib';
console.log('result: ' + Alpha);
`,
"node_modules/barrel-lib/package.json": JSON.stringify({
name: "barrel-lib",
version: "1.0.0",
main: "./index.js",
sideEffects: false,
}),
"node_modules/barrel-lib/index.js": `
export { Alpha } from './alpha.js';
export { Beta } from './beta.js';
export { Gamma } from './gamma.js';
`,
"node_modules/barrel-lib/alpha.js": `export const Alpha = "ALPHA";`,
"node_modules/barrel-lib/beta.js": `export const Beta = "BETA";`,
"node_modules/barrel-lib/gamma.js": `export const Gamma = "GAMMA";`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("result: ALPHA");
// Add a second import from the barrel — Beta was previously deferred,
// now needs to be loaded. The barrel file should be re-bundled with
// Beta un-deferred.
await c.expectReload(async () => {
await dev.write(
"index.ts",
`
import { Alpha, Beta } from 'barrel-lib';
console.log('result: ' + Alpha + ' ' + Beta);
`,
);
});
await c.expectMessage("result: ALPHA BETA");
// Add a third import
await c.expectReload(async () => {
await dev.write(
"index.ts",
`
import { Alpha, Beta, Gamma } from 'barrel-lib';
console.log('result: ' + Alpha + ' ' + Beta + ' ' + Gamma);
`,
);
});
await c.expectMessage("result: ALPHA BETA GAMMA");
},
});
devTest("barrel optimization: multi-file imports preserved across rebuilds", {
files: {
"index.html": emptyHtmlFile({ scripts: ["index.ts"] }),
"index.ts": `
import { Alpha } from 'barrel-lib';
import { value } from './other';
console.log('result: ' + Alpha + ' ' + value);
`,
"other.ts": `
import { Beta } from 'barrel-lib';
export const value = Beta;
`,
"node_modules/barrel-lib/package.json": JSON.stringify({
name: "barrel-lib",
version: "1.0.0",
main: "./index.js",
sideEffects: false,
}),
"node_modules/barrel-lib/index.js": `
export { Alpha } from './alpha.js';
export { Beta } from './beta.js';
export { Gamma } from './gamma.js';
`,
"node_modules/barrel-lib/alpha.js": `export const Alpha = "ALPHA";`,
"node_modules/barrel-lib/beta.js": `export const Beta = "BETA";`,
"node_modules/barrel-lib/gamma.js": `export const Gamma = "GAMMA";`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("result: ALPHA BETA");
// Edit only other.ts to also import Gamma. Alpha (from index.ts) must
// still be available even though index.ts is not re-parsed.
await c.expectReload(async () => {
await dev.write(
"other.ts",
`
import { Beta, Gamma } from 'barrel-lib';
export const value = Beta + ' ' + Gamma;
`,
);
});
await c.expectMessage("result: ALPHA BETA GAMMA");
},
});
devTest("barrel optimization: export star target not deferred (#27521)", {
files: {
"index.html": emptyHtmlFile({ scripts: ["index.ts"] }),
// The user imports from consumer-lib, which is a non-barrel package
// that imports QueryClient from outer-lib.
"index.ts": `
import { useQuery } from 'consumer-lib';
console.log('result: ' + useQuery());
`,
// consumer-lib is NOT a barrel — it has real code that uses
// QueryClient from outer-lib. This mirrors @refinedev/core
// importing QueryClient from @tanstack/react-query.
"node_modules/consumer-lib/package.json": JSON.stringify({
name: "consumer-lib",
version: "1.0.0",
main: "./index.js",
}),
"node_modules/consumer-lib/index.js": `
import { QueryClient } from 'outer-lib';
export function useQuery() {
const client = new QueryClient();
return client instanceof QueryClient ? 'PASS' : 'FAIL';
}
`,
// outer-lib is a barrel with sideEffects:false that re-exports
// everything from inner-lib via export *. Mirrors @tanstack/react-query.
"node_modules/outer-lib/package.json": JSON.stringify({
name: "outer-lib",
version: "1.0.0",
main: "./index.js",
sideEffects: false,
}),
"node_modules/outer-lib/index.js": `
export * from 'inner-lib';
export { Unrelated } from './unrelated.js';
`,
"node_modules/outer-lib/unrelated.js": `export const Unrelated = "X";`,
// inner-lib is a barrel with sideEffects:false that re-exports
// from submodules. Mirrors @tanstack/query-core. Without the fix,
// the barrel optimizer defers queryClient.js because it doesn't
// know inner-lib is an export-star target (source_index is not
// set in dev-server mode), so QueryClient becomes undefined.
"node_modules/inner-lib/package.json": JSON.stringify({
name: "inner-lib",
version: "1.0.0",
main: "./index.js",
sideEffects: false,
}),
"node_modules/inner-lib/index.js": `
export { QueryClient } from './queryClient.js';
export { Other } from './other.js';
`,
"node_modules/inner-lib/queryClient.js": `
export class QueryClient { constructor() { this.ready = true; } }
`,
"node_modules/inner-lib/other.js": `export const Other = "OTHER";`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("result: PASS");
},
});
devTest("barrel optimization: two export-from blocks pointing to the same source", {
files: {
"index.html": emptyHtmlFile({ scripts: ["index.ts"] }),
"index.ts": `
import { invariant } from 'barrel-lib';
console.log('got: ' + typeof invariant);
`,
"node_modules/barrel-lib/package.json": JSON.stringify({
name: "barrel-lib",
version: "1.0.0",
main: "./index.js",
sideEffects: false,
}),
"node_modules/barrel-lib/index.js": `
export {
createDataProperty,
defineProperty,
} from './utils.js';
export { unrelated } from './other.js';
export {
invariant,
} from './utils.js';
`,
"node_modules/barrel-lib/utils.js": `
export function createDataProperty() {}
export function defineProperty() {}
export function invariant(cond, msg) {
if (!cond) throw new Error(msg);
}
`,
"node_modules/barrel-lib/other.js": `export const unrelated = "OTHER";`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("got: function");
},
});
// Regression: #28886
// Consumer has TWO separate `import { X } from 'barrel'` statements for the
// same barrel. HMR deduplicates the second into the first; the second's
// import record is marked is_unused=true and never gets its path resolved.
// Barrel optimization then fails to see the named import from the dedup'd
// record and marks its target submodule as unused → submodule stays `{}` →
// the export is `undefined` at runtime.
devTest("barrel optimization: two import statements from the same barrel (#28886)", {
// Flakes on darwin in CI (timing); fix is platform-agnostic, coverage via linux/windows/alpine.
skip: ["darwin"],
files: {
"index.html": emptyHtmlFile({ scripts: ["index.ts"] }),
"index.ts": `
import { Alpha } from 'barrel-lib';
import { Beta } from 'barrel-lib';
console.log('got: ' + Alpha() + ' ' + Beta());
`,
"node_modules/barrel-lib/package.json": JSON.stringify({
name: "barrel-lib",
version: "1.0.0",
main: "./index.js",
sideEffects: false,
}),
"node_modules/barrel-lib/index.js": `
export { Alpha } from './alpha.js';
export { Beta } from './beta.js';
export { Gamma } from './gamma.js';
`,
"node_modules/barrel-lib/alpha.js": `export const Alpha = () => "ALPHA";`,
"node_modules/barrel-lib/beta.js": `export const Beta = () => "BETA";`,
"node_modules/barrel-lib/gamma.js": `export const Gamma = () => "GAMMA";`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("got: ALPHA BETA");
},
});
devTest("barrel optimization: namespace re-export cycle through a star-exported module", {
files: {
"index.html": emptyHtmlFile({ scripts: ["index.ts"] }),
"index.ts": `
import { x, y, deepValue } from 'loop-lib';
import { keep } from 'loop-lib/w.js';
import { other } from 'loop-lib/g.js';
console.log('result: ' + typeof x + ' ' + y + ' ' + keep + ' ' + deepValue + ' ' + other);
`,
"node_modules/loop-lib/package.json": JSON.stringify({
name: "loop-lib",
version: "1.0.0",
main: "./index.js",
sideEffects: false,
}),
"node_modules/loop-lib/index.js": `
export * from './t.js';
`,
"node_modules/loop-lib/t.js": `
export { x } from './w.js';
export * from './r.js';
export * from './g.js';
`,
"node_modules/loop-lib/w.js": `
import * as ns from './t.js';
export { ns as x };
export { keep } from './keep.js';
`,
"node_modules/loop-lib/keep.js": `
export const keep = "KEEP";
`,
"node_modules/loop-lib/r.js": `
export const y = "Y";
`,
"node_modules/loop-lib/g.js": `
export { deepValue } from './deep.js';
export { other } from './other.js';
`,
"node_modules/loop-lib/deep.js": `
export const deepValue = "DEEP";
`,
"node_modules/loop-lib/other.js": `
export const other = "OTHER";
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("result: object Y KEEP DEEP OTHER");
},
});
+707
View File
@@ -0,0 +1,707 @@
// CSS tests concern bundling bugs with CSS files
import { expect } from "bun:test";
import assert from "node:assert";
import { devTest, emptyHtmlFile, imageFixtures } from "../bake-harness";
devTest("css file with syntax error does not kill old styles", {
files: {
"styles.css": `
body {
color: red;
}
`,
"index.html": emptyHtmlFile({
styles: ["styles.css"],
body: `hello world`,
}),
},
async test(dev) {
await using c = await dev.client("/");
await c.style("body").color.expect.toBe("red");
await dev.write(
"styles.css",
`
body {
color: red;
background-color
}
`,
{
errors: ["styles.css:4:1: error: Unexpected end of input"],
},
);
await c.style("body").color.expect.toBe("red");
await dev.write(
"styles.css",
`
body {
color: red;
background-color: blue;
}
`,
);
await c.style("body").backgroundColor.expect.toBe("#00f");
await dev.write("styles.css", ` `, { dedent: false });
await c.style("body").notFound();
},
});
devTest("css file with initial syntax error gets recovered", {
files: {
"index.html": emptyHtmlFile({
styles: ["styles.css"],
body: `hello world`,
}),
"styles.css": `
body {
color: red;
}}
`,
},
async test(dev) {
await using c = await dev.client("/", {
errors: ["styles.css:3:3: error: Unexpected end of input"],
});
// hard reload to dismiss the error overlay
await c.expectReload(async () => {
await dev.write(
"styles.css",
`
body {
color: red;
}
`,
);
});
await c.style("body").color.expect.toBe("red");
await dev.write(
"styles.css",
`
body {
color: blue;
}
`,
);
await c.style("body").color.expect.toBe("#00f");
await dev.write(
"styles.css",
`
body {
color: blue;
}}
`,
{
errors: ["styles.css:3:3: error: Unexpected end of input"],
},
);
},
});
devTest("add new css import later", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
body: `hello world`,
}),
"index.ts": `
// import "./styles.css";
export default function () {
return "hello world";
}
import.meta.hot.accept();
`,
"styles.css": `
body {
color: red;
}
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.style("body").notFound();
await dev.patch("index.ts", { find: "// import", replace: "import" });
await c.style("body").color.expect.toBe("red");
await dev.patch("index.ts", { find: "import", replace: "// import" });
await c.style("body").notFound();
},
});
devTest("css import another css file", {
files: {
"index.html": emptyHtmlFile({
styles: ["styles.css"],
}),
"styles.css": `
@import "./second.css";
body {
color: red;
}
`,
"second.css": `
h1 {
color: blue;
}
`,
},
async test(dev) {
await using c = await dev.client("/");
// Verify initial build
await c.style("h1").color.expect.toBe("#00f");
await c.style("body").color.expect.toBe("red");
// Hot reload
await dev.write(
"second.css",
`
h1 {
color: green;
}
`,
);
await c.style("h1").color.expect.toBe("green");
await c.style("body").color.expect.toBe("red");
// Check that the styles still work after a reload
await c.hardReload();
await c.style("h1").color.expect.toBe("green");
await c.style("body").color.expect.toBe("red");
},
});
devTest("asset referenced in css", {
files: {
"index.html": emptyHtmlFile({
styles: ["styles.css"],
}),
"styles.css": `
body {
background-image: url(./bun.png);
}
`,
"bun.png": imageFixtures.bun,
},
async test(dev) {
await using c = await dev.client("/");
let backgroundImage = await c.style("body").backgroundImage;
assert(backgroundImage);
await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun);
// The served stylesheet is the chunk with the asset reference resolved and
// nothing else: CSS never gets a source map, so no debugId trailer either.
const stylesheetHref = (await (await dev.fetch("/")).text()).match(/<link rel="stylesheet"[^>]*href="([^"]+)"/)![1];
const stylesheet = await (await dev.fetch(stylesheetHref)).text();
expect(stylesheet).toContain("background-image:");
expect(stylesheet).not.toContain("debugId");
await dev.write("bun.png", imageFixtures.bun2);
backgroundImage = await c.style("body").backgroundImage;
assert(backgroundImage);
await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun2);
},
});
devTest("syntax error crash", {
files: {
"styles.css": `
body {
background-image: url
}
`,
"index.html": emptyHtmlFile({
styles: ["styles.css"],
body: `hello world`,
}),
},
async test(dev) {
expect((await dev.fetch("/")).status).toBe(200);
// previously: panic(main thread): Asset double unref: 0000000000000000
await dev.patch("styles.css", { find: "url\n", replace: "url(\n" });
expect((await dev.fetch("/")).status).toBe(500);
},
});
devTest("css url resolve error on hot reload is recoverable", {
files: {
"styles.css": `
body {
color: red;
}
`,
"index.html": emptyHtmlFile({
styles: ["styles.css"],
body: `hello world`,
}),
},
async test(dev) {
{
await using c = await dev.client("/");
await c.style("body").color.expect.toBe("red");
// A CSS file that parses but fails import resolution must fail the
// rebuild with an error instead of being treated as a valid CSS chunk.
// previously: panic: assertion failed: !chunk.content.is_css()
await dev.write(
"styles.css",
`
body {
background-image: url(./missing.png);
}
`,
{
errors: ['styles.css:2:21: error: Could not resolve: "./missing.png"'],
},
);
expect((await dev.fetch("/")).status).toBe(500);
}
// Recovery is checked without a connected client: when a failed CSS root
// recovers, the patch currently ships the HTML route as a JS module
// without the route-reload flag, which trips a client-side debug assert
// (tracked in https://github.com/oven-sh/bun/issues/31908).
await dev.write(
"styles.css",
`
body {
color: blue;
}
`,
);
expect((await dev.fetch("/")).status).toBe(200);
},
});
devTest("circular css imports handle hot reload", {
files: {
"index.html": emptyHtmlFile({
styles: ["a.css"],
body: `
<div class="a">hello</div>
<div class="b">hello</div>
`,
}),
"a.css": `
@import "./b.css";
.a { color: red; }
`,
"b.css": `
@import "./a.css";
.b { color: blue; }
`,
},
async test(dev) {
await using client = await dev.client("/");
await client.style(".a").color.expect.toBe("red");
await client.style(".b").color.expect.toBe("#00f");
// Modify one of the circular dependencies
await dev.write(
"a.css",
`
@import "./b.css";
.a { color: green; }
`,
);
await client.style(".a").color.expect.toBe("green");
await client.style(".b").color.expect.toBe("#00f");
},
});
devTest("asset index stays valid after another css root is freed", {
// Two independent CSS roots each get an entry in `DevServer.Assets`.
// When the first one is freed (via a syntax error), its slot is removed
// with `swapRemoveAt`, which moves the second entry into the first slot.
// The second CSS file's `path_map` entry must be patched to the new slot
// so the next edit does not read past the end of the asset array.
files: {
"first.html": emptyHtmlFile({
styles: ["first.css"],
body: `<div class="first">hello</div>`,
}),
"second.html": emptyHtmlFile({
styles: ["second.css"],
body: `<div class="second">hello</div>`,
}),
"first.css": `
.first { color: red; }
`,
"second.css": `
.second { color: blue; }
`,
},
async test(dev) {
// Bundle /first before /second so that `first.css` is registered at
// a lower asset index than `second.css`.
{
await using c1 = await dev.client("/first");
await c1.style(".first").color.expect.toBe("red");
}
await using c2 = await dev.client("/second");
await c2.style(".second").color.expect.toBe("#00f");
// Failing `first.css` frees its asset slot via `unrefByPath`, which
// swap-removes it and moves the data for `second.css` into its slot.
await dev.write(
"first.css",
`
.first { color: red; }}
`,
{ errors: null },
);
// Editing `second.css` now goes through `replacePath`, which looks up
// its `path_map` entry. Previously this index was stale (pointed at
// `files.len`), causing an out-of-bounds read into `refs`/`files`.
await dev.write(
"second.css",
`
.second { color: green; }
`,
{ errors: null },
);
await c2.style(".second").color.expect.toBe("green");
// Fix the first file and ensure both pages still work afterwards.
await dev.write(
"first.css",
`
.first { color: yellow; }
`,
);
await c2.style(".second").color.expect.toBe("green");
{
await using c1 = await dev.client("/first");
await c1.style(".first").color.expect.toBe("#ff0");
}
},
});
devTest("css hot update carries the edited stylesheet when another root fails in the same rebuild", {
files: {
"bunfig.toml": `
[serve.static]
plugins = ["./css-plugin.ts"]
`,
"css-plugin.ts": `
export default {
name: "css-plugin",
setup(build) {
build.onResolve({ filter: /missing\\.png$/ }, () => undefined);
},
};
`,
"first.html": emptyHtmlFile({
styles: ["first.css"],
body: `<div class="first">hello</div>`,
}),
"second.html": emptyHtmlFile({
styles: ["second.css"],
body: `<div class="second">hello</div>`,
}),
"first.css": `
.first { color: red; }
`,
"second.css": `
.second { color: blue; }
`,
},
async test(dev) {
{
await using c1 = await dev.client("/first");
await c1.style(".first").color.expect.toBe("red");
await c1.style(".second").notFound();
await using c2 = await dev.client("/second");
await c2.style(".second").color.expect.toBe("#00f");
{
await using batch = await dev.batchChanges({ errors: null });
await dev.write(
"first.css",
`
.first {
background-image: url(./missing.png);
}
`,
);
await dev.write(
"second.css",
`
.second { color: green; }
`,
);
}
await c2.style(".second").color.expect.toBe("green");
await c1.style(".second").notFound();
}
await dev.write(
"first.css",
`
.first { color: yellow; }
`,
);
{
await using c2 = await dev.client("/second");
await c2.style(".second").color.expect.toBe("green");
}
{
await using c1 = await dev.client("/first");
await c1.style(".first").color.expect.toBe("#ff0");
}
},
});
devTest("multiple stylesheets importing same dependency", {
files: {
"first.html": emptyHtmlFile({
styles: ["first.css"],
body: `
<div class="first">hello</div>
<div class="shared">hello</div>
`,
}),
"second.html": emptyHtmlFile({
styles: ["second.css"],
body: `
<div class="second">hello</div>
<div class="shared">hello</div>
`,
}),
"first.css": `
@import "./shared.css";
.first { color: red; }
`,
"second.css": `
@import "./shared.css";
.second { color: blue; }
`,
"shared.css": `
.shared { color: green; }
`,
},
async test(dev) {
await using c1 = await dev.client("/first");
await using c2 = await dev.client("/second");
await c1.style(".first").color.expect.toBe("red");
await c2.style(".second").color.expect.toBe("#00f");
await c1.style(".shared").color.expect.toBe("green");
await c2.style(".shared").color.expect.toBe("green");
await dev.write(
"shared.css",
`
.shared { color: yellow; }
`,
);
await c1.style(".shared").color.expect.toBe("#ff0");
await c2.style(".shared").color.expect.toBe("#ff0");
},
});
devTest("removing and re-adding css import", {
files: {
"index.html": emptyHtmlFile({
styles: ["main.css"],
}),
"main.css": `
@import "./colors.css";
.main { background: white; }
`,
"colors.css": `
.colored { color: blue; }
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.style(".colored").color.expect.toBe("#00f");
// Remove the import
await dev.write(
"main.css",
`
/* @import "./colors.css"; */
.main { background: white; }
`,
);
await c.style(".colored").notFound();
// A change to 'colors.css' should not trigger a rebuild of 'main.css', nor notify any clients.
await c.expectNoWebSocketActivity(async () => {
await dev.write(
"colors.css",
`
.colored { color: yellow; }
`,
);
await dev.write(
"colors.css",
`
.colored { color: blue; }
`,
);
});
await c.style(".colored").notFound();
// Re-add the import
await dev.write(
"main.css",
`
@import "./colors.css";
.main { background: white; }
`,
);
await c.style(".colored").color.expect.toBe("#00f");
await c.style(".main").backgroundColor.expect.toBe("#fff");
},
});
devTest("changing html file with link tag works", {
files: {
"index.html": emptyHtmlFile({
styles: ["styles.css"],
}),
"styles.css": `
.test {
color: blue;
font-size: 24px;
}
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.style(".test").color.expect.toBe("#00f");
await c.style(".test").fontSize.expect.toBe("24px");
await c.expectReload(async () => {
await dev.writeNoChanges("index.html");
});
await c.style(".test").color.expect.toBe("#00f");
await c.style(".test").fontSize.expect.toBe("24px");
await c.hardReload();
await c.style(".test").color.expect.toBe("#00f");
await c.style(".test").fontSize.expect.toBe("24px");
await dev.write(
"index.html",
emptyHtmlFile({
styles: ["other.css"],
}),
{
errors: ['index.html: error: Could not resolve: "other.css". Maybe you need to "bun install"?'],
},
);
await c.expectReload(async () => {
await dev.write(
"other.css",
`
.other {
color: red;
}
`,
);
});
await c.style(".other").color.expect.toBe("red");
await c.style(".test").notFound();
await c.expectReload(async () => {
await dev.write(
"index.html",
emptyHtmlFile({
styles: ["styles.css"],
}),
);
});
await c.style(".test").color.expect.toBe("#00f");
await c.style(".test").fontSize.expect.toBe("24px");
await c.style(".other").notFound();
await c.expectReload(async () => {
await dev.write(
"index.html",
emptyHtmlFile({
styles: ["other.css", "styles.css"],
}),
);
});
await c.style(".other").color.expect.toBe("red");
await c.style(".test").color.expect.toBe("#00f");
await c.style(".test").fontSize.expect.toBe("24px");
},
});
devTest("css import before create", {
files: {
"index.html": emptyHtmlFile({
styles: ["styles.css"],
body: `
<div>HELLO</div>
`,
}),
},
async test(dev) {
await using c = await dev.client("/", {
errors: ['index.html: error: Could not resolve: "styles.css". Maybe you need to "bun install"?'],
});
await dev.fetch("/").expect.not.toContain("HELLO");
await dev.write(
"styles.css",
`
body {
background-image: url(bun.png);
}
`,
{
errors: ['styles.css:2:21: error: Could not resolve: "bun.png". Maybe you need to "bun install"?'],
},
);
await c.expectReload(async () => {
await dev.write("bun.png", imageFixtures.bun);
});
const backgroundImage = await c.style("body").backgroundImage;
assert(backgroundImage);
await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun);
await dev.fetch("/").expect.toContain("HELLO");
},
});
devTest("css import before create project relative", {
files: {
"html/index.html": emptyHtmlFile({
styles: ["/style/styles.css"],
body: `
<div>HELLO</div>
`,
}),
},
async test(dev) {
dev.mkdir("style"); // (See DevServer.zig "BUN-10968")
await using c = await dev.client("/", {
errors: ['html/index.html: error: Could not resolve: "/style/styles.css"'],
});
await dev.fetch("/").expect.not.toContain("HELLO");
await dev.write(
"style/styles.css",
`
body {
background-image: url(/assets/bun.png);
}
`,
{
errors: ['style/styles.css:2:21: error: Could not resolve: "/assets/bun.png"'],
},
);
await c.expectNoWebSocketActivity(async () => {
await dev.write("assets/bun.png", imageFixtures.bun, { errors: null });
await dev.delete("assets/bun.png", { errors: null });
});
await dev.fetch("/").expect.not.toContain("HELLO");
await dev.write(
"style/styles.css",
`
body {
background-image: url(../assets/bun.png);
}
`,
{
errors: ['style/styles.css:2:21: error: Could not resolve: "../assets/bun.png"'],
},
);
await c.expectReload(async () => {
await dev.write("assets/bun.png", imageFixtures.bun);
});
const backgroundImage = await c.style("body").backgroundImage;
assert(backgroundImage);
await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun);
await dev.fetch("/").expect.toContain("HELLO");
},
});
function extractCssUrl(backgroundImage: string): string {
const url = backgroundImage.match(/url\((['"])(.*?)\1\)/);
if (!url) {
throw new Error("No url found in background-image: " + backgroundImage);
}
return url[2];
}
+55
View File
@@ -0,0 +1,55 @@
// these tests involve ensuring certain libraries are working correctly. it
// should be preferred to write specific tests for the bugs that these libraries
// discovered, but it easy and still a reasonable idea to just test the library
// entirely.
import { expect } from "bun:test";
import { devTest } from "../bake-harness";
// Bugs discovered thanks to Svelte:
// - Circular import situations
// - export { live_binding }
// - export { x as y }
devTest("svelte component islands example", {
fixture: "svelte-component-islands",
timeoutMultiplier: 2,
skip: ["win32"],
async test(dev) {
const html = await dev.fetch("/").text();
if (html.includes("Bun__renderFallbackError")) throw new Error("failed");
// Expect SSR
expect(html).toContain('self.$islands={"pages/_Counter.svelte":[[0,"default",{initial:5}]]}');
expect(html).toContain(`<p>This is my svelte server component (non-interactive)</p> <p>Bun v${Bun.version}</p>`);
expect(html).toContain(`>This is a client component (interactive island)</p>`);
await using c = await dev.client("/");
expect(await c.elemText("button")).toBe("Clicked 5 times");
const result = await c.js`
document.querySelector("button").click();
await new Promise(resolve => setTimeout(resolve, 10));
return document.querySelector("button").textContent;
`;
expect(result).toBe("Clicked 6 times");
await c.expectReload(async () => {
await dev.patch("pages/index.svelte", {
find: "non-interactive",
replace: "awesome",
});
});
await dev.patch("pages/_Counter.svelte", {
find: "interactive island",
replace: "magical",
});
expect(await c.elemText("#counter_text")).toInclude("magical");
const html2 = await dev.fetch("/").text();
if (html2.includes("Bun__renderFallbackError")) throw new Error("failed");
// Expect SSR
expect(html2).toContain(`<p>This is my svelte server component (awesome)</p> <p>Bun v${Bun.version}</p>`);
expect(html2).toContain(`>This is a client component (magical)</p>`);
},
});
+567
View File
@@ -0,0 +1,567 @@
// ESM tests are about various esm features in development mode.
import { expect } from "bun:test";
import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness";
const liveBindingTest = devTest("live bindings with `var`", {
framework: minimalFramework,
files: {
"state.ts": `
export var value = 0;
export function increment() {
value++;
}
`,
"routes/index.ts": `
import { value, increment } from '../state';
export default function(req, meta) {
increment();
return new Response('State: ' + value);
}
`,
},
async test(dev) {
await dev.fetch("/").equals("State: 1");
await dev.fetch("/").equals("State: 2");
await dev.fetch("/").equals("State: 3");
await dev.patch("routes/index.ts", {
find: "State",
replace: "Value",
});
await dev.fetch("/").equals("Value: 4");
await dev.fetch("/").equals("Value: 5");
await dev.write(
"state.ts",
`
export var value = 0;
export function increment() {
value--;
}
`,
);
await dev.fetch("/").equals("Value: -1");
await dev.fetch("/").equals("Value: -2");
},
});
devTest("live bindings through export clause", {
framework: minimalFramework,
files: {
"state.ts": `
export var value = 0;
export function increment() {
value++;
}
`,
"proxy.ts": `
import { value } from './state';
export { value as live };
`,
"routes/index.ts": `
import { increment } from '../state';
import { live } from '../proxy';
export default function(req, meta) {
increment();
return new Response('State: ' + live);
}
`,
},
test: liveBindingTest.test,
});
devTest("live bindings through export from", {
framework: minimalFramework,
files: {
"state.ts": `
export var value = 0;
export function increment() {
value++;
}
`,
"proxy.ts": `
export { value as live } from './state';
`,
"routes/index.ts": `
import { increment } from '../state';
import { live } from '../proxy';
export default function(req, meta) {
increment();
return new Response('State: ' + live);
}
`,
},
test: liveBindingTest.test,
});
// devTest("live bindings through export star", {
// framework: minimalFramework,
// files: {
// "state.ts": `
// export var value = 0;
// export function increment() {
// value++;
// }
// `,
// "proxy.ts": `
// export * from './state';
// `,
// "routes/index.ts": `
// import { increment } from '../state';
// import { live } from '../proxy';
// export default function(req, meta) {
// increment();
// return new Response('State: ' + live);
// }
// `,
// },
// test: liveBindingTest.test,
// });
devTest("export { x as y }", {
framework: minimalFramework,
files: {
"module.ts": `
function x(value) {
return value + 1;
}
export { x as y };
`,
"routes/index.ts": `
import { y } from '../module';
export default function(req, meta) {
return new Response('Value: ' + y(1));
}
`,
},
async test(dev) {
await dev.fetch("/").equals("Value: 2");
await dev.patch("module.ts", {
find: "1",
replace: "2",
});
await dev.fetch("/").equals("Value: 3");
},
});
devTest("import { x as y }", {
framework: minimalFramework,
files: {
"module.ts": `
export const x = 1;
`,
"routes/index.ts": `
import { x as y } from '../module';
export default function(req, meta) {
return new Response('Value: ' + y);
}
`,
},
async test(dev) {
await dev.fetch("/").equals("Value: 1");
await dev.patch("module.ts", {
find: "1",
replace: "2",
});
await dev.fetch("/").equals("Value: 2");
},
});
devTest("import { default as y }", {
framework: minimalFramework,
files: {
"module.ts": `
export default 1;
`,
"routes/index.ts": `
import { default as y } from '../module';
export default function(req, meta) {
return new Response('Value: ' + y);
}
`,
},
async test(dev) {
await dev.fetch("/").equals("Value: 1");
await dev.patch("module.ts", {
find: "1",
replace: "2",
});
await dev.fetch("/").equals("Value: 2");
},
});
devTest("export { default as y }", {
framework: minimalFramework,
files: {
"module.ts": `
export default 1;
`,
"middle.ts": `
export { default as y } from './module';
`,
"routes/index.ts": `
import { y } from '../middle';
export default function(req, meta) {
return new Response('Value: ' + y);
}
`,
},
async test(dev) {
await dev.fetch("/").equals("Value: 1");
await dev.patch("module.ts", {
find: "1",
replace: "2",
});
await dev.fetch("/").equals("Value: 2");
},
});
devTest("export * as namespace", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
import { ns as renamed } from './module';
if (typeof renamed !== 'object') throw new Error('renamed should be an object');
if (renamed.x !== 1) throw new Error('renamed.x should be 1');
if (renamed.y !== 2) throw new Error('renamed.y should be 2');
console.log('PASS');
`,
"module.ts": `
export * as ns from './module2';
`,
"module2.ts": `
export const x = 1;
export const y = 2;
export const ns = "FAIL";
`,
},
async test(dev) {
await using c = await dev.client();
await c.expectMessage("PASS");
},
});
devTest("ESM <-> CJS sync", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
const mod = require('./esm');
if (!mod.__esModule) throw new Error('mod.__esModule should be set');
console.log('PASS');
`,
"esm.ts": `
export const x = 1;
`,
},
async test(dev) {
await using c = await dev.client();
await c.expectMessage("PASS");
},
});
devTest("ESM <-> CJS (async)", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
const esmImport = await import('./esm'); // TODO: implement sync ESM
const mod = require('./esm');
if (!mod.__esModule) throw new Error('mod.__esModule should be set');
if (esmImport.x !== mod.x) throw new Error('esmImport.x should be equal to mod.x');
if ('__esModule' in esmImport) throw new Error('esmImport.__esModule should be unset');
console.log('PASS');
`,
"esm.ts": `
export const x = 1;
`,
},
async test(dev) {
await using c = await dev.client();
await c.expectMessage("PASS");
},
});
devTest("importer tracking survives flipping a module from ESM to CJS", {
// https://github.com/oven-sh/bun/issues/31942
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
import './dep';
import.meta.hot.data.runs = (import.meta.hot.data.runs ?? 0) + 1;
console.log('index run ' + import.meta.hot.data.runs);
import.meta.hot.accept();
`,
"dep.ts": `
export const value = 'esm';
`,
"leaf.ts": `
console.log('leaf 1');
export const value = 'leaf1';
`,
},
async test(dev) {
await using c = await dev.client();
await c.expectMessage("index run 1");
// Flip `dep` from ESM to CJS. The dead branch gets `leaf` bundled (direct
// `require` calls are statically rewritten to `hmr.require`, which binds
// `this` correctly), while the indirect `m.require(...)` call is emitted
// verbatim and goes through the `require` function bound onto the
// replacement CJS module object, which must record `dep` as an importer
// of `leaf`.
await dev.write(
"dep.ts",
`
if (globalThis.__never_set) require('./leaf');
const m = module;
module.exports = { value: m.require(module.id.replace('dep', 'leaf')).value };
`,
);
await c.expectMessage("leaf 1", "index run 2");
// Editing `leaf` must propagate through the flipped module up to the
// self-accepting root as a hot update. If the importer edge was dropped,
// the dev server forces a full page reload instead (the client harness
// fails on unexpected reloads).
await dev.write(
"leaf.ts",
`
console.log('leaf 2');
export const value = 'leaf2';
`,
);
await c.expectMessage("leaf 2", "index run 3");
},
});
devTest("cannot require a module with top level await", {
// TODO: after the module-loader rewrite the dev server's /_bun/report_error
// handler can hang (never responds), so the client overlay never mounts and
// expectErrorOverlay times out. The error itself is thrown correctly.
// Previously gated on !(isCI && isASAN) for the same symptom. Tracked for
// follow-up — re-enable once the report_error hang is fixed.
skip: ["ci"],
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
const mod = require('./esm');
console.log('FAIL');
`,
"esm.ts": `
console.log("FAIL");
import { hello } from './dir';
hello;
`,
"dir/index.ts": `
import './async';
`,
"dir/async.ts": `
console.log("FAIL");
await 1;
`,
},
async test(dev) {
await using c = await dev.client("/", {
errors: [
`error: Cannot require "esm.ts" because "dir/async.ts" uses top-level await, but 'require' is a synchronous operation.`,
],
});
},
});
devTest("function that is assigned to should become a live binding", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
// 1. basic test
import { live, change } from "./live.js";
{
if (live() !== 1) throw new Error("live() should be 1");
change();
if (live() !== 2) throw new Error("live() should be 2");
}
// 2. integration test with @babel/runtime
import inheritsLoose from "./inheritsLoose.js";
{
function A() {}
function B() {}
inheritsLoose(B, A);
}
console.log('PASS');
`,
"live.js": `
export function live() {
return 1;
}
export function change() {
live = function() {
return 2;
}
}
`,
"inheritsLoose.js": `
import setPrototypeOf from "./setPrototypeOf.js";
function _inheritsLoose(t, o) {
t.prototype = Object.create(o.prototype), t.prototype.constructor = t, setPrototypeOf(t, o);
}
export { _inheritsLoose as default };
`,
"setPrototypeOf.js": `
function _setPrototypeOf(t, e) {
return _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function (t, e) {
return t.__proto__ = e, t;
}, _setPrototypeOf(t, e);
}
export { _setPrototypeOf as default };
`,
},
async test(dev) {
await using c = await dev.client();
await c.expectMessage("PASS");
},
});
devTest("browser field is used", {
files: {
// Ensure the package.json gets parsed before the HTML is bundled.
"bunfig.toml": `
preload = [
"axios/lib/utils.js",
]
`,
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"node_modules/axios/package.json": JSON.stringify({
name: "axios",
version: "1.0.0",
browser: {
"./lib/utils.js": "./lib/utils.browser.js",
},
}),
"node_modules/axios/lib/utils.js": `
export default "FAIL";
`,
"node_modules/axios/lib/utils.browser.js": `
export default "PASS";
`,
"index.ts": `
import axios from "axios/lib/utils.js";
console.log(axios);
`,
},
async test(dev) {
await using c = await dev.client();
await c.expectMessage("PASS");
},
});
devTest("browser console forwarding strips terminal control bytes", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
console.log("loaded");
`,
// The harness-generated bun.app.ts does not enable the dev console echo,
// so provide one directly. `htmlFiles: []` tells the harness not to
// generate its own config from index.html.
"bun.app.ts": `
import html from "./index.html";
export default {
static: {
"/": html,
},
development: {
console: true,
},
fetch(req) {
return new Response("Not Found", { status: 404 });
},
};
`,
},
htmlFiles: [],
async test(dev) {
// The harness already holds an open /_bun/hmr websocket in `dev.socket`.
// A ConsoleLog frame is 'l' (message id) + 'l' (kind = log) + payload;
// the payload is echoed to the dev server's terminal when
// `development.console` is enabled. Send a payload carrying an OSC 52
// clipboard-write sequence and assert the escape introducer never
// reaches the terminal.
dev.socket!.send("ll" + "\x1b]52;c;aGVsbG8=\x07" + "clipboard-probe-end");
const filtered = await dev.output.waitForLine(/\[browser\].*clipboard-probe-end/);
expect(filtered.input).not.toContain("\x1b]52");
expect(filtered.input).not.toContain("\x07");
// A plain printable payload is still forwarded verbatim.
dev.socket!.send("ll" + "plain-console-probe");
const plain = await dev.output.waitForLine(/\[browser\].*plain-console-probe/);
expect(plain.input).toContain("plain-console-probe");
},
});
devTest("error report endpoint tolerates a browser url whose normalized origin is longer than the input", {
framework: minimalFramework,
files: {
"routes/index.ts": `
export default function (req, meta) {
return new Response('OK');
}
`,
},
async test(dev) {
// /_bun/report_error payload: name, message and browser-url as
// (u32-LE length + bytes) each, followed by a u32-LE stack-frame count.
const enc = new TextEncoder();
function str32(s: string) {
const bytes = enc.encode(s);
const out = new Uint8Array(4 + bytes.length);
new DataView(out.buffer).setUint32(0, bytes.length, true);
out.set(bytes, 4);
return out;
}
// "http:h" serializes to "http://h/", so the parser reports an origin
// length (9) that is longer than the 6-byte input.
const parts = [str32("ReportName"), str32("report-message-sentinel"), str32("http:h"), new Uint8Array(4)];
const body = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let offset = 0;
for (const part of parts) {
body.set(part, offset);
offset += part.length;
}
// Fire the report without awaiting the response (the handler's reply
// path is independently flaky; see the skip note on the top-level-await
// test above). The handler logs the reported error to the terminal only
// after it has parsed the payload, including the malformed browser url.
dev.fetch("/_bun/report_error", { method: "POST", body }).catch(() => {});
await dev.output.waitForLine(/report-message-sentinel/);
// The dev server is still alive and serving requests.
await dev.fetch("/").equals("OK");
},
});
devTest("html routes reject requests whose host header does not match the dev server", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
console.log("loaded");
`,
},
async test(dev) {
// A request that reaches the listening socket but carries a foreign Host
// header (the shape of a DNS-rebound origin) must not receive the HTML
// document, which embeds the secret-bearing /_bun/client/... script URL.
const rebound = await dev.fetch("/", { headers: { Host: "rebound-host.example" } });
const reboundBody = await rebound.text();
expect(reboundBody).not.toContain("/_bun/client/");
expect(rebound.status).toBe(403);
// The same request with the dev server's own host still serves the page
// and its client bundle script tag.
const normal = await dev.fetch("/");
expect(await normal.text()).toContain("/_bun/client/");
expect(normal.status).toBe(200);
},
});
+644
View File
@@ -0,0 +1,644 @@
// Hot tests ensure that the `import.meta.hot` interface is functional
import { expect } from "bun:test";
import { renameSync, unlinkSync, writeFileSync } from "node:fs";
import { devTest, emptyHtmlFile } from "../bake-harness";
devTest("import.meta.hot.accept basic", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
console.log("Hello, world!");
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("Hello, world!");
await c.expectReload(async () => {
await dev.write(
"index.ts",
`
console.log("Hello, Bun!");
import.meta.hot.accept(newModule => {
console.log(Object.keys(newModule));
console.log(newModule.method());
});
`,
);
});
await c.expectMessage("Hello, Bun!");
await dev.write(
"index.ts",
`
export function method() {
return "Bun";
}
import.meta.hot.accept(newModule => {
console.log(Object.keys(newModule));
});
`,
);
await c.expectMessage(["method"], "Bun");
await dev.write(
"index.ts",
`
console.log("Without anything.");
`,
);
await c.expectMessage("Without anything.", []);
await c.expectReload(async () => {
await dev.writeNoChanges("index.ts");
});
await c.expectMessage("Without anything.");
},
});
devTest("import.meta.hot.accept patches imports", {
files: {
"index.html": emptyHtmlFile({
scripts: ["a.ts"],
}),
"a.ts": `
import { doSomething } from './b';
console.log("A");
globalThis.callFunction = () => doSomething();
`,
"b.ts": `
import { reasonableState, inc } from './c';
console.log("B");
let b = 0;
export function doSomething() {
using _ = { [Symbol.dispose]: inc };
return "A!" + (b++) + "!" + (reasonableState);
}
import.meta.hot.accept();
`,
"c.ts": `
export let reasonableState = 0;
export function inc() {
reasonableState++;
}
console.log("C");
// import.meta.hot.accept();
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("C", "B", "A");
expect(await c.js<string>`callFunction()`).toBe("A!0!0");
expect(await c.js<string>`callFunction()`).toBe("A!1!1");
await dev.patch("c.ts", { find: "0", replace: "5" });
await c.expectMessage("C", "B"); // C does not self-accept
expect(await c.js<string>`callFunction()`).toBe("A!0!5");
expect(await c.js<string>`callFunction()`).toBe("A!1!6");
await dev.patch("b.ts", { find: "A!", replace: "B!" });
await c.expectMessage("B"); // B does not cause C to re-evaluate
expect(await c.js<string>`callFunction()`).toBe("B!0!7");
expect(await c.js<string>`callFunction()`).toBe("B!1!8");
await dev.patch("c.ts", { find: "// ", replace: "" });
await c.expectMessage("C", "B"); // C does not self-accept YET
expect(await c.js<string>`callFunction()`).toBe("B!0!5");
expect(await c.js<string>`callFunction()`).toBe("B!1!6");
await dev.patch("c.ts", { find: "import.meta.hot.accept();", replace: "" });
await c.expectMessage("C"); // C self accepted even if the new one doesnt
expect(await c.js<string>`callFunction()`).toBe("B!2!5");
expect(await c.js<string>`callFunction()`).toBe("B!3!6");
},
});
devTest("import.meta.hot.accept specifier", {
timeoutMultiplier: 3,
files: {
"index.html": emptyHtmlFile({
scripts: ["a.ts"],
}),
// a
// b c
// d
"a.ts": `
import './b';
import './c';
console.log("A");
`,
"b.ts": `
import './d';
console.log("B");
import.meta.hot.accept("oh no", (newModule) => {
console.log('B:' + newModule.default);
})
`,
"c.ts": `
import './d';
console.log("C");
`,
"d.ts": `
console.log("D");
export default "hey!";
queueMicrotask(() => {
console.log("end");
});
`,
"unrelated.ts": `
export default "unrelated";
`,
},
async test(dev) {
{
await using c = await dev.client("/", {
errors: [
"b.ts:3:24: error: Dependencies to `import.meta.hot.accept` must be statically analyzable module specifiers matching direct imports.",
],
});
await dev.patch("b.ts", {
find: "oh no",
replace: "./d.ts",
errors: [
"b.ts:3:24: error: Dependencies to `import.meta.hot.accept` must be statically analyzable module specifiers matching direct imports.",
],
});
await c.expectReload(async () => {
await dev.patch("b.ts", { find: "./d.ts", replace: "./d" });
});
// Module evaluation order is guaranteed since there are no top-level
// await. `hmr-module.ts` does not use promises for synchronous ESM.
await c.expectMessage("D", "B", "C", "A", "end");
await c.expectReload(async () => {
// D -> C -> A causes a page reload.
await dev.write(
"d.ts",
`
console.log("D2");
export default "hey2!";
`,
);
});
await c.expectMessage("D2", "B", "C", "A");
}
await dev.write(
"c.ts",
`
import './d';
import './unrelated';
console.log("C");
import.meta.hot.accept();
`,
);
{
await using c = await dev.client("/");
await c.expectMessage("D2", "B", "C", "A");
await dev.write(
"d.ts",
`
console.log("D3");
export default "hey3!";
`,
);
await c.expectMessage("D3", "C", "B:hey3!");
await dev.write(
"c.ts",
`
import './d';
import './unrelated';
console.log("C");
import.meta.hot.accept("oh no", (newModule) => {
console.log('C:' + newModule.default);
});
`,
{
errors: [
"c.ts:4:24: error: Dependencies to `import.meta.hot.accept` must be statically analyzable module specifiers matching direct imports.",
],
},
);
await dev.patch("c.ts", {
find: "oh no",
replace: "./d",
});
await c.expectMessage("C"); // no-reload because prev self-accepted
await dev.write(
"d.ts",
`
console.log("D4");
export default "hey4!";
import.meta.hot.accept();
`,
);
// This order is guaranteed regardless of top-level await if it had existed.
await c.expectMessage("D4", "B:hey4!", "C:hey4!");
await dev.write(
"d.ts",
`
console.log("D5");
export default "hey5!";
import.meta.hot.accept();
`,
);
await c.expectMessage("D5", "B:hey5!", "C:hey5!");
await c.hardReload();
await c.expectMessage("D5", "B", "C", "A");
await dev.write(
"d.ts",
`
console.log("D6");
export default "hey6!";
import.meta.hot.accept();
`,
);
await c.expectMessage("D6", "B:hey6!", "C:hey6!");
}
},
});
devTest("import.meta.hot.accept multiple modules", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
import { count } from "./counter.ts";
import { name } from "./name.ts";
console.log("Initial: " + name + " " + count);
import.meta.hot.accept(["./counter.ts", "./name.ts"], (newModules) => {
if (newModules[0]) console.log("Counter updated: " + newModules[0].count);
if (newModules[1]) console.log("Name updated: " + newModules[1].name);
});
`,
"counter.ts": `
export const count = 1;
`,
"name.ts": `
export const name = "Alice";
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("Initial: Alice 1");
await dev.write(
"counter.ts",
`
export const count = 2;
`,
);
await c.expectMessage("Counter updated: 2");
await dev.write(
"name.ts",
`
export const name = "Bob";
`,
);
await c.expectMessage("Name updated: Bob");
// Test updating both files
{
await using batch = await dev.batchChanges();
await dev.write(
"counter.ts",
`
export const count = 3;
`,
);
await dev.write(
"name.ts",
`
export const name = "Charlie";
`,
);
}
await c.expectMessageInAnyOrder("Counter updated: 3", "Name updated: Charlie");
},
});
devTest("import.meta.hot.data persistence", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
// Initialize or retrieve stored value
import.meta.hot.data.count ??= 0;
console.log("Initial count: " + import.meta.hot.data.count);
// Increment the count on each evaluation
import.meta.hot.data.count++;
// By using hot.data, you opt into implicit self-acceptance
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("Initial count: 0");
await dev.writeNoChanges("index.ts");
await c.expectMessage("Initial count: 1");
await dev.writeNoChanges("index.ts");
await c.expectMessage("Initial count: 2");
await dev.writeNoChanges("index.ts");
await c.expectMessage("Initial count: 3");
},
});
devTest("import.meta.hot.dispose cleanup", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
console.log("Setting up");
const id = setInterval(() => {}, 1000);
import.meta.hot.dispose(() => {
console.log("Cleaning up");
clearInterval(id);
});
import.meta.hot.accept();
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("Setting up");
await dev.write(
"index.ts",
`
console.log("Setting up again");
const id = setInterval(() => {}, 1000);
import.meta.hot.dispose(() => {
console.log("Cleaning up");
clearInterval(id);
});
import.meta.hot.accept();
`,
);
await c.expectMessage("Cleaning up", "Setting up again");
await dev.write(
"index.ts",
`
console.log("Third setup");
`,
);
await c.expectMessage("Cleaning up", "Third setup");
},
});
devTest("import.meta.hot invalid usage", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
const hot = import.meta.hot;
try {
hot.accept;
throw 'did not throw';
} catch (e) {
console.log(e?.message ?? e);
}
const accept = import.meta.hot.accept;
try {
accept("./something.ts", () => {});
throw 'did not throw';
} catch (e) {
console.log(e?.message ?? e);
}
const meta = import.meta;
try {
meta.hot.accept();
throw 'did not throw';
} catch (e) {
console.log(e?.message ?? e);
}
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage(
"import.meta.hot.accept cannot be used indirectly.",
'"import.meta.hot.accept" must be directly called with string literals for the specifiers. This way, the bundler can pre-process the arguments.',
"import.meta.hot cannot be used indirectly.",
);
},
});
devTest("import.meta.hot on/off events", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
console.log("Initial setup");
// Add event listener
import.meta.hot.on("vite:beforeUpdate", () => {
console.log("Before update event");
});
import.meta.hot.accept();
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("Initial setup");
await dev.write(
"index.ts",
`
console.log("Updated setup");
// Events implementation is partial according to docs
import.meta.hot.on("vite:beforeUpdate", () => {
console.log("Before update event 2");
});
const handler = () => {
console.log("Another handler");
};
import.meta.hot.on("vite:beforeUpdate", handler);
// Remove the handler
import.meta.hot.off("vite:beforeUpdate", handler);
import.meta.hot.accept();
`,
);
await c.expectMessage("Updated setup");
await dev.write(
"index.ts",
`
console.log("Third update");
import.meta.hot.accept();
`,
);
await c.expectMessage("Third update");
},
});
devTest("hmr forwards every merged inotify sub-path from a directory batch", {
// Windows can't rename over an open file (EPERM) and the merged-names
// code path under test is `Environment.isLinux`-gated anyway.
skip: ["win32", "darwin"],
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
import value from "./dep";
console.log(value);
import.meta.hot.accept();
`,
"dep.ts": `
export default "initial";
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("initial");
// Editors that save atomically (vim, emacs, IntelliJ) write to a temp
// file in the same directory and rename over the target. inotify
// reports CREATE tmp + MODIFY tmp + MOVED_FROM tmp + MOVED_TO target on
// the directory watch, and INotifyWatcher merges same-index events
// into one WatchEvent carrying N names. `DevServer.onFileUpdate` must
// forward every name to appendDir — indexing only the first drops the
// rename target.
//
// The per-file watch on the target's old inode is dead after rename-
// over, so to keep it from independently masking the directory-watch
// bug we first unlink the target (removing the file watch) and then
// flood the directory with decoy CREATE events so the rename target
// is never alone in its inotify batch.
for (let round = 1; round <= 5; round++) {
const target = dev.join("dep.ts");
const content = `export default "atomic ${round}";\n`;
{
await using _wait = await dev.batchChanges();
// Remove the direct file watch so only the directory watch can
// pick up the new dep.ts.
unlinkSync(target);
// Decoys: many rapid CREATEs in the same directory force inotify
// to coalesce into a single read() batch so the merge path runs.
for (let i = 0; i < 32; i++) {
writeFileSync(`${target}.${i}.swp`, content);
}
renameSync(`${target}.0.swp`, target);
for (let i = 1; i < 32; i++) {
unlinkSync(`${target}.${i}.swp`);
}
}
await c.expectMessage(`atomic ${round}`);
}
},
});
devTest("hot update frames are not delivered to application websocket topics", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
console.log("initial");
import.meta.hot.accept();
`,
"bun.app.ts": `
import html from "./index.html";
export default {
static: {
"/": html,
},
fetch(req, server) {
if (new URL(req.url).pathname === "/app-ws") {
if (server.upgrade(req)) return;
return new Response("upgrade failed", { status: 400 });
}
return new Response("Not Found", { status: 404 });
},
websocket: {
open(ws) {
ws.subscribe("h");
ws.subscribe("e");
ws.subscribe("E");
ws.send("subscribed");
},
message(ws, message) {
ws.send("echo:" + message);
},
},
};
`,
},
htmlFiles: [],
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("initial");
const received: string[] = [];
const ws = new WebSocket(dev.baseUrl.replace("http", "ws") + "/app-ws");
try {
const opened = Promise.withResolvers<void>();
const echoed = Promise.withResolvers<void>();
ws.onerror = () => {
opened.reject(new Error("application websocket errored"));
echoed.reject(new Error("application websocket errored"));
};
ws.onclose = () => {
opened.reject(new Error("application websocket closed"));
echoed.reject(new Error("application websocket closed"));
};
ws.onmessage = event => {
if (event.data === "subscribed") {
opened.resolve();
return;
}
received.push(typeof event.data === "string" ? event.data : "<binary frame>");
if (event.data === "echo:after-update") {
echoed.resolve();
}
};
await opened.promise;
await dev.write(
"index.ts",
`
console.log("updated");
import.meta.hot.accept();
`,
);
await c.expectMessage("updated");
ws.send("after-update");
await echoed.promise;
expect(received).toEqual(["echo:after-update"]);
} finally {
ws.onclose = null;
ws.close();
}
},
});
devTest("dev.write resolves only after the new module body has run", {
files: {
"index.html": emptyHtmlFile({ scripts: ["index.ts"] }),
"index.ts": `
globalThis.marker = "initial";
console.log("ready");
import.meta.hot.accept();
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("ready");
expect(await c.js`globalThis.marker`).toBe("initial");
await dev.write(
"index.ts",
`
await new Promise(r => setTimeout(r, 500));
globalThis.marker = "updated";
import.meta.hot.accept();
`,
// errors: null skips the post-write expectErrorOverlay poll (5 * 200ms),
// which would otherwise mask a premature ack.
{ errors: null },
);
// dev.write resolves on bun:afterUpdate, i.e. after replaceModules has
// awaited the 500ms TLA. Acking on WS receipt would see "initial" here.
expect(await c.js`globalThis.marker`).toBe("updated");
},
});
+372
View File
@@ -0,0 +1,372 @@
// HTML tests are tests relating to HTML files themselves.
import { devTest, emptyHtmlFile } from "../bake-harness";
devTest("html file is watched", {
files: {
"index.html": emptyHtmlFile({
scripts: ["/script.ts"],
body: "<h1>Hello</h1>",
}),
"script.ts": `
console.log("hello");
`,
},
async test(dev) {
await dev.fetch("/").expect.toInclude("<h1>Hello</h1>");
await dev.fetch("/").expect.toInclude("<h1>Hello</h1>");
await dev.patch("index.html", {
find: "Hello",
replace: "World",
});
await dev.fetch("/").expect.toInclude("<h1>World</h1>");
// Works
await using c = await dev.client("/");
await c.expectMessage("hello");
// Editing HTML reloads
await c.expectReload(async () => {
await dev.patch("index.html", {
find: "World",
replace: "Hello",
});
await dev.fetch("/").expect.toInclude("<h1>Hello</h1>");
});
await c.expectMessage("hello");
await c.expectReload(async () => {
await dev.patch("index.html", {
find: "Hello",
replace: "Bar",
});
await dev.fetch("/").expect.toInclude("<h1>Bar</h1>");
});
await c.expectMessage("hello");
await c.expectReload(async () => {
await dev.patch("script.ts", {
find: "hello",
replace: "world",
});
});
await c.expectMessage("world");
},
});
devTest("image tag", {
files: {
"index.html": `
<!DOCTYPE html><html><head></head><body>
<img src="image.png" alt="test image">
</body></html>
`,
"image.png": "FIRST",
},
async test(dev) {
await using c = await dev.client("/");
const url: string = await c.js`document.querySelector("img").src`;
expect(url).toBeString(); // image tag exists
await dev.fetch(url).expect.toBe("FIRST");
// Editing HTML causes reload but image still works
await c.expectReload(async () => {
await dev.patch("index.html", {
find: 'alt="test image"',
replace: 'alt="modified image"',
});
await dev.fetch("/").expect.toInclude('alt="modified image"');
});
// Editing image content causes a hard reload because the html must reflect the new image content
await c.expectReload(async () => {
await dev.patch("image.png", {
find: "FIRST",
replace: "SECOND",
});
});
const url2 = await c.js`document.querySelector("img").src`;
expect(url).not.toBe(url2);
await dev.fetch(url2).expect.toBe("SECOND");
await dev.fetch(url).expect404(); // TODO
},
});
devTest("image import in JS", {
files: {
"index.html": `
<!DOCTYPE html><html><head></head><body>
<script type="module" src="script.ts"></script>
</body></html>
`,
"script.ts": `
import img from "./image.png";
console.log(img);
`,
"image.png": "FIRST",
},
async test(dev) {
await using c = await dev.client("/");
const img1 = await c.getStringMessage();
await dev.fetch(img1).expect.toBe("FIRST");
// Editing image content updates the image URL
await c.expectReload(async () => {
await dev.patch("image.png", {
find: "FIRST",
replace: "SECOND",
});
});
const img2 = await c.getStringMessage();
await dev.fetch(img2).expect.toBe("SECOND");
// await dev.fetch(img1).expect404();
},
});
devTest("import then create", {
files: {
"index.html": `
<!DOCTYPE html>
<html>
<head></head>
<body>
<script type="module" src="/script.ts"></script>
</body>
</html>
`,
"script.ts": `
import data from "./data";
console.log(data);
`,
},
async test(dev) {
const c = await dev.client("/", {
errors: ['script.ts:1:18: error: Could not resolve: "./data"'],
});
await c.expectReload(async () => {
await dev.write("data.ts", "export default 'data';");
});
await c.expectMessage("data");
},
});
devTest("external links", {
files: {
"index.html": `
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>index | Powered by Bun</title>
<link rel="stylesheet" href="./index.css" />
<link rel="icon" type="image/x-icon" href="https://bun.sh/favicon.ico" />
</head>
<body>
<div id="root"></div>
<script src="./index.client.tsx" type="module"></script>
</body>
</html>
`,
"index.css": `
body {
background-color: red;
}
`,
"index.client.tsx": `
console.log("hello");
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("hello");
const ico: string = await c.js`document.querySelector("link[rel='icon']").href`;
expect(ico).toBe("https://bun.sh/favicon.ico");
},
});
devTest("memory leak case 1", {
files: {
"index.html": `
<script type="module" src="/script.ts"></script>
`,
"script.ts": `
import data from "./data";
`,
},
async test(dev) {
await dev.fetch("/"); // previously leaked source map
},
});
devTest("chrome devtools automatic workspace folders", {
files: {
"index.html": `
<script type="module" src="/script.ts"></script>
`,
"script.ts": `
console.log("hello");
`,
},
async test(dev) {
const response = await dev.fetch("/.well-known/appspecific/com.chrome.devtools.json");
expect(response.status).toBe(200);
const json = await response.json();
const root = dev.join(".");
expect(json).toMatchObject({
workspace: {
root,
uuid: expect.any(String),
},
});
},
});
devTest("error report endpoint handles stack frames with very long absolute paths", {
files: {
"index.html": emptyHtmlFile({
scripts: ["/script.ts"],
body: "<h1>Error Report</h1>",
}),
"script.ts": `
console.log("hello");
`,
},
async test(dev) {
// Wire format of POST /_bun/report_error (length-prefixed binary):
// string32 error name, string32 message, string32 browser url,
// u32 frame count, then per frame: i32 line, i32 column,
// string32 function name, string32 file name.
function u32(n: number) {
const b = Buffer.alloc(4);
b.writeUInt32LE(n >>> 0, 0);
return b;
}
function i32(n: number) {
const b = Buffer.alloc(4);
b.writeInt32LE(n, 0);
return b;
}
function str32(s: string) {
const bytes = Buffer.from(s, "utf8");
return Buffer.concat([u32(bytes.length), bytes]);
}
function frame(line: number, column: number, functionName: string, fileName: string) {
return Buffer.concat([i32(line), i32(column), str32(functionName), str32(fileName)]);
}
// One ordinary frame pointing at a real project file, plus one frame whose
// absolute path is far larger than any platform path buffer (16 KiB).
const normalPath = dev.join("script.ts");
const oversizedPath = "/" + "A/".repeat(8192);
const body = Buffer.concat([
str32("Error"), // error name
str32("test message"), // error message
str32(dev.baseUrl + "/"), // browser url
u32(2), // stack frame count
frame(1, 1, "first", normalPath),
frame(1, 1, "second", oversizedPath),
]);
const res = await dev.fetch("/_bun/report_error", { method: "POST", body });
expect(res.status).toBe(200);
// The reply still references the legitimate frame's file.
const text = await res.text();
expect(text).toContain("script.ts");
// The dev server must still be serving requests afterwards.
await dev.fetch("/").expect.toInclude("<h1>Error Report</h1>");
},
});
devTest("error report endpoint rejects requests whose origin header does not match the dev server", {
files: {
"index.html": emptyHtmlFile({
scripts: ["/script.ts"],
body: "<h1>Origin Check</h1>",
}),
"script.ts": `
console.log("hello");
`,
},
async test(dev) {
function u32(n: number) {
const b = Buffer.alloc(4);
b.writeUInt32LE(n >>> 0, 0);
return b;
}
function str32(s: string) {
const bytes = Buffer.from(s, "utf8");
return Buffer.concat([u32(bytes.length), bytes]);
}
const body = Buffer.concat([str32("Error"), str32("origin-check-message"), str32(dev.baseUrl + "/"), u32(0)]);
const crossOrigin = await dev.fetch("/_bun/report_error", {
method: "POST",
headers: { Origin: "http://other-page.example" },
body,
});
expect(await crossOrigin.text()).toBe("Blocked: Origin header does not match the dev server");
expect(crossOrigin.status).toBe(403);
const sameOrigin = await dev.fetch("/_bun/report_error", {
method: "POST",
headers: { Origin: dev.baseUrl },
body,
});
expect(sameOrigin.status).toBe(200);
await dev.fetch("/").expect.toInclude("<h1>Origin Check</h1>");
},
});
devTest("error report endpoint blanks stray non-text bytes in reported frames", {
files: {
"index.html": emptyHtmlFile({
scripts: ["/script.ts"],
body: "<h1>Frame Bytes</h1>",
}),
"script.ts": `
console.log("hello");
`,
},
async test(dev) {
function u32(n: number) {
const b = Buffer.alloc(4);
b.writeUInt32LE(n >>> 0, 0);
return b;
}
function i32(n: number) {
const b = Buffer.alloc(4);
b.writeInt32LE(n, 0);
return b;
}
function bytes32(bytes: Buffer) {
return Buffer.concat([u32(bytes.length), bytes]);
}
function str32(s: string) {
return bytes32(Buffer.from(s, "utf8"));
}
const functionName = Buffer.concat([Buffer.from("fnstart"), Buffer.from([0x9b]), Buffer.from("fnend")]);
const body = Buffer.concat([
str32("Error"),
str32("frame-bytes-message"),
str32(dev.baseUrl + "/"),
u32(1),
i32(1),
i32(1),
bytes32(functionName),
str32("foo.ts"),
]);
const res = await dev.fetch("/_bun/report_error", { method: "POST", body });
const reply = Buffer.from(await res.arrayBuffer());
expect(reply.includes(Buffer.from("fnstart fnend", "latin1"))).toBe(true);
expect(reply.includes(0x9b)).toBe(false);
expect(res.status).toBe(200);
await dev.fetch("/").expect.toInclude("<h1>Frame Bytes</h1>");
},
});
@@ -0,0 +1,40 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("import.meta properties are NOT inlined without bake framework", async () => {
await using dir = tempDir("import-meta-no-inline", {
"index.ts": `
console.log("dir:", import.meta.dir);
console.log("dirname:", import.meta.dirname);
console.log("file:", import.meta.file);
console.log("path:", import.meta.path);
console.log("url:", import.meta.url);
`,
});
// Run without bundling - should show actual values
await using proc = Bun.spawn({
cmd: [bunExe(), "index.ts"],
env: bunEnv,
cwd: dir,
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
// When not bundled, these properties should resolve to actual values
expect(stdout).toContain("dir:");
expect(stdout).toContain("dirname:");
expect(stdout).toContain("file:");
expect(stdout).toContain("path:");
expect(stdout).toContain("url:");
// The values should NOT be inlined - they should be the actual runtime values
expect(stdout).not.toContain("undefined");
});
+305
View File
@@ -0,0 +1,305 @@
// import.meta properties are inlined at parse time in Bake
import { expect } from "bun:test";
import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness";
const platformPath = (path: string) => {
if (process.platform === "win32") {
return path.replace(/\//g, "\\");
}
return path;
};
devTest("import.meta properties are inlined in bake", {
framework: minimalFramework,
files: {
"routes/index.ts": `
export default function (req, meta) {
return Response.json({
dir: import.meta.dir,
dirname: import.meta.dirname,
file: import.meta.file,
path: import.meta.path,
url: import.meta.url,
});
}
`,
},
async test(dev) {
const response = await dev.fetch("/");
const json = await response.json();
// Check that all properties are strings, not undefined
expect(typeof json.dir).toBe("string");
expect(typeof json.dirname).toBe("string");
expect(typeof json.file).toBe("string");
expect(typeof json.path).toBe("string");
expect(typeof json.url).toBe("string");
// Check that dir and dirname are the same
expect(json.dir).toBe(json.dirname);
// Check that file is just the filename
expect(json.file).toBe("index.ts");
// Check that path contains the full path including filename
expect(json.path).toContain(platformPath("routes/index.ts"));
expect(json.path).toEndWith("index.ts");
// Check that url is a file:// URL
expect(json.url).toStartWith("file://");
expect(json.url).toContain("routes/index.ts");
},
});
devTest("import.meta properties work with dynamic updates", {
framework: minimalFramework,
files: {
"routes/test.ts": `
export default function (req, meta) {
const values = [
"dir: " + import.meta.dir,
"file: " + import.meta.file,
"path: " + import.meta.path,
];
return new Response(values.join("\\n"));
}
`,
},
async test(dev) {
const response = await dev.fetch("/test");
const text = await response.text();
// Verify the values are inlined strings
expect(text).toContain("dir: ");
expect(text).toContain("file: test.ts");
expect(text).toContain("path: ");
expect(text).toContain(platformPath("routes/test.ts"));
// Update the file with a meaningful change
await dev.patch("routes/test.ts", {
find: '"dir: "',
replace: '"directory: "',
});
const response2 = await dev.fetch("/test");
const text2 = await response2.text();
// After the patch, the first line should say "directory:" instead of "dir:"
expect(text2).toContain("directory: ");
expect(text2).toContain("file: test.ts");
expect(text2).toContain("path: ");
expect(text2).toContain(platformPath("routes/test.ts"));
},
});
devTest("import.meta properties with nested directories", {
framework: minimalFramework,
files: {
"routes/api/v1/handler.ts": `
export default function (req, meta) {
return Response.json({
dir: import.meta.dir,
file: import.meta.file,
path: import.meta.path,
url: import.meta.url,
});
}
`,
},
async test(dev) {
const response = await dev.fetch("/api/v1/handler");
const json = await response.json();
expect(json.file).toBe("handler.ts");
expect(json.path).toContain(platformPath("routes/api/v1/handler.ts"));
expect(json.dir).toContain(platformPath("routes/api/v1"));
expect(json.url).toMatch(/^file:\/\/.*routes\/api\/v1\/handler\.ts$/);
},
});
devTest("import.meta properties in client-side code show runtime values", {
framework: minimalFramework,
files: {
"test_import_meta_inline.js": `
// Test file for import.meta inlining
console.log("import.meta.dir:", import.meta.dir);
console.log("import.meta.dirname:", import.meta.dirname);
console.log("import.meta.file:", import.meta.file);
console.log("import.meta.path:", import.meta.path);
console.log("import.meta.url:", import.meta.url);
`,
"index.html": emptyHtmlFile({
scripts: ["test_import_meta_inline.js"],
}),
},
async test(dev) {
await using c = await dev.client("/");
// In client-side code, import.meta properties show runtime values
// They are NOT inlined because this is not server-side code
const messages = [
await c.getStringMessage(),
await c.getStringMessage(),
await c.getStringMessage(),
await c.getStringMessage(),
await c.getStringMessage(),
];
// Verify all properties are logged
expect(messages.some(m => m.startsWith("import.meta.dir:"))).toBe(true);
expect(messages.some(m => m.startsWith("import.meta.dirname:"))).toBe(true);
expect(messages.some(m => m.startsWith("import.meta.file:"))).toBe(true);
expect(messages.some(m => m.startsWith("import.meta.path:"))).toBe(true);
expect(messages.some(m => m.startsWith("import.meta.url:"))).toBe(true);
},
});
devTest("import.meta properties in catch-all routes", {
framework: minimalFramework,
files: {
"routes/blog/[...slug].ts": `
export default function BlogPost(req, meta) {
const url = new URL(req.url);
const slug = url.pathname.replace('/blog/', '').split('/').filter(Boolean);
const metaInfo = {
file: import.meta.file,
dir: import.meta.dir,
path: import.meta.path,
url: import.meta.url,
dirname: import.meta.dirname,
};
return Response.json({
slug: slug,
title: slug.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' '),
meta: metaInfo,
content: "This is a blog post at: " + slug.join('/'),
});
}
`,
},
async test(dev) {
// Test single segment
const post1 = await dev.fetch("/blog/hello");
const json1 = await post1.json();
expect(json1.slug).toEqual(["hello"]);
expect(json1.title).toBe("Hello");
expect(json1.content).toBe("This is a blog post at: hello");
// Verify import.meta properties are inlined
expect(json1.meta.file).toBe("[...slug].ts");
expect(json1.meta.dir).toContain(platformPath("routes/blog"));
expect(json1.meta.dirname).toBe(json1.meta.dir);
expect(json1.meta.path).toContain(platformPath("routes/blog/[...slug].ts"));
// url encoded!
expect(json1.meta.url).toMatch(/^file:\/\/.*routes\/blog\/%5B\.\.\.slug%5D\.ts$/);
// Test multiple segments
const post2 = await dev.fetch("/blog/2024/tech/bun-framework");
const json2 = await post2.json();
expect(json2.slug).toEqual(["2024", "tech", "bun-framework"]);
expect(json2.title).toBe("2024 Tech Bun-framework");
expect(json2.content).toBe("This is a blog post at: 2024/tech/bun-framework");
// Meta properties should be the same regardless of the route
expect(json2.meta.file).toBe("[...slug].ts");
expect(json2.meta.path).toContain(platformPath("routes/blog/[...slug].ts"));
// Test empty slug (just /blog/)
const post3 = await dev.fetch("/blog/");
const json3 = await post3.json();
expect(json3.slug).toEqual([]);
expect(json3.title).toBe("");
expect(json3.content).toBe("This is a blog post at: ");
},
});
devTest("import.meta properties in nested catch-all routes with static siblings", {
framework: minimalFramework,
files: {
"routes/docs/[...path].ts": `
export default function DocsPage(req, meta) {
const url = new URL(req.url);
const path = url.pathname.replace('/docs/', '').split('/').filter(Boolean);
return Response.json({
type: "catch-all",
path: path,
file: import.meta.file,
dir: import.meta.dir,
fullPath: import.meta.path,
});
}
`,
"routes/docs/api.ts": `
export default function ApiDocs(req, meta) {
return Response.json({
type: "static",
page: "API Documentation",
file: import.meta.file,
dir: import.meta.dir,
fullPath: import.meta.path,
});
}
`,
"routes/docs/getting-started.ts": `
export default function GettingStarted(req, meta) {
return Response.json({
type: "static",
page: "Getting Started",
file: import.meta.file,
dir: import.meta.dir,
fullPath: import.meta.path,
});
}
`,
},
async test(dev) {
// Test static route - should match api.ts, not catch-all
const apiResponse = await dev.fetch("/docs/api");
const apiJson = await apiResponse.json();
expect(apiJson.type).toBe("static");
expect(apiJson.page).toBe("API Documentation");
expect(apiJson.file).toBe("api.ts");
expect(apiJson.dir).toContain(platformPath("routes/docs"));
expect(apiJson.fullPath).toContain(platformPath("routes/docs/api.ts"));
// Test another static route
const startResponse = await dev.fetch("/docs/getting-started");
const startJson = await startResponse.json();
expect(startJson.type).toBe("static");
expect(startJson.page).toBe("Getting Started");
expect(startJson.file).toBe("getting-started.ts");
expect(startJson.fullPath).toContain(platformPath("routes/docs/getting-started.ts"));
// Test catch-all route - should match for non-static paths
const guideResponse = await dev.fetch("/docs/guides/advanced/optimization");
expect(guideResponse.status).toBe(200);
const guideJson = await guideResponse.json();
expect(guideJson.type).toBe("catch-all");
expect(guideJson.path).toEqual(["guides", "advanced", "optimization"]);
expect(guideJson.file).toBe("[...path].ts");
expect(guideJson.dir).toContain(platformPath("routes/docs"));
expect(guideJson.fullPath).toContain(platformPath("routes/docs/[...path].ts"));
// Update catch-all route and verify import.meta values remain inlined
await dev.patch("routes/docs/[...path].ts", {
find: '"catch-all"',
replace: '"dynamic-catch-all"',
});
const updatedResponse = await dev.fetch("/docs/tutorials/intro");
const updatedJson = await updatedResponse.json();
expect(updatedJson.type).toBe("dynamic-catch-all");
expect(updatedJson.file).toBe("[...path].ts");
expect(updatedJson.fullPath).toContain(platformPath("routes/docs/[...path].ts"));
},
});
@@ -0,0 +1,88 @@
import { devTest } from "../bake-harness";
// This test is specifically testing the fix for disconnectEdgeFromDependencyList
// where it was incorrectly setting first_dep to .none when there was still a next dependency
devTest("incremental graph handles edge deletion with next dependency", {
timeoutMultiplier: 4, // 1 minute timeout
files: {
"index.html": `<html>
<head><title>Test</title></head>
<body>
<div id="root"></div>
<script src="/index.js" type="module"></script>
</body>
</html>`,
"index.js": `
import { a } from './a.js';
import { b } from './b.js';
import { c } from './c.js';
console.log('index', a, b, c);
`.trim(),
"a.js": `
import { util } from './util.js';
export const a = 'A' + util;
console.log('a.js loaded');
`.trim(),
"b.js": `
import { util } from './util.js';
export const b = 'B' + util;
console.log('b.js loaded');
`.trim(),
"c.js": `
import { util } from './util.js';
export const c = 'C' + util;
console.log('c.js loaded');
`.trim(),
"util.js": `
export const util = '!';
console.log('util.js loaded');
`.trim(),
},
async test(dev) {
await using client = await dev.client("/", { allowUnlimitedReloads: true });
// This creates a stress test scenario where multiple files import util.js
// When we delete and recreate files rapidly, it tests the edge case where
// disconnectEdgeFromDependencyList needs to properly handle multiple dependencies
await dev.stressTest(async () => {
for (let i = 0; i < 10; i++) {
console.log(`Cycle ${i + 1}/10`);
// Delete util.js which is imported by multiple files
await Bun.write(dev.join("util.js"), "");
await Bun.sleep(10);
// Recreate it
await Bun.write(
dev.join("util.js"),
`
export const util = '!';
console.log('util.js loaded');
`.trim(),
);
await Bun.sleep(10);
// Delete and recreate one of the importers
await Bun.write(dev.join("a.js"), "");
await Bun.sleep(10);
await Bun.write(
dev.join("a.js"),
`
import { util } from './util.js';
export const a = 'A' + util;
console.log('a.js loaded');
`.trim(),
);
await Bun.sleep(10);
}
});
// If we get here without crashing, the test passed
console.log("Test completed successfully - no crash occurred");
// Clear the messages array to satisfy the test harness
client.messages.length = 0;
},
});
+149
View File
@@ -0,0 +1,149 @@
// Plugin tests concern plugins in development mode.
import { devTest, minimalFramework } from "../bake-harness";
// Note: more in depth testing of plugins is done in test/bundler/bundler_plugin.test.ts
devTest("onResolve", {
framework: minimalFramework,
pluginFile: `
import * as path from 'path';
export default [
{
name: 'a',
setup(build) {
build.onResolve({ filter: /trigger/ }, (args) => {
return { path: path.join(import.meta.dirname, '/file.ts') };
});
},
}
];
`,
files: {
"file.ts": `
export const value = 1;
`,
"routes/index.ts": `
import { value } from 'trigger';
export default function (req, meta) {
return new Response('value: ' + value);
}
`,
},
async test(dev) {
await dev.fetch("/").equals("value: 1");
},
});
devTest("onLoad", {
framework: minimalFramework,
pluginFile: `
import * as path from 'path';
export default [
{
name: 'a',
setup(build) {
build.onLoad({ filter: /trigger/ }, (args) => {
return { contents: 'export const value = 1;', loader: 'ts' };
});
},
}
];
`,
files: {
"trigger.ts": `
throw new Error('should not be loaded');
`,
"routes/index.ts": `
import { value } from '../trigger.ts';
export default function (req, meta) {
return new Response('value: ' + value);
}
`,
},
async test(dev) {
await dev.fetch("/").equals("value: 1");
await dev.fetch("/").equals("value: 1");
await dev.fetch("/").equals("value: 1");
},
});
devTest("onResolve + onLoad virtual file", {
framework: minimalFramework,
pluginFile: `
import * as path from 'path';
export default [
{
name: 'a',
setup(build) {
build.onResolve({ filter: /^trigger$/ }, (args) => {
return { path: "hello.ts", namespace: "virtual" };
});
build.onLoad({ filter: /.*/, namespace: "virtual" }, (args) => {
return { contents: 'export default ' + JSON.stringify(args) + ';', loader: 'ts' };
});
},
}
];
`,
files: {
// this file must not collide with the virtual file
"hello.ts": `
export default "file-on-disk";
`,
"routes/index.ts": `
import disk from '../hello';
import virtual from 'trigger';
export default function (req, meta) {
return Response.json([virtual, disk]);
}
`,
},
async test(dev) {
await dev.fetch("/").equals([
{
path: "hello.ts",
namespace: "virtual",
loader: "ts",
side: "server",
},
"file-on-disk",
]);
},
});
// devTest("onLoad with watchFile", {
// framework: minimalFramework,
// pluginFile: `
// import * as path from 'path';
// export default [
// {
// name: 'a',
// setup(build) {
// let a = 0;
// build.onLoad({ filter: /trigger/ }, (args) => {
// a += 1;
// return { contents: 'export const value = ' + a + ';', loader: 'ts' };
// });
// },
// }
// ];
// `,
// files: {
// "trigger.ts": `
// throw new Error('should not be loaded');
// `,
// "routes/index.ts": `
// import { value } from '../trigger.ts';
// export default function (req, meta) {
// return new Response('value: ' + value);
// }
// `,
// },
// async test(dev) {
// await dev.fetch("/").expect('value: 1');
// await dev.fetch("/").expect('value: 1');
// await dev.write("trigger.ts", "throw new Error('should not be loaded 2');");
// await dev.fetch("/").expect('value: 2');
// await dev.fetch("/").expect('value: 2');
// },
// });
+662
View File
@@ -0,0 +1,662 @@
import { describe, expect, test } from "bun:test";
import { existsSync } from "fs";
import { bunEnv, bunExe } from "harness";
import path from "path";
import { tempDirWithBakeDeps } from "../bake-harness";
const normalizePath = (path: string) => (process.platform === "win32" ? path.replaceAll("\\", "/") : path);
const platformPath = (path: string) => (process.platform === "win32" ? path.replaceAll("/", "\\") : path);
/**
* Production build tests
*/
describe("production", () => {
test("works with sourcemaps - error thrown in React component", async () => {
const dir = await tempDirWithBakeDeps("bake-production-sourcemap", {
"src/index.tsx": `export default { app: { framework: "react" } };`,
"pages/index.tsx": `export default function IndexPage() {
throw new Error("oh no!");
return <div>Hello World</div>;
}`,
"package.json": JSON.stringify({
"name": "test-app",
"version": "1.0.0",
"devDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
},
}),
});
// Run the build command
const {
exitCode: buildExitCode,
stdout: buildStdout,
stderr: buildStderr,
} = await Bun.$`${bunExe()} build --app ./src/index.tsx`.cwd(dir).throws(false);
// The build should fail due to the runtime error during SSG
expect(buildExitCode).toBe(1);
// Check that the error message shows the proper source location
expect(buildStderr.toString()).toContain("throw new Error");
expect(buildStderr.toString()).toContain("oh no!");
});
test("import.meta properties are inlined in production build", async () => {
const dir = await tempDirWithBakeDeps("bake-production-import-meta", {
"src/index.tsx": `export default {
app: {
framework: "react",
}
};`,
"pages/index.tsx": `
export default function IndexPage() {
const metaInfo = {
dir: import.meta.dir,
dirname: import.meta.dirname,
file: import.meta.file,
path: import.meta.path,
url: import.meta.url,
};
return (
<div>
<h1>Import Meta Test</h1>
<pre>{JSON.stringify(metaInfo, null, 2)}</pre>
<div id="meta-data" style={{display: 'none'}}>{JSON.stringify(metaInfo)}</div>
</div>
);
}
`,
"pages/api/test.tsx": `
export default function TestPage() {
const values = [
"dir=" + import.meta.dir,
"dirname=" + import.meta.dirname,
"file=" + import.meta.file,
"path=" + import.meta.path,
"url=" + import.meta.url,
];
return (
<div>
<h1>API Test</h1>
<pre>{values.join("\\n")}</pre>
<div id="api-meta-data" style={{display: 'none'}}>{values.join("|")}</div>
</div>
);
}
`,
});
// Run the build command
const buildProc = await Bun.$`${bunExe()} build --app ./src/index.tsx --outdir ./dist`
.cwd(dir)
.env(bunEnv)
.throws(false);
expect(buildProc.exitCode).toBe(0);
// Check that the build output contains the generated files
const distFiles = await Bun.$`ls -la dist/`.cwd(dir).text();
expect(distFiles).toContain("index.html");
expect(distFiles).toContain("_bun");
// In production SSG, the import.meta values are inlined during build time
// and rendered into the static HTML. The values should appear in the HTML output.
// Check the generated static HTML files
const indexHtml = await Bun.file(path.join(dir, "dist", "index.html")).text();
const apiTestHtml = await Bun.file(path.join(dir, "dist", "api", "test", "index.html")).text();
// The HTML output should contain the rendered import.meta values
// Check for the presence of the expected values in the HTML
// For the index page, check that it contains the expected file paths
expect(indexHtml).toContain("index.tsx");
expect(indexHtml).toContain("pages");
// Check if the HTML contains evidence of import.meta values being used
// The exact format might be HTML-escaped, so we check for key patterns
const hasIndexPath =
indexHtml.includes("pages/index.tsx") ||
indexHtml.includes("pages&#x2F;index.tsx") ||
indexHtml.includes("pages\\index.tsx");
expect(hasIndexPath).toBe(true);
// For the API test page
expect(apiTestHtml).toContain("test.tsx");
expect(apiTestHtml).toContain("pages");
const hasApiPath =
apiTestHtml.includes("pages/api/test.tsx") ||
apiTestHtml.includes("pages&#x2F;api&#x2F;test.tsx") ||
apiTestHtml.includes("pages\\api\\test.tsx");
expect(hasApiPath).toBe(true);
});
test("import.meta properties are inlined in catch-all routes during production build", async () => {
const dir = await tempDirWithBakeDeps("bake-production-catch-all", {
"src/index.tsx": `export default {
app: {
framework: "react",
}
};`,
"pages/blog/[...slug].tsx": `
export default function BlogPost({ params }) {
const slug = params.slug || [];
const metaInfo = {
file: import.meta.file,
dir: import.meta.dir,
path: import.meta.path,
url: import.meta.url,
dirname: import.meta.dirname,
};
return (
<article>
<h1>Blog Post: {slug.join(' / ')}</h1>
<p>You are reading: {slug.length === 0 ? 'the blog index' : slug.join('/')}</p>
<div id="blog-meta" data-file={metaInfo.file} data-dir={metaInfo.dir} data-path={metaInfo.path}>
<pre>{JSON.stringify(metaInfo, null, 2)}</pre>
</div>
</article>
);
}
export async function getStaticPaths() {
return {
paths: [
{ params: { slug: ['2024', 'hello-world'] } },
{ params: { slug: ['2024', 'tech', 'bun-framework'] } },
{ params: { slug: ['tutorials', 'getting-started'] } },
],
fallback: false,
};
}
`,
"pages/docs/[...path].tsx": `
export default function DocsPage({ params }) {
const path = params.path || [];
return (
<div>
<h1>Documentation</h1>
<nav aria-label="Breadcrumb">
<ol>
<li>Docs</li>
{path.map((segment, i) => (
<li key={i}>{segment}</li>
))}
</ol>
</nav>
<div id="docs-content">
<p>Reading docs at: /{path.join('/')}</p>
<div id="docs-meta" style={{display: 'none'}}>
<span data-file={import.meta.file}></span>
<span data-dir={import.meta.dir}></span>
<span data-path={import.meta.path}></span>
<span data-url={import.meta.url}></span>
</div>
</div>
</div>
);
}
export async function getStaticPaths() {
return {
paths: [
{ params: { path: ['api', 'reference'] } },
{ params: { path: ['guides', 'advanced', 'optimization'] } },
{ params: { path: [] } }, // docs index
],
fallback: false,
};
}
`,
"pages/docs/getting-started.tsx": `
export default function GettingStarted() {
return (
<div>
<h1>Getting Started</h1>
<p>This is a static page, not a catch-all route.</p>
<div id="static-meta" style={{display: 'none'}}>
<span data-file={import.meta.file}></span>
<span data-path={import.meta.path}></span>
</div>
</div>
);
}
`,
});
console.error("DIR", dir);
// Run the build command
const buildProc = await Bun.$`${bunExe()} build --app ./src/index.tsx --outdir ./dist`
.cwd(dir)
.env(bunEnv)
.throws(false);
expect(buildProc.exitCode).toBe(0);
// Check that the build output contains the generated files
const htmlFiles = Array.from(new Bun.Glob("dist/**/*.html").scanSync(dir))
.sort()
.map(p => normalizePath(p));
// Should have generated all the static paths
// Note: React's routing may flatten the paths
expect(htmlFiles).toContain("dist/blog/2024/hello-world/index.html");
expect(htmlFiles).toContain("dist/blog/2024/tech/bun-framework/index.html");
expect(htmlFiles).toContain("dist/blog/tutorials/getting-started/index.html");
expect(htmlFiles).toContain("dist/docs/api/reference/index.html");
expect(htmlFiles).toContain("dist/docs/guides/advanced/optimization/index.html");
expect(htmlFiles).toContain("dist/docs/index.html");
expect(htmlFiles).toContain("dist/docs/getting-started/index.html");
// Check blog post with multiple segments
const blogPostHtml = await Bun.file(
path.join(dir, "dist", "blog", "2024", "tech", "bun-framework", "index.html"),
).text();
// Verify the content is rendered (may include HTML comments)
expect(blogPostHtml).toContain("Blog Post:");
expect(blogPostHtml).toContain("2024 / tech / bun-framework");
expect(blogPostHtml).toContain("You are reading:");
expect(blogPostHtml).toContain("2024/tech/bun-framework");
// Check that import.meta values are inlined in the HTML
expect(blogPostHtml).toContain('data-file="[...slug].tsx"');
expect(blogPostHtml).toContain("data-dir=");
expect(blogPostHtml).toContain(platformPath('/pages/blog"')); // The full path will include the temp directory
expect(blogPostHtml).toContain("data-path=");
expect(blogPostHtml).toContain(platformPath('/pages/blog/[...slug].tsx"'));
// Check docs catch-all route
const docsHtml = await Bun.file(
path.join(dir, "dist", "docs", "guides", "advanced", "optimization", "index.html"),
).text();
expect(docsHtml).toContain("Reading docs at:");
expect(docsHtml).toContain("guides/advanced/optimization");
expect(docsHtml).toContain('data-file="[...path].tsx"');
expect(docsHtml).toContain(platformPath('/pages/docs/[...path].tsx"'));
// Check that the static getting-started page uses its own file name, not the catch-all
const staticHtml = await Bun.file(path.join(dir, "dist", "docs", "getting-started", "index.html")).text();
expect(staticHtml).toContain("Getting Started");
expect(staticHtml).toContain("This is a static page");
expect(staticHtml).toContain('data-file="getting-started.tsx"');
expect(staticHtml).toContain(platformPath('/pages/docs/getting-started.tsx"'));
expect(staticHtml).not.toContain("[...path].tsx");
// Verify that import.meta values are consistent across all catch-all instances
const blogIndex = await Bun.file(
path.join(dir, "dist", "blog", "tutorials", "getting-started", "index.html"),
).text();
expect(blogIndex).toContain('data-file="[...slug].tsx"');
expect(blogIndex).toContain(platformPath('/pages/blog/[...slug].tsx"'));
});
test("params are collected from the page's parent routes too", async () => {
const dir = await tempDirWithBakeDeps("bake-production-nested-params", {
"src/index.tsx": `export default { app: { framework: "react" } };`,
"pages/[category]/[id].tsx": `
export default function Item({ params }) {
return <p>{params.category + "/" + params.id}</p>;
}
export async function getStaticPaths() {
return {
paths: [
{ params: { category: "tech", id: "bun" } },
{ params: { category: "news", id: "release" } },
],
};
}
`,
});
const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./src/index.tsx --outdir ./dist`
.cwd(dir)
.env(bunEnv)
.throws(false);
expect(stderr.toString()).not.toContain("error:");
expect(exitCode).toBe(0);
const htmlFiles = Array.from(new Bun.Glob("dist/**/*.html").scanSync(dir))
.sort()
.map(p => normalizePath(p));
expect(htmlFiles).toEqual(["dist/news/release/index.html", "dist/tech/bun/index.html"]);
expect(await Bun.file(path.join(dir, "dist", "tech", "bun", "index.html")).text()).toContain("tech/bun");
});
test("optional catch-all routes are rejected", async () => {
const dir = await tempDirWithBakeDeps("bake-production-optional-catch-all", {
"src/index.tsx": `export default { app: { framework: "react" } };`,
"pages/docs/[[...slug]].tsx": `
export default function Docs() {
return <p>docs</p>;
}
`,
});
const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./src/index.tsx --outdir ./dist`
.cwd(dir)
.env(bunEnv)
.throws(false);
expect(stderr.toString()).toContain("catch-all routes are not supported in static site generation");
expect(exitCode).toBe(1);
});
test("two pages resolving to the same route are reported", async () => {
const dir = await tempDirWithBakeDeps("bake-production-route-collision", {
"src/index.tsx": `export default { app: { framework: "react" } };`,
"pages/about.tsx": `export default function About() { return <p>about</p>; }`,
"pages/about/index.tsx": `export default function About() { return <p>about</p>; }`,
});
const { stderr } = await Bun.$`${bunExe()} build --app ./src/index.tsx --outdir ./dist`
.cwd(dir)
.env(bunEnv)
.throws(false);
expect(stderr.toString()).toContain("Multiple pages matching the same route pattern is ambiguous");
});
test("handles build with no pages directory without crashing", async () => {
const dir = await tempDirWithBakeDeps("bake-production-no-pages", {
"app.ts": `export default { app: { framework: "react" } };`,
"package.json": JSON.stringify({
"name": "test-app",
"version": "1.0.0",
"devDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
},
}),
});
// Run the build command - should not crash even with no pages
const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./app.ts`.cwd(dir).throws(false);
// The build should complete successfully (or fail gracefully, not crash)
// We're testing that it doesn't crash with the StringBuilder assertion
expect(exitCode).toBeDefined();
// If it fails, it should be a graceful failure, not a crash
if (exitCode !== 0) {
expect(stderr.toString()).not.toContain("reached unreachable code");
expect(stderr.toString()).not.toContain("assert(this.cap > 0)");
}
});
test("client-side component with default import should work", async () => {
const dir = await tempDirWithBakeDeps("bake-production-client-import", {
"src/index.tsx": `export default { app: { framework: "react" } };`,
"pages/index.tsx": `import Client from "../components/Client";
export default function IndexPage() {
return (
<div>
<title>LMAO</title>Hello World
<Client />
</div>
);
}`,
"components/Client.tsx": `"use client";
export default function Client() {
console.log("Client-side!");
return <div>Hello World</div>;
}`,
"package.json": JSON.stringify({
"name": "test-app",
"version": "1.0.0",
"devDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
},
}),
});
// Run the build command
const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./src/index.tsx`.cwd(dir).throws(false);
expect(exitCode).toBe(0);
// Check the generated HTML file for pages/index.tsx
const htmlPage = path.join(dir, "dist", "index.html");
expect(existsSync(htmlPage)).toBe(true);
const htmlContent = await Bun.file(htmlPage).text();
// Verify the static content is rendered
expect(htmlContent).toContain("<title>LMAO</title>");
expect(htmlContent).toContain("Hello World");
});
test("importing useState server-side", async () => {
const dir = await tempDirWithBakeDeps("bake-production-react-import", {
"src/index.tsx": `export default { app: { framework: "react" } };`,
"pages/index.tsx": `import { useState } from 'react';
export default function IndexPage() {
const [count, setCount] = useState(0);
return (
<div>
<title>LMAO</title>Hello World
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}`,
"package.json": JSON.stringify({
"name": "test-app",
"version": "1.0.0",
"devDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
},
}),
});
// Run the build command
const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./src/index.tsx`.cwd(dir).throws(false);
// The build should succeed - client components should support default imports
expect(stderr.toString()).toContain(
'"useState" is not available in a server component. If you need interactivity, consider converting part of this to a Client Component (by adding `"use client";` to the top of the file).',
);
expect(exitCode).toBe(1);
});
test("importing useState from client component", async () => {
const dir = await tempDirWithBakeDeps("bake-production-client-useState", {
"src/index.tsx": `
const bundlerOptions = {
sourcemap: "inline",
minify: {
whitespace: false,
identifiers: false,
syntax: false,
},
};
export default { app: { framework: "react", bundlerOptions: { server: bundlerOptions, client: bundlerOptions, ssr: bundlerOptions } } };`,
"pages/index.tsx": `import Counter from "../components/Counter";
export default function IndexPage() {
return (
<div>
<h1>Counter Example</h1>
<Counter />
</div>
);
}`,
"components/Counter.tsx": `"use client";
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}`,
"package.json": JSON.stringify({
"name": "test-app",
"version": "1.0.0",
"devDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
},
}),
});
// Run the build command
const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./src/index.tsx`.cwd(dir).throws(false);
// The build should succeed - client components CAN use useState
expect(stderr.toString()).not.toContain("useState");
expect(exitCode).toBe(0);
// Check the generated HTML file
const htmlPage = path.join(dir, "dist", "index.html");
expect(existsSync(htmlPage)).toBe(true);
const htmlContent = await Bun.file(htmlPage).text();
// Verify the static content is rendered
expect(htmlContent).toContain("<h1>Counter Example</h1>");
// Verify client component script tags exist
expect(htmlContent).toContain("<script");
expect(htmlContent).toContain("/_bun/");
// Extract the JS bundle filename from the HTML
const scriptMatch = htmlContent.match(/src="[/]_bun[/]([a-z0-9]+\.js)"/);
expect(scriptMatch).toBeTruthy();
const bundleFilename = scriptMatch![1];
// Check that the client bundle was created
const clientBundle = path.join(dir, "dist", "_bun", bundleFilename);
expect(existsSync(clientBundle)).toBe(true);
// Also check for component-specific bundle by looking for all JS files
const bundles = await Bun.$`ls ${path.join(dir, "dist", "_bun")}/*.js`.cwd(dir).text();
const bundleFiles = bundles.trim().split("\n").filter(Boolean);
// Read all bundles to find the one with our component code
let foundCounterBundle = false;
for (const bundleFile of bundleFiles) {
const content = await Bun.file(bundleFile).text();
if (content.includes("useState") && content.includes("setCount") && content.includes("Click me")) {
foundCounterBundle = true;
break;
}
}
expect(foundCounterBundle).toBe(true);
});
test("inline flight data is escaped as a single unit across stream chunks", async () => {
const dir = await tempDirWithBakeDeps("bake-production-flight-escaping", {
"src/index.tsx": `export default { app: { framework: "react" } };`,
"components/Box.tsx": `"use client";
export default function Box({ children }) {
return <b>{children}</b>;
}`,
"pages/index.tsx": `import Box from "../components/Box";
const filler = Buffer.alloc(495, "</script>").toString();
async function Item({ index }: { index: number }) {
return <i>{index + ":" + filler}</i>;
}
export default function IndexPage() {
return (
<div>
<h1>Chunked</h1>
<Box>hydrated</Box>
{Array.from({ length: 120 }, (_, i) => (
<Item key={i} index={i} />
))}
</div>
);
}`,
"package.json": JSON.stringify({
"name": "test-app",
"version": "1.0.0",
"devDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
},
}),
});
const { exitCode } = await Bun.$`${bunExe()} build --app ./src/index.tsx --outdir ./dist`
.cwd(dir)
.env(bunEnv)
.throws(false);
expect(exitCode).toBe(0);
const htmlContent = await Bun.file(path.join(dir, "dist", "index.html")).text();
const opener = "(self.__bun_f||=[]).push('";
const start = htmlContent.indexOf(opener);
expect(start).toBeGreaterThan(-1);
const end = htmlContent.indexOf("')</script>", start);
expect(end).toBeGreaterThan(start);
const payload = htmlContent.slice(start + opener.length, end);
expect(payload).toContain("</\\script></\\script>");
expect(payload).not.toContain("</script");
});
test("don't include client code if fully static route", async () => {
const dir = await tempDirWithBakeDeps("bake-production-no-client-js", {
"src/index.tsx": `export default { app: { framework: "react" } };`,
"pages/index.tsx": `
export default function IndexPage() {
return (
<div>
Hello World
</div>
);
}`,
"package.json": JSON.stringify({
"name": "test-app",
"version": "1.0.0",
"devDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
},
}),
});
// Run the build command
const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./src/index.tsx`.cwd(dir).throws(false);
// The build should succeed
// expect(stderr.toString()).toBe("");
expect(exitCode).toBe(0);
// Check the generated HTML file
const htmlPage = path.join(dir, "dist", "index.html");
expect(existsSync(htmlPage)).toBe(true);
const htmlContent = await Bun.file(htmlPage).text();
// Verify the content is rendered
expect(htmlContent).toContain("Hello World");
// Verify NO JavaScript imports are included in the HTML
expect(htmlContent).not.toContain('<script type="module"');
});
});
+403
View File
@@ -0,0 +1,403 @@
import { expect } from "bun:test";
import { devTest } from "../bake-harness";
// The dev error page embeds its payload as JSON (see src/runtime/server/DevErrorPage.rs).
function getFallbackMessageContainer(text: string) {
const regex = /<script id="__bunfallback" type="application\/json">([^<]*)<\/script>/m;
return JSON.parse(regex.exec(text)![1]);
}
// Test case 1: Simple page which throws an error when streaming = false
devTest("error thrown when streaming = false", {
framework: "react",
files: {
"pages/index.tsx": `
export const streaming = false;
export const mode = "ssr";
export default async function IndexPage() {
throw new Error('LMAO')
}
`,
},
async test(dev) {
const response = await dev.fetch("/");
expect(response.status).toBe(500);
},
});
// Test case 2: Simple page which throws an error when streaming = true
devTest("error thrown when streaming = true", {
framework: "react",
files: {
"pages/index.tsx": `
export const streaming = true;
export const mode = "ssr";
export default async function IndexPage() {
throw new Error('LMAO')
}
`,
},
async test(dev) {
const response = await dev.fetch("/");
// Streaming might return 200 and then error, or 500
const text = await response.text();
const fallback_message_container = getFallbackMessageContainer(text);
expect(fallback_message_container.problems?.exceptions[0].message).toContain("LMAO");
},
});
// Test case 3: Using Response.render() with streaming = true (should error)
devTest("Response.render() with streaming = true should error", {
framework: "react",
files: {
"pages/index.tsx": `
export const streaming = true;
export const mode = "ssr";
export default async function IndexPage() {
return Response.render("/other");
}
`,
"pages/other.tsx": `
export default function OtherPage() {
return <h1>Other Page</h1>;
}
`,
},
async test(dev) {
const response = await dev.fetch("/");
const text = await response.text();
// Response.render() is not available during streaming
expect(text.toLowerCase()).toContain("error");
},
});
// Test case 4: Using new Response(<jsx />, { ... }) with custom headers
devTest("new Response with JSX and custom headers", {
framework: "react",
files: {
"pages/index.tsx": `
export const streaming = false;
export const mode = "ssr";
export default async function IndexPage() {
return new Response(<h1>Hello World</h1>, {
status: 201,
headers: {
"X-Custom-Header": "test-value",
"X-Another-Header": "another-value"
}
});
}
`,
},
async test(dev) {
const response = await dev.fetch("/");
expect(response.status).toBe(201);
expect(response.headers.get("X-Custom-Header")).toBe("test-value");
expect(response.headers.get("X-Another-Header")).toBe("another-value");
const text = await response.text();
expect(text).toContain("<h1>Hello World</h1>");
},
});
// Test case 5: new Response with JSX when streaming = true (should error)
devTest("new Response with JSX when streaming = true should error", {
framework: "react",
files: {
"pages/index.tsx": `
export const streaming = true;
export const mode = "ssr";
export default async function IndexPage() {
return new Response(<h1>Hello World</h1>, {
status: 201,
headers: {
"X-Custom-Header": "test-value"
}
});
}
`,
},
async test(dev) {
const response = await dev.fetch("/");
const text = await response.text();
const fallback_message_container = getFallbackMessageContainer(text);
expect(fallback_message_container.problems?.exceptions[0].message).toContain(
'"new Response(<jsx />, { ... })" is not available when `export const streaming = true`',
);
},
});
// Test case 6: Response.redirect() - content matching
devTest("Response.redirect() - content matching", {
framework: "react",
files: {
"pages/index.tsx": `
export const streaming = false;
export const mode = "ssr";
export default async function IndexPage() {
return Response.redirect("/lmao");
}
`,
"pages/lmao.tsx": `
export default function LmaoPage() {
return <h1>LMAO Page</h1>;
}
`,
},
async test(dev) {
// Test with redirect following (default behavior)
const response = await dev.fetch("/");
expect(response.status).toBe(200); // After following redirect
const text = await response.text();
expect(text).toContain("<h1>LMAO Page</h1>");
},
});
// Test case 7: Response.redirect() - HTTP redirect status/headers
devTest("Response.redirect() - HTTP redirect status and headers", {
framework: "react",
files: {
"pages/index.tsx": `
export const streaming = false;
export const mode = "ssr";
export default async function IndexPage() {
return Response.redirect("/lmao");
}
`,
"pages/lmao.tsx": `
export default function LmaoPage() {
return <h1>LMAO Page</h1>;
}
`,
},
async test(dev) {
// Test without following redirects
const response = await dev.fetch("/", { redirect: "manual" });
expect(response.status).toBe(302); // Default redirect status
expect(response.headers.get("Location")).toBe("/lmao");
},
});
// Test case 8: Response.redirect() when streaming = true (should error)
devTest("Response.redirect() when streaming = true should error", {
framework: "react",
files: {
"pages/index.tsx": `
export const streaming = true;
export const mode = "ssr";
export default async function IndexPage() {
return Response.redirect("/lmao");
}
`,
"pages/lmao.tsx": `
export default function LmaoPage() {
return <h1>LMAO Page</h1>;
}
`,
},
async test(dev) {
const response = await dev.fetch("/");
const text = await response.text();
// Response.redirect() during streaming should error
expect(text.toLowerCase()).toContain("error");
},
});
// Test case 9: Response.render() acts like Next.js rewrite
devTest("Response.render() works like Next.js rewrite", {
framework: "react",
files: {
"pages/index.tsx": `
export const streaming = false;
export const mode = "ssr";
export default async function IndexPage() {
return Response.render("/new-route");
}
`,
"pages/new-route.tsx": `
export default function NewRoutePage() {
return <h1>New Route Content</h1>;
}
`,
},
async test(dev) {
const response = await dev.fetch("/");
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toContain("<h1>New Route Content</h1>");
// Verify it's a rewrite, not a redirect
expect(response.url).toContain("/"); // URL should remain the original
},
});
// Test case 10: Response.render() with dynamic route
devTest("Response.render() with dynamic route", {
framework: "react",
files: {
"pages/product.tsx": `
export const streaming = false;
export const mode = "ssr";
export default async function ProductPage() {
return Response.render("/category/electronics");
}
`,
"pages/category/[slug].tsx": `
export default function CategoryPage({ params }) {
return <h1>Category: {params.slug}</h1>;
}
`,
},
async test(dev) {
const response = await dev.fetch("/product");
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toContain("<h1>Category: <!-- -->electronics</h1>");
},
});
// Test case 12: Concurrent requests with different Response options (AsyncLocalStorage isolation)
devTest("concurrent requests maintain isolated Response options via AsyncLocalStorage", {
framework: "react",
files: {
"pages/request-a.tsx": `
export const streaming = false;
export const mode = "ssr";
export default async function RequestA() {
// Simulate some async work to increase chance of overlapping
await new Promise(resolve => setTimeout(resolve, 10));
return new Response(<h1>Request A</h1>, {
status: 201,
headers: {
"X-Request-Id": "request-a",
"X-Custom-A": "value-a"
}
});
}
`,
"pages/request-b.tsx": `
export const streaming = false;
export const mode = "ssr";
export default async function RequestB() {
// Different timing to create overlapping requests
await new Promise(resolve => setTimeout(resolve, 5));
return new Response(<h2>Request B</h2>, {
status: 202,
headers: {
"X-Request-Id": "request-b",
"X-Custom-B": "value-b"
}
});
}
`,
"pages/request-c.tsx": `
export const streaming = false;
export const mode = "ssr";
export default async function RequestC() {
// No delay for this one
return new Response(<h3>Request C</h3>, {
status: 203,
headers: {
"X-Request-Id": "request-c",
"X-Custom-C": "value-c"
}
});
}
`,
},
async test(dev) {
// Launch multiple concurrent requests
const promises: Promise<any>[] = [];
const requestCount = 5; // Multiple iterations to increase chance of catching issues
for (let i = 0; i < requestCount; i++) {
console.log("Iteration", i);
// Interleave different request types
promises.push(
dev.fetch("/request-a").then(async res => ({
path: "/request-a",
status: res.status,
headers: {
requestId: res.headers.get("X-Request-Id"),
customA: res.headers.get("X-Custom-A"),
customB: res.headers.get("X-Custom-B"),
customC: res.headers.get("X-Custom-C"),
},
text: await res.text(),
})),
);
promises.push(
dev.fetch("/request-b").then(async res => ({
path: "/request-b",
status: res.status,
headers: {
requestId: res.headers.get("X-Request-Id"),
customA: res.headers.get("X-Custom-A"),
customB: res.headers.get("X-Custom-B"),
customC: res.headers.get("X-Custom-C"),
},
text: await res.text(),
})),
);
promises.push(
dev.fetch("/request-c").then(async res => ({
path: "/request-c",
status: res.status,
headers: {
requestId: res.headers.get("X-Request-Id"),
customA: res.headers.get("X-Custom-A"),
customB: res.headers.get("X-Custom-B"),
customC: res.headers.get("X-Custom-C"),
},
text: await res.text(),
})),
);
}
const results = await Promise.all(promises);
// Verify each request maintained its own isolated Response options
for (const result of results) {
if (result.path === "/request-a") {
expect(result.status).toBe(201);
expect(result.headers.requestId).toBe("request-a");
expect(result.headers.customA).toBe("value-a");
expect(result.headers.customB).toBeNull(); // Should not leak from request-b
expect(result.headers.customC).toBeNull(); // Should not leak from request-c
expect(result.text).toContain("<h1>Request A</h1>");
} else if (result.path === "/request-b") {
expect(result.status).toBe(202);
expect(result.headers.requestId).toBe("request-b");
expect(result.headers.customA).toBeNull(); // Should not leak from request-a
expect(result.headers.customB).toBe("value-b");
expect(result.headers.customC).toBeNull(); // Should not leak from request-c
expect(result.text).toContain("<h2>Request B</h2>");
} else if (result.path === "/request-c") {
expect(result.status).toBe(203);
expect(result.headers.requestId).toBe("request-c");
expect(result.headers.customA).toBeNull(); // Should not leak from request-a
expect(result.headers.customB).toBeNull(); // Should not leak from request-b
expect(result.headers.customC).toBe("value-c");
expect(result.text).toContain("<h3>Request C</h3>");
}
}
},
});
+750
View File
@@ -0,0 +1,750 @@
// these tests involve ensuring react (html loader + single page app) works
// react is big and we do lots of stuff like fast refresh.
import { expect } from "bun:test";
import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness";
/** To test react refresh's registration system */
const reactAndRefreshStub = {
"node_modules/react-refresh/runtime.js": /* js */ `
exports.performReactRefresh = () => {};
exports.injectIntoGlobalHook = () => {};
exports.isLikelyComponentType = () => true;
exports.register = require("bun-devserver-react-mock").register;
exports.createSignatureFunctionForTransform = require("bun-devserver-react-mock").createSignatureFunctionForTransform;
`,
"node_modules/react/index.js": /* js */ `
exports.useState = (y) => [y, x => {}];
`,
"node_modules/bun-devserver-react-mock/index.js": /* js */ `
globalThis.components = new Map();
globalThis.functionToComponent = new Map();
exports.expectComponent = function(fn, filename, exportId) {
const name = filename + ":" + exportId;
try {
if (!components.has(name)) {
for (const [k, v] of components) {
if (v.fn === fn) throw new Error("Component registered under name " + k + " instead of " + name);
}
throw new Error("Component not registered: " + name);
}
if (components.get(name).fn !== fn) throw new Error("Component registered with wrong name: " + name);
} catch (e) {
console.log(components);
throw e;
}
}
exports.expectHook = function(fn) {
if (!functionToComponent.has(fn)) throw new Error("Hook not registered: " + fn.name);
const entry = functionToComponent.get(fn);
const { calls, hash, name } = entry;
fn();
if (calls === entry.calls) throw new Error("Hook " + (name ?? fn.name) + " was not called");
return hash;
}
exports.expectHookComponent = function(fn, filename, exportId) {
exports.expectComponent(fn, filename, exportId);
exports.expectHook(fn);
}
exports.hashFromFunction = function(fn) {
if (!keyFromFunction.has(fn)) throw new Error("Function not registered: " + fn);
return keyFromFunction.get(fn).hash;
}
exports.register = function(fn, name) {
if (typeof name !== "string") throw new Error("name must be a string");
if (typeof fn !== "function") throw new Error("fn must be a function");
if (components.has(name)) console.warn("WARNING: Component already registered: " + name + ". Read its hash from test harness first");
const entry = functionToComponent.get(fn) ?? { fn, calls: 0, hash: undefined, name: undefined, customHooks: undefined };
entry.name = name;
components.set(name, entry);
functionToComponent.set(fn, entry);
}
exports.createSignatureFunctionForTransform = function(fn) {
let entry = null;
return function(fn, hash, force, customHooks) {
if (fn !== undefined) {
entry = functionToComponent.get(fn) ?? { fn, calls: 0, hash: undefined, name: undefined, customHooks: undefined };
functionToComponent.set(fn, entry);
entry.hash = hash;
entry.calls = 0;
entry.customHooks = customHooks;
return fn;
} else {
if (!entry) throw new Error("Function not registered");
entry.calls++;
return entry.fn;
}
}
}
exports.getCustomHooks = function(fn) {
const entry = functionToComponent.get(fn);
if (!entry) throw new Error("Function not registered");
if (!entry.customHooks) throw new Error("Function has no custom hooks");
return entry.customHooks();
}
`,
"node_modules/react/jsx-dev-runtime.js": /* js */ `
export const $$typeof = Symbol.for("react.element");
export const jsxDEV = (tag, props, key) => ({
$$typeof,
props,
key,
ref: null,
type: tag,
});
`,
};
devTest("react in html", {
fixture: "react-spa-simple",
async test(dev) {
await using c = await dev.client();
expect(await c.elemText("h1")).toBe("Hello World");
await dev.write(
"App.tsx",
`
console.log('reload');
export default function App() {
return <h1>Yay</h1>;
}
`,
);
await c.expectMessage("reload");
expect(await c.elemText("h1")).toBe("Yay");
await c.hardReload();
await c.expectMessage("reload");
expect(await c.elemText("h1")).toBe("Yay");
},
});
// https://github.com/oven-sh/bun/issues/17447
devTest("react refresh should register and track hook state", {
framework: minimalFramework,
files: {
...reactAndRefreshStub,
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.tsx"],
}),
"index.tsx": `
import { expectHookComponent } from 'bun-devserver-react-mock';
import App from './App.tsx';
expectHookComponent(App, "App.tsx", "default");
`,
"App.tsx": `
import { useState } from "react";
export default function App() {
let [a, b] = useState(1);
return <div>Hello, world!</div>;
}
`,
},
async test(dev) {
await using c = await dev.client("/", {});
const firstHash = await c.reactRefreshComponentHash("App.tsx", "default");
expect(firstHash).toBeDefined();
// hash does not change when hooks stay same
await dev.write(
"App.tsx",
`
import { useState } from "react";
export default function App() {
let [a, b] = useState(1);
return <div>Hello, world! {a}</div>;
}
`,
);
const secondHash = await c.reactRefreshComponentHash("App.tsx", "default");
expect(secondHash).toEqual(firstHash);
// hash changes when hooks change
await dev.write(
"App.tsx",
`
export default function App() {
let [a, b] = useState(2);
return <div>Hello, world! {a}</div>;
}
`,
);
const thirdHash = await c.reactRefreshComponentHash("App.tsx", "default");
expect(thirdHash).not.toEqual(firstHash);
},
});
devTest("react refresh cases", {
framework: minimalFramework,
files: {
...reactAndRefreshStub,
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.tsx"],
}),
"index.tsx": `
import { expectComponent, expectHookComponent } from 'bun-devserver-react-mock';
expectComponent((await import("./default_unnamed")).default, "default_unnamed.tsx", "default");
expectComponent((await import("./default_named")).default, "default_named.tsx", "default");
expectComponent((await import("./default_arrow")).default, "default_arrow.tsx", "default");
expectComponent((await import("./local_var")).LocalVar, "local_var.tsx", "LocalVar");
expectComponent((await import("./local_const")).LocalConst, "local_const.tsx", "LocalConst");
await import("./non_exported");
expectHookComponent((await import("./default_unnamed_hooks")).default, "default_unnamed_hooks.tsx", "default");
expectHookComponent((await import("./default_named_hooks")).default, "default_named_hooks.tsx", "default");
expectHookComponent((await import("./default_arrow_hooks")).default, "default_arrow_hooks.tsx", "default");
expectHookComponent((await import("./local_var_hooks")).LocalVar, "local_var_hooks.tsx", "LocalVar");
expectHookComponent((await import("./local_const_hooks")).LocalConst, "local_const_hooks.tsx", "LocalConst");
await import("./non_exported_hooks");
console.log("PASS");
`,
"default_unnamed.tsx": `
export default function() {
return <div></div>;
}
`,
"default_named.tsx": `
export default function Hello() {
return <div></div>;
}
`,
"default_arrow.tsx": `
export default () => {
return <div></div>;
}
`,
"local_var.tsx": `
export var LocalVar = () => {
return <div></div>;
}
`,
"local_const.tsx": `
export const LocalConst = () => {
return <div></div>;
}
`,
"non_exported.tsx": `
import { expectComponent } from 'bun-devserver-react-mock';
function NonExportedFunc() {
return <div></div>;
}
const NonExportedVar = () => {
return <div></div>;
}
// Anonymous function with name
const NonExportedAnon = (function MyNamedAnon() {
return <div></div>;
});
// Anonymous function without name
const NonExportedAnonUnnamed = (function() {
return <div></div>;
});
expectComponent(NonExportedFunc, "non_exported.tsx", "NonExportedFunc");
expectComponent(NonExportedVar, "non_exported.tsx", "NonExportedVar");
expectComponent(NonExportedAnon, "non_exported.tsx", "NonExportedAnon");
expectComponent(NonExportedAnonUnnamed, "non_exported.tsx", "NonExportedAnonUnnamed");
`,
"default_unnamed_hooks.tsx": `
import { useState } from "react";
export default function() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
`,
"default_named_hooks.tsx": `
import { useState } from "react";
export default function Hello() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
`,
"default_arrow_hooks.tsx": `
import { useState } from "react";
export default () => {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
`,
"local_var_hooks.tsx": `
import { useState } from "react";
export var LocalVar = () => {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
`,
"local_const_hooks.tsx": `
import { useState } from "react";
export const LocalConst = () => {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
`,
"non_exported_hooks.tsx": `
import { useState } from "react";
import { expectHookComponent } from 'bun-devserver-react-mock';
function NonExportedFunc() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
const NonExportedVar = () => {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
// Anonymous function with name
const NonExportedAnon = (function MyNamedAnon() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
});
// Anonymous function without name
const NonExportedAnonUnnamed = (function() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
});
expectHookComponent(NonExportedFunc, "non_exported_hooks.tsx", "NonExportedFunc");
expectHookComponent(NonExportedVar, "non_exported_hooks.tsx", "NonExportedVar");
expectHookComponent(NonExportedAnon, "non_exported_hooks.tsx", "NonExportedAnon");
expectHookComponent(NonExportedAnonUnnamed, "non_exported_hooks.tsx", "NonExportedAnonUnnamed");
`,
},
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("PASS");
},
});
devTest("two functions with hooks should be independently tracked", {
framework: minimalFramework,
files: {
...reactAndRefreshStub,
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.tsx"],
}),
"index.tsx": `
import { useState } from "react";
import { expectHook } from 'bun-devserver-react-mock';
function method1() {
const _ = useState(1);
}
const method2 = function method2() {
const _ = useState(2);
}
const method3 = () => {
const _ = useState(3);
}
expectHook(method1);
expectHook(method2);
expectHook(method3);
console.log("PASS");
`,
},
async test(dev) {
await using c = await dev.client("/", {});
await c.expectMessage("PASS");
},
});
devTest("custom hook tracking", {
framework: minimalFramework,
files: {
...reactAndRefreshStub,
"index.html": emptyHtmlFile({
styles: [],
scripts: ["index.tsx"],
}),
"index.tsx": `
import { useCustom1, useCustom2 } from "./custom-hook";
import { expectHook, getCustomHooks } from 'bun-devserver-react-mock';
function method1() {
const _ = useCustom1();
}
function method2() {
const _ = useCustom1();
}
function method3() {
const _ = useCustom2();
}
function method4() {
const a = useCustom1();
const b = useCustom2();
}
const hash1 = expectHook(method1);
const hash2 = expectHook(method2);
const hash3 = expectHook(method3);
const hash4 = expectHook(method4);
if (hash1 !== hash2) throw new Error("hash1 and hash2 should be the same: " + hash1 + " " + hash2);
if (hash1 === hash3) throw new Error("hash1 and hash3 should be different: " + hash1 + " " + hash3);
if (hash1 === hash4) throw new Error("hash1 and hash4 should be different: " + hash1 + " " + hash4);
if (hash3 === hash4) throw new Error("hash3 and hash4 should be different: " + hash3 + " " + hash4);
const customHooks1 = getCustomHooks(method1);
const customHooks2 = getCustomHooks(method2);
const customHooks3 = getCustomHooks(method3);
function assertCustomHooks(method, expected) {
const customHooks = getCustomHooks(method);
if (customHooks.length !== expected.length) throw new Error("customHooks should have " + expected.length + " hooks: " + customHooks.length);
for (let i = 0; i < expected.length; i++) {
if (customHooks[i] !== expected[i]) throw new Error(\`customHooks[\${i}] should be \${expected[i]} but got \${customHooks[i]}\`);
}
}
assertCustomHooks(method1, [useCustom1]);
assertCustomHooks(method2, [useCustom1]);
assertCustomHooks(method3, [useCustom2]);
assertCustomHooks(method4, [useCustom1, useCustom2]);
console.log("PASS");
`,
"custom-hook.ts": `
export function useCustom1() {
return 1;
}
export function useCustom2() {
return 2;
}
`,
},
async test(dev) {
await using c = await dev.client("/", {});
await c.expectMessage("PASS");
},
});
devTest("react component with hooks and mutual recursion renders without error", {
files: {
...reactAndRefreshStub,
"index.tsx": `
import ComponentWithConst, { helper } from './component-with-const';
import ComponentWithLet, { getCounter } from './component-with-let';
import ComponentWithVar, { getGlobalState } from './component-with-var';
import MathComponent, { utilityFunction } from './component-with-function';
import ProcessorComponent, { DataProcessor } from './component-with-class';
function useThis() {
return null;
}
function useFakeState(initial) {
return [initial, () => {}];
}
function useFakeEffect(fn) {
fn();
}
export default function AA({ depth = 0 }: { depth: number }) {
const [count, setCount] = useFakeState(0);
useThis();
useFakeEffect(() => {});
return depth === 0 && <B />
}
function B() {
const [value, setValue] = useFakeState(42);
useFakeEffect(() => {});
return <AA depth={1} />
}
// Call B outside the function body to test statement -> expression transform
B();
// Call all imported default functions outside their bodies
ComponentWithConst();
ComponentWithLet();
ComponentWithVar();
MathComponent({ input: 10 });
ProcessorComponent({ text: "test" });
// Use all the imported components and their non-default exports
console.log("ComponentWithConst:", ComponentWithConst());
console.log("helper:", helper());
console.log("ComponentWithLet:", ComponentWithLet());
console.log("getCounter:", getCounter());
console.log("ComponentWithVar:", ComponentWithVar());
console.log("getGlobalState:", getGlobalState());
console.log("MathComponent:", MathComponent({ input: 10 }));
console.log("utilityFunction:", utilityFunction(15));
console.log("ProcessorComponent:", ProcessorComponent({ text: "test" }));
const processor = new DataProcessor();
console.log("DataProcessor:", processor.process("world"));
console.log("PASS");
`,
"component-with-const.tsx": `
const helperValue = "helper-result";
function useFakeState(initial) {
return [initial, () => {}];
}
function useFakeCallback(fn) {
return fn;
}
export default function Component() {
const [state, setState] = useFakeState(helperValue);
const [count, setCount] = useFakeState(0);
const callback = useFakeCallback(() => {});
return helperValue;
}
export const helper = () => helperValue;
// Call Component outside its body to test statement -> expression transform
Component();
const result1 = Component();
helper();
`,
"component-with-let.tsx": `
let counter = 0;
function useFakeState(initial) {
return [initial, () => {}];
}
function useFakeEffect(fn, deps) {
fn();
}
function useFakeMemo(fn, deps) {
return fn();
}
export default function Counter() {
const [localCount, setLocalCount] = useFakeState(0);
const [multiplier, setMultiplier] = useFakeState(1);
useFakeEffect(() => {
setLocalCount(counter * multiplier);
}, [multiplier]);
const memoized = useFakeMemo(() => counter * 2, [counter]);
return ++counter;
}
export const getCounter = () => counter;
// Call Counter outside its body multiple times
Counter();
Counter();
const currentCount = Counter();
getCounter();
// Test with different call patterns
[1, 2, 3].forEach(() => Counter());
const counters = [Counter, Counter, Counter].map(fn => fn());
`,
"component-with-var.tsx": `
var globalState = { value: 42 };
function useFakeState(initial) {
return [initial, () => {}];
}
function useFakeMemo(fn, deps) {
return fn();
}
function useFakeRef(initial) {
return { current: initial };
}
export default function StateComponent() {
const [localState, setLocalState] = useFakeState(globalState.value);
const [factor, setFactor] = useFakeState(2);
const computed = useFakeMemo(() => localState * factor, [localState, factor]);
const ref = useFakeRef(null);
return globalState.value;
}
export const getGlobalState = () => globalState;
// Call StateComponent outside its body
StateComponent();
const state1 = StateComponent();
const state2 = StateComponent();
getGlobalState();
// Test with object method calls
const obj = { fn: StateComponent };
obj.fn();
// Test with array of functions
const fns = [StateComponent, getGlobalState];
fns[0]();
fns[1]();
`,
"component-with-function.tsx": `
function multiply(x: number) {
return x * 2;
}
function useFakeState(initial) {
return [initial, () => {}];
}
function useFakeCallback(fn, deps) {
return fn;
}
function useFakeReducer(reducer, initial) {
return [initial, () => {}];
}
export default function MathComponent({ input }: { input: number }) {
const [result, setResult] = useFakeState(0);
const [operations, setOperations] = useFakeState(0);
const [state, dispatch] = useFakeReducer((s, a) => s, {});
const calculate = useFakeCallback(() => {
const value = multiply(input);
setResult(value);
setOperations(prev => prev + 1);
return value;
}, [input]);
return multiply(input);
}
export const utilityFunction = multiply;
// Call MathComponent outside its body with various patterns
MathComponent({ input: 5 });
MathComponent({ input: 10 });
const result1 = MathComponent({ input: 15 });
utilityFunction(20);
// Test with function composition
const compose = (fn: Function) => fn({ input: 25 });
compose(MathComponent);
// Test with conditional calls
const shouldCall = true;
if (shouldCall) {
MathComponent({ input: 30 });
}
// Test with ternary
const ternaryResult = true ? MathComponent({ input: 35 }) : null;
// Test with logical operators
true && MathComponent({ input: 40 });
false || MathComponent({ input: 45 });
`,
"component-with-class.tsx": `
class Processor {
process(data: string) {
return data.toUpperCase();
}
}
function useFakeState(initial) {
return [initial, () => {}];
}
function useFakeReducer(reducer, initial) {
return [initial, () => {}];
}
function useFakeRef(initial) {
return { current: initial };
}
function useFakeContext() {
return {};
}
const reducer = (state: any, action: any) => {
switch (action.type) {
case 'process':
return { ...state, processed: action.payload };
default:
return state;
}
};
export default function ProcessorComponent({ text }: { text: string }) {
const [state, setState] = useFakeState({ text, processed: '' });
const [history, dispatch] = useFakeReducer(reducer, { processed: [] });
const processorRef = useFakeRef(new Processor());
const context = useFakeContext();
const processor = new Processor();
const result = processor.process(text);
dispatch({ type: 'process', payload: result });
return processor.process(text);
}
export const DataProcessor = Processor;
// Call ProcessorComponent outside its body
ProcessorComponent({ text: "hello" });
ProcessorComponent({ text: "world" });
const processed1 = ProcessorComponent({ text: "test1" });
const processed2 = ProcessorComponent({ text: "test2" });
// Test with new DataProcessor
const proc1 = new DataProcessor();
const proc2 = new DataProcessor();
proc1.process("data1");
proc2.process("data2");
// Test with function binding
const boundProcessor = ProcessorComponent.bind(null);
boundProcessor({ text: "bound" });
// Test with apply/call
ProcessorComponent.call(null, { text: "called" });
ProcessorComponent.apply(null, [{ text: "applied" }]);
// Test with destructuring
const { process } = new DataProcessor();
// Test with spread operator
const args = [{ text: "spread" }];
ProcessorComponent(...args);
`,
"index.html": emptyHtmlFile({
scripts: ["index.tsx"],
body: `<div id="root"></div>`,
}),
},
async test(dev) {
await using c = await dev.client("/", {});
await c.expectMessage(
"ComponentWithConst:",
"helper:",
"ComponentWithLet:",
"getCounter:",
"ComponentWithVar:",
"getGlobalState:",
"MathComponent:",
"utilityFunction:",
"ProcessorComponent:",
"DataProcessor:",
"PASS",
);
},
});
+68
View File
@@ -0,0 +1,68 @@
import { expect } from "bun:test";
import { devTest } from "../bake-harness";
// Basic test to verify request.cookies functionality
devTest("request.cookies.get() basic functionality", {
framework: "react",
files: {
"pages/index.tsx": `
export const mode = "ssr";
export const streaming = false;
export default async function IndexPage({ request }) {
// Try to access cookies
const userName = request.cookies?.get?.("userName") || "not-found";
return (
<div>
<p data-testid="cookie-value">{userName}</p>
</div>
);
}
`,
},
async test(dev) {
const response = await dev.fetch("/", {
headers: {
Cookie: "userName=TestUser",
},
});
const html = await response.text();
// Check if the cookie value appears in the rendered HTML
// The values appear with HTML comments (<!-- -->) in the output
expect(html).toContain("TestUser");
},
});
// Test that request object is passed to the component
devTest("request object is passed to SSR component", {
framework: "react",
files: {
"pages/index.tsx": `
export const mode = "ssr";
export const streaming = false;
export default async function IndexPage({ request }) {
// Check if request exists
const hasRequest = request !== undefined;
const requestType = typeof request;
return (
<div>
<p>Has request: {hasRequest ? "yes" : "no"}</p>
<p>Request type: {requestType}</p>
</div>
);
}
`,
},
async test(dev) {
const response = await dev.fetch("/");
const html = await response.text();
// The values appear with HTML comments in the rendered output
expect(html).toContain("yes");
expect(html).toContain("object");
},
});
@@ -0,0 +1,247 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import path from "node:path";
test("Response -> import { Response } from 'bun:app' transform in server components", async () => {
await using dir = tempDir("response-transform", {
"server-component.js": `
export const mode = "ssr";
export const streaming = false;
export default async function ServerPage({ request }) {
// Response should be imported from 'bun:app'
const response1 = new Response("Hello", { status: 200 });
// Response.redirect should work with imported Response
if (!request.userId) {
return Response.redirect("/login");
}
// Response.render should work with imported Response
if (request.page === "404") {
return Response.render("/404");
}
// Response in string content should also be transformed
return new Response("Hello from server", { status: 200 });
}
`,
"client-component.js": `
"use client";
export default function ClientPage() {
// Response should NOT be transformed in client components
const response = new Response("Client", { status: 200 });
return "Client Component";
}
`,
});
// Build with server components enabled for server-side
const serverResult =
await Bun.$`${bunExe()} build ${path.join(dir, "server-component.js")} --target=bun --server-components`
.env(bunEnv)
.text();
// Check that Response import was added from 'bun:app'
expect(serverResult).toContain('import { Response } from "bun:app"');
// Response is transformed to import_bun_app.Response
expect(serverResult).toContain("new import_bun_app.Response");
expect(serverResult).toContain("import_bun_app.Response.redirect");
expect(serverResult).toContain("import_bun_app.Response.render");
// Build client component (should not have the transform)
const clientResult = await Bun.$`${bunExe()} build ${path.join(dir, "client-component.js")} --target=browser`
.env(bunEnv)
.text();
// Check that Response import was NOT added in client component
expect(clientResult).not.toContain('import { Response } from "bun:app"');
expect(clientResult).toContain("new Response");
});
test("Response import is added for global Response in various contexts", async () => {
await using dir = tempDir("response-contexts", {
"server.js": `
export const mode = "ssr";
export default function Page() {
// As constructor
const r1 = new Response();
// As type check
if (obj instanceof Response) {
console.log("is response");
}
// As property access
const status = Response.prototype.status;
// As method call
const json = Response.json({ data: true });
// In destructuring (should not transform if it's a binding)
const { Response: LocalResponse } = imports;
return r1;
}
`,
});
const result = await Bun.$`${bunExe()} build ${path.join(dir, "server.js")} --target=bun --server-components`
.env(bunEnv)
.text();
// Check that import was added
expect(result).toContain('import { Response } from "bun:app"');
// Response is transformed to import_bun_app.Response
expect(result).toContain("new import_bun_app.Response");
expect(result).toContain("instanceof import_bun_app.Response");
expect(result).toContain("import_bun_app.Response.prototype.status");
expect(result).toContain("import_bun_app.Response.json");
});
test("Response import is not added when Response is already imported or shadowed", async () => {
await using dir = tempDir("response-shadowing", {
"server.js": `
export const mode = "ssr";
// Import shadowing Response
import { Response } from "./custom-response";
export default function Page() {
// Should use the imported Response, not transform to Bun.SSRResponse
const r = new Response();
return r;
}
`,
"server2.js": `
export const mode = "ssr";
export default function Page() {
// Local variable shadowing Response
const Response = CustomResponse;
// Should use the local Response, not transform
const r = new Response();
return r;
}
export function inner() {
// But here it should transform since it's not shadowed
return new Response();
}
`,
"custom-response.ts": `
export class Response {
constructor() {
this.custom = true;
}
}
`,
});
const result1 = await Bun.$`${bunExe()} build ${path.join(dir, "server.js")} --target=bun --server-components`
.env(bunEnv)
.text();
// When Response is already imported from another source, no bun:app import should be added
expect(result1).not.toContain('import { Response } from "bun:app"');
const result2 = await Bun.$`${bunExe()} build ${path.join(dir, "server2.js")} --target=bun --server-components`
.env(bunEnv)
.text();
// Should preserve local variable
expect(result2).toContain("return new CustomResponse");
// The file should have the import added for the inner function
expect(result2).toContain('import { Response } from "bun:app"');
});
test("Response import is NOT added in client components", async () => {
await using dir = tempDir("client-no-transform", {
"client-component.js": `
"use client";
// Response should NOT be transformed to Bun.SSRResponse in client components
const response = new Response("Client data", {
status: 200,
headers: { "Content-Type": "text/plain" }
});
// Response.json should remain Response.json
const jsonResponse = Response.json({ data: "test" });
// instanceof Response should remain as-is
if (response instanceof Response) {
console.log("Is a Response");
}
// Response.redirect should remain Response.redirect
const redirect = Response.redirect("/new-page");
export default response;
`,
"server-component.js": `
export const mode = "ssr";
// Response should be imported from 'bun:app' in server component
const serverResponse = new Response("Server", { status: 200 });
// Response static methods should work with imported Response
const json = Response.json({ server: true });
export default serverResponse;
`,
});
// Test 1: Client component - Response should NOT be transformed
const clientResult = await Bun.$`${bunExe()} build ${path.join(dir, "client-component.js")} --target=browser`
.env(bunEnv as any)
.text();
// Verify Response import is NOT added in client components
expect(clientResult).not.toContain('import { Response } from "bun:app"');
expect(clientResult).toContain("new Response");
expect(clientResult).toContain("Response.json");
expect(clientResult).toContain("instanceof Response");
expect(clientResult).toContain("Response.redirect");
// Test 2: Server component - Response SHOULD be transformed
const serverResult =
await Bun.$`${bunExe()} build ${path.join(dir, "server-component.js")} --target=bun --server-components`
.env(bunEnv as any)
.text();
// Server component should have import from bun:app
expect(serverResult).toContain('import { Response } from "bun:app"');
expect(serverResult).toContain("new import_bun_app.Response");
});
test("Response import is added when Response is global, but not when shadowed", async () => {
await using dir = tempDir("response-shadowing", {
"server-component.js": `
export const mode = "ssr";
export function inner() {
const Response = 'ooga booga!';
const foo = new Response('test', { status: 200 });
return foo;
}
export const lmao = new Response()
`,
});
const serverResult =
await Bun.$`${bunExe()} build ${path.join(dir, "server-component.js")} --target=bun --server-components`
.env(bunEnv as any)
.text();
// Import should be added for the global Response usage
expect(serverResult).toContain('import { Response } from "bun:app"');
// Local shadowed Response should not be affected
expect(serverResult).toContain('new "ooga booga!"');
// Global Response is transformed to import_bun_app.Response
expect(serverResult).toContain("var lmao = new import_bun_app.Response");
});
+236
View File
@@ -0,0 +1,236 @@
import { expect } from "bun:test";
import { isASAN } from "harness";
import { devTest } from "../bake-harness";
devTest("server-side source maps show correct error lines", {
files: {
"pages/[...slug].tsx": `export default async function MyPage(params) {
myFunc();
return <h1>{JSON.stringify(params)}</h1>;
}
function myFunc() {
throw new Error("Test error for source maps!");
}
export async function getStaticPaths() {
return {
paths: [
{
params: {
slug: ["test-error"],
},
},
],
};
}`,
},
framework: "react",
async test(dev) {
// Make a request that will trigger the error
await dev.fetch("/test-error").catch(() => {});
// The output we saw shows the stack trace with correct source mapping
// We need to check that the error shows the right file:line:column
const lines = dev.output.lines.join("\n");
// Check that we got the error
expect(lines).toContain("Test error for source maps!");
// Check that the stack trace shows correct file and line numbers
// The source maps are working if we see the correct patterns
// We need to check for the patterns because ANSI codes might be embedded
// Strip ANSI codes for cleaner checking
const cleanLines = lines.replace(/\x1b\[[0-9;]*m/g, "");
const hasCorrectThrowLine = cleanLines.includes("myFunc") && cleanLines.includes("6:16");
// const hasCorrectCallLine = cleanLines.includes("MyPage") && cleanLines.includes("2") && cleanLines.includes("3");
const hasCorrectFileName = cleanLines.includes("pages/[...slug].tsx");
expect(hasCorrectThrowLine).toBe(true);
// TODO: renable this when async stacktraces are enabled?
// expect(hasCorrectCallLine).toBe(true);
expect(hasCorrectFileName).toBe(true);
},
timeoutMultiplier: 2, // Give more time for the test
});
devTest("server-side source maps work with HMR updates", {
files: {
"pages/error-page.tsx": `export default function ErrorPage() {
return <div>Initial content</div>;
}
export async function getStaticPaths() {
return {
paths: [{ params: {} }],
};
}`,
},
framework: "react",
async test(dev) {
// First fetch should work
const response1 = await dev.fetch("/error-page");
expect(response1.status).toBe(200);
expect(await response1.text()).toContain("Initial content");
// Update the file to throw an error
await dev.write(
"pages/error-page.tsx",
`export default function ErrorPage() {
throwError();
return <div>Updated content</div>;
}
function throwError() {
throw new Error("HMR error test");
}
export async function getStaticPaths() {
return {
paths: [{ params: {} }],
};
}`,
);
await Promise.all([dev.fetch("/error-page").catch(() => {}), dev.output.waitForLine(/HMR error test/)]);
// Check source map points to correct lines after HMR
const lines = dev.output.lines.join("\n");
// Strip ANSI codes for cleaner checking
const cleanLines = lines.replace(/\x1b\[[0-9;]*m/g, "");
const hasCorrectThrowLine = cleanLines.includes("throwError") && cleanLines.includes("6:1");
const hasCorrectCallLine = cleanLines.includes("ErrorPage") && cleanLines.includes("1:16");
expect(hasCorrectThrowLine).toBe(true);
expect(hasCorrectCallLine).toBe(true);
},
});
devTest("server-side source maps handle nested imports", {
files: {
"pages/nested.tsx": `import { doSomething } from "../lib/utils";
export default function NestedPage() {
const result = doSomething();
return <div>{result}</div>;
}
export async function getStaticPaths() {
return {
paths: [{ params: {} }],
};
}`,
"lib/utils.ts": `export function doSomething() {
return helperFunction();
}
function helperFunction() {
throw new Error("Nested error");
}`,
},
framework: "react",
async test(dev) {
await Promise.all([dev.fetch("/nested").catch(() => {}), dev.output.waitForLine(/Nested error/)]);
// Check that stack trace shows both files with correct lines
const lines = dev.output.lines.join("\n");
// Strip ANSI codes for cleaner checking
const cleanLines = lines.replace(/\x1b\[[0-9;]*m/g, "");
const hasUtilsThrowLine = cleanLines.includes("helperFunction") && cleanLines.includes("5:1");
const hasUtilsCallLine = cleanLines.includes("doSomething2") && cleanLines.includes("1:28");
const hasPageCallLine = cleanLines.includes("NestedPage") && cleanLines.includes("3:38");
expect(hasUtilsThrowLine).toBe(true);
expect(hasUtilsCallLine).toBe(true);
expect(hasPageCallLine).toBe(true);
},
});
// Each round re-registers the file's source provider over the previous one
// and re-materializes the parsed map from it, so stack remapping must stay
// correct through repeated provider replacement, not just the first install.
// `filler` comment lines shift the throwing function down one line per round,
// so a stale map from an earlier round would remap the frame to the wrong
// line and fail that round's assertion.
function churnPage(name: string, filler: number) {
const fillerLines = Array.from({ length: filler }, (_, n) => `// filler ${n}\n`).join("");
return `export default function ChurnPage() {
churn${name}();
return <div>churn ${name}</div>;
}
${fillerLines}function churn${name}() {
throw new Error("Churn error ${name}");
}
export async function getStaticPaths() {
return {
paths: [{ params: {} }],
};
}`;
}
devTest("server-side source maps stay correct across repeated reloads", {
files: {
"pages/churn.tsx": churnPage("Alpha", 0),
},
framework: "react",
async test(dev) {
const rounds = ["Alpha", "Bravo", "Charlie", "Delta"];
for (let i = 0; i < rounds.length; i++) {
const name = rounds[i];
if (i > 0) {
await dev.write("pages/churn.tsx", churnPage(name, i));
}
await Promise.all([
dev.fetch("/churn").catch(() => {}),
dev.output.waitForLine(new RegExp(`Churn error ${name}`)),
]);
// Strip ANSI codes; they interleave within stack-frame lines.
const cleanLines = dev.output.lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
// The throwing function is declared on line 6 + i of round i's version
// of the source file; frames remap to the declaration position (see the
// `helperFunction`/`5:1` expectation above). `\w*` tolerates bundler
// symbol renaming (see `doSomething2` above).
expect(cleanLines).toMatch(new RegExp(`at churn${name}\\w* \\(.*pages[/\\\\]churn\\.tsx:${6 + i}:1\\)`));
}
},
timeoutMultiplier: 2,
});
// ~DevServerSourceProvider ran after the Zig::GlobalObject cell was swept.
// BUN_DESTRUCT_VM_ON_EXIT=1 triggers that teardown; Malloc=1 puts JSC cells
// under system malloc so ASAN poisons the freed cell and the UAF is deterministic.
if (isASAN) {
devTest("DevServerSourceProvider destructor does not touch the swept global object on process exit", {
framework: "react",
files: {
"pages/index.tsx": `
export const mode = "ssr";
export const streaming = false;
export default function IndexPage() {
return <div>alive</div>;
}
`,
},
env: {
Malloc: "1",
BUN_DESTRUCT_VM_ON_EXIT: "1",
// The test is about the use-after-free, not LSan; keep it hermetic
// against whatever ASAN_OPTIONS the outer runner chose.
ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=0",
},
async test(dev) {
const response = await dev.fetch("/");
const html = await response.text();
expect(html).toContain("alive");
// The harness calls gracefulExit() after this, which sends the dev
// server through server.stop(true) + Bun.gc(true) + process.exit(0).
// The teardown that follows is what this test is about.
},
});
}
+142
View File
@@ -0,0 +1,142 @@
// Source maps are non-trivial to test because the tests shouldn't rely on any
// hardcodings of the generated line/column numbers. Hardcoding wouldn't even
// work because hmr-runtime is minified in release builds, which would affect
// the generated line/column numbers across different build configurations.
import { expect } from "bun:test";
import { BasicSourceMapConsumer, IndexedSourceMapConsumer, SourceMapConsumer } from "source-map";
import { Dev, devTest, emptyHtmlFile } from "../bake-harness";
devTest("source map emitted for primary chunk", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
import other from "./❤️.js";
console.log("Hello, " + other + "!");
`,
"❤️.ts": `
// hello
export default "♠️";
`,
},
async test(dev) {
const html = await dev.fetch("/").text();
using sourceMap = await extractSourceMapHtml(dev, html);
expect(sourceMap.sources.slice(1).map(Bun.fileURLToPath)) //
.toEqual([dev.join("index.html"), dev.join("index.ts"), dev.join("❤️.ts")]);
const generated = indexOfLineColumn(sourceMap.script, "♠️");
const original = sourceMap.originalPositionFor(generated);
expect(original).toEqual({
source: sourceMap.sources[3],
name: null,
line: 2,
column: "export default ".length,
});
},
});
devTest("source map emitted for hmr chunk", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
import other from "./App";
console.log("Hello, " + other + "!");
import.meta.hot.accept();
`,
"App.tsx": `
console.log("some text here");
export default "world";
import.meta.hot.accept();
`,
},
async test(dev) {
await using c = await dev.client("/", { storeHotChunks: true });
await dev.write("App.tsx", "// yay\nconsole.log('magic');\nimport.meta.hot.accept();");
const chunk = await c.getMostRecentHmrChunk();
using sourceMap = await extractSourceMap(dev, chunk);
expect(sourceMap.sources.slice(1).map(Bun.fileURLToPath)) //
.toEqual([dev.join("App.tsx")]);
const generated = indexOfLineColumn(sourceMap.script, "magic");
const original = sourceMap.originalPositionFor(generated);
expect(original).toEqual({
source: sourceMap.sources[1],
name: null,
line: 2,
column: "console.log(".length,
});
await c.expectMessage("some text here", "Hello, world!", "magic");
},
});
type SourceMap = (BasicSourceMapConsumer | IndexedSourceMapConsumer) & {
/** Original script generated */
script: string;
[Symbol.dispose](): void;
};
async function extractSourceMapHtml(dev: Dev, html: string) {
const scriptUrls = [...html.matchAll(/src="([^"]+.js)"/g)];
if (scriptUrls.length !== 1) {
throw new Error("Expected 1 source file, got " + scriptUrls.length);
}
const scriptUrl = scriptUrls[0][1];
const scriptSource = await dev.fetch(scriptUrl).text();
return extractSourceMap(dev, scriptSource);
}
async function extractSourceMap(dev: Dev, scriptSource: string) {
const sourceMapUrl = scriptSource.match(/\n\/\/# sourceMappingURL=([^"]+)/);
if (!sourceMapUrl) {
throw new Error("Source map URL not found in " + scriptSource);
}
const sourceMap = await dev.fetch(sourceMapUrl[1]).text();
if (!sourceMap.startsWith("{")) {
throw new Error("Source map is not valid JSON: " + sourceMap);
}
console.log(sourceMap);
return new Promise<SourceMap>((resolve, reject) => {
try {
SourceMapConsumer.with(sourceMap, null, async (consumer: any) => {
const { promise, resolve: release } = Promise.withResolvers();
consumer[Symbol.dispose] = () => release();
consumer.script = scriptSource;
resolve(consumer as SourceMap);
await promise;
});
} catch (error) {
reject(error);
}
});
}
function indexOfLineColumn(text: string, search: string) {
const index = text.indexOf(search);
if (index === -1) {
throw new Error("Search not found");
}
return charOffsetToLineColumn(text, index);
}
function charOffsetToLineColumn(text: string, offset: number) {
// sourcemap lines are 0-based.
// > If present, the **zero-based** starting line in the original source. This
// > field contains a base64 VLQ relative to the previous occurrence of this
// > field, unless it is the first occurrence of this field, in which case the
// > whole value is represented. Shall be present if there is a source field.
let line = 0;
let i = 0;
let prevI = 0;
while (i < offset) {
const nextIndex = text.indexOf("\n", i);
if (nextIndex === -1) {
break;
}
prevI = i;
i = nextIndex + 1;
line++;
}
return { line: line, column: offset - prevI };
}
+389
View File
@@ -0,0 +1,389 @@
// Test SSG pages router functionality
import { expect } from "bun:test";
import { devTest } from "../bake-harness";
devTest("SSG pages router - multiple static pages", {
framework: "react",
files: {
"pages/about.tsx": `
export default function AboutPage() {
return <h1>About Page</h1>;
}
`,
"pages/contact.tsx": `
export default function ContactPage() {
return <h1>Contact Page</h1>;
}
`,
},
async test(dev) {
// Test about page
await using c2 = await dev.client("/about");
expect(await c2.elemText("h1")).toBe("About Page");
// Test contact page
await using c3 = await dev.client("/contact");
expect(await c3.elemText("h1")).toBe("Contact Page");
},
});
devTest("SSG pages router - dynamic routes with [slug]", {
framework: "react",
files: {
"pages/[slug].tsx": `
type Props = Bun.SSGProps;
const Page: Bun.SSGPage = async ({ params }) => {
return (
<div>
<h1>Dynamic Page: {params.slug}</h1>
<p>Slug value: {params.slug}</p>
</div>
);
};
export default Page;
export const getStaticPaths: Bun.GetStaticPaths = async () => {
return {
paths: [
{ params: { slug: "first-post" } },
{ params: { slug: "second-post" } },
{ params: { slug: "third-post" } },
],
};
};
`,
},
async test(dev) {
// Test dynamic routes
await using c1 = await dev.client("/first-post");
expect(await c1.elemText("h1")).toBe("Dynamic Page: <!-- -->first-post");
expect(await c1.elemText("p")).toBe("Slug value: <!-- -->first-post");
await using c2 = await dev.client("/second-post");
expect(await c2.elemText("h1")).toBe("Dynamic Page: <!-- -->second-post");
await using c3 = await dev.client("/third-post");
expect(await c3.elemText("h1")).toBe("Dynamic Page: <!-- -->third-post");
},
});
devTest("SSG pages router - nested routes", {
framework: "react",
files: {
"pages/blog/index.tsx": `
export default function BlogIndex() {
return <h1>Blog Index</h1>;
}
`,
"pages/blog/[id].tsx": `
const BlogPost: Bun.SSGPage = ({ params }) => {
return <h1>Blog Post {params.id}</h1>;
};
export default BlogPost;
export const getStaticPaths: Bun.GetStaticPaths = async () => {
return {
paths: [
{ params: { id: "1" } },
{ params: { id: "2" } },
],
};
};
`,
"pages/blog/categories/[category].tsx": `
const CategoryPage: Bun.SSGPage = ({ params }) => {
return <h1>Category: {params.category}</h1>;
};
export default CategoryPage;
export const getStaticPaths: Bun.GetStaticPaths = async () => {
return {
paths: [
{ params: { category: "tech" } },
{ params: { category: "lifestyle" } },
],
};
};
`,
},
async test(dev) {
// Test blog index
await using c1 = await dev.client("/blog");
expect(await c1.elemText("h1")).toBe("Blog Index");
// Test blog posts
await using c2 = await dev.client("/blog/1");
expect(await c2.elemText("h1")).toBe("Blog Post <!-- -->1");
await using c3 = await dev.client("/blog/2");
expect(await c3.elemText("h1")).toBe("Blog Post <!-- -->2");
// Test categories
await using c4 = await dev.client("/blog/categories/tech");
expect(await c4.elemText("h1")).toBe("Category: <!-- -->tech");
await using c5 = await dev.client("/blog/categories/lifestyle");
expect(await c5.elemText("h1")).toBe("Category: <!-- -->lifestyle");
},
});
devTest("SSG pages router - hot reload on page changes", {
framework: "react",
files: {
"pages/index.tsx": `
export default function IndexPage() {
return <h1>Welcome to SSG</h1>;
}
`,
},
async test(dev) {
await using c = await dev.client("/");
expect(await c.elemText("h1")).toBe("Welcome to SSG");
// Update the page
await dev.write(
"pages/index.tsx",
`
export default function IndexPage() {
console.log("updated load");
return <h1>Updated Content</h1>;
}
`,
);
// this %c%s%c is a react devtools thing and I don't know how to turn it off
await c.expectMessage("%c%s%c updated load");
expect(await c.elemText("h1")).toBe("Updated Content");
},
});
devTest("SSG pages router - data fetching with async components", {
framework: "react",
files: {
"pages/data.tsx": `
async function fetchData() {
// Simulate API call
return new Promise(resolve => {
setTimeout(() => {
resolve({ message: "Data from API", items: ["Item 1", "Item 2", "Item 3"] });
}, 10);
});
}
export default async function DataPage() {
const data = await fetchData();
return (
<div>
<h1>{data.message}</h1>
<ul>
{data.items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}
`,
},
async test(dev) {
await using c = await dev.client("/data");
expect(await c.elemText("h1")).toBe("Data from API");
const items = await c.elemsText("li");
expect(items).toEqual(["Item 1", "Item 2", "Item 3"]);
},
});
devTest("SSG pages router - multiple dynamic segments", {
framework: "react",
files: {
"pages/[category]/[year]/[slug].tsx": `
const ArticlePage: Bun.SSGPage = ({ params }) => {
return (
<div>
<h1>{params.slug}</h1>
<p>Category: {params.category}</p>
<p>Year: {params.year}</p>
</div>
);
};
export default ArticlePage;
export const getStaticPaths: Bun.GetStaticPaths = async () => {
return {
paths: [
{ params: { category: "tech", year: "2024", slug: "bun-release" } },
{ params: { category: "news", year: "2024", slug: "breaking-story" } },
{ params: { category: "tech", year: "2023", slug: "year-review" } },
],
};
};
`,
},
async test(dev) {
// Test first path
await using c1 = await dev.client("/tech/2024/bun-release");
expect(await c1.elemText("h1")).toBe("bun-release");
expect(await c1.elemsText("p")).toEqual(["Category: <!-- -->tech", "Year: <!-- -->2024"]);
// Test second path
await using c2 = await dev.client("/news/2024/breaking-story");
expect(await c2.elemText("h1")).toBe("breaking-story");
expect(await c2.elemsText("p")).toEqual(["Category: <!-- -->news", "Year: <!-- -->2024"]);
// Test third path
await using c3 = await dev.client("/tech/2023/year-review");
expect(await c3.elemText("h1")).toBe("year-review");
expect(await c3.elemsText("p")).toEqual(["Category: <!-- -->tech", "Year: <!-- -->2023"]);
},
});
devTest("SSG pages router - file loading with Bun.file", {
framework: "react",
fixture: "ssg-pages-router",
files: {
"pages/[slug].tsx": `
import { join } from "path";
const PostPage: Bun.SSGPage = async ({ params }) => {
const content = await Bun.file(
join(process.cwd(), "posts", params.slug + ".txt")
).text();
return (
<div>
<h1>{params.slug}</h1>
<div>{content}</div>
</div>
);
};
export default PostPage;
export const getStaticPaths: Bun.GetStaticPaths = async () => {
const glob = new Bun.Glob("**/*.txt");
const paths = [];
for (const file of Array.from(glob.scanSync({ cwd: join(process.cwd(), "posts") }))) {
const slug = file.replace(/\\.txt$/, "");
paths.push({ params: { slug } });
}
return { paths };
};
`,
"posts/hello-world.txt": "This is the content of hello world post",
"posts/second-post.txt": "This is the second post content",
},
async test(dev) {
// Test first post
await using c1 = await dev.client("/hello-world");
expect(await c1.elemText("h1")).toBe("hello-world");
expect(await c1.elemText("div div")).toBe("This is the content of hello world post");
// Test second post
await using c2 = await dev.client("/second-post");
expect(await c2.elemText("h1")).toBe("second-post");
expect(await c2.elemText("div div")).toBe("This is the second post content");
},
});
devTest("SSG pages router - named import edge case", {
framework: "react",
fixture: "ssg-pages-router",
files: {
"pages/index.tsx": `
import Markdoc, * as md from '../src/ooga'
console.log(md);
export default function IndexPage() {
return <h1>Welcome to SSG</h1>;
}
`,
"src/ooga.ts": `var Markdoc = function () {
return {
parse: () => {},
transform: () => {},
};
};
export { Markdoc as default };`,
"posts/hello-world.txt": "This is the content of hello world post",
"posts/second-post.txt": "This is the second post content",
},
async test(dev) {
// Should not error
await using c1 = await dev.client("/");
expect(await c1.elemText("h1")).toBe("Welcome to SSG");
},
});
devTest("SSG pages router - catch-all routes [...slug]", {
framework: "react",
files: {
"pages/[...slug].tsx": `
const CatchAllPage: Bun.SSGPage = ({ params }) => {
return (
<div>
<h1>Catch-all Route</h1>
<p id="params">{JSON.stringify(params)}</p>
<ul>
{params.slug && Array.isArray(params.slug) ? (
params.slug.map((segment, index) => (
<li key={index}>{segment}</li>
))
) : (
<li>No slug array</li>
)}
</ul>
</div>
);
};
export default CatchAllPage;
export const getStaticPaths: Bun.GetStaticPaths = async () => {
return {
paths: [
{ params: { slug: ["docs"] } },
{ params: { slug: ["docs", "getting-started"] } },
{ params: { slug: ["docs", "api", "reference"] } },
{ params: { slug: ["blog", "2024", "january", "new-features"] } },
],
};
};
`,
},
async test(dev) {
// Test single segment
await using c1 = await dev.client("/docs");
expect(await c1.elemText("h1")).toBe("Catch-all Route");
expect(await c1.elemText("#params")).toBe('{"slug":"docs"}');
expect(await c1.elemsText("li")).toEqual(["No slug array"]);
// Test two segments
await using c2 = await dev.client("/docs/getting-started");
expect(await c2.elemText("h1")).toBe("Catch-all Route");
expect(await c2.elemText("#params")).toBe('{"slug":["docs","getting-started"]}');
expect(await c2.elemsText("li")).toEqual(["docs", "getting-started"]);
// Test three segments
await using c3 = await dev.client("/docs/api/reference");
expect(await c3.elemText("h1")).toBe("Catch-all Route");
expect(await c3.elemText("#params")).toBe('{"slug":["docs","api","reference"]}');
expect(await c3.elemsText("li")).toEqual(["docs", "api", "reference"]);
// Test four segments
await using c4 = await dev.client("/blog/2024/january/new-features");
expect(await c4.elemText("h1")).toBe("Catch-all Route");
expect(await c4.elemText("#params")).toBe('{"slug":["blog","2024","january","new-features"]}');
expect(await c4.elemsText("li")).toEqual(["blog", "2024", "january", "new-features"]);
},
});
+34
View File
@@ -0,0 +1,34 @@
// Stress tests perform a large number of filesystem or network operations in a test.
//
// Run with `DEV_SERVER_STRESS=` to run tests for 10 minutes each.
// - "DEV_SERVER_STRESS='crash #18910'" will run the first test for 10 min.
// - "DEV_SERVER_STRESS=ALL" will run all for 10 min each.
//
// Without this flag, each test is a "smoke test", running the iteration once.
import { expect } from "bun:test";
import { devTest } from "../bake-harness";
// https://github.com/oven-sh/bun/issues/18910
devTest("crash #18910", {
files: {
"index.html": `<script src="./b.js"></script>`,
"b.js": ``,
},
async test(dev) {
await using c = await dev.client("/", { allowUnlimitedReloads: true });
const absPath = dev.join("b.js");
await dev.stressTest(async () => {
for (let i = 0; i < 10; i++) {
await Bun.write(absPath, "let a = 0;");
await Bun.sleep(10);
await Bun.write(absPath, "// let a = 0;");
await Bun.sleep(10);
}
});
await dev.write("b.js", "globalThis.a = 1;");
expect(await c.js<number>`a`).toBe(1);
},
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect } from "bun:test";
import { devTest, minimalFramework } from "../bake-harness";
/**
* Enure that node builtins imported on the server behave properly
*/
describe("node builtin test", () => {
/**
*
* This creates a minimal reproduction of an issue when VFile was imported on the dev server.
*
* The issue was that it was importing node:process and this was not correctly handled
*/
devTest("vfile import in server component", {
framework: minimalFramework,
files: {
"node_modules/vfile/package.json": JSON.stringify({
name: "vfile",
version: "6.0.3",
type: "module",
exports: {
".": "./lib/index.js",
},
}),
"node_modules/vfile/lib/process.js": `
export { default as minproc } from 'process';
`,
"node_modules/vfile/lib/index.js": `
// Minimal VFile implementation for testing
import { minproc } from './process.js';
export class VFile {
constructor(value) {
this.value = value;
this.data = {};
this.messages = [];
this.history = [];
this.cwd = minproc.cwd();
}
}
`,
"routes/test.ts": `
import { VFile } from "vfile";
export default function (req, meta) {
const foo = new VFile("hello world");
console.log(foo.value);
return new Response(\`VFile content: \${foo.value}\`, {
headers: { "Content-Type": "text/plain" }
});
}
`,
},
async test(dev) {
// Test that the dev server can bundle the page without errors
const response = await dev.fetch("/test");
expect(response.status).toBe(200);
// Check that VFile is properly bundled and works
const text = await response.text();
expect(text).toBe("VFile content: hello world");
},
});
});