import { describe, expect } from "bun:test";
import { itBundled } from "./expectBundled";
describe("bundler", () => {
// Basic test for bundling HTML with JS and CSS
itBundled("html/basic", {
outdir: "out/",
files: {
"/index.html": `
Hello World
`,
"/styles.css": "body { background-color: red; }",
"/script.js": "console.log('Hello World')",
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
// Check that output HTML references hashed filenames
api.expectFile("out/index.html").not.toContain("styles.css");
api.expectFile("out/index.html").not.toContain("script.js");
api.expectFile("out/index.html").toMatch(/href=".*\.css"/);
api.expectFile("out/index.html").toMatch(/src=".*\.js"/);
},
});
// Test relative paths without "./" in script src
itBundled("html/implicit-relative-paths", {
outdir: "out/",
files: {
"/src/index.html": `
Hello World
`,
"/src/styles.css": "body { background-color: red; }",
"/src/script.js": "console.log('Hello World')",
},
root: "/src",
entryPoints: ["/src/index.html"],
onAfterBundle(api) {
// Check that output HTML references hashed filenames
api.expectFile("out/index.html").not.toContain("styles.css");
api.expectFile("out/index.html").not.toContain("script.js");
api.expectFile("out/index.html").toMatch(/href=".*\.css"/);
api.expectFile("out/index.html").toMatch(/src=".*\.js"/);
},
});
// Test multiple script and style bundling
itBundled("html/multiple-assets", {
outdir: "out/",
files: {
"/index.html": `
Multiple Assets
`,
"/style1.css": "body { color: blue; }",
"/style2.css": "h1 { color: red; }",
"/script1.js": "console.log('First script')",
"/script2.js": "console.log('Second script')",
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
// Should combine CSS files into one
api.expectFile("out/index.html").toMatch(/href=".*\.css"/);
api.expectFile("out/index.html").not.toMatch(/href=".*style1\.css"/);
api.expectFile("out/index.html").not.toMatch(/href=".*style2\.css"/);
// Should combine JS files into one
api.expectFile("out/index.html").toMatch(/src=".*\.js"/);
api.expectFile("out/index.html").not.toMatch(/src=".*script1\.js"/);
api.expectFile("out/index.html").not.toMatch(/src=".*script2\.js"/);
},
});
// Test image hashing
itBundled("html/image-hashing", {
outdir: "out/",
files: {
"/index.html": `
`,
"/image.jpg": "fake image content",
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
// Local image should be hashed
api.expectFile("out/index.html").not.toContain("./image.jpg");
api.expectFile("out/index.html").toMatch(/src=".*-[a-zA-Z0-9]+\.jpg"/);
// External image URL should remain unchanged
api.expectFile("out/index.html").toContain("https://example.com/image.jpg");
},
});
// Test external assets preservation
itBundled("html/external-assets", {
outdir: "out/",
files: {
"/index.html": `
External Assets
`,
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
// External URLs should remain unchanged
api.expectFile("out/index.html").toContain("https://cdn.example.com/style.css");
api.expectFile("out/index.html").toContain("https://cdn.example.com/script.js");
},
});
// Test mixed local and external assets
itBundled("html/mixed-assets", {
outdir: "out/",
files: {
"/index.html": `
Mixed Assets
`,
"/local.css": "body { margin: 0; }",
"/local.js": "console.log('Local script')",
"/local.jpg": "fake image content",
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
// Local assets should be hashed
api.expectFile("out/index.html").not.toContain("local.css");
api.expectFile("out/index.html").not.toContain("local.js");
api.expectFile("out/index.html").not.toContain("local.jpg");
// External assets should remain unchanged
api.expectFile("out/index.html").toContain("https://cdn.example.com/style.css");
api.expectFile("out/index.html").toContain("https://cdn.example.com/script.js");
api.expectFile("out/index.html").toContain("https://cdn.example.com/image.jpg");
},
});
// Test JS imports
itBundled("html/js-imports", {
outdir: "out/",
files: {
"/in/index.html": `
JS Imports
`,
"/in/main.js": `
import { greeting } from './utils/strings.js';
import { formatDate } from './utils/date.js';
console.log(greeting('World'));
console.log(formatDate(new Date()));`,
"/in/utils/strings.js": `
export const greeting = (name) => \`Hello, \${name}!\`;`,
"/in/utils/date.js": `
import { padZero } from './numbers.js';
export const formatDate = (date) => \`\${date.getFullYear()}-\${padZero(date.getMonth() + 1)}-\${padZero(date.getDate())}\`;`,
"/in/utils/numbers.js": `
export const padZero = (num) => String(num).padStart(2, '0');`,
},
entryPoints: ["/in/index.html"],
onAfterBundle(api) {
// All JS should be bundled into one file
api.expectFile("out/index.html").toMatch(/src=".*\.js"/);
api.expectFile("out/index.html").not.toContain("main.js");
const htmlContent = api.readFile("out/index.html");
// Check that the bundle contains all the imported code
const jsMatch = htmlContent.match(/src="(.*\.js)"/);
const jsBundle = api.readFile("out/" + jsMatch![1]);
expect(jsBundle).toContain("Hello");
expect(jsBundle).toContain("padZero");
expect(jsBundle).toContain("formatDate");
},
});
// Test CSS imports
itBundled("html/css-imports", {
outdir: "out/",
files: {
"/in/index.html": `
CSS Imports
`,
"/in/styles/main.css": `
@import './variables.css';
@import './typography.css';
body {
background-color: var(--background-color);
}`,
"/in/styles/variables.css": `
:root {
--background-color: #f0f0f0;
--text-color: #333;
--heading-color: #000;
}`,
"/in/styles/typography.css": `
@import './fonts.css';
h1 {
color: var(--heading-color);
font-family: var(--heading-font);
}`,
"/in/styles/fonts.css": `
:root {
--heading-font: 'Arial', sans-serif;
--body-font: 'Helvetica', sans-serif;
}`,
},
entryPoints: ["/in/index.html"],
onAfterBundle(api) {
// All CSS should be bundled into one file
api.expectFile("out/index.html").toMatch(/href=".*\.css"/);
api.expectFile("out/index.html").not.toContain("main.css");
// Check that the bundle contains all the imported CSS
const htmlContent = api.readFile("out/index.html");
const cssMatch = htmlContent.match(/href="(.*?\.css)"/);
if (!cssMatch) throw new Error("Could not find CSS file reference in HTML");
const cssBundle = api.readFile("out/" + cssMatch[1]);
expect(cssBundle).toContain("--background-color");
expect(cssBundle).toContain("--heading-font");
expect(cssBundle).toContain("font-family");
},
});
// Test multiple HTML entry points
itBundled("html/multiple-entries", {
outdir: "out/",
files: {
"/in/pages/index.html": `
Home Page
About
`,
"/in/pages/about.html": `
About Page
Home
`,
"/in/styles/home.css": `
@import './common.css';
.home { color: blue; }`,
"/in/styles/about.css": `
@import './common.css';
.about { color: green; }`,
"/in/styles/common.css": `
body { margin: 0; padding: 20px; }`,
"/in/scripts/home.js": `
import { initNav } from './common.js';
console.log('Home page');
initNav();`,
"/in/scripts/about.js": `
import { initNav } from './common.js';
console.log('About page');
initNav();`,
"/in/scripts/common.js": `
export const initNav = () => console.log('Navigation initialized');`,
},
entryPoints: ["/in/pages/index.html", "/in/pages/about.html"],
onAfterBundle(api) {
// Check index.html
api.expectFile("out/index.html").toMatch(/href=".*\.css"/);
api.expectFile("out/index.html").toMatch(/src=".*\.js"/);
api.expectFile("out/index.html").not.toContain("home.css");
api.expectFile("out/index.html").not.toContain("home.js");
// Check about.html
api.expectFile("out/about.html").toMatch(/href=".*\.css"/);
api.expectFile("out/about.html").toMatch(/src=".*\.js"/);
api.expectFile("out/about.html").not.toContain("about.css");
api.expectFile("out/about.html").not.toContain("about.js");
// Verify we don't update the filenames for these
const indexHtml = api.readFile("out/index.html");
const aboutHtml = api.readFile("out/about.html");
expect(indexHtml).toContain('href="./about.html"');
expect(aboutHtml).toContain('href="index.html"');
// Check that each page has its own bundle
const indexHtmlContent = api.readFile("out/index.html");
const aboutHtmlContent = api.readFile("out/about.html");
const indexJsMatch = indexHtmlContent.match(/src="(.*\.js)"/);
const aboutJsMatch = aboutHtmlContent.match(/src="(.*\.js)"/);
const indexJs = api.readFile("out/" + indexJsMatch![1]);
const aboutJs = api.readFile("out/" + aboutJsMatch![1]);
expect(indexJs).toContain("Home page");
expect(aboutJs).toContain("About page");
expect(indexJs).toContain("Navigation initialized");
expect(aboutJs).toContain("Navigation initialized");
// Check that each page has its own CSS bundle
const indexCssMatch = indexHtmlContent.match(/href="(.*\.css)"/);
const aboutCssMatch = aboutHtmlContent.match(/href="(.*\.css)"/);
const indexCss = api.readFile("out/" + indexCssMatch![1]);
const aboutCss = api.readFile("out/" + aboutCssMatch![1]);
expect(indexCss).toContain(".home");
expect(aboutCss).toContain(".about");
expect(indexCss).toContain("margin: 0");
expect(aboutCss).toContain("margin: 0");
},
});
// Test multiple HTML entries with shared chunks
itBundled("html/shared-chunks", {
outdir: "out/",
// Makes this test easier to write
minifyWhitespace: true,
files: {
"/in/pages/page1.html": `
Page 1
`,
"/in/pages/page2.html": `
Page 2
`,
"/in/styles/page1.css": `
@import './shared.css';
.page1 { font-size: 20px; }`,
"/in/styles/page2.css": `
@import './shared.css';
.page2 { font-size: 18px; }`,
"/in/styles/shared.css": `
@import './reset.css';
.shared { color: blue; }`,
"/in/styles/reset.css": `
* { box-sizing: border-box; }`,
"/in/scripts/page1.js": `
import { sharedUtil } from './shared.js';
import { largeModule } from './large-module.js';
console.log('Page 1');
sharedUtil();`,
"/in/scripts/page2.js": `
import { sharedUtil } from './shared.js';
import { largeModule } from './large-module.js';
console.log('Page 2');
sharedUtil();`,
"/in/scripts/shared.js": `
export const sharedUtil = () => console.log('Shared utility');`,
"/in/scripts/large-module.js": `
export const largeModule = {
// Simulate a large shared module
bigData: new Array(1000).fill('data'),
methods: { /* ... */ }
};`,
},
entryPoints: ["/in/pages/page1.html", "/in/pages/page2.html"],
splitting: true,
onAfterBundle(api) {
// Check both pages
for (const page of ["page1", "page2"]) {
api.expectFile(`out/${page}.html`).toMatch(/href=".*\.css"/);
api.expectFile(`out/${page}.html`).toMatch(/src=".*\.js"/);
api.expectFile(`out/${page}.html`).not.toContain(`${page}.css`);
api.expectFile(`out/${page}.html`).not.toContain(`${page}.js`);
}
// Verify that shared code exists in both bundles
const page1Html = api.readFile("out/page1.html");
const page2Html = api.readFile("out/page2.html");
const page1JsPath = page1Html.match(/src="(.*\.js)"/)?.[1];
const page2JsPath = page2Html.match(/src="(.*\.js)"/)?.[1];
expect(page1JsPath).toBeDefined();
expect(page2JsPath).toBeDefined();
const page1Js = api.readFile("out/" + page1JsPath!);
const page2Js = api.readFile("out/" + page2JsPath!);
// Check we imported the shared module
expect(page2Js).toContain("import{sharedUtil}");
expect(page1Js).toContain("import{sharedUtil}");
// Check CSS bundles
const page1CssPath = page1Html.match(/href="(.*\.css)"/)?.[1];
const page2CssPath = page2Html.match(/href="(.*\.css)"/)?.[1];
expect(page1CssPath).toBeDefined();
expect(page2CssPath).toBeDefined();
const page1Css = api.readFile("out/" + page1CssPath!);
const page2Css = api.readFile("out/" + page2CssPath!);
expect(page1Css).toContain("box-sizing:border-box");
expect(page2Css).toContain("box-sizing:border-box");
expect(page1Css).toContain(".shared");
expect(page2Css).toContain(".shared");
},
});
// Test JS importing HTML
itBundled("html/js-importing-html", {
outdir: "out/",
files: {
"/in/entry.js": `
import htmlContent from './template.html' with { type: 'file' };
console.log('Loaded HTML:', htmlContent);`,
"/in/template.html": `
HTML Template
HTML Template
`,
},
// This becomes:
//
// - out/entry.js
// - out/template-hash.html
//
// Like a regular asset.
entryPoints: ["/in/entry.js"],
onAfterBundle(api) {
const entryBundle = api.readFile("out/entry.js");
// Check taht we dind't bundle the HTML file
expect(entryBundle).toMatch(/\.\/template-.*\.html/);
},
});
itBundled("html/js-importing-html-and-entry-point-side-effect-import", {
outdir: "out/",
target: "browser",
files: {
"/in/2nd.js": `
console.log('2nd');`,
"/in/entry.js": `
import './template.html';
console.log('Loaded HTML!');`,
"/in/template.html": `
HTML Template
HTML Template
`,
},
// This becomes:
// - ./template.html
// - ./template-*.js
// - ./entry.js
entryPointsRaw: ["in/template.html", "in/entry.js"],
onAfterBundle(api) {
const templateBundle = api.readFile("out/template.html");
expect(templateBundle).toContain("HTML Template");
// Get the entry.js file from looking at