import { describe, expect, it, vi } from "vitest"; import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { CACHEABLE_ASSET_CONTENT_TYPES, decodeStaticAssetManifest, isCanonicalStaticAssetUrl, } from "../../src/contracts/service-worker-static-manifest.ts"; /** * SW-RR-03. The build generator and the shared runtime decoder must agree on * exactly which asset kinds exist. A generator that emits `.mjs` or `.png` while * the decoder refuses them turns a correct build into a runtime contract * failure, and the reverse admits a kind no build produces. */ describe("SW-RR-03 one authoritative cacheable asset table", () => { it("covers every extension the generator emits", async () => { const generator = await import( "../../scripts/generate-service-worker-assets.ts" ); expect(generator).toBeDefined(); for (const extension of [ ".js", ".mjs", ".css", ".woff2", ".svg", ".png", ".webp", ]) { expect(CACHEABLE_ASSET_CONTENT_TYPES[extension]).toBeDefined(); } }); it("decodes a manifest row for every table entry", () => { const assets = Object.entries(CACHEABLE_ASSET_CONTENT_TYPES).map( ([extension, contentType], index) => ({ url: `/assets/name-abcdefgh${index}${extension}`, sha256: `sha256:${"a".repeat(64)}`, bytes: 16, contentType, }), ); const decoded = decodeStaticAssetManifest({ schemaVersion: 1, buildId: "build-1", releaseId: "release-1", assets, setDigest: `sha256:${"b".repeat(64)}`, }); expect(decoded).toMatchObject({ ok: true }); }); it("refuses a row whose extension is not in the table", () => { const decoded = decodeStaticAssetManifest({ schemaVersion: 1, buildId: "build-1", releaseId: "release-1", setDigest: `sha256:${"b".repeat(64)}`, assets: [ { url: "/assets/control-abcdefgh.json", sha256: `sha256:${"a".repeat(64)}`, bytes: 16, contentType: "application/json", }, ], }); expect(decoded.ok).toBe(false); }); }); /** * SW-02. Sharing only the extension table left the generator and the decoder * with different path grammars: the generator emitted a URL for a directory * containing an `@` or a space, and the decoder then refused the manifest it * had just produced, failing the release build at install time. */ describe("SW-02 the generator and the decoder share one path grammar", () => { async function distWith( files: Readonly>, ): Promise { const root = await mkdtemp(path.join(tmpdir(), "sw-assets-")); for (const [relative, content] of Object.entries(files)) { const absolute = path.join(root, relative); await mkdir(path.dirname(absolute), { recursive: true }); await writeFile(absolute, content); } return root; } it("emits a manifest the runtime decoder accepts for every table entry", async () => { const { collectStaticAssets } = await import( "../../scripts/generate-service-worker-assets.ts" ); const files: Record = {}; for (const [index, extension] of Object.keys( CACHEABLE_ASSET_CONTENT_TYPES, ).entries()) { files[`assets/name-abcdefg${index}${extension}`] = `content-${index}`; } files["assets/nested/deep/name-abcdefgz.js"] = "nested"; const root = await distWith(files); const manifest = await collectStaticAssets(root, "build-1", "release-1"); expect(manifest.assets.length).toBe( Object.keys(CACHEABLE_ASSET_CONTENT_TYPES).length + 1, ); expect(decodeStaticAssetManifest(manifest)).toMatchObject({ ok: true }); for (const asset of manifest.assets) { expect(isCanonicalStaticAssetUrl(asset.url)).toBe(true); } }); it.each([ { label: "an at sign", name: "bad@name-abcdefgh.js" }, { label: "a space", name: "bad name-abcdefgh.js" }, { label: "a percent escape", name: "bad%20name-abcdefgh.js" }, { label: "a hash", name: "bad#name-abcdefgh.js" }, ])( "stops the build rather than emitting a path the decoder refuses ($label)", async ({ name }) => { const { collectStaticAssets } = await import( "../../scripts/generate-service-worker-assets.ts" ); const root = await distWith({ "assets/good-abcdefgh.js": "ok", [`assets/${name}`]: "bad", }); await expect( collectStaticAssets(root, "build-1", "release-1"), ).rejects.toThrow(/not canonical/u); }, ); it("agrees with the decoder on the whole path policy table", () => { const cases: readonly (readonly [string, boolean])[] = [ ["/assets/name-abcdefgh.js", true], ["/assets/nested/name-abcdefgh.js", true], ["/assets/bad@name-abcdefgh.js", false], ["/assets/bad name-abcdefgh.js", false], ["/assets/bad%20name-abcdefgh.js", false], ["/assets/bad#name-abcdefgh.js", false], ["/assets/../name-abcdefgh.js", false], ["/assets/./name-abcdefgh.js", false], ["assets/name-abcdefgh.js", false], ["/assets/\\name-abcdefgh.js", false], ["/assets/naïve-abcdefgh.js", false], ]; for (const [url, canonical] of cases) { expect(isCanonicalStaticAssetUrl(url), url).toBe(canonical); const decoded = decodeStaticAssetManifest({ schemaVersion: 1, buildId: "build-1", releaseId: "release-1", setDigest: `sha256:${"a".repeat(64)}`, assets: [ { url, sha256: `sha256:${"a".repeat(64)}`, bytes: 4, contentType: "text/javascript", }, ], }); // The decoder still owns the digest check, so a canonical path may fail // for other reasons; a non-canonical one must always fail. if (!canonical) expect(decoded.ok, url).toBe(false); } }); });