Files
ApiCore/ApiCore-Frontend/src/app/manifest.spec.ts
T
Nick.RosenloecherandClaude Opus 5.5 2395576c88 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]>
2026-10-16 17:51:41 +02:00

176 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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);
}
});
});