diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts
index 18d665708976..7dc2e888f0ad 100644
--- a/test/bake/dev/css.test.ts
+++ b/test/bake/dev/css.test.ts
@@ -1,293 +1,825 @@
// 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 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
+// 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.
+ *
+ * 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);
+ 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": `
+ "script.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();
+ // 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;
+ }
+
+ /* shared-first.css */
+ .first {
+ color: red;
+ }
+ "
+ `);
+ expect(await servedCss(dev, "/shared-second")).toMatchInlineSnapshot(`
+ "/* shared.css */
+ .shared {
+ color: green;
+ }
+
+ /* 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("css import another css file", {
+
+devTest("bundling errors in stylesheets and recovering from them", {
files: {
- "index.html": emptyHtmlFile({
- styles: ["styles.css"],
- }),
- "styles.css": `
- @import "./second.css";
+ // syntax error crash
+ "crash.html": emptyHtmlFile({ styles: ["crash.css"], body: `hello world` }),
+ "crash.css": `
+ body {
+ background-image: url
+ }
+ `,
+ // css url resolve error on hot reload is recoverable
+ "resolve.html": emptyHtmlFile({ styles: ["resolve.css"], body: `hello world` }),
+ "resolve.css": `
body {
color: red;
}
`,
- "second.css": `
- h1 {
- color: blue;
+ // css file with syntax error does not kill old styles
+ "keep.html": emptyHtmlFile({ styles: ["keep.css"], body: `hello world` }),
+ "keep.css": `
+ body {
+ color: red;
}
`,
+ // 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) {
- 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");
+ // 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;
+ }
+ "
+ `);
+ }
- // Hot reload
- await dev.write(
- "second.css",
- `
- h1 {
- color: green;
+ // css url resolve error on hot reload is recoverable
+ {
+ {
+ 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 client that had this page loaded is gone, see expectBuildFailed.
+ await expectBuildFailed(dev, "/resolve");
+ await dev.write(
+ "resolve.css",
+ `
+ body {
+ color: blue;
+ }
+ `,
+ );
+ expect(await servedCss(dev, "/resolve")).toMatchInlineSnapshot(`
+ "/* resolve.css */
+ body {
+ color: #00f;
}
- `,
- );
- 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");
- },
-});
-devTest("asset referenced in css", {
- files: {
- "index.html": emptyHtmlFile({
- styles: ["styles.css"],
- }),
- "styles.css": `
- body {
- background-image: url(./bun.png);
+ // 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");
+ await dev.write(
+ "keep.css",
+ `
+ body {
+ color: red;
+ background-color
+ }
+ `,
+ {
+ errors: ["keep.css:4:1: error: Unexpected end of input"],
+ },
+ );
+ // 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(
+ "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: red;
+ background-color: #00f;
+ }
+ "
+ `);
+
+ 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
+ {
+ let blue: string;
+ {
+ 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");
+ blue = await servedCss(dev, "/initial");
+ expect(blue).toMatchInlineSnapshot(`
+ "/* initial.css */
+ body {
+ color: #00f;
+ }
+ "
+ `);
+ await dev.write(
+ "initial.css",
+ `
+ body {
+ color: blue;
+ }}
+ `,
+ {
+ errors: ["initial.css:3:3: error: Unexpected end of input"],
+ },
+ );
}
- `,
- "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);
+ // 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);
+ }
},
});
-devTest("syntax error crash", {
+
+devTest("stylesheets created after the server starts, changing html link tags", {
files: {
- "styles.css": `
- body {
- background-image: url
- }
- `,
- "index.html": emptyHtmlFile({
- styles: ["styles.css"],
- body: `hello world`,
+ // css import before create
+ "before.html": emptyHtmlFile({
+ styles: ["before.css"],
+ body: `
+ HELLO
+ `,
}),
- },
- 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": `
- body {
- color: red;
+ // changing html file with link tag works
+ "relink.html": emptyHtmlFile({ styles: ["relink.css"] }),
+ "relink.css": `
+ .test {
+ color: blue;
+ font-size: 24px;
}
`,
- "index.html": emptyHtmlFile({
- styles: ["styles.css"],
- body: `hello world`,
- }),
},
async test(dev) {
+ // css import before create
{
- await using c = await dev.client("/");
- 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 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(
- "styles.css",
+ "before.css",
`
body {
- background-image: url(./missing.png);
+ background-image: url(before.png);
}
`,
{
- errors: ['styles.css:2:21: error: Could not resolve: "./missing.png"'],
+ errors: ['before.css:2:21: error: Could not resolve: "before.png". Maybe you need to "bun install"?'],
},
);
- expect((await dev.fetch("/")).status).toBe(500);
+ 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");
}
- // 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",
- `
- body {
- color: blue;
+
+ // 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"?'],
+ });
+ // 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",
+ `
+ .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;
}
- `,
- );
- expect((await dev.fetch("/")).status).toBe(200);
+ "
+ `);
+
+ 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("circular css imports handle hot reload", {
+
+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: {
- "index.html": emptyHtmlFile({
- styles: ["a.css"],
+ "html/index.html": emptyHtmlFile({
+ styles: ["/style/styles.css"],
body: `
- hello
- hello
+ HELLO
`,
}),
- "a.css": `
- @import "./b.css";
- .a { color: red; }
- `,
- "b.css": `
- @import "./a.css";
- .b { color: blue; }
- `,
},
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");
-
- // Modify one of the circular dependencies
+ 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(
- "a.css",
+ "style/styles.css",
`
- @import "./b.css";
- .a { color: green; }
+ body {
+ background-image: url(../assets/bun.png);
+ }
`,
+ {
+ errors: ['style/styles.css:2:21: error: Could not resolve: "../assets/bun.png"'],
+ },
);
- await client.style(".a").color.expect.toBe("green");
- await client.style(".b").color.expect.toBe("#00f");
+ 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
@@ -313,12 +845,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 +883,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 +900,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 +969,31 @@ 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;
+ }
+ "
+ `);
+ // 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");
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");
+ "
+ `);
},
});