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