feat(t5): App-Icons, Manifest und Offline-Cache v5 (T5a A9)

- Honigzelle auf Tinte als Master-SVG; scripts/generate-icons.mjs erzeugt
  any/maskable 192/512, Monochrom 512, drei Shortcut-Icons, Apple-Icon und
  favicon.ico (16/32/48, ICO-Container von Hand) byte-gleich bei jedem Lauf.
- manifest.ts exakt nach Spec 12.3, Farben gleich dem hellen --background.
- Service Worker: SHELL_CACHE apicore-shell-v5, Icons im Shell-Cache; ein
  fehlendes Icon hält das Vorladen der Seiten nicht auf.
- Proxy-Matcher: nur echte Icon-Dateien (icons/*.png) ohne
  Sicherheits-Header, Punkte maskiert.
- BrandMark-Deckel mittig (linke Kante 18.8).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 17:51:41 +02:00
1 parent fee84c7a6b
commit 2395576c88
23 files changed
+632 -67

No files matched your search

+69 -5
View File
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { beforeEach, describe, expect, it, vi } from "vitest";
import manifest from "@/app/manifest";
// public/sw.js itself, run in a sandbox with an in-memory Cache Storage and a
// fake network - the worker's wiring, beyond the pure rules in sw-routes.js.
@@ -10,6 +11,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const APP = "https://apicore.example";
const API = "https://apicorebackend.example";
const PUBLIC_DIR = path.resolve(process.cwd(), "public");
// Spec 12.3: raised so installed apps fetch the new icons and tokens.
const SHELL = "apicore-shell-v5";
interface MatchOptions {
ignoreSearch?: boolean;
@@ -154,7 +157,7 @@ function page(asset: string, marker: string): string {
async function workerWithBackend(network: ReturnType<typeof vi.fn>): Promise<Worker> {
const caches = new FakeCacheStorage();
const shell = await caches.open("apicore-shell-v4");
const shell = await caches.open(SHELL);
await shell.put("/api/runtime-config", json({ apiUrl: `${API}/api/v1` }));
const worker = loadWorker(caches, network);
// Let the worker read the backend origin from the cached config.
@@ -289,7 +292,7 @@ describe("service worker", () => {
describe("background refresh of a cached offline page", () => {
async function workerWithOldOfflinePage(): Promise<Worker> {
const caches = new FakeCacheStorage();
const shell = await caches.open("apicore-shell-v4");
const shell = await caches.open(SHELL);
await shell.put("/offline/colony", html(page("/_next/static/chunks/old.js", "alt")));
await shell.put("/_next/static/chunks/old.js", new Response("old()"));
caches.log.length = 0;
@@ -297,7 +300,7 @@ describe("service worker", () => {
}
async function cachedOfflinePage(worker: Worker): Promise<string | undefined> {
const shell = await worker.caches.open("apicore-shell-v4");
const shell = await worker.caches.open(SHELL);
return (await shell.match("/offline/colony"))?.text();
}
@@ -317,8 +320,8 @@ describe("service worker", () => {
// ... and the new one is kept with its assets, the assets first.
expect(await cachedOfflinePage(worker)).toContain("neu");
expect(worker.caches.log).toEqual([
"apicore-shell-v4 /_next/static/chunks/new.js",
"apicore-shell-v4 /offline/colony",
`${SHELL} /_next/static/chunks/new.js`,
`${SHELL} /offline/colony`,
]);
});
@@ -335,4 +338,65 @@ describe("service worker", () => {
expect(await cachedOfflinePage(worker)).toContain("alt");
});
});
describe("app icons in the shell (spec 12.3)", () => {
function answerEverything() {
network.mockImplementation(async (request: RequestInfo) => {
const { pathname } = new URL(keyOf(request));
if (pathname === "/api/runtime-config") return json({ apiUrl: `${API}/api/v1` });
if (pathname.startsWith("/_next/static/")) return new Response("asset()");
if (/\.(png|ico|svg)$/.test(pathname)) {
return new Response("image", { headers: { "Content-Type": "image/png" } });
}
return html(page("/_next/static/chunks/app.js", pathname));
});
}
it("the service worker cache version is apicore-shell-v5", async () => {
const caches = new FakeCacheStorage();
await (await caches.open("apicore-shell-v4")).put("/dashboard", html("alt"));
answerEverything();
const worker = loadWorker(caches, network);
await dispatch(worker, "install", {});
await dispatch(worker, "activate", {});
expect(await caches.keys()).toContain(SHELL);
expect(await caches.keys()).not.toContain("apicore-shell-v4");
});
it("install keeps every manifest icon in the shell cache", async () => {
answerEverything();
const worker = loadWorker(new FakeCacheStorage(), network);
await dispatch(worker, "install", {});
const data = manifest();
const icons = [
...(data.icons ?? []),
...(data.shortcuts ?? []).flatMap((shortcut) => shortcut.icons ?? []),
].map((icon) => icon.src);
const shell = await worker.caches.open(SHELL);
for (const src of icons) {
expect(await shell.match(src), src).toBeDefined();
}
});
it("a missing icon does not stop the warm-up of the pages", async () => {
answerEverything();
const pages = network.getMockImplementation() as (request: RequestInfo) => Promise<Response>;
network.mockImplementation(async (request: RequestInfo) =>
new URL(keyOf(request)).pathname.startsWith("/icons/")
? new Response("gone", { status: 404 })
: pages(request),
);
const worker = loadWorker(new FakeCacheStorage(), network);
await dispatch(worker, "install", {});
const shell = await worker.caches.open(SHELL);
expect(await shell.match("/dashboard")).toBeDefined();
expect(await shell.match("/icons/icon-192.png")).toBeUndefined();
});
});
});