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: `
+
- `,
- }),
- },
- 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",
- `,
- }),
// 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