From 71c6663ad3a4a6fc2ccb9f511e5d745c96f13d4c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:31:54 +0000 Subject: [PATCH 1/4] test(bake): run the dev server css cases against shared servers and assert the served stylesheets test/bake/dev/css.test.ts booted one dev server per case (15) and a happy-dom client for nearly every assertion (20 clients, 2 hard reloads) to check what is mostly server state, and every write made with a client attached paid the harness's one second error overlay poll. Group the cases onto 6 servers, one HTML route per case, keep one client per case that exercises the client side of a hot update (16), and read the rest over HTTP: the exact stylesheet chunk served for each route, response status and content type, and the Build Failed page for routes with errors. Writes whose following assertion can only pass once the rebuild reached the client pass errors: null; writes that recover from an error keep the overlay check, since that is what they assert. Cases that depend on the exact contents of the server (the asset table layout, the bunfig plugin) keep their own server. Routes with a failing stylesheet are fetched only after their client is gone, since re-bundling such a route ships the HTML module to connected clients (issue 31908); the existing resolve-error case only passed because the client was disposed before it applied that update. --- test/bake/dev/css.test.ts | 1242 +++++++++++++++++++++++-------------- 1 file changed, 760 insertions(+), 482 deletions(-) diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 18d665708976..203733917327 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -1,293 +1,797 @@ // CSS tests concern bundling bugs with CSS files +// +// Most cases here share a dev server: each case gets its own HTML route (and +// its own happy-dom client where the case is about applying a hot update in +// the browser), while the server state the cases assert on (served HTML, the +// stylesheet chunks, build failures) is read over plain HTTP. Build errors are +// reported to every connected client, so cases that produce errors run with no +// other client connected and clean up after themselves. Cases whose bug depends +// on the exact contents of the server (the asset table layout, a bunfig plugin) +// keep a server to themselves. +// +// By default every write made while a client is connected ends with the +// harness checking that client for an error overlay, which costs a second when +// there is none. Writes pass `errors: null` to skip that check when the +// assertion that follows can only pass if the rebuild succeeded and reached the +// client: a stylesheet that fails to rebuild keeps its old rules in the page +// (the "does not kill old styles" case asserts exactly that), and page reloads +// are only sent while nothing is failing. Writes that recover from an error keep +// the default, since the overlay going away is the point of those. import { expect } from "bun:test"; import assert from "node:assert"; +import type { Dev } from "../bake-harness"; import { devTest, emptyHtmlFile, imageFixtures } from "../bake-harness"; -devTest("css file with syntax error does not kill old styles", { +/** + * Fetches an HTML route and returns the stylesheet URLs the dev server injected + * into it. Source `` tags are ignored: a route that was bundled while its + * stylesheet was failing currently keeps its source tag after the stylesheet + * recovers (#37844). On these multi-route servers that leftover tag is a 404, + * which is also why the page reload after such a recovery sits through the + * client fixture's stylesheet-load check before it is acknowledged. + */ +async function stylesheetUrls(dev: Dev, route: string): Promise { + const res = await dev.fetch(route); + expect(res.status).toBe(200); + const html = await res.text(); + return [...html.matchAll(//g)].map(m => m[1]); +} + +async function fetchCss(dev: Dev, url: string): Promise { + const res = await dev.fetch(url); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/css;charset=utf-8"); + return res.text(); +} + +/** The exact stylesheet served for a route that links exactly one stylesheet. */ +async function servedCss(dev: Dev, route: string): Promise { + const urls = await stylesheetUrls(dev, route); + expect(urls).toHaveLength(1); + return fetchCss(dev, urls[0]); +} + +/** A route with a bundling error anywhere in its graph serves the error page instead of the HTML. */ +async function expectBuildFailed(dev: Dev, route: string) { + const res = await dev.fetch(route); + expect(res.status).toBe(500); + expect(await res.text()).toContain("Bun - Build Failed"); +} + +devTest("hot updates through @import graphs", { files: { - "styles.css": ` + // css import another css file + "import.html": emptyHtmlFile({ styles: ["import.css"] }), + "import.css": ` + @import "./imported.css"; body { color: red; } `, - "index.html": emptyHtmlFile({ - styles: ["styles.css"], - body: `hello world`, + "imported.css": ` + h1 { + color: blue; + } + `, + // circular css imports handle hot reload + "circular.html": emptyHtmlFile({ + styles: ["circular-a.css"], + body: ` +
hello
+
hello
+ `, }), + "circular-a.css": ` + @import "./circular-b.css"; + .a { color: red; } + `, + "circular-b.css": ` + @import "./circular-a.css"; + .b { color: blue; } + `, + // removing and re-adding css import + "toggle.html": emptyHtmlFile({ styles: ["toggle.css"] }), + "toggle.css": ` + @import "./toggle-colors.css"; + .main { background: white; } + `, + "toggle-colors.css": ` + .colored { color: blue; } + `, }, 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 + // css import another css file + { + await using c = await dev.client("/import"); + await c.style("h1").color.expect.toBe("#00f"); + await c.style("body").color.expect.toBe("red"); + expect(await servedCss(dev, "/import")).toMatchInlineSnapshot(` + "/* imported.css */ + h1 { + color: #00f; } - `, - { - errors: ["styles.css:4:1: error: Unexpected end of input"], - }, - ); - await c.style("body").color.expect.toBe("red"); - await dev.write( - "styles.css", - ` + + /* import.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", + "imported.css", ` - body { - color: red; + h1 { + color: green; } `, + { errors: null }, ); - }); - await c.style("body").color.expect.toBe("red"); - await dev.write( - "styles.css", - ` - body { - color: blue; + await c.style("h1").color.expect.toBe("green"); + await c.style("body").color.expect.toBe("red"); + // A fresh load of the page gets the updated chunk too. + expect(await servedCss(dev, "/import")).toMatchInlineSnapshot(` + "/* imported.css */ + h1 { + color: green; } - `, - ); - await c.style("body").color.expect.toBe("#00f"); - await dev.write( - "styles.css", - ` + + /* import.css */ body { - color: blue; - }} - `, - { - errors: ["styles.css:3:3: error: Unexpected end of input"], - }, - ); + color: red; + } + " + `); + } + + // circular css imports handle hot reload + { + await using c = await dev.client("/circular"); + await c.style(".a").color.expect.toBe("red"); + await c.style(".b").color.expect.toBe("#00f"); + expect(await servedCss(dev, "/circular")).toMatchInlineSnapshot(` + "/* circular-b.css */ + .b { + color: #00f; + } + + /* circular-a.css */ + .a { + color: red; + } + " + `); + + await dev.write( + "circular-a.css", + ` + @import "./circular-b.css"; + .a { color: green; } + `, + { errors: null }, + ); + await c.style(".a").color.expect.toBe("green"); + await c.style(".b").color.expect.toBe("#00f"); + expect(await servedCss(dev, "/circular")).toMatchInlineSnapshot(` + "/* circular-b.css */ + .b { + color: #00f; + } + + /* circular-a.css */ + .a { + color: green; + } + " + `); + } + + // removing and re-adding css import + { + await using c = await dev.client("/toggle"); + await c.style(".colored").color.expect.toBe("#00f"); + const withImport = await servedCss(dev, "/toggle"); + expect(withImport).toMatchInlineSnapshot(` + "/* toggle-colors.css */ + .colored { + color: #00f; + } + + /* toggle.css */ + .main { + background: #fff; + } + " + `); + + await dev.write( + "toggle.css", + ` + /* @import "./toggle-colors.css"; */ + .main { background: white; } + `, + { errors: null }, + ); + await c.style(".colored").notFound(); + await c.style(".main").backgroundColor.expect.toBe("#fff"); + const withoutImport = await servedCss(dev, "/toggle"); + expect(withoutImport).toMatchInlineSnapshot(` + "/* toggle.css */ + .main { + background: #fff; + } + " + `); + + // Editing the file that is no longer imported must not rebuild the + // stylesheet nor notify the client (the client exits on any socket + // message inside this block, so the overlay cannot change either). + await c.expectNoWebSocketActivity(async () => { + await dev.write("toggle-colors.css", `.colored { color: yellow; }`, { errors: null }); + await dev.write("toggle-colors.css", `.colored { color: blue; }`, { errors: null }); + }); + await c.style(".colored").notFound(); + expect(await servedCss(dev, "/toggle")).toBe(withoutImport); + + await dev.write( + "toggle.css", + ` + @import "./toggle-colors.css"; + .main { background: white; } + `, + { errors: null }, + ); + await c.style(".colored").color.expect.toBe("#00f"); + await c.style(".main").backgroundColor.expect.toBe("#fff"); + expect(await servedCss(dev, "/toggle")).toBe(withImport); + } }, }); -devTest("add new css import later", { + +devTest("hot updates through shared imports, assets and script imports", { files: { - "index.html": emptyHtmlFile({ - scripts: ["index.ts"], + // multiple stylesheets importing same dependency + "shared-first.html": emptyHtmlFile({ + styles: ["shared-first.css"], + body: ` +
hello
+
hello
+ `, + }), + "shared-second.html": emptyHtmlFile({ + styles: ["shared-second.css"], + body: ` +
hello
+
hello
+ `, + }), + "shared-first.css": ` + @import "./shared.css"; + .first { color: red; } + `, + "shared-second.css": ` + @import "./shared.css"; + .second { color: blue; } + `, + "shared.css": ` + .shared { color: green; } + `, + // asset referenced in css + "asset.html": emptyHtmlFile({ styles: ["asset.css"] }), + "asset.css": ` + body { + background-image: url(./asset.png); + } + `, + "asset.png": imageFixtures.bun, + // add new css import later + "script.html": emptyHtmlFile({ + scripts: ["script.ts"], body: `hello world`, }), - "index.ts": ` - // import "./styles.css"; + "script.ts": ` + // import "./script.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"; + "script.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"); + // multiple stylesheets importing same dependency + { + await using c1 = await dev.client("/shared-first"); + await using c2 = await dev.client("/shared-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"); + expect(await servedCss(dev, "/shared-first")).toMatchInlineSnapshot(` + "/* shared.css */ + .shared { + color: green; + } - // Hot reload - await dev.write( - "second.css", - ` - h1 { + /* shared-first.css */ + .first { + color: red; + } + " + `); + expect(await servedCss(dev, "/shared-second")).toMatchInlineSnapshot(` + "/* shared.css */ + .shared { 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"); + /* shared-second.css */ + .second { + color: #00f; + } + " + `); + + await dev.write( + "shared.css", + ` + .shared { color: yellow; } + `, + { errors: null }, + ); + await c1.style(".shared").color.expect.toBe("#ff0"); + await c2.style(".shared").color.expect.toBe("#ff0"); + await c1.style(".first").color.expect.toBe("red"); + await c2.style(".second").color.expect.toBe("#00f"); + // Both roots were rebuilt on the server, not just patched in the clients. + expect(await servedCss(dev, "/shared-first")).toMatchInlineSnapshot(` + "/* shared.css */ + .shared { + color: #ff0; + } + + /* shared-first.css */ + .first { + color: red; + } + " + `); + expect(await servedCss(dev, "/shared-second")).toMatchInlineSnapshot(` + "/* shared.css */ + .shared { + color: #ff0; + } + + /* shared-second.css */ + .second { + color: #00f; + } + " + `); + } + + // asset referenced in css + { + await using c = await dev.client("/asset"); + let backgroundImage = await c.style("body").backgroundImage; + assert(backgroundImage); + await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun); + await dev.fetch(extractCssUrl(await servedCss(dev, "/asset"))).expectFile(imageFixtures.bun); + + await dev.write("asset.png", imageFixtures.bun2, { errors: null }); + backgroundImage = await c.style("body").backgroundImage; + assert(backgroundImage); + await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun2); + await dev.fetch(extractCssUrl(await servedCss(dev, "/asset"))).expectFile(imageFixtures.bun2); + } + + // add new css import later + { + await using c = await dev.client("/script"); + await c.style("body").notFound(); + expect(await stylesheetUrls(dev, "/script")).toEqual([]); + + await dev.patch("script.ts", { find: "// import", replace: "import", errors: null }); + await c.style("body").color.expect.toBe("red"); + expect(await servedCss(dev, "/script")).toMatchInlineSnapshot(` + "/* script.css */ + body { + color: red; + } + " + `); + + await dev.patch("script.ts", { find: "import", replace: "// import", errors: null }); + await c.style("body").notFound(); + expect(await stylesheetUrls(dev, "/script")).toEqual([]); + } }, }); -devTest("asset referenced in css", { + +devTest("bundling errors in stylesheets and recovering from them", { files: { - "index.html": emptyHtmlFile({ - styles: ["styles.css"], - }), - "styles.css": ` + // syntax error crash + "crash.html": emptyHtmlFile({ styles: ["crash.css"], body: `hello world` }), + "crash.css": ` body { - background-image: url(./bun.png); + background-image: url } `, - "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); - 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": ` + // css url resolve error on hot reload is recoverable + "resolve.html": emptyHtmlFile({ styles: ["resolve.css"], body: `hello world` }), + "resolve.css": ` body { - background-image: url + color: red; } `, - "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": ` + // css file with syntax error does not kill old styles + "keep.html": emptyHtmlFile({ styles: ["keep.css"], body: `hello world` }), + "keep.css": ` body { color: red; } `, - "index.html": emptyHtmlFile({ - styles: ["styles.css"], - body: `hello world`, - }), + // css file with initial syntax error gets recovered + "initial.html": emptyHtmlFile({ styles: ["initial.css"], body: `hello world` }), + "initial.css": ` + body { + color: red; + }} + `, }, async test(dev) { + // syntax error crash + { + expect(await servedCss(dev, "/crash")).toMatchInlineSnapshot(` + "/* crash.css */ + body { + background-image: url; + } + " + `); + // previously: panic(main thread): Asset double unref: 0000000000000000 + await dev.patch("crash.css", { find: "url\n", replace: "url(\n" }); + await expectBuildFailed(dev, "/crash"); + await dev.write( + "crash.css", + ` + body { + color: red; + } + `, + ); + expect(await servedCss(dev, "/crash")).toMatchInlineSnapshot(` + "/* crash.css */ + body { + color: red; + } + " + `); + } + + // css url resolve error on hot reload is recoverable { - await using c = await dev.client("/"); + { + await using c = await dev.client("/resolve"); + 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( + "resolve.css", + ` + body { + background-image: url(./missing.png); + } + `, + { + errors: ['resolve.css:2:21: error: Could not resolve: "./missing.png"'], + }, + ); + await c.style("body").color.expect.toBe("red"); + } + // The failing route is fetched, and the recovery is checked, without a + // connected client: re-bundling the route while its CSS root is failing + // or recovering 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 expectBuildFailed(dev, "/resolve"); + await dev.write( + "resolve.css", + ` + body { + color: blue; + } + `, + ); + expect(await servedCss(dev, "/resolve")).toMatchInlineSnapshot(` + "/* resolve.css */ + body { + color: #00f; + } + " + `); + } + + // css file with syntax error does not kill old styles + { + await using c = await dev.client("/keep"); 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", + "keep.css", ` body { - background-image: url(./missing.png); + color: red; + background-color } `, { - errors: ['styles.css:2:21: error: Could not resolve: "./missing.png"'], + errors: ["keep.css:4:1: error: Unexpected end of input"], }, ); - 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", - ` + // The route is not fetched while it is broken: that re-bundles the HTML + // file, and the recovery below would then reload the page instead of + // hot-swapping the stylesheet (https://github.com/oven-sh/bun/issues/31908). + await c.style("body").color.expect.toBe("red"); + + await dev.write( + "keep.css", + ` + body { + color: red; + background-color: blue; + } + `, + ); + await c.style("body").backgroundColor.expect.toBe("#00f"); + expect(await servedCss(dev, "/keep")).toMatchInlineSnapshot(` + "/* keep.css */ body { - color: blue; + color: red; + background-color: #00f; } - `, - ); - expect((await dev.fetch("/")).status).toBe(200); + " + `); + + await dev.write("keep.css", ` `, { dedent: false, errors: null }); + await c.style("body").notFound(); + expect(await servedCss(dev, "/keep")).toMatchInlineSnapshot(` + "/* keep.css */ + + " + `); + } + + // css file with initial syntax error gets recovered + { + { + await using c = await dev.client("/initial", { + errors: ["initial.css:3:3: error: Unexpected end of input"], + }); + // hard reload to dismiss the error overlay + await c.expectReload(async () => { + await dev.write( + "initial.css", + ` + body { + color: red; + } + `, + ); + }); + await c.style("body").color.expect.toBe("red"); + await dev.write( + "initial.css", + ` + body { + color: blue; + } + `, + { errors: null }, + ); + await c.style("body").color.expect.toBe("#00f"); + expect(await servedCss(dev, "/initial")).toMatchInlineSnapshot(` + "/* initial.css */ + body { + color: #00f; + } + " + `); + await dev.write( + "initial.css", + ` + body { + color: blue; + }} + `, + { + errors: ["initial.css:3:3: error: Unexpected end of input"], + }, + ); + } + // Fetched after the client is gone for the same reason as above. + await expectBuildFailed(dev, "/initial"); + } }, }); -devTest("circular css imports handle hot reload", { + +devTest("stylesheets created after the server starts, changing html link tags", { files: { - "index.html": emptyHtmlFile({ - styles: ["a.css"], + // css import before create + "before.html": emptyHtmlFile({ + styles: ["before.css"], body: ` -
hello
-
hello
+
HELLO
`, }), - "a.css": ` - @import "./b.css"; - .a { color: red; } - `, - "b.css": ` - @import "./a.css"; - .b { color: blue; } + // css import before create project relative + "html/index.html": emptyHtmlFile({ + styles: ["/style/styles.css"], + body: ` +
HELLO
+ `, + }), + // changing html file with link tag works + "relink.html": emptyHtmlFile({ styles: ["relink.css"] }), + "relink.css": ` + .test { + color: blue; + font-size: 24px; + } `, }, 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"); + dev.mkdir("style"); // (See DevServer.zig "BUN-10968") - // 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"); + // css import before create + { + await using c = await dev.client("/before", { + errors: ['before.html: error: Could not resolve: "before.css". Maybe you need to "bun install"?'], + }); + await expectBuildFailed(dev, "/before"); + await dev.write( + "before.css", + ` + body { + background-image: url(before.png); + } + `, + { + errors: ['before.css:2:21: error: Could not resolve: "before.png". Maybe you need to "bun install"?'], + }, + ); + await expectBuildFailed(dev, "/before"); + await c.expectReload(async () => { + await dev.write("before.png", imageFixtures.bun); + }); + const backgroundImage = await c.style("body").backgroundImage; + assert(backgroundImage); + await dev.fetch(extractCssUrl(backgroundImage)).expectFile(imageFixtures.bun); + await dev.fetch("/before").expect.toContain("HELLO"); + } + + // css import before create project relative + { + await using c = await dev.client("/html", { + errors: ['html/index.html: error: Could not resolve: "/style/styles.css"'], + }); + await expectBuildFailed(dev, "/html"); + 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"'], + }, + ); + // Unlike the HTML's "/style/styles.css" link, an absolute url() in CSS is + // not resolved against the project root, so creating that file is not a + // change the stylesheet depends on. + await c.expectNoWebSocketActivity(async () => { + await dev.write("assets/bun.png", imageFixtures.bun, { errors: null }); + await dev.delete("assets/bun.png", { errors: null }); + }); + await expectBuildFailed(dev, "/html"); + 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("/html").expect.toContain("HELLO"); + } + + // changing html file with link tag works + { + await using c = await dev.client("/relink"); + await c.style(".test").color.expect.toBe("#00f"); + await c.style(".test").fontSize.expect.toBe("24px"); + const testCss = await servedCss(dev, "/relink"); + expect(testCss).toMatchInlineSnapshot(` + "/* relink.css */ + .test { + color: #00f; + font-size: 24px; + } + " + `); + + // Rewriting the HTML file unchanged reloads the page; the stylesheet survives the rebuild. + await c.expectReload(async () => { + await dev.write("relink.html", dev.read("relink.html"), { dedent: false, errors: null }); + }); + await c.style(".test").color.expect.toBe("#00f"); + await c.style(".test").fontSize.expect.toBe("24px"); + expect(await servedCss(dev, "/relink")).toBe(testCss); + + await dev.write("relink.html", emptyHtmlFile({ styles: ["relink-other.css"] }), { + errors: ['relink.html: error: Could not resolve: "relink-other.css". Maybe you need to "bun install"?'], + }); + await expectBuildFailed(dev, "/relink"); + await c.expectReload(async () => { + await dev.write( + "relink-other.css", + ` + .other { + color: red; + } + `, + ); + }); + await c.style(".other").color.expect.toBe("red"); + await c.style(".test").notFound(); + const otherCss = await servedCss(dev, "/relink"); + expect(otherCss).toMatchInlineSnapshot(` + "/* relink-other.css */ + .other { + color: red; + } + " + `); + + await c.expectReload(async () => { + await dev.write("relink.html", emptyHtmlFile({ styles: ["relink.css"] }), { errors: null }); + }); + await c.style(".test").color.expect.toBe("#00f"); + await c.style(".test").fontSize.expect.toBe("24px"); + await c.style(".other").notFound(); + expect(await servedCss(dev, "/relink")).toBe(testCss); + + await c.expectReload(async () => { + await dev.write("relink.html", emptyHtmlFile({ styles: ["relink-other.css", "relink.css"] }), { + errors: null, + }); + }); + await c.style(".other").color.expect.toBe("red"); + await c.style(".test").color.expect.toBe("#00f"); + await c.style(".test").fontSize.expect.toBe("24px"); + // Each becomes its own chunk. The order they are injected in is + // covered by the source-order case (#37845); only the set is asserted here. + const urls = await stylesheetUrls(dev, "/relink"); + const chunks = await Promise.all(urls.map(url => fetchCss(dev, url))); + expect(chunks.sort()).toEqual([otherCss, testCss].sort()); + } }, }); + 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 @@ -313,12 +817,22 @@ devTest("asset index stays valid after another css root is freed", { 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"); - } + expect(await servedCss(dev, "/first")).toMatchInlineSnapshot(` + "/* first.css */ + .first { + color: red; + } + " + `); await using c2 = await dev.client("/second"); await c2.style(".second").color.expect.toBe("#00f"); + expect(await servedCss(dev, "/second")).toMatchInlineSnapshot(` + "/* second.css */ + .second { + color: #00f; + } + " + `); // Failing `first.css` frees its asset slot via `unrefByPath`, which // swap-removes it and moves the data for `second.css` into its slot. @@ -341,6 +855,14 @@ devTest("asset index stays valid after another css root is freed", { { errors: null }, ); await c2.style(".second").color.expect.toBe("green"); + const greenSecond = await servedCss(dev, "/second"); + expect(greenSecond).toMatchInlineSnapshot(` + "/* second.css */ + .second { + color: green; + } + " + `); // Fix the first file and ensure both pages still work afterwards. await dev.write( @@ -350,12 +872,17 @@ devTest("asset index stays valid after another css root is freed", { `, ); await c2.style(".second").color.expect.toBe("green"); - { - await using c1 = await dev.client("/first"); - await c1.style(".first").color.expect.toBe("#ff0"); - } + expect(await servedCss(dev, "/second")).toBe(greenSecond); + expect(await servedCss(dev, "/first")).toMatchInlineSnapshot(` + "/* first.css */ + .first { + color: #ff0; + } + " + `); }, }); + devTest("css hot update carries the edited stylesheet when another root fails in the same rebuild", { files: { "bunfig.toml": ` @@ -414,281 +941,32 @@ devTest("css hot update carries the edited stylesheet when another root fails in await c2.style(".second").color.expect.toBe("green"); await c1.style(".second").notFound(); } + const greenSecond = await servedCss(dev, "/second"); + expect(greenSecond).toMatchInlineSnapshot(` + "/* second.css */ + .second { + color: green; + } + " + `); + await expectBuildFailed(dev, "/first"); + // Recovery happens with no client connected, see + // https://github.com/oven-sh/bun/issues/31908. 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: ` -
hello
-
hello
- `, - }), - "second.html": emptyHtmlFile({ - styles: ["second.css"], - body: ` -
hello
-
hello
- `, - }), - "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; + expect(await servedCss(dev, "/second")).toBe(greenSecond); + expect(await servedCss(dev, "/first")).toMatchInlineSnapshot(` + "/* first.css */ + .first { + color: #ff0; } - `, - }, - 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: ` -
HELLO
- `, - }), - }, - 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: ` -
HELLO
- `, - }), - }, - 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"); + " + `); }, }); From 3d6385d17e64ce09baf3eecc3baf4736f47c4f3d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:13:10 +0000 Subject: [PATCH 2/4] test(bake): keep the project-relative css case on its own single-route server Its HTML file has to live in a subdirectory, and the harness builds multi-route keys from path.relative output without normalizing separators, so on Windows a nested file registers as /html\index and the shared-server version of the case would 404 there. As its own server it takes the single-file catch-all route, as it did before. --- test/bake/dev/css.test.ts | 119 ++++++++++++++++++++------------------ 1 file changed, 62 insertions(+), 57 deletions(-) diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 203733917327..37576bb7ec83 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -5,9 +5,9 @@ // the browser), while the server state the cases assert on (served HTML, the // stylesheet chunks, build failures) is read over plain HTTP. Build errors are // reported to every connected client, so cases that produce errors run with no -// other client connected and clean up after themselves. Cases whose bug depends -// on the exact contents of the server (the asset table layout, a bunfig plugin) -// keep a server to themselves. +// other client connected and clean up after themselves. Cases that cannot share +// a server (the asset table layout, a bunfig plugin, a nested HTML file; see +// their comments) keep one to themselves. // // By default every write made while a client is connected ends with the // harness checking that client for an error overlay, which costs a second when @@ -628,13 +628,6 @@ devTest("stylesheets created after the server starts, changing html link tags",
HELLO
`, }), - // css import before create project relative - "html/index.html": emptyHtmlFile({ - styles: ["/style/styles.css"], - body: ` -
HELLO
- `, - }), // changing html file with link tag works "relink.html": emptyHtmlFile({ styles: ["relink.css"] }), "relink.css": ` @@ -645,8 +638,6 @@ devTest("stylesheets created after the server starts, changing html link tags", `, }, async test(dev) { - dev.mkdir("style"); // (See DevServer.zig "BUN-10968") - // css import before create { await using c = await dev.client("/before", { @@ -674,51 +665,6 @@ devTest("stylesheets created after the server starts, changing html link tags", await dev.fetch("/before").expect.toContain("HELLO"); } - // css import before create project relative - { - await using c = await dev.client("/html", { - errors: ['html/index.html: error: Could not resolve: "/style/styles.css"'], - }); - await expectBuildFailed(dev, "/html"); - 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"'], - }, - ); - // Unlike the HTML's "/style/styles.css" link, an absolute url() in CSS is - // not resolved against the project root, so creating that file is not a - // change the stylesheet depends on. - await c.expectNoWebSocketActivity(async () => { - await dev.write("assets/bun.png", imageFixtures.bun, { errors: null }); - await dev.delete("assets/bun.png", { errors: null }); - }); - await expectBuildFailed(dev, "/html"); - 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("/html").expect.toContain("HELLO"); - } - // changing html file with link tag works { await using c = await dev.client("/relink"); @@ -792,6 +738,65 @@ devTest("stylesheets created after the server starts, changing html link tags", }, }); +devTest("css import before create project relative", { + // The HTML file has to live in a subdirectory for the "/style/..." link to + // tell project-relative resolution apart from HTML-relative resolution, and + // the harness only registers nested HTML files correctly on Windows when they + // are the server's single (catch-all) route, so this case keeps its own server. + files: { + "html/index.html": emptyHtmlFile({ + styles: ["/style/styles.css"], + body: ` +
HELLO
+ `, + }), + }, + 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 expectBuildFailed(dev, "/"); + 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"'], + }, + ); + // Unlike the HTML's "/style/styles.css" link, an absolute url() in CSS is + // not resolved against the project root, so creating that file is not a + // change the stylesheet depends on. + await c.expectNoWebSocketActivity(async () => { + await dev.write("assets/bun.png", imageFixtures.bun, { errors: null }); + await dev.delete("assets/bun.png", { errors: null }); + }); + await expectBuildFailed(dev, "/"); + 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"); + }, +}); + 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 From 4f96628216016d0fde11d0904223b468bc747d1a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:29:56 +0000 Subject: [PATCH 3/4] test(bake): end the initial-error css case recovered, document when a failing route may be fetched The error group's header promised every case leaves the shared server without failures, but the initial-error case ended broken and only worked because it was last: the dev server sends no route reloads to anyone while any failure exists. Recover it once more and assert the stylesheet is served again. Fetching a route whose stylesheet is failing re-bundles it and pushes the still-compiling HTML module to clients as a JS hot update (issue 31908), so the hazard is the fetch, not the recovery; verified that a recovery with the page's client attached is fine, that fetching kills a client with that page loaded, and that clients on other routes or on the error page are unaffected. State that once on expectBuildFailed instead of three differing comments. Drop the redundant second fetch in the before-create case, whose ack would otherwise be counted by the recovery write that follows, and in the link-tag case assert the loaded page keeps its stylesheet while the HTML is broken, which also drains that ack. --- test/bake/dev/css.test.ts | 52 ++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 37576bb7ec83..7dc2e888f0ad 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -51,7 +51,18 @@ async function servedCss(dev: Dev, route: string): Promise { return fetchCss(dev, urls[0]); } -/** A route with a bundling error anywhere in its graph serves the error page instead of the HTML. */ +/** + * A route with a bundling error anywhere in its graph serves the error page + * instead of the HTML. + * + * Requesting such a route re-bundles it, and when the HTML file itself still + * compiles (the failure is in a stylesheet) the HTML module is pushed to + * connected clients as a plain JS hot update, which kills a client that has + * that page loaded (https://github.com/oven-sh/bun/issues/31908). Clients on + * other routes and clients showing the error page are unaffected, and recovery + * writes never push the HTML module. So: only call this for a route whose page + * no connected client has loaded, unless the HTML file is the failing file. + */ async function expectBuildFailed(dev: Dev, route: string) { const res = await dev.fetch(route); expect(res.status).toBe(500); @@ -494,11 +505,7 @@ devTest("bundling errors in stylesheets and recovering from them", { ); await c.style("body").color.expect.toBe("red"); } - // The failing route is fetched, and the recovery is checked, without a - // connected client: re-bundling the route while its CSS root is failing - // or recovering 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). + // The client that had this page loaded is gone, see expectBuildFailed. await expectBuildFailed(dev, "/resolve"); await dev.write( "resolve.css", @@ -533,9 +540,8 @@ devTest("bundling errors in stylesheets and recovering from them", { errors: ["keep.css:4:1: error: Unexpected end of input"], }, ); - // The route is not fetched while it is broken: that re-bundles the HTML - // file, and the recovery below would then reload the page instead of - // hot-swapping the stylesheet (https://github.com/oven-sh/bun/issues/31908). + // Not fetched while broken: this client has the page loaded and has to + // survive until the stylesheet is hot-swapped back in (see expectBuildFailed). await c.style("body").color.expect.toBe("red"); await dev.write( @@ -568,6 +574,7 @@ devTest("bundling errors in stylesheets and recovering from them", { // css file with initial syntax error gets recovered { + let blue: string; { await using c = await dev.client("/initial", { errors: ["initial.css:3:3: error: Unexpected end of input"], @@ -594,7 +601,8 @@ devTest("bundling errors in stylesheets and recovering from them", { { errors: null }, ); await c.style("body").color.expect.toBe("#00f"); - expect(await servedCss(dev, "/initial")).toMatchInlineSnapshot(` + blue = await servedCss(dev, "/initial"); + expect(blue).toMatchInlineSnapshot(` "/* initial.css */ body { color: #00f; @@ -613,8 +621,19 @@ devTest("bundling errors in stylesheets and recovering from them", { }, ); } - // Fetched after the client is gone for the same reason as above. + // The client that had this page loaded is gone, see expectBuildFailed. await expectBuildFailed(dev, "/initial"); + // Recovering a second time serves the stylesheet again (and leaves the + // shared server without failures, like the other cases in this group). + await dev.write( + "initial.css", + ` + body { + color: blue; + } + `, + ); + expect(await servedCss(dev, "/initial")).toBe(blue); } }, }); @@ -655,7 +674,6 @@ devTest("stylesheets created after the server starts, changing html link tags", errors: ['before.css:2:21: error: Could not resolve: "before.png". Maybe you need to "bun install"?'], }, ); - await expectBuildFailed(dev, "/before"); await c.expectReload(async () => { await dev.write("before.png", imageFixtures.bun); }); @@ -691,7 +709,12 @@ devTest("stylesheets created after the server starts, changing html link tags", await dev.write("relink.html", emptyHtmlFile({ styles: ["relink-other.css"] }), { errors: ['relink.html: error: Could not resolve: "relink-other.css". Maybe you need to "bun install"?'], }); + // The HTML file itself is what fails here, so fetching is safe with the + // page loaded, and the page keeps its old stylesheet meanwhile. (Checking + // that also drains the ack the client sent for the rebuild the fetch + // triggered, before the next write waits for acks of its own.) await expectBuildFailed(dev, "/relink"); + await c.style(".test").color.expect.toBe("#00f"); await c.expectReload(async () => { await dev.write( "relink-other.css", @@ -954,10 +977,9 @@ devTest("css hot update carries the edited stylesheet when another root fails in } " `); + // Both clients are gone by now, see expectBuildFailed. A fresh load of + // either page after the fix is checked over HTTP below. await expectBuildFailed(dev, "/first"); - - // Recovery happens with no client connected, see - // https://github.com/oven-sh/bun/issues/31908. await dev.write( "first.css", ` From 2613c6b7d1e5b663f7c96fc13a9d62ef745ae877 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:34:34 +0000 Subject: [PATCH 4/4] ci: retrigger