The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
5.9 KiB
TypeScript
177 lines
5.9 KiB
TypeScript
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<Record<string, string>>,
|
|
): Promise<string> {
|
|
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<string, string> = {};
|
|
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);
|
|
}
|
|
});
|
|
});
|