diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 706e739ccb98..1da6804f5101 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5860,6 +5860,21 @@ pub mod bv2_impl { // // Actual empty files will contain a part exporting an empty object. if part_list.len() != 0 { + // Resolution through a plugin, or to a target already in the bundle, leaves the path as written, and the dev server names the module by that path: https://github.com/oven-sh/bun/issues/20877 + for record in import_records.as_mut_slice() { + if !record.source_index.is_valid() { + continue; + } + let resolved = record.source_index.get() as usize; + let loader = loaders[resolved]; + // The dev server files the build errors of a stylesheet that is not a file under `path.text`, which only the path as written can match. + let is_stylesheet_file = + loader.is_css() && sources[resolved].path.is_file(); + // An html file or a stylesheet prints the path of any other target, such as an asset URL. + if loader.is_javascript_like() || is_stylesheet_file { + record.path = sources[resolved].path; + } + } if maybe_css.is_some() { // CSS has restrictions on what files can be imported. // This means the file can become an error after diff --git a/src/runtime/bake/dev_server/incremental_graph.rs b/src/runtime/bake/dev_server/incremental_graph.rs index 37b3b7e0f176..92318c603e41 100644 --- a/src/runtime/bake/dev_server/incremental_graph.rs +++ b/src/runtime/bake/dev_server/incremental_graph.rs @@ -920,7 +920,7 @@ impl IncrementalGraph { // takes `&mut ctx`. let (flags, src, key) = { let ir = &ctx.import_records[index.get() as usize].as_slice()[i]; - // If an `onResolve` plugin resolved the import, `ir.path` is still the specifier. + // The path of a record that names neither a script nor a stylesheet file can still be the specifier. let path = if ir.source_index.is_valid() { &ctx.sources[ir.source_index.get() as usize].path } else { diff --git a/test/bake/bake-harness.ts b/test/bake/bake-harness.ts index c5ba2f53efaf..2203a84aee00 100644 --- a/test/bake/bake-harness.ts +++ b/test/bake/bake-harness.ts @@ -134,7 +134,8 @@ export interface DevServerTest { */ cwd?: string; - skip?: ("win32" | "darwin" | "linux" | "ci")[]; + /** Register the test as `test.todo` on these platforms, in CI, or on a debug build of bun. */ + skip?: ("win32" | "darwin" | "linux" | "ci" | "debug")[]; /** * Only run this test. */ @@ -1929,7 +1930,7 @@ export function indexHtmlScript(htmlFiles: string[]) { ].join("\n"); } -const skipTargets = [process.platform, isCI ? "ci" : null].filter(Boolean); +const skipTargets = [process.platform, isCI ? "ci" : null, isDebugBuild ? "debug" : null].filter(Boolean); function testImpl( description: string, diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 9ce744ae8636..c2ea79ac2c95 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -1,7 +1,7 @@ // 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"; +import { type Dev, devTest, emptyHtmlFile, imageFixtures } from "../bake-harness"; devTest("css file with syntax error does not kill old styles", { files: { @@ -810,6 +810,232 @@ devTest("css import before create project relative", { await dev.fetch("/").expect.toContain("HELLO"); }, }); +// Two `[serve.static]` plugins: one that declines an import, and one that answers with the path. +// Each callback prints "onResolve ". +const resolvePlugin = { + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + "plugin.ts": ` + import path from "node:path"; + export default { + name: "resolve-some", + setup(build) { + build.onResolve({ filter: /declined\\.\\w+$/ }, args => { + console.log("onResolve " + args.path); + }); + build.onResolve({ filter: /answered\\.\\w+$/ }, args => { + console.log("onResolve " + args.path); + return { path: path.resolve(path.dirname(args.importer), args.path) }; + }); + }, + }; + `, +}; +// The next `count` calls that `resolvePlugin` printed, sorted. +const onResolveCalls = async (dev: Dev, count: number) => { + const calls: string[] = []; + while (calls.length < count) calls.push((await dev.output.waitForLine(/^onResolve (.+)$/))[1]); + return calls.sort(); +}; +devTest("stylesheet that a script imports through an onResolve plugin comes back after a failed first build", { + files: { + ...resolvePlugin, + "index.html": emptyHtmlFile({ + scripts: ["./app.ts"], + body: "

Hello

", + }), + "app.ts": ` + import "./declined.css"; + import "./answered.css"; + console.log("app"); + `, + "declined.css": ` + h1 { + color: red; + }} + `, + "answered.css": ` + body { + color: red; + }} + `, + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(500); + expect(await onResolveCalls(dev, 2)).toEqual(["./answered.css", "./declined.css"]); + await dev.write("declined.css", `h1 { color: red; }`, { errors: null }); + await dev.write("answered.css", `body { color: blue; }`); + // previously: the page had no stylesheet + await using c = await dev.client("/"); + await c.expectMessage("app"); + await c.style("h1").color.expect.toBe("red"); + await c.style("body").color.expect.toBe("#00f"); + }, +}); +devTest("stylesheet imported through an onResolve plugin follows its edits", { + files: { + ...resolvePlugin, + "index.html": emptyHtmlFile({ + styles: ["./main.css"], + body: "

Hello

", + }), + "main.css": ` + @import "./declined.css"; + @import "./answered.css"; + `, + "declined.css": ` + h1 { + color: red; + } + `, + "answered.css": ` + body { + color: red; + } + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.style("h1").color.expect.toBe("red"); + await c.style("body").color.expect.toBe("red"); + expect(await onResolveCalls(dev, 2)).toEqual(["./answered.css", "./declined.css"]); + // previously: the edit did not reach the page + await dev.write("declined.css", `h1 { color: blue; }`); + await c.style("h1").color.expect.toBe("#00f"); + await dev.write("answered.css", `body { color: blue; }`); + await c.style("body").color.expect.toBe("#00f"); + }, +}); +// The dev server makes the stylesheets of a plugin whose name has "tailwind" in it entry points of every +// bundle. The plugin has the shape of bun-plugin-tailwind: one onLoad for css files, and no onResolve. +devTest("stylesheet of a tailwind plugin keeps its build error and comes back once fixed", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./tailwind-like.ts"] + `, + "tailwind-like.ts": ` + export default { + name: "tailwind-like", + setup(build) { + build.onLoad({ filter: /\\.css$/ }, async args => ({ + contents: "/*! tailwindcss */\\n" + (await Bun.file(args.path).text()), + loader: "css", + })); + }, + }; + `, + "index.html": emptyHtmlFile({ + styles: ["./styles.css"], + body: "

Hello

", + }), + "styles.css": ` + h1 { + color: red; + } + `, + }, + async test(dev) { + // The dev server takes a stylesheet for a tailwind file when the first bytes of its output have "tailwind" in them. + const href = (await dev.fetch("/").text()).match(/ = { + "a syntax error in a stylesheet": { + importer: "css", + text: `@import "NAME.css";`, + onLoad: `() => ({ contents: "h1 { color: red; }}", loader: "css" })`, + }, + "a syntax error in a stylesheet that a script imports": { + importer: "ts", + text: `import "NAME.css";`, + onLoad: `() => ({ contents: "h1 { color: red; }}", loader: "css" })`, + }, + "a syntax error in a stylesheet that the html file links": { + importer: "html", + text: emptyHtmlFile({ styles: ["NAME.css"] }), + onLoad: `() => ({ contents: "h1 { color: red; }}", loader: "css" })`, + }, + "a url() in a stylesheet that does not resolve": { + importer: "css", + text: `@import "NAME.css";`, + onLoad: `() => ({ contents: 'h1 { background: url("./missing.png"); }', loader: "css" })`, + }, + "a url() in a stylesheet that the plugin declines and that does not resolve": { + importer: "css", + text: `@import "NAME.css";`, + onLoad: `() => ({ contents: 'h1 { background: url("./declined.png"); }', loader: "css" })`, + }, + "a stylesheet that imports a script": { + importer: "css", + text: `@import "NAME.css";`, + onLoad: `args => + args.path.endsWith(".ts") + ? { contents: "export {};", loader: "ts" } + : { contents: '@import "' + args.path.replace(/css$/, "ts") + '";', loader: "css" }`, + }, + "a syntax error in a toml file that a stylesheet uses": { + importer: "css", + text: `h1 { background: url("NAME.toml"); }`, + onLoad: `() => ({ contents: "key = ", loader: "toml" })`, + }, +}; +// The second load bundles the route again. It answers 500 only if the build error is on a module that the +// page still imports, and the dev server files the error of a module that is not a file under its path as written. +devTest("page keeps the build error of a module that a plugin loads from a namespace", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./virtual-modules.ts"] + `, + "virtual-modules.ts": ` + const onLoad = [${Object.values(virtualModuleErrors) + .map(({ onLoad }) => onLoad) + .join(", ")}]; + export default { + name: "virtual-modules", + setup(build) { + build.onResolve({ filter: /^virtual\\./ }, args => ({ path: args.path, namespace: "virtual" })); + build.onResolve({ filter: /^\\.\\/declined\\./ }, () => undefined); + build.onLoad({ filter: /.*/, namespace: "virtual" }, args => onLoad[args.path.split(".")[1]](args)); + }, + }; + `, + ...Object.fromEntries( + Object.values(virtualModuleErrors).flatMap(({ importer, text }, n) => { + text = text.replace("NAME", `virtual.${n}`); + if (importer === "html") return [[`page-${n}.html`, text]]; + return [ + [ + `page-${n}.html`, + emptyHtmlFile({ [importer === "css" ? "styles" : "scripts"]: [`./entry-${n}.${importer}`] }), + ], + [`entry-${n}.${importer}`, text], + ]; + }), + ), + }, + async test(dev) { + const statuses: Record = {}; + for (const [n, name] of Object.keys(virtualModuleErrors).entries()) { + statuses[name] = [(await dev.fetch(`/page-${n}`)).status, (await dev.fetch(`/page-${n}`)).status]; + } + expect(statuses).toEqual(Object.fromEntries(Object.keys(virtualModuleErrors).map(name => [name, [500, 500]]))); + }, +}); function extractCssUrl(backgroundImage: string): string { const url = backgroundImage.match(/url\((['"])(.*?)\1\)/); diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts index 1166efda57a6..2d5adb458683 100644 --- a/test/bake/dev/html.test.ts +++ b/test/bake/dev/html.test.ts @@ -1,6 +1,7 @@ // HTML tests are tests relating to HTML files themselves. import { expect } from "bun:test"; -import { devTest, emptyHtmlFile } from "../bake-harness"; +import { renameSync } from "node:fs"; +import { type Dev, devTest, emptyHtmlFile } from "../bake-harness"; devTest("html file is watched", { files: { @@ -516,3 +517,491 @@ devTest("editing a file imported from outside the project root hot-reloads", { await c.expectMessage("three"); }, }); +// One watch batch that holds an html file and a file it references makes both +// entry points of one rebuild. https://github.com/oven-sh/bun/issues/22263 +// The client connects after the rebuild, because a client that is connected gets +// the html file as a hot update. https://github.com/oven-sh/bun/issues/31908 +devTest("html file and its scripts rebuilt together load by their module ids", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["script.ts", "./other.ts"], + body: "

Hello

", + }), + "script.ts": ` + console.log("v1"); + `, + "other.ts": ` + console.log("other v1"); + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + { + await using _batch = await dev.batchChanges(); + await dev.patch("index.html", { find: "Hello", replace: "World" }); + await dev.write("script.ts", `console.log("v2");`); + await dev.write("other.ts", `console.log("other v2");`); + } + // previously: Failed to load bundled module '/script.ts' + await using c = await dev.client("/"); + await c.expectMessage("v2", "other v2"); + await dev.fetch("/").expect.toInclude("

World

"); + }, +}); +devTest("script that fails in the same rebuild as its html file comes back once fixed", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["./first.ts"], + body: "

Hello

", + }), + "first.ts": ` + import { value } from "./dep.ts"; + console.log("first " + value); + `, + "dep.ts": ` + export const value = "dep"; + `, + "second.ts": ` + console.log("second"); + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + { + await using _batch = await dev.batchChanges(); + await dev.write( + "index.html", + emptyHtmlFile({ + scripts: ["./first.ts", "./second.ts"], + body: "

World

", + }), + ); + await dev.write( + "first.ts", + ` + import { value } from "./dep.ts"; + import "./missing.ts"; + console.log("first " + value); + `, + ); + } + // Not a page request: that bundles the whole route again. + await dev.output.waitForLine(/Could not resolve: "\.\/missing\.ts"/); + await dev.write( + "first.ts", + ` + import { value } from "./dep.ts"; + console.log("first fixed " + value); + `, + ); + // previously: Failed to load bundled module './first.ts' + await using c = await dev.client("/"); + await c.expectMessage("first fixed dep", "second"); + await dev.fetch("/").expect.toInclude("

World

"); + }, +}); +devTest("stylesheet that fails in the same rebuild as its html file comes back once fixed", { + files: { + "index.html": emptyHtmlFile({ + styles: ["./styles.css"], + body: "

Hello

", + }), + "styles.css": ` + body { + color: red; + } + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + { + await using _batch = await dev.batchChanges(); + await dev.patch("index.html", { find: "Hello", replace: "World" }); + await dev.write( + "styles.css", + ` + body { + color: blue; + }} + `, + ); + } + // Not a page request: that bundles the whole route again. + await dev.output.waitForLine(/Unexpected end of input/); + await dev.write( + "styles.css", + ` + body { + color: blue; + } + `, + ); + // previously: the page had no stylesheet + await using c = await dev.client("/"); + await c.style("body").color.expect.toBe("#00f"); + await dev.fetch("/").expect.toInclude("

World

"); + }, +}); +// Each `onResolve` callback of a test plugin prints "onResolve ". These are the next `count` calls, sorted. +const onResolveCalls = async (dev: Dev, count: number) => { + const calls: string[] = []; + while (calls.length < count) calls.push((await dev.output.waitForLine(/^onResolve (.+)$/))[1]); + return calls.sort(); +}; +devTest("html script that an onResolve plugin resolves loads by its module id", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + "plugin.ts": ` + import path from "node:path"; + export default { + name: "resolve-scripts", + setup(build) { + build.onResolve({ filter: /declined\\.ts$/ }, args => { + console.log("onResolve " + args.path); + }); + build.onResolve({ filter: /answered\\.ts$/ }, args => { + console.log("onResolve " + args.path); + return { path: path.resolve(path.dirname(args.importer), args.path) }; + }); + }, + }; + `, + "index.html": emptyHtmlFile({ + scripts: ["./declined.ts", "./answered.ts"], + }), + "declined.ts": ` + console.log("declined"); + `, + "answered.ts": ` + console.log("answered"); + `, + }, + async test(dev) { + // previously: Failed to load bundled module './declined.ts' + await using c = await dev.client("/"); + await c.expectMessage("declined", "answered"); + expect(await onResolveCalls(dev, 2)).toEqual(["./answered.ts", "./declined.ts"]); + }, +}); +devTest("html script that an onResolve plugin puts in a namespace loads by its module id", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + // Only onLoad says that "plain" is a script. "virtual:prefixed.ts" keeps its prefix in its path. + "plugin.ts": ` + export default { + name: "virtual-scripts", + setup(build) { + build.onResolve({ filter: /^(plain|virtual:prefixed\\.ts)$/ }, args => ({ + path: args.path, + namespace: "virtual", + })); + build.onLoad({ filter: /.*/, namespace: "virtual" }, args => ({ + contents: "console.log(" + JSON.stringify(args.path) + ");", + loader: "ts", + })); + }, + }; + `, + "index.html": emptyHtmlFile({ + scripts: ["plain", "virtual:prefixed.ts"], + }), + }, + async test(dev) { + // previously: Failed to load bundled module 'plain' + await using c = await dev.client("/"); + await c.expectMessage("plain", "virtual:prefixed.ts"); + }, +}); +// Only the linker gives these records the path of their file: the target is neither a script nor a stylesheet. +devTest("script import of a json file that an onResolve plugin resolves loads by its module id", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + "plugin.ts": ` + import path from "node:path"; + export default { + name: "resolve-json", + setup(build) { + build.onResolve({ filter: /declined\\.json$/ }, args => { + console.log("onResolve " + args.path); + }); + build.onResolve({ filter: /answered\\.json$/ }, args => { + console.log("onResolve " + args.path); + return { path: path.resolve(path.dirname(args.importer), args.path) }; + }); + }, + }; + `, + "index.html": emptyHtmlFile({ + scripts: ["./index.ts"], + }), + "index.ts": ` + import declined from "./declined.json"; + import answered from "./answered.json"; + console.log(declined.name); + console.log(answered.name); + `, + "declined.json": `{ "name": "declined" }`, + "answered.json": `{ "name": "answered" }`, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("declined", "answered"); + expect(await onResolveCalls(dev, 2)).toEqual(["./answered.json", "./declined.json"]); + }, +}); +devTest("stylesheet that an onResolve plugin resolves comes back after a failed first build", { + files: { + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + "plugin.ts": ` + export default { + name: "resolve-styles", + setup(build) { + build.onResolve({ filter: /styles\\.css$/ }, args => { + console.log("onResolve " + args.path); + }); + }, + }; + `, + "index.html": emptyHtmlFile({ + styles: ["./styles.css"], + body: "

Hello

", + }), + "styles.css": ` + body { + color: red; + }} + `, + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(500); + expect(await onResolveCalls(dev, 1)).toEqual(["./styles.css"]); + await dev.write("styles.css", `body { color: red; }`); + // previously: the page had no stylesheet + await using c = await dev.client("/"); + await c.style("body").color.expect.toBe("red"); + }, +}); +// A `[serve.static]` plugin that holds the index.html that says "World" until another file of the +// same rebuild has resolved its imports. The signal is the load of `marker`, which that file imports. +const holdHtmlRebuildUntilLoaded = (marker: string) => ({ + "bunfig.toml": ` + [serve.static] + plugins = ["./plugin.ts"] + `, + "plugin.ts": ` + export default { + name: "hold-html-rebuild", + setup(build) { + const markerLoaded = Promise.withResolvers(); + build.onLoad({ filter: /${marker.replace(".", "[.]")}$/ }, () => { + markerLoaded.resolve(); + }); + build.onLoad({ filter: /index[.]html$/ }, async args => { + const contents = await Bun.file(args.path).text(); + if (contents.includes("World")) { + await markerLoaded.promise; + console.log("index.html waited for ${marker}"); + } + return { contents, loader: "html" }; + }); + }, + }; + `, +}); +devTest("html link to a file that a stylesheet of the same rebuild also uses keeps its asset URL", { + files: { + ...holdHtmlRebuildUntilLoaded("marker.css"), + "index.html": `

Hello

`, + "styles.css": ` + body { + background: url(./shared.txt); + color: red; + } + `, + "marker.css": ` + h1 { + color: red; + } + `, + "shared.txt": "SHARED", + }, + async test(dev) { + const iconHref = async () => (await dev.fetch("/").text()).match(/World"); + }, +}); +devTest("html file and a saved script that start to use the same script in one rebuild", { + files: { + ...holdHtmlRebuildUntilLoaded("marker.ts"), + "index.html": emptyHtmlFile({ + scripts: ["./first.ts"], + body: "

Hello

", + }), + "first.ts": ` + import "./dep.ts"; + console.log("first"); + `, + "dep.ts": ` + console.log("dep v1"); + `, + "second.ts": ` + console.log("second"); + `, + "marker.ts": ` + export {}; + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + { + await using _batch = await dev.batchChanges(); + await dev.write("index.html", emptyHtmlFile({ scripts: ["./first.ts", "./second.ts"], body: "

World

" })); + await dev.write( + "dep.ts", + ` + import "./second.ts"; + import "./marker.ts"; + console.log("dep v2"); + `, + ); + } + // dep.ts put second.ts in the rebuild before index.html resolved it. + await dev.output.waitForLine(/index\.html waited for marker\.ts/); + // previously: Failed to load bundled module './second.ts' + await using c = await dev.client("/"); + await c.expectMessage("second", "dep v2", "first"); + }, +}); +// Vim saves a file this way: it renames the script to a backup and writes a new file. The script is gone +// for a moment, the rebuild of the html file fails, and the next rebuild holds both files. +// https://github.com/oven-sh/bun/issues/20877 +const renameToBackup = async (dev: Dev, file: string) => { + await using _batch = await dev.batchChanges({ errors: null }); + renameSync(dev.join(file), dev.join(file + "~")); +}; +devTest("script that is renamed away and written again loads by its module id", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["./main.js"], + body: "

Hello

", + }), + "main.js": ` + console.log("v1"); + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + await renameToBackup(dev, "main.js"); + await dev.output.waitForLine(/Could not resolve: "\.\/main\.js"/); + await dev.write("main.js", `console.log("v2");`); + // previously: Failed to load bundled module './main.js' + await using c = await dev.client("/"); + await c.expectMessage("v2"); + }, +}); +// The rename save, and the save of an html file together with its script, with the page open. A debug build +// stops at `ASSERTION FAILED` in `replaceModules` of the HMR runtime, because the open page gets the html file +// as a hot update: https://github.com/oven-sh/bun/issues/31908 +devTest("script that is renamed away and written again reloads the open page", { + skip: ["debug"], + files: { + "index.html": emptyHtmlFile({ + scripts: ["./main.js"], + body: "

Hello

", + }), + "main.js": ` + console.log("v1"); + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("v1"); + await renameToBackup(dev, "main.js"); + await c.expectReload(async () => { + await dev.write("main.js", `console.log("v2");`, { errors: null }); + }); + // previously: Failed to load bundled module './main.js' + await c.expectMessage("v2"); + }, +}); +devTest("html file and its script saved together reload the open page", { + skip: ["debug"], + files: { + "index.html": emptyHtmlFile({ + scripts: ["script.ts"], + body: "

Hello

", + }), + "script.ts": ` + console.log("v1"); + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("v1"); + await c.expectReload(async () => { + await using _batch = await dev.batchChanges(); + await dev.patch("index.html", { find: "Hello", replace: "World" }); + await dev.write("script.ts", `console.log("v2");`); + }); + // previously: Failed to load bundled module '/script.ts' + await c.expectMessage("v2"); + expect(await c.elemText("h1")).toBe("World"); + }, +}); +devTest("html routes that share a script and are first bundled together", { + files: { + "a.html": emptyHtmlFile({ scripts: ["./ab.ts", "./ac.ts"] }), + "b.html": emptyHtmlFile({ scripts: ["./ab.ts", "./bc.ts"] }), + "c.html": emptyHtmlFile({ scripts: ["./ac.ts", "./bc.ts"] }), + "ab.ts": `console.log("ab");`, + "ac.ts": `console.log("ac");`, + "bc.ts": `console.log("bc");`, + }, + async test(dev) { + // The first request starts a bundle. The two other routes wait and are bundled together, + // and each pair of routes shares one script. + const responses = await Promise.all([dev.fetch("/a"), dev.fetch("/b"), dev.fetch("/c")]); + expect(responses.map(response => response.status)).toEqual([200, 200, 200]); + for (const [route, messages] of [ + ["/a", ["ab", "ac"]], + ["/b", ["ab", "bc"]], + ["/c", ["ac", "bc"]], + ] as const) { + // previously: Failed to load bundled module './bc.ts' + await using c = await dev.client(route); + await c.expectMessage(...messages); + } + }, +});