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:
1 parent
fee84c7a6b
commit
2395576c88
23 files changed
+632
-67
No files matched your search
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hexOf, oklchOf, tokenSet } from "@/styles/css-tokens";
|
||||
import { config as proxyConfig } from "@/proxy";
|
||||
import manifest from "./manifest";
|
||||
|
||||
// Spec 12.2/12.3 and 13.1: the web manifest, its files and what serves them.
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const PUBLIC_DIR = path.join(ROOT, "public");
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
interface Icon {
|
||||
src: string;
|
||||
sizes?: string;
|
||||
type?: string;
|
||||
purpose?: string;
|
||||
}
|
||||
|
||||
/** Width and height from the IHDR chunk of a PNG. */
|
||||
function pngSize(png: Buffer): { width: number; height: number } {
|
||||
expect(png.subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true);
|
||||
expect(png.toString("latin1", 12, 16)).toBe("IHDR");
|
||||
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
function publicFile(src: string): string {
|
||||
return path.join(PUBLIC_DIR, ...src.split("/").filter(Boolean));
|
||||
}
|
||||
|
||||
const data = manifest();
|
||||
const icons: Icon[] = data.icons ?? [];
|
||||
const shortcutIcons: Icon[] = (data.shortcuts ?? []).flatMap((shortcut) => shortcut.icons ?? []);
|
||||
|
||||
describe("web manifest (spec 12.3)", () => {
|
||||
it("every manifest icon exists under public with the declared size", () => {
|
||||
const all = [...icons, ...shortcutIcons];
|
||||
expect(all.length).toBeGreaterThan(0);
|
||||
for (const icon of all) {
|
||||
const file = publicFile(icon.src);
|
||||
expect(existsSync(file), icon.src).toBe(true);
|
||||
if (icon.sizes === "any") {
|
||||
expect(icon.type).toBe("image/svg+xml");
|
||||
expect(readFileSync(file, "utf8")).toContain("<svg");
|
||||
continue;
|
||||
}
|
||||
expect(icon.type ?? "image/png").toBe("image/png");
|
||||
const [width, height] = (icon.sizes ?? "").split("x").map(Number);
|
||||
expect(pngSize(readFileSync(file)), icon.src).toEqual({ width, height });
|
||||
}
|
||||
});
|
||||
|
||||
it("declares any and maskable icons at 192 and 512 and one monochrome icon", () => {
|
||||
const sizesOf = (purpose: string) =>
|
||||
icons.filter((icon) => (icon.purpose ?? "any") === purpose).map((icon) => icon.sizes);
|
||||
|
||||
expect(sizesOf("any")).toEqual(expect.arrayContaining(["192x192", "512x512", "any"]));
|
||||
expect(sizesOf("maskable")).toEqual(["192x192", "512x512"]);
|
||||
expect(sizesOf("monochrome")).toEqual(["512x512"]);
|
||||
});
|
||||
|
||||
it("background_color and theme_color equal the light --background", () => {
|
||||
const css = readFileSync(path.join(ROOT, "src/app/globals.css"), "utf8");
|
||||
const lightBackground = hexOf(oklchOf(tokenSet(css, "light").background));
|
||||
|
||||
expect(lightBackground).toBe("#f9f4ea");
|
||||
expect(data.background_color).toBe(lightBackground);
|
||||
expect(data.theme_color).toBe(lightBackground);
|
||||
});
|
||||
|
||||
it("is installable from the root with German metadata", () => {
|
||||
expect(data).toMatchObject({
|
||||
id: "/",
|
||||
name: "ApiCore – Imkereiverwaltung",
|
||||
short_name: "ApiCore",
|
||||
lang: "de",
|
||||
dir: "ltr",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
categories: ["productivity", "business"],
|
||||
});
|
||||
});
|
||||
|
||||
it("offers exactly the three spec shortcuts with their 96 px icons", () => {
|
||||
expect(
|
||||
(data.shortcuts ?? []).map((shortcut) => ({
|
||||
name: shortcut.name,
|
||||
url: shortcut.url,
|
||||
icons: shortcut.icons?.map((icon) => `${icon.src} ${icon.sizes}`),
|
||||
})),
|
||||
).toEqual([
|
||||
{ name: "Durchsicht erfassen", url: "/inspections/new", icons: ["/icons/shortcut-inspection-96.png 96x96"] },
|
||||
{ name: "Varroa messen", url: "/varroa/new", icons: ["/icons/shortcut-varroa-96.png 96x96"] },
|
||||
{ name: "Heute", url: "/dashboard", icons: ["/icons/shortcut-today-96.png 96x96"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("every shortcut url is in SHELL_ROUTES", () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { SHELL_ROUTES } = require(path.join(PUBLIC_DIR, "sw-routes.js")) as {
|
||||
SHELL_ROUTES: readonly string[];
|
||||
};
|
||||
for (const shortcut of data.shortcuts ?? []) {
|
||||
expect(SHELL_ROUTES, shortcut.url).toContain(shortcut.url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("app icons (spec 12.2)", () => {
|
||||
it("favicon.ico holds 16, 32 and 48 px entries", () => {
|
||||
const ico = readFileSync(path.join(ROOT, "src/app/favicon.ico"));
|
||||
expect(ico.readUInt16LE(0)).toBe(0);
|
||||
expect(ico.readUInt16LE(2)).toBe(1);
|
||||
const count = ico.readUInt16LE(4);
|
||||
const entries = Array.from({ length: count }, (_, index) => {
|
||||
const at = 6 + index * 16;
|
||||
const width = ico.readUInt8(at);
|
||||
const length = ico.readUInt32LE(at + 8);
|
||||
const offset = ico.readUInt32LE(at + 12);
|
||||
const image = ico.subarray(offset, offset + length);
|
||||
expect(image.length).toBe(length);
|
||||
expect(pngSize(image)).toEqual({ width, height: ico.readUInt8(at + 1) });
|
||||
return width;
|
||||
});
|
||||
|
||||
expect(entries).toEqual([16, 32, 48]);
|
||||
});
|
||||
|
||||
it("apple-icon.png is a 180 px PNG and replaces the generated apple-icon route", () => {
|
||||
expect(pngSize(readFileSync(path.join(ROOT, "src/app/apple-icon.png")))).toEqual({ width: 180, height: 180 });
|
||||
expect(existsSync(path.join(ROOT, "src/app/apple-icon.tsx"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy matcher", () => {
|
||||
const [matcher] = proxyConfig.matcher;
|
||||
const runsProxy = (pathname: string) => new RegExp(`^${matcher}$`).test(pathname);
|
||||
|
||||
it("the proxy matcher excludes icons and apple-icon.png", () => {
|
||||
for (const pathname of [
|
||||
"/icons/icon-192.png",
|
||||
"/icons/maskable-512.png",
|
||||
"/icons/shortcut-today-96.png",
|
||||
"/apple-icon.png",
|
||||
"/icon.svg",
|
||||
"/favicon.ico",
|
||||
"/sw.js",
|
||||
"/manifest.webmanifest",
|
||||
]) {
|
||||
expect(runsProxy(pathname), pathname).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the security headers on pages that only resemble an excluded file", () => {
|
||||
for (const pathname of [
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/inspections/new",
|
||||
"/icons",
|
||||
"/icons/x/y",
|
||||
"/icons/missing",
|
||||
"/iconsx/page",
|
||||
"/apple-iconxpng",
|
||||
"/iconxsvg",
|
||||
"/faviconxico",
|
||||
"/swxjs",
|
||||
]) {
|
||||
expect(runsProxy(pathname), pathname).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user