import { assert, assertEquals, assertRejects, assertStringIncludes } from "@std/assert"; import { basename, dirname, join } from "node:path"; import { HOST_GLOBAL } from "@berry/api/host.ts"; import { extensionWeb } from "./web.ts"; import { tempDir } from "@berry/util"; async function extension(files: Record, run: (dir: string) => Promise) { await using temporary = await tempDir({ prefix: "berry-web-" }); const dir = temporary.path; for (const [name, text] of Object.entries(files)) { await Deno.mkdir(dirname(join(dir, name)), { recursive: true }); await Deno.writeTextFile(join(dir, name), text); } await run(dir); } const importBuilt = (js: string) => import(`data:text/javascript;base64,${btoa(unescape(encodeURIComponent(js)))}`); Deno.test("a frontend cache survives a new builder and invalidates imported inputs", () => extension({ "source/web.tsx": `import value from "../shared.ts"; import "./web.css"; import asset from "./large.wasm?url"; export default { value, asset };`, "shared.ts": `export default "first";`, "source/web.css": ".probe { color: red }", "source/large.wasm": "\0asm".padEnd(64 * 1024, "x"), "source/mod.ts": "export default 1;", "source/web.test.ts": "export default 1;", }, async (dir) => { await using cache = await tempDir(); const sources: string[] = []; const fresh = () => extensionWeb({ cacheDirectory: cache.path }); const build = (web = fresh()) => web.build("probe", entry, (source) => { sources.push(source); }); const entry = join(dir, "source/web.tsx"); await using api = await tempDir({ dir: join(Deno.cwd(), "api"), prefix: ".web-cache-test-" }); const unrelatedApi = join(api.path, "unrelated.ts"); await Deno.writeTextFile(unrelatedApi, "export default 1;"); const importedApi = join(api.path, "shared.ts"); await Deno.writeTextFile(importedApi, `export default "first";`); await Deno.writeTextFile( join(dir, "shared.ts"), `export { default } from "@berry/api/${basename(api.path)}/shared.ts";`, ); const web = fresh(); const first = await build(web); const restarted = fresh(); assertEquals(await build(restarted), first); assertEquals( await restarted.serve(first.module)!.text(), await web.serve(first.module)!.text(), ); assertEquals( await restarted.serve(first.styles[0])!.text(), await web.serve(first.styles[0])!.text(), ); const js = await restarted.serve(first.module)!.text(); const asset = js.match(/new URL\("([^"]+)", import\.meta\.url\)/)![1]; const path = new URL(asset, `http://berry${first.module}`).pathname; assertEquals(await restarted.serve(path)!.bytes(), await web.serve(path)!.bytes()); assertEquals(sources, ["build", "cache"]); await Deno.writeTextFile(join(dir, "source/mod.ts"), "export default 2;"); await Deno.writeTextFile(join(dir, "source/web.test.ts"), "export default 2;"); await Deno.writeTextFile(unrelatedApi, "export default 2;"); assertEquals(await build(), first); assertEquals(sources.at(-1), "cache"); await Deno.writeTextFile(importedApi, `export default "api changed";`); const apiChanged = fresh(); const apiBuilt = await build(apiChanged); assertStringIncludes(await apiChanged.serve(apiBuilt.module)!.text(), "api changed"); assertEquals(sources.at(-1), "build"); const shared = join(dir, "shared.ts"); const mtime = Deno.statSync(shared).mtime!; await Deno.writeTextFile(shared, `export default "other";`); await Deno.utime(shared, mtime, mtime); const changed = fresh(); const second = await build(changed); assert(second.module !== first.module); assertStringIncludes(await changed.serve(second.module)!.text(), "other"); assertEquals(sources.at(-1), "build"); await Deno.writeTextFile(join(dir, "source/web.css"), ".probe { color: blue }"); const third = await build(); assert(third.styles[0] !== second.styles[0]); await Deno.writeTextFile(join(dir, "source/large.wasm"), "\0asm".padEnd(64 * 1024, "y")); assert((await build()).module !== third.module); })); Deno.test("CSS imports and inlined URL assets invalidate frontend caches", () => extension({ "web.tsx": `import "./web.css"; export default {};`, "web.css": `@import "./colors.css"; .probe { background: url("./dot.svg") }`, "colors.css": ".probe { color: red }", "dot.svg": ``, }, async (dir) => { await using cache = await tempDir(); const sources: string[] = []; const build = () => extensionWeb({ cacheDirectory: cache.path }).build( "probe", join(dir, "web.tsx"), (source) => { sources.push(source); }, ); const first = await build(); assertEquals(await build(), first); assertEquals(sources, ["build", "cache"]); await Deno.writeTextFile(join(dir, "colors.css"), ".probe { color: blue }"); const second = await build(); assert(second.styles[0] !== first.styles[0]); assertEquals(sources.at(-1), "build"); await Deno.writeTextFile( join(dir, "dot.svg"), ``, ); assert((await build()).styles[0] !== second.styles[0]); assertEquals(sources.at(-1), "build"); })); Deno.test("a broken or unwritable frontend cache does not prevent a build", () => extension({ "web.tsx": `export default "usable";` }, async (dir) => { await using cache = await tempDir(); const entry = join(dir, "web.tsx"); const web = extensionWeb({ cacheDirectory: cache.path }); const first = await web.build("probe", entry); for await (const file of Deno.readDir(cache.path)) { const path = join(cache.path, file.name); const text = await Deno.readTextFile(path); await Deno.writeTextFile(path, text.replace("usable", "broken")); } const rebuilt = extensionWeb({ cacheDirectory: cache.path }); assertEquals(await rebuilt.build("probe", entry), first); assertEquals(await rebuilt.serve(first.module)!.text(), await web.serve(first.module)!.text()); const blocked = join(dir, "blocked"); await Deno.writeTextFile(blocked, "not a directory"); const uncached = extensionWeb({ cacheDirectory: blocked }); const built = await uncached.build("probe", entry); assertStringIncludes(await uncached.serve(built.module)!.text(), "usable"); })); Deno.test("frontends compile out of process, while cache hits start no compiler", () => extension({ "web.tsx": "export default 1;" }, async (dir) => { await using cache = await tempDir(); const Command = Deno.Command; let started = 0; Deno.Command = class extends Command { constructor(...args: ConstructorParameters) { super(...args); started++; } }; try { const web = extensionWeb({ cacheDirectory: cache.path }); const entry = join(dir, "web.tsx"); await web.build("probe", entry); assert(started > 0); const previous = started; await web.build("probe", entry); assertEquals(started, previous); } finally { Deno.Command = Command; } })); Deno.test("a built frontend uses the app's instances of the shared modules", () => extension({ "web.tsx": ` import { Signal } from "@char/aftercare"; import * as api from "@berry/api/web.ts"; import "./web.css"; export default { Signal, api, view: hi }; `, "web.css": ".probe { color: red }", }, async (dir) => { const web = extensionWeb(); const { module, styles } = await web.build("probe", join(dir, "web.tsx")); const js = await web.serve(module)!.text(); assert(!js.includes(`from "@char/aftercare"`), "no import of its own copy"); class Signal {} const api = { code() {} }; (globalThis as Record)[HOST_GLOBAL] = { "@char/aftercare": { Signal }, "@char/aftercare/jsx-runtime": { jsx: (tag: string) => `<${tag}>`, jsxs: (tag: string) => `<${tag}>`, }, "@berry/api/web.ts": api, }; try { const built = (await importBuilt(js)).default; assert(built.Signal === Signal); assert(built.api === api); assertEquals(built.view, ""); } finally { delete (globalThis as Record)[HOST_GLOBAL]; } assertEquals(styles.length, 1); const css = web.serve(styles[0])!; assertEquals(css.headers.get("content-type"), "text/css; charset=utf-8"); assertStringIncludes(await css.text(), ".probe"); })); Deno.test("a rebuild moves to a new url, and a failed one keeps the last", () => extension({ "web.tsx": `export default 1;` }, async (dir) => { const web = extensionWeb(); const first = await web.build("probe", join(dir, "web.tsx")); assertEquals(first.styles, []); assertEquals( web.serve(first.module)!.headers.get("cache-control"), "public, max-age=31536000, immutable", ); await Deno.writeTextFile(join(dir, "web.tsx"), `export default 2;`); const second = await web.build("probe", join(dir, "web.tsx")); assert(second.module !== first.module); assertEquals(web.serve(first.module), undefined); await Deno.writeTextFile(join(dir, "web.tsx"), `export default (;`); await assertRejects(() => web.build("probe", join(dir, "web.tsx")), Error, "web.tsx"); assertStringIncludes(await web.serve(second.module)!.text(), "2"); await Deno.writeTextFile(join(dir, "web.tsx"), `export default 3;`); const third = await web.build("probe", join(dir, "web.tsx")); assertStringIncludes(await web.serve(third.module)!.text(), "3"); })); Deno.test("a frontend's large files are served beside its module rather than inlined into it", () => extension({ "web.tsx": `export default new URL("./big.wasm", import.meta.url).href;`, "big.wasm": "\0asm".padEnd(64 * 1024, "x"), }, async (dir) => { await using cache = await tempDir(); const web = extensionWeb({ cacheDirectory: cache.path }); const { module } = await web.build("probe", join(dir, "web.tsx")); const js = await web.serve(module)!.text(); assert(js.length < 16 * 1024, "the module only names it"); const named = js.match(/new URL\("([^"]+)", import\.meta\.url\)/)![1]; const file = web.serve(new URL(named, `http://berry${module}`).pathname)!; assertEquals(file.headers.get("content-type"), "application/wasm"); assertEquals((await file.bytes()).length, 64 * 1024); await Deno.writeTextFile(join(dir, "big.wasm"), "\0asm".padEnd(64 * 1024, "y")); const changed = await extensionWeb({ cacheDirectory: cache.path }).build( "probe", join(dir, "web.tsx"), ); assert(changed.module !== module); })); Deno.test("a dynamic import is served beside the module and loaded only when imported", () => extension({ "web.tsx": `export default { load: () => import("./heavy.ts").then((m) => m.heavy) };`, "heavy.ts": `export const heavy = "heavy:" + "x".repeat(32 * 1024);`, }, async (dir) => { await using cache = await tempDir(); const entry = join(dir, "web.tsx"); const first = await extensionWeb({ cacheDirectory: cache.path }).build("probe", entry); const web = extensionWeb({ cacheDirectory: cache.path }); let source; const { module } = await web.build("probe", entry, (value) => { source = value; }); assertEquals(source, "cache"); assertEquals(module, first.module); assert((await web.serve(module)!.text()).length < 16 * 1024, "the module leaves it out"); // imported from files, as a page imports it from the server, so that relative imports resolve beside it await using copy = await tempDir(); const served = async (path: string) => { const file = join(copy.path, path); await Deno.mkdir(join(file, ".."), { recursive: true }); await Deno.writeTextFile(file, await web.serve(path)!.text()); for (const [, name] of (await Deno.readTextFile(file)).matchAll(/import\("\.\/([^"]+)"\)/g)) { await served(join(path, "..", name)); } }; await served(module); const built = (await import(`file://${join(copy.path, module)}`)).default; assert((await built.load()).startsWith("heavy:")); await Deno.writeTextFile(join(dir, "heavy.ts"), `export const heavy = "changed";`); const changed = await web.build("probe", entry, (value) => { source = value; }); assertEquals(source, "build"); assert(changed.module !== module); })); Deno.test("a change to a frontend's styles alone moves its stylesheet", () => extension({ "web.tsx": `import "./web.css"; export default 1;`, "web.css": ".probe { color: red }", }, async (dir) => { const web = extensionWeb(); const first = await web.build("probe", join(dir, "web.tsx")); await Deno.writeTextFile(join(dir, "web.css"), ".probe { color: blue }"); const second = await web.build("probe", join(dir, "web.tsx")); assert(second.styles[0] !== first.styles[0]); assertStringIncludes(await web.serve(second.styles[0])!.text(), "blue"); assertEquals(web.serve(first.styles[0]), undefined); }));