diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 40fd3ee559d2..f9a918dc81f7 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -46,14 +46,8 @@ pub struct Chunk<'a> { /// Like `unique_key`, but replaced with `id()` rather than the chunk's path. pub(crate) id_key: &'static [u8], - /// Maps source index to bytes contributed to this chunk's output (for metafile). - /// Empty for a CSS or HTML chunk: the metafile takes the inputs of those from the compile results. - /// The value is updated during parallel chunk generation to track bytesInOutput. - /// CONCURRENCY: the key set is frozen before codegen starts; worker threads - /// only `fetch_add` the per-source counters (see - /// `generate_compile_result_for_js_chunk`), so the value type is - /// `AtomicUsize` rather than `usize` to avoid materializing aliased `&mut`. - pub(crate) files_with_parts_in_chunk: ArrayHashMap, + /// The files assigned to a JS chunk, in graph order. Not all that it prints: the JS code of a stylesheet has no key (`files_in_chunk_order` lists it). Empty for a CSS or HTML chunk. + pub(crate) files_with_parts_in_chunk: ArrayHashMap, /// We must not keep pointers to this type until all chunks have been allocated. pub(crate) entry_bits: AutoBitSet, @@ -124,12 +118,10 @@ impl Default for Content<'_> { // (UnsafeCell-per-slot) so the per-task write is routed through interior // mutability and never requires an aliased `&mut Chunk` / // `&mut [CompileResult]` — see [`Chunk::write_compile_result_slot`]. -// `files_with_parts_in_chunk` values are bumped via atomic RMW; -// the renamer is fully populated before fan-out, and the printer only reads it. +// The renamer is fully populated before fan-out, and the printer only reads it. unsafe impl Send for Chunk<'_> {} // SAFETY: shared `&Chunk` access during the worker fan-out writes only -// `compile_results_for_chunk` (UnsafeCell-per-slot, disjoint indices) and -// `files_with_parts_in_chunk` atomic counters; the remaining fields are +// `compile_results_for_chunk` (UnsafeCell-per-slot, disjoint indices); the remaining fields are // frozen before fan-out. unsafe impl Sync for Chunk<'_> {} diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index f51818fb9c88..0c5cbcadba05 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1888,7 +1888,6 @@ pub struct PendingPartRange<'c, 'a> { /// - `chunk.compile_results_for_chunk[i]` is written at a per-task unique `i` /// via [`Chunk::write_compile_result_slot`] (raw `addr_of_mut!` + /// `UnsafeCell` slot write — never `&mut Chunk`), -/// - `chunk.files_with_parts_in_chunk` entries are updated via atomic RMW only, /// - all other access through `ctx.c` / `ctx.chunk` during codegen is read-only. #[inline] pub(crate) unsafe fn pending_part_range_prologue<'c, 'a>( diff --git a/src/bundler/linker_context/MetafileBuilder.rs b/src/bundler/linker_context/MetafileBuilder.rs index aae65f2021ec..c5124ee4b08c 100644 --- a/src/bundler/linker_context/MetafileBuilder.rs +++ b/src/bundler/linker_context/MetafileBuilder.rs @@ -56,13 +56,30 @@ fn fmt_size(bytes: u64) -> bfmt::SizeFormatter { /// The inputs of the output of `chunk`, each with its "bytesInOutput", in the order of the record. fn chunk_inputs(chunk: &Chunk) -> crate::Result> { match &chunk.content { - ChunkContent::Javascript(_) => { - let counted = chunk.files_with_parts_in_chunk.iter(); - let load = |bytes: &core::sync::atomic::AtomicUsize| { - // The codegen workers that add to the counters are joined before any output is emitted. - bytes.load(core::sync::atomic::Ordering::Relaxed) - }; - Ok(counted.map(|(&file, bytes)| (file, load(bytes))).collect()) + ChunkContent::Javascript(js) => { + let results = &chunk.compile_results_for_chunk; + debug_assert_eq!( + results.len(), + js.parts_in_chunk_in_order.len(), + "the metafile reads the compile results of the chunk", + ); + // Unlike esbuild, which lists sources in print order, the files of the chunk come first, in graph order. + let files = &chunk.files_with_parts_in_chunk; + let mut inputs: Vec<(IndexInt, usize)> = + files.keys().iter().map(|&file| (file, 0)).collect(); + // A stylesheet is a file of no JS chunk. The ones that the chunk prints code of come next, in print order. + let mut copied: ArrayHashMap = ArrayHashMap::new(); + for result in results.iter() { + let bytes = result.code().len(); + match files.get_index(&result.source_index()) { + Some(file) => inputs[file].1 += bytes, + None => *copied.get_or_put(result.source_index())?.value_ptr += bytes, + } + } + // A wrapped stylesheet has a compile result also when the chunk drops its wrapper. That one gets no record. + let printed = copied.iter().filter(|&(_, &bytes)| bytes > 0); + inputs.extend(printed.map(|(&file, &bytes)| (file, bytes))); + Ok(inputs) } ChunkContent::Css(_) => { // One input for each stylesheet, also when the chunk prints it under two import conditions. diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index 8ac8d537acfe..9abe08a2be1b 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -1,5 +1,4 @@ use crate::mal_prelude::*; -use core::sync::atomic::AtomicUsize; use bun_alloc::Arena; // bumpalo::Bump re-export use bun_collections::{ArrayHashMap, AutoBitSet, VecExt, index_sort}; @@ -354,14 +353,10 @@ pub(crate) fn compute_chunks<'a>( .insert(chunk::Flags::IS_BROWSER_CHUNK_FROM_SERVER_BUILD); } - let entry = js_chunk_entry + js_chunk_entry .value_ptr .files_with_parts_in_chunk - .get_or_put(source_index.get() as u32) - .expect("unreachable"); - if !entry.found_existing { - *entry.value_ptr = AtomicUsize::new(0); // Initialize byte count to 0 - } + .put(source_index.get(), ())?; } else { // Pass a context struct + fn pointer to entry_bits' forEach. struct Handler<'a, 'c> { @@ -377,13 +372,8 @@ pub(crate) fn compute_chunks<'a>( return; } - let entry = c.chunks[chunk_idx as usize] - .files_with_parts_in_chunk - .get_or_put(c.source_id as u32) - .expect("unreachable"); - if !entry.found_existing { - *entry.value_ptr = AtomicUsize::new(0); // Initialize byte count to 0 - } + let files = &mut c.chunks[chunk_idx as usize].files_with_parts_in_chunk; + bun_core::handle_oom(files.put(c.source_id, ())); } let mut handler = Handler { chunks: js_chunks.values_mut(), diff --git a/src/bundler/linker_context/generateCompileResultForJSChunk.rs b/src/bundler/linker_context/generateCompileResultForJSChunk.rs index f39841f65f5e..a91f48b50e92 100644 --- a/src/bundler/linker_context/generateCompileResultForJSChunk.rs +++ b/src/bundler/linker_context/generateCompileResultForJSChunk.rs @@ -1,8 +1,7 @@ use crate::mal_prelude::*; -use core::sync::atomic::Ordering; use bun_ast::Scope; -use bun_js_printer::{self as js_printer, PrintResult}; +use bun_js_printer as js_printer; use bun_threading::thread_pool as ThreadPoolLib; use crate::analyze_transpiled_module::ModuleInfo; @@ -15,8 +14,7 @@ use super::generate_code_for_file_in_chunk_js::generate_code_for_file_in_chunk_j // CONCURRENCY: thread-pool callback — runs on worker threads, one task per // `PendingPartRange`. Writes: `chunk.compile_results_for_chunk[i]` (disjoint -// by per-task `i`), `chunk.files_with_parts_in_chunk[source].counter` -// (atomic RMW). Reads `c.graph`/`c.parse_graph` SoA columns shared. Never +// by per-task `i`). Reads `c.graph`/`c.parse_graph` SoA columns shared. Never // forms `&mut LinkerContext` or `&mut Chunk`. // `PendingPartRange` is `Send` because its only non-auto-`Send` field is // `&GenerateChunkCtx` whose pointee is `Send + Sync`. @@ -136,25 +134,6 @@ fn generate_compile_result_for_js_chunk_impl( module_info.as_deref_mut(), ); - // Update bytesInOutput for this source in the chunk (for metafile) - // Use atomic operation since multiple threads may update the same counter - let code_len = match &result { - PrintResult::Result(r) => r.code.len(), - _ => 0, - }; - if code_len > 0 && !part_range.source_index.is_runtime() { - // CONCURRENCY: the map's key set is frozen before parallel codegen; we - // only need a shared `&AtomicUsize` to RMW the counter. Using `get` - // (not `get_ptr_mut`) avoids materializing an aliased `&mut` to a slot - // that other worker threads may be updating for the same source. - if let Some(bytes) = chunk - .files_with_parts_in_chunk - .get(&part_range.source_index.get()) - { - let _ = bytes.fetch_add(code_len, Ordering::Relaxed); - } - } - CompileResult::Javascript { source_index: part_range.source_index.get(), result, diff --git a/src/bundler/linker_context/postProcessJSChunk.rs b/src/bundler/linker_context/postProcessJSChunk.rs index 1455395e4bc6..f72831a05194 100644 --- a/src/bundler/linker_context/postProcessJSChunk.rs +++ b/src/bundler/linker_context/postProcessJSChunk.rs @@ -673,7 +673,6 @@ pub(crate) fn post_process_js_chunk( } } - // TODO: metafile newline_before_comment = !compile_result.code().is_empty(); } // An entry chunk whose code all lives in shared chunks has no compile results of its own. diff --git a/test/bundler/metafile.test.ts b/test/bundler/metafile.test.ts index 2bc3a453808d..6b0e0996e181 100644 --- a/test/bundler/metafile.test.ts +++ b/test/bundler/metafile.test.ts @@ -996,6 +996,345 @@ describe.concurrent("metafile outputs[..].inputs of a JS chunk", () => { ], ]); }); + + // With --splitting, a file that the chunk has no compile result for is not a file of the chunk. + test.each([false, true])( + "keep the record of a file of the chunk that prints nothing (splitting: %p)", + async splitting => { + using dir = tempDir("metafile-js-inputs-no-code", { + "entry.js": `import { x } from "./reexport.js";\nimport "./barrel.js";\nconsole.log(x);\n`, + "reexport.js": `export { x } from "./x.js";\n`, + "x.js": `export const x = 1;\n`, + "barrel.js": `import "./side.js";\n`, + "side.js": `console.log("side");\n`, + }); + + const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], splitting, metafile: true }); + expect(result.success).toBe(true); + + expect(inputsOf(result)).toEqual([ + [ + ["x.js", `var x = 1;\n`.length], + ["reexport.js", 0], + ["side.js", `console.log("side");\n`.length], + // A file that only has `import` statements has no compile result. + ...(splitting ? [] : [["barrel.js", 0]]), + ["entry.js", `console.log(x);\n`.length], + ], + ]); + }, + ); + + /** The inputs of each metafile output by file name, in the order of the record. */ + const inputsByOutput = (metafile: Metafile) => + Object.fromEntries(Object.entries(outputRecords(metafile)).map(([file, output]) => [file, output.inputs])); + + /** The code that a JS output has of `file`: what follows each `// ` comment, up to the next comment. */ + function codeOf(text: string, file: string) { + // [the text before the first comment, a path, its code, a path, its code, ...] + const parts = text.split(/^\/\/ (.+)\n/m); + let code = ""; + for (let i = 1; i < parts.length; i += 2) { + if (fileName(parts[i]) !== file) continue; + // The blank line before the next comment is not code of the file. + code += parts[i + 1].endsWith("\n\n") ? parts[i + 1].slice(0, -1) : parts[i + 1]; + } + return code; + } + + /** The file names in the `// ` comments of a JS output: the files in the order the output prints them. */ + const printOrder = (text: string) => Array.from(text.matchAll(/^\/\/ (.+)$/gm), comment => fileName(comment[1])); + + /** The rules of a CSS output. Its first line is the comment with the path of the stylesheet. */ + const rulesOf = (css: string) => css.slice(css.indexOf("\n") + 1); + + const textOf = (result: Bun.BuildOutput, extension: string) => + result.outputs.find(artifact => artifact.path.endsWith(extension))!.text(); + + /** What a JS chunk prints of `a.module.css` with the class `foo`, for a default import that reads `foo`. */ + const classNames = /^var a_module_default = \{\n foo: "foo_[^"]+"\n\};\n$/; + + // A stylesheet is a file of no JS chunk. A JS chunk that uses what the stylesheet exports prints that itself. + // `prints` is what the chunk prints of the stylesheet, or how that starts. The metafile counts it as it is: + // #38286 and #38307 change what the chunk prints for a namespace import, for `require()` and for `import()`. + const uses = [ + { + use: "a default import", + stylesheet: "a.module.css", + entry: `import styles from "./a.module.css";\nconsole.log(styles.foo);\n`, + prints: classNames, + }, + { + use: "a named import", + stylesheet: "a.module.css", + entry: `import { foo } from "./a.module.css";\nconsole.log(foo);\n`, + prints: /^var foo = "foo_[^"]+";\n$/, + }, + { + use: "a namespace import", + stylesheet: "a.module.css", + entry: `import * as styles from "./a.module.css";\nconsole.log(styles);\n`, + prints: /^var exports_a_module = \{\};\n/, + }, + { + use: "require()", + stylesheet: "a.module.css", + entry: `const styles = require("./a.module.css");\nconsole.log(styles.foo);\nexport {};\n`, + prints: /^var require_a_module = /, + }, + { + use: "import() with no splitting", + stylesheet: "a.module.css", + entry: `import("./a.module.css").then(styles => console.log(styles.foo));\n`, + prints: /^var require_a_module = /, + }, + { + use: "a default import of a stylesheet that is not a CSS module", + stylesheet: "plain.css", + entry: `import styles from "./plain.css";\nconsole.log(styles);\n`, + prints: /^var plain_default = \{\};\n$/, + }, + ]; + + test.each(uses.flatMap(use => (["esm", "cjs"] as const).map(format => ({ ...use, format }))))( + "list a stylesheet that the chunk prints code of ($use, format: $format)", + async ({ stylesheet, entry, prints, format }) => { + using dir = tempDir("metafile-js-inputs-stylesheet", { + "entry.js": entry, + [stylesheet]: `.foo { color: red; }\n`, + }); + + const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], format, metafile: true }); + expect(result.success).toBe(true); + const metafile = result.metafile as Metafile; + + const js = await textOf(result, ".js"); + expect(codeOf(js, stylesheet)).toMatch(prints); + // The files of the chunk come first. The stylesheet has its own bytes in each output. + expect(inputsByOutput(metafile)).toEqual({ + "entry.js": [ + ["entry.js", codeOf(js, "entry.js").length], + [stylesheet, codeOf(js, stylesheet).length], + ], + "entry.css": [[stylesheet, rulesOf(await textOf(result, ".css")).length]], + }); + const listed = Object.values(metafile.outputs).flatMap(output => Object.keys(output.inputs)); + expect(listed.filter(input => !(input in metafile.inputs))).toEqual([]); + }, + ); + + // The chunk prints the namespace objects of the two stylesheets first, and then the class names of each. + test("list a stylesheet one time, with all the pieces that the chunk prints of it", async () => { + using dir = tempDir("metafile-js-inputs-stylesheet-pieces", { + "entry.js": `import * as a from "./a.module.css";\nimport * as b from "./b.module.css";\nconsole.log(a, b);\n`, + "a.module.css": `.foo { color: red; }\n`, + "b.module.css": `.longer { color: blue; }\n`, + }); + + const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], metafile: true }); + expect(result.success).toBe(true); + + const js = await textOf(result, ".js"); + expect(printOrder(js)).toEqual(["a.module.css", "b.module.css", "a.module.css", "b.module.css", "entry.js"]); + expect(inputsByOutput(result.metafile as Metafile)["entry.js"]).toEqual([ + ["entry.js", `console.log(exports_a_module, exports_b_module);\n`.length], + ["a.module.css", codeOf(js, "a.module.css").length], + ["b.module.css", codeOf(js, "b.module.css").length], + ]); + expect(codeOf(js, "b.module.css").length).toBeGreaterThan(codeOf(js, "a.module.css").length); + }); + + // The two entry points import the same two stylesheets in opposite orders. + test.each([false, true])( + "list the stylesheets after the files of the chunk, in the order the chunk prints them (splitting: %p)", + async splitting => { + using dir = tempDir("metafile-js-inputs-stylesheet-order", { + "first.js": `import a from "./a.module.css";\nimport b from "./b.module.css";\nconsole.log("first", a.foo, b.longer);\n`, + "second.js": `import b from "./b.module.css";\nimport a from "./a.module.css";\nconsole.log("second", b.longer, a.foo);\n`, + "a.module.css": `.foo { color: red; }\n`, + "b.module.css": `.longer { color: blue; }\n`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/first.js`, `${dir}/second.js`], + splitting, + metafile: true, + }); + expect(result.success).toBe(true); + + const inputs = inputsByOutput(result.metafile as Metafile); + const printed = { "first.js": ["a.module.css", "b.module.css"], "second.js": ["b.module.css", "a.module.css"] }; + for (const [script, stylesheets] of Object.entries(printed)) { + const js = await textOf(result, script); + expect(printOrder(js)).toEqual([...stylesheets, script]); + expect(inputs[script]).toEqual([script, ...stylesheets].map(file => [file, codeOf(js, file).length])); + } + }, + ); + + // `route.js` is a wrapped file here, so the chunk prints a wrapper for the stylesheet that it imports. + test("list a stylesheet with a bare import when the chunk prints a wrapper for it", async () => { + const wrapper = `var init_plain = () => {};\n`; + using dir = tempDir("metafile-js-inputs-stylesheet-wrapper", { + "entry.js": `import("./route.js").then(route => console.log(route.name));\n`, + "route.js": `import "./plain.css";\nexport const name = "route";\n`, + "plain.css": `.foo { color: red; }\n`, + }); + + const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], metafile: true }); + expect(result.success).toBe(true); + + const js = await textOf(result, ".js"); + expect(codeOf(js, "plain.css")).toBe(wrapper); + expect(inputsByOutput(result.metafile as Metafile)["entry.js"]).toEqual([ + ["route.js", codeOf(js, "route.js").length], + ["entry.js", codeOf(js, "entry.js").length], + ["plain.css", wrapper.length], + ]); + }); + + // With --splitting a JS file is in one chunk. Each chunk that uses the class names has its own copy of them. + test.each([false, true])("list a stylesheet in each chunk that prints it (splitting: %p)", async splitting => { + const printed = { + "one.js": `console.log("one", a_module_default.foo, shared);\n`, + "two.js": `console.log("two", a_module_default.foo, shared);\n`, + "shared.js": `var shared = a_module_default.foo + "!";\n`, + }; + using dir = tempDir("metafile-js-inputs-stylesheet-chunks", { + "one.js": `import styles from "./a.module.css";\nimport { shared } from "./shared.js";\nconsole.log("one", styles.foo, shared);\n`, + "two.js": `import styles from "./a.module.css";\nimport { shared } from "./shared.js";\nconsole.log("two", styles.foo, shared);\n`, + "shared.js": `import styles from "./a.module.css";\nexport const shared = styles.foo + "!";\n`, + "a.module.css": `.foo { color: red; }\n`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/one.js`, `${dir}/two.js`], + splitting, + metafile: true, + }); + expect(result.success).toBe(true); + + const scripts = result.outputs.filter(artifact => artifact.path.endsWith(".js")); + const copies = await Promise.all(scripts.map(async script => codeOf(await script.text(), "a.module.css"))); + expect(copies).toEqual(scripts.map(() => expect.stringMatching(classNames))); + + const bytes = (...files: (keyof typeof printed)[]) => files.map(file => [file, printed[file].length]); + const stylesheet = ["a.module.css", copies[0].length]; + const inputs = Object.entries(inputsByOutput(result.metafile as Metafile)); + expect(inputs.filter(([file]) => file.endsWith(".js")).map(([, inputs]) => inputs)).toEqual( + splitting + ? [ + [...bytes("one.js"), stylesheet], + [...bytes("shared.js"), stylesheet], + [...bytes("two.js"), stylesheet], + ] + : [ + [...bytes("shared.js", "one.js"), stylesheet], + [...bytes("shared.js", "two.js"), stylesheet], + ], + ); + }); + + // The page is a file of its JS chunk and prints nothing there. The stylesheet that the page links is not. + test("list a stylesheet in the JS chunk of an HTML page", async () => { + using dir = tempDir("metafile-js-inputs-stylesheet-html", { + "index.html": `\n`, + "app.js": `import styles from "./a.module.css";\nconsole.log(styles.foo);\n`, + "a.module.css": `.foo { color: red; }\n`, + "page.css": `.page { color: red; }\n`, + }); + + const result = await Bun.build({ entrypoints: [`${dir}/index.html`], metafile: true }); + expect(result.success).toBe(true); + + const js = await textOf(result, ".js"); + const inputs = Object.entries(inputsByOutput(result.metafile as Metafile)); + expect(inputs.filter(([file]) => file.endsWith(".js")).map(([, inputs]) => inputs)).toEqual([ + [ + ["app.js", `console.log(a_module_default.foo);\n`.length], + ["index.html", 0], + ["a.module.css", codeOf(js, "a.module.css").length], + ], + ]); + expect(codeOf(js, "a.module.css")).toMatch(classNames); + }); + + /** The inputs of the JS output of a build of `entry.js`. That output must be the code of `entry.js` alone. */ + async function jsInputsOfEntryAlone(files: Record) { + using dir = tempDir("metafile-js-inputs-stylesheet-no-code", files); + + const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], metafile: true }); + expect(result.success).toBe(true); + + expect((await textOf(result, ".js")).replace(/^\/\/ .+\n/, "")).toBe(`console.log("entry");\n`); + // The build has the stylesheet: its rules are in the CSS output. + const rules = (await textOf(result, ".css")).match(/^\.foo_[^ ]+ \{\n color: red;\n\}\n/m)![0]; + const inputs = inputsByOutput(result.metafile as Metafile); + expect(inputs["entry.css"]).toContainEqual(["a.module.css", rules.length]); + return inputs["entry.js"]; + } + + // esbuild lists the stylesheet with 0 bytes in these two JS outputs. + test.each([ + ["a bare import", `import "./a.module.css";\nconsole.log("entry");\n`], + ["a default import that nothing reads", `import styles from "./a.module.css";\nconsole.log("entry");\n`], + ])("do not list a stylesheet that the chunk prints nothing of (%s)", async (_, entry) => { + const files = { "entry.js": entry, "a.module.css": `.foo { color: red; }\n` }; + expect(await jsInputsOfEntryAlone(files)).toEqual([["entry.js", `console.log("entry");\n`.length]]); + }); + + // The `require()` makes the stylesheet a wrapped file, so the chunk has a piece for it. Nothing calls the wrapper, + // and the piece is empty. esbuild keeps the wrapper, and lists the stylesheet with the bytes of it. + test("do not list a wrapped stylesheet whose wrapper the chunk drops", async () => { + const files = { + "entry.js": `import "./own.css";\nimport "kit/a.module.css";\nfunction dead() {\n return require("kit/a.module.css");\n}\nconsole.log("entry");\n`, + "own.css": `@import "kit/a.module.css";\n.own { color: red; }\n`, + "node_modules/kit/package.json": JSON.stringify({ name: "kit", version: "1.0.0", sideEffects: false }), + "node_modules/kit/a.module.css": `.foo { color: red; }\n`, + }; + expect(await jsInputsOfEntryAlone(files)).toEqual([["entry.js", `console.log("entry");\n`.length]]); + }); + + test("bun build --metafile --metafile-md list a stylesheet that the chunk prints code of", async () => { + const entryCode = `console.log(a_module_default.foo);\n`; + using dir = tempDir("metafile-js-inputs-stylesheet-cli", { + "entry.js": `import styles from "./a.module.css";\nconsole.log(styles.foo);\n`, + "a.module.css": `.foo { color: red; }\n`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "./entry.js", "--outdir=dist", "--metafile=meta.json", "--metafile-md=meta.md"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + + const js = await Bun.file(`${dir}/dist/entry.js`).text(); + const stylesheetCode = codeOf(js, "a.module.css"); + expect(stylesheetCode).toMatch(classNames); + expect(js).toBe(`// a.module.css\n${stylesheetCode}\n// entry.js\n${entryCode}`); + const inJs = stylesheetCode.length; + const inCss = rulesOf(await Bun.file(`${dir}/dist/entry.css`).text()).length; + + // The JSON has the key of the stylesheet one time in each of the two outputs. + const json = await Bun.file(`${dir}/meta.json`).text(); + expect(json.split(`"a.module.css": {\n "bytesInOutput"`)).toHaveLength(3); + expect(inputsByOutput(JSON.parse(json) as Metafile)).toEqual({ + "entry.js": [ + ["entry.js", entryCode.length], + ["a.module.css", inJs], + ], + "entry.css": [["a.module.css", inCss]], + }); + + // The report adds up what the stylesheet has in the two outputs. + const report = await Bun.file(`${dir}/meta.md`).text(); + expect(report).toContain(`| ${inJs} bytes | \`a.module.css\` |\n| ${entryCode.length} bytes | \`entry.js\` |\n`); + expect(report).toContain(`[OUTPUT_BYTES: a.module.css = ${inJs + inCss} bytes]\n`); + }); }); describe("Bun.build metafile option variants", () => {