Compare commits

...

13 Commits

Author SHA1 Message Date
Alistair Smith
7bf7786ba9 Merge branch 'main' into claude/virtual-bundler-tests 2026-02-10 17:42:56 -08:00
Claude Bot
18322da1e9 Fix virtual mode path comments by using relative paths
Use relative paths (strip leading /) for virtual files to get consistent
path comments in CSS output regardless of working directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 22:44:31 +00:00
Claude Bot
c04ee28892 Add virtual: true to WPT CSS tests
Use in-memory bundling for WPT CSS tests for faster execution.
Update path comments to match virtual mode output.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 21:50:18 +00:00
Claude Bot
8bac1dd4f3 Remove comments from esbuild/css.test.ts
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 21:29:27 +00:00
Claude Bot
8695e45f59 Retry CI build (infrastructure issue with SetupBuildkite.cmake) 2026-01-18 11:55:07 +00:00
Claude Bot
44340bf7e7 Revert all CSS tests with path-dependent expectations to non-virtual mode
The virtual mode generates CSS comments with relative paths (../../) that
depend on the cwd depth, causing test failures in CI where the directory
structure differs from local development.

Reverted all CSS tests to non-virtual mode and updated the expected
comment paths from "/* ../../a.css */" to "/* a.css */".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 11:35:37 +00:00
Claude Bot
85ca1a67cf Revert esbuild/css.test.ts tests to non-virtual mode
The virtual mode generates relative path comments that depend on the
cwd depth. CI runs from a different directory structure than local,
causing path mismatches (../../entry.css vs ../../../../entry.css).

Revert these tests to non-virtual mode since they have hardcoded path
expectations in the CSS comments. The WPT tests use simpler paths like
/a.css that work consistently.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 10:51:39 +00:00
Claude Bot
19606f0c12 Address additional code review feedback
- Add features to unsupported options validation
- Remove redundant default assignments for entryPoints, format, target
  (already set earlier in the function)
- Improve bundleErrors validation with proper file/message matching:
  - Check expected errors match actual errors by file path suffix and
    message substring
  - Report unexpected errors and missing expected errors separately
- Extract duplicated captureFile logic into shared extractCaptures helper

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 10:03:43 +00:00
Claude Bot
6dcd16657e Address code review feedback for virtual mode
- Change bundleWarnings check from key length check to truthy check
- Add missing unsupported options: keepNames, emitDCEAnnotations,
  ignoreDCEAnnotations, bytecode, compile
- Pass define, drop, conditions to Bun.build since they are supported
- Fix readFile to use 'in' operator instead of truthy check (handles
  empty string outputs correctly)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 09:55:29 +00:00
Claude Bot
07bdea0d03 fix(tests): address code review feedback for virtual mode
- Add validation for unsupported options (runtimeFiles, run, dce, cjs2esm,
  matchesReference, snapshotSourceMap, expectExactFilesize, onAfterApiBundle,
  bundleWarnings, outdir) with descriptive error messages
- Preserve binary file content (Buffer, Uint8Array, Blob) instead of
  converting to string with .toString()
- Tighten readFile to require exact path matches instead of loose
  basename/extension fallbacks, only allowing outfile alias for single outputs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 09:42:42 +00:00
Claude Bot
32cdb2cfd4 perf(tests): convert 7 esbuild CSS tests to virtual mode
Convert simple CSS tests in esbuild/css.test.ts to use virtual mode:
- CSSEntryPoint
- CSSEntryPointEmpty
- CSSNesting
- CSSAtImportSimple
- CSSAtImportDiamond
- CSSAtImportCycle

Note: CSSAtImportMissing cannot use virtual mode because Bun.build
throws on resolution errors instead of returning { success: false }.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 09:29:48 +00:00
Claude Bot
a22f005365 perf(tests): convert more CSS tests to virtual mode
Convert 3 additional CSS test files to use virtual mode for faster execution:
- css-modules.test.ts (first test)
- is-selector-21169.test.ts
- view-transition-23600.test.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 09:29:48 +00:00
Claude Bot
fce146899e perf(tests): add virtual mode to itBundled for in-memory builds
Add `virtual: true` option to itBundled that uses Bun.build's `files` API
to run bundler tests entirely in memory without disk I/O:

- Uses virtual files passed directly to Bun.build
- Does not set outdir/outfile so outputs stay in memory
- Reads output directly from BuildArtifact.text()
- Same onAfterBundle API (api.expectFile(), etc.)

Updated CSS WPT tests to use virtual mode:
- color-computed-rgb.test.ts (94 tests)
- color-computed.test.ts (14 tests)
- background-computed.test.ts (25 tests)
- relative_color_out_of_gamut.test.ts (27 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 09:29:48 +00:00
9 changed files with 285 additions and 59 deletions

View File

@@ -3,16 +3,15 @@ import { itBundled } from "../expectBundled";
describe("css", () => {
itBundled("css-module/GlobalPseudoFunction", {
files: {
"index.module.css": /* css */ `
"/index.module.css": /* css */ `
:global(.foo) {
color: red;
}
`,
},
outdir: "/out",
entryPoints: ["/index.module.css"],
outfile: "/out.css",
onAfterBundle(api) {
api.expectFile("/out/index.module.css").toEqualIgnoringWhitespace(`
api.expectFile("/out.css").toEqualIgnoringWhitespace(`
/* index.module.css */
.foo {
color: red;

View File

@@ -3,16 +3,15 @@ import { itBundled } from "../expectBundled";
describe("css", () => {
itBundled("css/is-selector", {
files: {
"index.css": /* css */ `
"/index.css": /* css */ `
.foo:is(input:checked) {
color: red;
}
`,
},
outdir: "/out",
entryPoints: ["/index.css"],
outfile: "/out.css",
onAfterBundle(api) {
api.expectFile("/out/index.css").toMatchInlineSnapshot(`
api.expectFile("/out.css").toMatchInlineSnapshot(`
"/* index.css */
.foo:-webkit-any(input:checked) {
color: red;

View File

@@ -3,7 +3,7 @@ import { itBundled } from "../expectBundled";
describe("css", () => {
itBundled("css/view-transition-class-selector-23600", {
files: {
"index.css": /* css */ `
"/index.css": /* css */ `
@keyframes slide-out {
from {
opacity: 1;
@@ -33,10 +33,9 @@ describe("css", () => {
}
`,
},
outdir: "/out",
entryPoints: ["/index.css"],
outfile: "/out.css",
onAfterBundle(api) {
api.expectFile("/out/index.css").toMatchInlineSnapshot(`
api.expectFile("/out.css").toMatchInlineSnapshot(`
"/* index.css */
@keyframes slide-out {
from {

View File

@@ -4,6 +4,7 @@ import { itBundled } from "../../expectBundled";
const runTest = (property: string, input: string, expected: string) => {
const testTitle = `${property}: ${input}`;
itBundled(testTitle, {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -11,7 +12,7 @@ h1 {
}
`,
},
outfile: "out.css",
outfile: "/out.css",
onAfterBundle(api) {
api.expectFile("/out.css").toEqualIgnoringWhitespace(`

View File

@@ -4,6 +4,7 @@ import { itBundled } from "../../expectBundled";
const runTest = (testTitle: string, input: string, expected: string) => {
testTitle = testTitle.length === 0 ? input : testTitle;
itBundled(testTitle, {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -11,7 +12,7 @@ h1 {
}
`,
},
outfile: "out.css",
outfile: "/out.css",
onAfterBundle(api) {
api.expectFile("/out.css").toEqualIgnoringWhitespace(`

View File

@@ -3,6 +3,7 @@ import { itBundled } from "../../expectBundled";
const runTest = (input: string, expected: string) => {
itBundled(input, {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -10,7 +11,7 @@ h1 {
}
`,
},
outfile: "out.css",
outfile: "/out.css",
onAfterBundle(api) {
api.expectFile("/out.css").toEqualIgnoringWhitespace(`

View File

@@ -5,6 +5,7 @@ let i = 0;
const testname = () => `test-${i++}`;
describe("relative_color_out_of_gamut", () => {
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -12,7 +13,7 @@ h1 {
}
`,
},
outfile: "out.css",
outfile: "/out.css",
onAfterBundle(api) {
api.expectFile("/out.css").toEqualIgnoringWhitespace(`
@@ -25,6 +26,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -45,6 +47,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -65,6 +68,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -85,6 +89,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -105,6 +110,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -125,6 +131,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -145,6 +152,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -165,6 +173,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -185,6 +194,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -205,6 +215,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -225,6 +236,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -245,6 +257,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -265,6 +278,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -285,6 +299,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -305,6 +320,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -325,6 +341,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -345,6 +362,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -365,6 +383,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -385,6 +404,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -405,6 +425,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -425,6 +446,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -445,6 +467,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -465,6 +488,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -485,6 +509,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -505,6 +530,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {
@@ -525,6 +551,7 @@ h1 {
});
itBundled(testname(), {
virtual: true,
files: {
"/a.css": /* css */ `
h1 {

View File

@@ -16,9 +16,9 @@ describe("bundler", () => {
color: black }
`,
},
outfile: "/out.js",
outfile: "/out.css",
onAfterBundle(api) {
api.expectFile("/out.js").toEqualIgnoringWhitespace(`
api.expectFile("/out.css").toEqualIgnoringWhitespace(`
/* entry.css */
body {
color: #000;
@@ -31,9 +31,9 @@ describe("bundler", () => {
files: {
"/entry.css": /* css */ `\n`,
},
outfile: "/out.js",
outfile: "/out.css",
onAfterBundle(api) {
api.expectFile("/out.js").toEqualIgnoringWhitespace(`
api.expectFile("/out.css").toEqualIgnoringWhitespace(`
/* entry.css */`);
},
});
@@ -48,12 +48,12 @@ describe("bundler", () => {
}
}`,
},
outfile: "/out.js",
outfile: "/out.css",
onAfterBundle(api) {
api.expectFile("/out.js").toEqualIgnoringWhitespace(`
api.expectFile("/out.css").toEqualIgnoringWhitespace(`
/* entry.css */
body {
&h1 {
& h1 {
color: #fff;
}
}

View File

@@ -299,6 +299,13 @@ export interface BundlerTestInput {
/** Run after the bun.build function is called with its output */
onAfterApiBundle?(build: BuildOutput): Promise<void> | void;
/**
* Run the build entirely in memory using Bun.build's `files` API.
* No temp directories or files are created. Outputs are read from BuildArtifact.text().
* The `onAfterBundle` callback still works with the same API.
*/
virtual?: boolean;
}
export interface SourceMapTests {
@@ -408,6 +415,40 @@ function testRef(id: string, options: BundlerTestInput): BundlerTestRef {
return { id, options };
}
/**
* Extract capture function calls from file contents.
* Finds all occurrences of fnName(...) and returns the argument contents.
*/
function extractCaptures(fileContents: string, file: string, fnName: string): string[] {
let i = 0;
const length = fileContents.length;
const matches: string[] = [];
while (i < length) {
i = fileContents.indexOf(fnName, i);
if (i === -1) break;
const start = i;
let depth = 0;
while (i < length) {
const char = fileContents[i];
if (char === "(") depth++;
else if (char === ")") {
depth--;
if (depth === 0) break;
}
i++;
}
if (depth !== 0) {
throw new Error(`Could not find closing paren for ${fnName} call in ${file}`);
}
matches.push(fileContents.slice(start + fnName.length + 1, i));
i++;
}
if (matches.length === 0) {
throw new Error(`No ${fnName} calls found in ${file}`);
}
return matches;
}
function expectBundled(
id: string,
opts: BundlerTestInput,
@@ -494,6 +535,7 @@ function expectBundled(
generateOutput = true,
onAfterApiBundle,
throw: _throw = false,
virtual = false,
...unknownProps
} = opts;
@@ -580,6 +622,198 @@ function expectBundled(
return testRef(id, opts);
}
// Virtual mode: run entirely in memory without disk I/O
if (virtual) {
// Validate that unsupported options are not set
const unsupportedOptions: string[] = [];
if (runtimeFiles && Object.keys(runtimeFiles).length > 0) unsupportedOptions.push("runtimeFiles");
if (run) unsupportedOptions.push("run");
if (dce) unsupportedOptions.push("dce");
if (cjs2esm) unsupportedOptions.push("cjs2esm");
if (matchesReference) unsupportedOptions.push("matchesReference");
if (snapshotSourceMap) unsupportedOptions.push("snapshotSourceMap");
if (expectExactFilesize) unsupportedOptions.push("expectExactFilesize");
if (onAfterApiBundle) unsupportedOptions.push("onAfterApiBundle");
if (bundleWarnings) unsupportedOptions.push("bundleWarnings");
if (keepNames) unsupportedOptions.push("keepNames");
if (emitDCEAnnotations) unsupportedOptions.push("emitDCEAnnotations");
if (ignoreDCEAnnotations) unsupportedOptions.push("ignoreDCEAnnotations");
if (bytecode) unsupportedOptions.push("bytecode");
if (compile) unsupportedOptions.push("compile");
if (features && features.length > 0) unsupportedOptions.push("features");
if (outdir) unsupportedOptions.push("outdir (use outfile instead)");
if (unsupportedOptions.length > 0) {
throw new Error(`Virtual mode does not support the following options: ${unsupportedOptions.join(", ")}`);
}
return (async () => {
// Prepare virtual files with dedent applied for strings, preserve binary content as-is
// Use relative paths (strip leading /) to get consistent path comments in CSS output
const virtualFiles: Record<string, string | Buffer | Uint8Array | Blob> = {};
for (const [file, contents] of Object.entries(files)) {
const relativePath = file.startsWith("/") ? file.slice(1) : file;
virtualFiles[relativePath] = typeof contents === "string" ? dedent(contents) : contents;
}
// Convert entrypoints to relative paths too
const relativeEntryPoints = entryPoints.map(ep => (ep.startsWith("/") ? ep.slice(1) : ep));
const build = await Bun.build({
entrypoints: relativeEntryPoints,
files: virtualFiles,
target,
format,
minify: {
whitespace: minifyWhitespace,
syntax: minifySyntax,
identifiers: minifyIdentifiers,
},
external,
plugins: typeof plugins === "function" ? [{ name: "plugin", setup: plugins }] : plugins,
splitting,
treeShaking,
sourcemap: sourceMap,
publicPath,
banner,
footer,
packages,
loader,
jsx: jsx
? {
runtime: jsx.runtime,
importSource: jsx.importSource,
factory: jsx.factory,
fragment: jsx.fragment,
sideEffects: jsx.sideEffects,
development: jsx.development,
}
: undefined,
define,
drop,
conditions,
});
const expectedErrors = bundleErrors
? Object.entries(bundleErrors).flatMap(([file, v]) => v.map(error => ({ file, error })))
: null;
if (!build.success) {
// Collect actual errors from build logs
const actualErrors = build.logs
.filter(x => x.level === "error")
.map(x => ({
file: x.position?.file || "",
error: x.message,
}));
// Check if errors were expected
if (expectedErrors && expectedErrors.length > 0) {
const errorsLeft = [...expectedErrors];
const unexpectedErrors: typeof actualErrors = [];
for (const error of actualErrors) {
const i = errorsLeft.findIndex(item => error.file.endsWith(item.file) && error.error.includes(item.error));
if (i === -1) {
unexpectedErrors.push(error);
} else {
errorsLeft.splice(i, 1);
}
}
if (unexpectedErrors.length > 0) {
throw new Error(
"Unexpected errors reported while bundling:\n" +
unexpectedErrors.map(e => `${e.file}: ${e.error}`).join("\n") +
"\n\nExpected errors:\n" +
expectedErrors.map(e => `${e.file}: ${e.error}`).join("\n"),
);
}
if (errorsLeft.length > 0) {
throw new Error(
"Expected errors were not found while bundling:\n" +
errorsLeft.map(e => `${e.file}: ${e.error}`).join("\n") +
"\n\nActual errors:\n" +
actualErrors.map(e => `${e.file}: ${e.error}`).join("\n"),
);
}
return testRef(id, opts);
}
throw new Error(`Bundle failed:\n${actualErrors.map(e => `${e.file}: ${e.error}`).join("\n")}`);
} else if (expectedErrors && expectedErrors.length > 0) {
throw new Error(
"Errors were expected while bundling:\n" + expectedErrors.map(e => `${e.file}: ${e.error}`).join("\n"),
);
}
// Build in-memory file cache from BuildArtifact outputs
const outputCache: Record<string, string> = {};
for (const output of build.outputs) {
// Normalize path: "./a.css" -> "/a.css"
let outputPath = output.path;
if (outputPath.startsWith("./")) outputPath = outputPath.slice(1);
if (!outputPath.startsWith("/")) outputPath = "/" + outputPath;
outputCache[outputPath] = await output.text();
}
// Determine the main output file path
const mainOutputPath = Object.keys(outputCache)[0] || "/out.js";
const outfileVirtual = outfile ? (outfile.startsWith("/") ? outfile : "/" + outfile) : mainOutputPath;
// Create API object that reads from in-memory cache
const readFile = (file: string): string => {
// Normalize the file path
let normalizedFile = file;
if (normalizedFile.startsWith("./")) normalizedFile = normalizedFile.slice(1);
if (!normalizedFile.startsWith("/")) normalizedFile = "/" + normalizedFile;
// Try exact match first
if (normalizedFile in outputCache) return outputCache[normalizedFile];
// For single-output builds, allow accessing the output by the configured outfile path
const outputs = Object.keys(outputCache);
if (outputs.length === 1 && normalizedFile === outfileVirtual) {
return outputCache[outputs[0]];
}
throw new Error(`Virtual file not found: ${file}. Available: ${Object.keys(outputCache).join(", ")}`);
};
const api = {
root: "/virtual",
outfile: outfileVirtual,
outdir: "/virtual/out",
join: (...paths: string[]) => "/" + paths.join("/").replace(/^\/+/, ""),
readFile,
writeFile: (_file: string, _contents: string) => {
throw new Error("writeFile not supported in virtual mode");
},
expectFile: (file: string) => expect(readFile(file)),
prependFile: (_file: string, _contents: string) => {
throw new Error("prependFile not supported in virtual mode");
},
appendFile: (_file: string, _contents: string) => {
throw new Error("appendFile not supported in virtual mode");
},
assertFileExists: (file: string) => {
readFile(file); // Will throw if not found
},
warnings: {} as Record<string, ErrorMeta[]>,
options: opts,
captureFile: (file: string, fnName = "capture") => extractCaptures(readFile(file), file, fnName),
} satisfies BundlerTestBundleAPI;
if (onAfterBundle) {
onAfterBundle(api);
}
return testRef(id, opts);
})();
}
return (async () => {
if (!backend) {
backend =
@@ -1321,42 +1555,7 @@ for (const [key, blob] of build.outputs) {
},
warnings: warningReference,
options: opts,
captureFile: (file, fnName = "capture") => {
const fileContents = readFile(file);
let i = 0;
const length = fileContents.length;
const matches = [];
while (i < length) {
i = fileContents.indexOf(fnName, i);
if (i === -1) {
break;
}
const start = i;
let depth = 0;
while (i < length) {
const char = fileContents[i];
if (char === "(") {
depth++;
} else if (char === ")") {
depth--;
if (depth === 0) {
break;
}
}
i++;
}
if (depth !== 0) {
throw new Error(`Could not find closing paren for ${fnName} call in ${file}`);
}
matches.push(fileContents.slice(start + fnName.length + 1, i));
i++;
}
if (matches.length === 0) {
throw new Error(`No ${fnName} calls found in ${file}`);
}
return matches;
},
captureFile: (file, fnName = "capture") => extractCaptures(readFile(file), file, fnName),
} satisfies BundlerTestBundleAPI;
// DCE keep scan