Files
tech-log-frontend/tests/unit/service-worker-web-push-remediation.test.ts
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
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>
2026-08-15 12:04:58 +09:00

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);
}
});
});