- 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]>
176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
// @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);
|
||
}
|
||
});
|
||
});
|