import { expect } from "@std/expect"; import { join } from "@std/path"; import { describe, it as test } from "@std/testing/bdd"; import { withGeneratedOutputRollback } from "#scripts/static-assets/output-rollback.ts"; import { type BuiltStaticBundle, buildOrReuseStaticAssets, createStaticAssetBuild, deferStaticAssetBuild, fileExists, type StaticAssetBuild, type StaticAssetFiles, type StaticBundle, } from "#scripts/static-assets/session.ts"; interface FakeContext { disposeCalls: number; rebuildCalls: number; value: BuiltStaticBundle["context"]; } const fakeContext = (rebuildFails = false): FakeContext => { const fake: FakeContext = { disposeCalls: 0, rebuildCalls: 0, value: { dispose: () => { fake.disposeCalls += 1; return Promise.resolve(); }, rebuild: () => { fake.rebuildCalls += 1; return rebuildFails ? Promise.reject(new Error("Build failed")) : Promise.resolve(); }, }, }; return fake; }; const bundle = (label: string, outfile: string): StaticBundle => ({ label, options: { outfile }, }); interface Fixture { admin: StaticBundle; adminContext: FakeContext; scanner: StaticBundle; scannerContext: FakeContext; session: ReturnType; stopCalls: { count: number }; writes: Array<{ contents: Uint8Array; file: string }>; } const fixture = ( scannerFails = false, write?: StaticAssetFiles["write"], ): Fixture => { const admin = bundle("Admin", "static/admin.js"); const scanner = bundle("Scanner", "static/scanner.js"); const adminContext = fakeContext(); const scannerContext = fakeContext(scannerFails); const stopCalls = { count: 0 }; const writes: Array<{ contents: Uint8Array; file: string }> = []; const built: BuiltStaticBundle[] = [ { baseline: new Uint8Array([1, 2]), bundle: admin, context: adminContext.value, inputs: ["client/admin.ts", "client/shared.ts"], }, { baseline: new Uint8Array([3, 4]), bundle: scanner, context: scannerContext.value, inputs: ["client/scanner.ts", "client/shared.ts"], }, ]; return { admin, adminContext, scanner, scannerContext, session: createStaticAssetBuild(built, { resolve: (file) => `/project/${file}`, stop: () => { stopCalls.count += 1; }, write: (file, contents) => { writes.push({ contents, file: `/project/${file}` }); return write?.(file, contents) ?? Promise.resolve(); }, }), stopCalls, writes, }; }; describe("static asset build session", () => { test("finds every bundle affected by a source file", async () => { const { admin, scanner, session } = fixture(); expect(await session.affected("client/shared.ts")).toEqual([ admin, scanner, ]); expect(await session.affected("client/admin.ts")).toEqual([admin]); expect(await session.affected("client/other.ts")).toEqual([]); }); test("rebuilds only the selected bundles", async () => { const { adminContext, scanner, scannerContext, session } = fixture(); expect(await session.rebuild([scanner])).toBe(true); expect(adminContext.rebuildCalls).toBe(0); expect(scannerContext.rebuildCalls).toBe(1); }); test("reports an incremental rebuild failure", async () => { const { scanner, scannerContext, session } = fixture(true); expect(await session.rebuild([scanner])).toBe(false); expect(scannerContext.rebuildCalls).toBe(1); }); test("waits for every selected rebuild after one fails", async () => { const { admin, adminContext, scanner, scannerContext, session } = fixture(); const scannerBuild = Promise.withResolvers(); adminContext.value.rebuild = () => { adminContext.rebuildCalls += 1; return Promise.reject(new Error("Admin build failed")); }; scannerContext.value.rebuild = () => { scannerContext.rebuildCalls += 1; return scannerBuild.promise; }; let settled = false; const result = (async () => { const value = await session.rebuild([admin, scanner]); settled = true; return value; })(); await Promise.resolve(); expect(settled).toBe(false); scannerBuild.resolve(); expect(await result).toBe(false); expect(adminContext.rebuildCalls).toBe(1); expect(scannerContext.rebuildCalls).toBe(1); }); test("rejects a bundle that was not built in the session", () => { const { session } = fixture(); expect(() => session.restore([bundle("Other", "static/other.js")])).toThrow( "Static bundle was not built: Other", ); }); test("restores the exact baseline files for selected bundles", async () => { const { admin, session, writes } = fixture(); await session.restore([admin]); expect(writes).toEqual([ { contents: new Uint8Array([1, 2]), file: "/project/static/admin.js", }, ]); }); test("waits for every selected restore write after one fails", async () => { const scannerWrite = Promise.withResolvers(); const { admin, scanner, session, writes } = fixture(false, (file) => file === "static/admin.js" ? Promise.reject(new Error("Admin write failed")) : scannerWrite.promise, ); let settled = false; const result = (async () => { try { await session.restore([admin, scanner]); } finally { settled = true; } })(); await Promise.resolve(); expect(settled).toBe(false); scannerWrite.resolve(); await expect(result).rejects.toThrow("Admin write failed"); expect(writes.map(({ file }) => file)).toEqual([ "/project/static/admin.js", "/project/static/scanner.js", ]); }); test("disposes every bundle context", async () => { const { adminContext, scannerContext, session, stopCalls } = fixture(); await session.dispose(); expect(adminContext.disposeCalls).toBe(1); expect(scannerContext.disposeCalls).toBe(1); expect(stopCalls.count).toBe(1); }); test("waits for every bundle disposal when one fails", async () => { const { adminContext, scannerContext, session, stopCalls } = fixture(); adminContext.value.dispose = () => { adminContext.disposeCalls += 1; return Promise.reject(new Error("Admin dispose failed")); }; await expect(session.dispose()).rejects.toThrow("Admin dispose failed"); expect(adminContext.disposeCalls).toBe(1); expect(scannerContext.disposeCalls).toBe(1); expect(stopCalls.count).toBe(1); }); }); describe("static asset output rollback", () => { test("removes partial new output when a build fails", async () => { const dir = await Deno.makeTempDir(); const existing = join(dir, "existing.js"); const generated = join(dir, "generated.js"); await Deno.writeTextFile(existing, "keep"); try { await expect( withGeneratedOutputRollback( [existing, generated], async () => { await Deno.writeTextFile(existing, "partial"); await Deno.writeTextFile(generated, "partial"); throw new Error("Deliberate build failure"); }, () => [], ), ).rejects.toThrow("Deliberate build failure"); expect(await Deno.readTextFile(existing)).toBe("keep"); expect(await fileExists(generated)).toBe(false); } finally { await Deno.remove(dir, { recursive: true }); } }); test("keeps generated output after a successful build", async () => { const dir = await Deno.makeTempDir(); const generated = join(dir, "generated.js"); try { expect( await withGeneratedOutputRollback( [generated], async () => { await Deno.writeTextFile(generated, "complete"); return "built"; }, () => [], ), ).toBe("built"); expect(await Deno.readTextFile(generated)).toBe("complete"); } finally { await Deno.remove(dir, { recursive: true }); } }); }); describe("buildOrReuseStaticAssets", () => { const session = (): StaticAssetBuild => ({ affected: () => Promise.resolve([]), dispose: () => Promise.resolve(), rebuild: () => Promise.resolve(true), restore: () => Promise.resolve(), }); test("builds straight away when the assets are out of date", async () => { const built = { count: 0 }; const ready = session(); const result = await buildOrReuseStaticAssets(false, () => { built.count += 1; return Promise.resolve(ready); }); expect(built.count).toBe(1); expect(result).toBe(ready); }); test("waits, and builds nothing, when the assets are current", async () => { const built = { count: 0 }; const result = await buildOrReuseStaticAssets(true, () => { built.count += 1; return Promise.resolve(session()); }); expect(built.count).toBe(0); // Still a usable build — it just holds off until something asks. await result.dispose(); expect(built.count).toBe(0); expect(await result.rebuild([])).toBe(true); expect(built.count).toBe(1); }); }); describe("deferStaticAssetBuild", () => { const bundle: StaticBundle = { label: "Admin", options: { outfile: "static/admin.js" }, }; const fakeBuild = () => { const calls = { affected: 0, build: 0, dispose: 0, rebuild: 0 }; const build: StaticAssetBuild = { affected: () => { calls.affected += 1; return Promise.resolve([bundle]); }, dispose: () => { calls.dispose += 1; return Promise.resolve(); }, rebuild: () => { calls.rebuild += 1; return Promise.resolve(true); }, restore: () => Promise.resolve(), }; return { calls, deferred: deferStaticAssetBuild(() => { calls.build += 1; return Promise.resolve(build); }), }; }; test("does not build when nobody asks for a rebuild", async () => { const { calls, deferred } = fakeBuild(); await deferred.dispose(); expect(calls.build).toBe(0); expect(calls.dispose).toBe(0); }); test("builds once, on the first question about the bundles", async () => { const { calls, deferred } = fakeBuild(); expect(await deferred.affected("src/ui/client/admin.ts")).toEqual([bundle]); expect(await deferred.rebuild([bundle])).toBe(true); expect(calls.build).toBe(1); expect(calls.affected).toBe(1); expect(calls.rebuild).toBe(1); }); test("passes a restore through to the real build", async () => { const { calls, deferred } = fakeBuild(); await deferred.restore([bundle]); expect(calls.build).toBe(1); }); test("disposes the real build once it has happened", async () => { const { calls, deferred } = fakeBuild(); await deferred.rebuild([bundle]); await deferred.dispose(); expect(calls.dispose).toBe(1); }); });