clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-demo-review-fixes
lofi / package / pwa_assets_test.ts
pwa_assets_test.ts 8.1K
1import { basename } from "node:path";23type ManifestIcon = { src: string; sizes: string; type: string; purpose: string };4type ManifestShortcut = {5 name: string;6 short_name?: string;7 description?: string;8 url: string;9 icons?: Array<Omit<ManifestIcon, "purpose">>;10};11type ManifestScreenshot = {12 src: string;13 sizes: string;14 type: string;15 form_factor: "narrow" | "wide";16 label: string;17};1819type Manifest = {20 id: string;21 lang: string;22 dir: string;23 start_url: string;24 scope: string;25 orientation: string;26 icons: ManifestIcon[];27 screenshots: ManifestScreenshot[];28 shortcuts: ManifestShortcut[];29 [member: string]: unknown;30};3132function assert(condition: unknown, message: string): asserts condition {33 if (!condition) throw new Error(message);34}3536function pngDimensions(bytes: Uint8Array): { width: number; height: number } {37 const signature = [137, 80, 78, 71, 13, 10, 26, 10];38 assert(signature.every((byte, index) => bytes[index] === byte), "asset is not a PNG");39 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);40 assert(new TextDecoder().decode(bytes.subarray(12, 16)) === "IHDR", "PNG has no IHDR");41 return { width: view.getUint32(16), height: view.getUint32(20) };42}4344Deno.test("starter manifest declares stable identity, locale, and orientation defaults", async () => {45 const publicRoot = new URL("../apps/reference/public/", import.meta.url);46 const manifest = JSON.parse(47 await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),48 ) as Manifest;49 assert(typeof manifest.id === "string" && manifest.id.length > 0, "manifest id is missing");50 assert(manifest.id !== manifest.start_url, "manifest id must not be derived from start_url");51 assert(manifest.lang === "en", "starter manifest language must be explicit");52 assert(manifest.dir === "ltr", "starter manifest direction must be explicit");53 assert(manifest.orientation === "any", "starter manifest must explicitly allow any orientation");5455 const manifestUrl = new URL("https://example.test/field-notes/manifest.webmanifest");56 const identity = new URL(manifest.id, manifestUrl);57 const startUrl = new URL(manifest.start_url, manifestUrl);58 assert(identity.origin === startUrl.origin, "manifest id must remain same-origin");5960 for (61 const member of [62 "capture_links",63 "display_override",64 "edge_side_panel",65 "file_handlers",66 "handle_links",67 "launch_handler",68 "protocol_handlers",69 "scope_extensions",70 "share_target",71 "tab_strip",72 ]73 ) {74 assert(!(member in manifest), `experimental member ${member} must be opt-in`);75 }76 assert(!("iarc_rating_id" in manifest), "starter manifest must not claim an IARC rating");77 assert(!("categories" in manifest), "product categories must be chosen by the app author");78});7980Deno.test("starter manifest covers regular, maskable, monochrome, and Apple icons", async () => {81 const publicRoot = new URL("../apps/reference/public/", import.meta.url);82 const manifest = JSON.parse(83 await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),84 ) as Manifest;85 const expected = new Map([86 ["icon-192.png", { size: 192, purpose: "any" }],87 ["icon-512.png", { size: 512, purpose: "any" }],88 ["icon-maskable-512.png", { size: 512, purpose: "maskable" }],89 ]);90 const rasterIcons = manifest.icons.filter((icon) => icon.type === "image/png");91 for (const icon of rasterIcons) {92 const file = basename(icon.src);93 const contract = expected.get(file);94 assert(contract, `manifest references unexpected icon ${file}`);95 assert(icon.type === "image/png", `${file} must declare image/png`);96 assert(icon.purpose === contract.purpose, `${file} has the wrong purpose`);97 assert(98 icon.sizes === `${contract.size}x${contract.size}`,99 `${file} has the wrong declared size`,100 );101 const dimensions = pngDimensions(await Deno.readFile(new URL(file, publicRoot)));102 assert(103 dimensions.width === contract.size && dimensions.height === contract.size,104 `${file} is ${dimensions.width}x${dimensions.height}`,105 );106 }107 assert(rasterIcons.length === expected.size, "starter raster icon roles are incomplete");108109 const monochrome = manifest.icons.find((icon) => icon.purpose === "monochrome");110 assert(monochrome, "manifest has no monochrome icon");111 assert(monochrome.type === "image/svg+xml", "monochrome icon must be a vector silhouette");112 assert(monochrome.sizes === "any", "vector monochrome icon must declare any size");113 const monochromeSource = await Deno.readTextFile(new URL(monochrome.src, publicRoot));114 assert(monochromeSource.includes("<svg"), "monochrome icon is not SVG");115 assert(116 !monochromeSource.includes("<rect"),117 "monochrome icon must keep its background transparent",118 );119120 const apple = pngDimensions(await Deno.readFile(new URL("apple-touch-icon.png", publicRoot)));121 assert(apple.width === 180 && apple.height === 180, "apple-touch-icon.png must be 180x180");122 const shell = await Deno.readTextFile(123 new URL("../apps/reference/src/layouts/Shell.astro", import.meta.url),124 );125 assert(126 shell.includes("const deploymentBase = import.meta.env.BASE_URL") &&127 shell.includes('publicAsset("apple-touch-icon.png")') &&128 shell.includes('publicAsset("manifest.webmanifest")'),129 "install assets are not linked through the deployment base",130 );131});132133Deno.test("starter shortcut and its icons stay inside the application scope", async () => {134 const publicRoot = new URL("../apps/reference/public/", import.meta.url);135 const manifest = JSON.parse(136 await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),137 ) as Manifest;138 assert(manifest.shortcuts.length === 1, "starter must carry one clearly replaceable shortcut");139140 const manifestUrl = new URL("https://example.test/field-notes/manifest.webmanifest");141 const scope = new URL(manifest.scope, manifestUrl);142 const shortcut = manifest.shortcuts[0];143 const shortcutUrl = new URL(shortcut.url, manifestUrl);144 assert(shortcutUrl.href.startsWith(scope.href), "starter shortcut escapes manifest scope");145 assert(shortcut.icons?.length, "starter shortcut has no icon");146 for (const icon of shortcut.icons) {147 const iconUrl = new URL(icon.src, manifestUrl);148 assert(iconUrl.href.startsWith(scope.href), "starter shortcut icon escapes manifest scope");149 assert(150 manifest.icons.some((candidate) => candidate.src === icon.src),151 "starter shortcut icon is not part of the install icon set",152 );153 }154155 const taskIsland = await Deno.readTextFile(156 new URL("../apps/reference/src/islands/TaskList.tsx", import.meta.url),157 );158 assert(taskIsland.includes('id="tasks"'), "starter shortcut has no matching task anchor");159});160161Deno.test("starter install screenshots are labeled narrow and wide captures", async () => {162 const publicRoot = new URL("../apps/reference/public/", import.meta.url);163 const manifest = JSON.parse(164 await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),165 ) as Manifest;166 const expected = new Map([167 ["narrow", { file: "screenshot-narrow.png", width: 540, height: 720 }],168 ["wide", { file: "screenshot-wide.png", width: 1280, height: 720 }],169 ]);170 assert(manifest.screenshots.length === expected.size, "starter screenshot set must stay small");171 for (const screenshot of manifest.screenshots) {172 const contract = expected.get(screenshot.form_factor);173 assert(contract, `unexpected screenshot form factor ${screenshot.form_factor}`);174 assert(basename(screenshot.src) === contract.file, `${screenshot.form_factor} file drifted`);175 assert(screenshot.type === "image/png", `${contract.file} must declare image/png`);176 assert(screenshot.label.trim().length > 0, `${contract.file} needs an accessible label`);177 assert(178 screenshot.sizes === `${contract.width}x${contract.height}`,179 `${contract.file} has the wrong declared size`,180 );181 const dimensions = pngDimensions(await Deno.readFile(new URL(contract.file, publicRoot)));182 assert(183 dimensions.width === contract.width && dimensions.height === contract.height,184 `${contract.file} is ${dimensions.width}x${dimensions.height}`,185 );186 }187});