clone

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

feat/prf-provision-custody
lofi / package / tooling / pwa-validation_test.ts
pwa-validation_test.ts 22.2K
1import { join } from "node:path";2import { createProject } from "../create_core.ts";3import {4 expectedPrecacheUrls,5 productionContentType,6 productionPwaIssues,7 screenshotAssetPaths,8 sourcePwaIssues,9} from "./pwa-validation.ts";1011function assert(condition: unknown, message: string): asserts condition {12 if (!condition) throw new Error(message);13}1415async function makeProject(): Promise<{ root: string; project: string }> {16 const root = await Deno.makeTempDir({ dir: ".", prefix: ".lofi-pwa-validation-test-" });17 const result = await createProject({ cwd: root, name: "app" });18 return { root, project: result.destination };19}2021async function readManifest(project: string): Promise<Record<string, unknown>> {22 return JSON.parse(await Deno.readTextFile(join(project, "public", "manifest.webmanifest")));23}2425async function writeManifest(project: string, manifest: Record<string, unknown>): Promise<void> {26 await Deno.writeTextFile(27 join(project, "public", "manifest.webmanifest"),28 `${JSON.stringify(manifest, null, 2)}\n`,29 );30}3132function hasIssue(issues: readonly { detail: string }[], text: string): boolean {33 return issues.some((entry) => entry.detail.includes(text));34}3536Deno.test("source PWA validation accepts the generated root and subpath contracts", async () => {37 const { root, project } = await makeProject();38 try {39 assert((await sourcePwaIssues(project)).length === 0, "root manifest was rejected");40 assert(41 (await sourcePwaIssues(project, "/field-notes/")).length === 0,42 "subpath manifest was rejected",43 );44 } finally {45 await Deno.remove(root, { recursive: true });46 }47});4849Deno.test("source PWA validation names malformed identity and scope failures", async () => {50 const { root, project } = await makeProject();51 const path = join(project, "public", "manifest.webmanifest");52 try {53 await Deno.writeTextFile(path, "{\n");54 const malformed = await sourcePwaIssues(project);55 assert(56 hasIssue(malformed, "public/manifest.webmanifest: malformed JSON"),57 "JSON failure was vague",58 );5960 const manifest = await readManifest(join(import.meta.dirname!, "../../apps/reference"));61 delete manifest.id;62 manifest.start_url = "../outside";63 manifest.scope = "./";64 await writeManifest(project, manifest);65 const invalid = await sourcePwaIssues(project, "/field-notes/");66 assert(hasIssue(invalid, "id must be a non-empty string"), "missing id was accepted");67 assert(hasIssue(invalid, "start_url must stay inside scope"), "escaped start_url was accepted");68 } finally {69 await Deno.remove(root, { recursive: true });70 }71});7273Deno.test("build preflight rejects a malformed manifest before Astro runs", async () => {74 const root = await Deno.makeTempDir({ dir: ".", prefix: ".lofi-pwa-build-test-" });75 try {76 const result = await createProject({77 cwd: root,78 name: "app",79 packagePrefix: new URL("../", import.meta.url).href,80 });81 await Deno.writeTextFile(join(result.destination, "public", "manifest.webmanifest"), "{\n");82 const command = await new Deno.Command(Deno.execPath(), {83 args: ["task", "build"],84 cwd: result.destination,85 stdout: "piped",86 stderr: "piped",87 }).output();88 const output = `${new TextDecoder().decode(command.stdout)}\n${89 new TextDecoder().decode(command.stderr)90 }`;91 assert(!command.success, "build accepted a malformed manifest");92 assert(93 output.includes("public/manifest.webmanifest: malformed JSON"),94 "build failure was vague",95 );96 assert(97 !output.includes("Building static entrypoints"),98 "build reached Astro after failed preflight",99 );100 } finally {101 await Deno.remove(root, { recursive: true });102 }103});104105Deno.test("source PWA validation detects missing and incorrectly sized icons", async () => {106 const { root, project } = await makeProject();107 try {108 await Deno.copyFile(109 join(project, "public", "apple-touch-icon.png"),110 join(project, "public", "icon-192.png"),111 );112 await Deno.remove(join(project, "public", "icon-monochrome.svg"));113 const issues = await sourcePwaIssues(project);114 assert(hasIssue(issues, "icon-192.png (180x180)"), "incorrect raster dimensions were accepted");115 assert(hasIssue(issues, "missing asset icon-monochrome.svg"), "missing icon was accepted");116 assert(hasIssue(issues, "scalable monochrome image"), "missing icon role was not reported");117 } finally {118 await Deno.remove(root, { recursive: true });119 }120});121122Deno.test("source PWA validation checks optional shortcuts and screenshots", async () => {123 const { root, project } = await makeProject();124 try {125 const manifest = await readManifest(project);126 manifest.shortcuts = [{ name: "Escape", url: "../outside" }];127 manifest.screenshots = [{128 src: "./missing-shot.png",129 sizes: "1200x800",130 type: "image/png",131 }];132 await writeManifest(project, manifest);133 const issues = await sourcePwaIssues(project, "/field-notes/");134 assert(hasIssue(issues, "shortcuts[0].url must stay inside"), "escaped shortcut was accepted");135 assert(hasIssue(issues, "missing asset missing-shot.png"), "missing screenshot was accepted");136 assert(hasIssue(issues, "label must describe"), "unlabeled screenshot was accepted");137 assert(hasIssue(issues, "form_factor must be narrow or wide"), "missing form factor passed");138 } finally {139 await Deno.remove(root, { recursive: true });140 }141});142143Deno.test("source PWA validation checks an opt-in text share target", async () => {144 const { root, project } = await makeProject();145 try {146 const manifest = await readManifest(project);147 manifest.share_target = {148 action: "../outside?existing=1",149 method: "POST",150 enctype: "multipart/form-data",151 params: { title: "shared", text: "shared", files: { name: "files" }, extra: "value" },152 };153 await writeManifest(project, manifest);154 const issues = await sourcePwaIssues(project, "/field-notes/");155 assert(hasIssue(issues, "action must stay inside"), "escaped share action was accepted");156 assert(hasIssue(issues, "must not contain a query"), "share action query was accepted");157 assert(hasIssue(issues, "method must be GET"), "POST share action was accepted");158 assert(hasIssue(issues, "enctype must be application"), "multipart share action was accepted");159 assert(hasIssue(issues, "params.files requires"), "file share params were accepted");160 assert(hasIssue(issues, "unsupported members"), "unknown share param was accepted");161 assert(hasIssue(issues, "params names must be unique"), "duplicate param names were accepted");162163 manifest.share_target = {164 action: "./share/",165 method: "GET",166 enctype: "application/x-www-form-urlencoded",167 params: { title: "title", text: "text", url: "url" },168 };169 await writeManifest(project, manifest);170 const valid = await sourcePwaIssues(project, "/field-notes/");171 assert(valid.length === 0, `valid share target was rejected: ${JSON.stringify(valid)}`);172 } finally {173 await Deno.remove(root, { recursive: true });174 }175});176177Deno.test("source PWA validation checks an opt-in launch handler", async () => {178 const { root, project } = await makeProject();179 try {180 const manifest = await readManifest(project);181 manifest.launch_handler = {182 client_mode: ["focus-existing", "focus-existing", "invented"],183 navigation: "unsafe",184 };185 await writeManifest(project, manifest);186 const issues = await sourcePwaIssues(project);187 assert(hasIssue(issues, "contains unsupported members"), "unknown launch member was accepted");188 assert(hasIssue(issues, "supports auto"), "unknown client mode was accepted");189 assert(hasIssue(issues, "must not repeat modes"), "duplicate client mode was accepted");190191 manifest.launch_handler = { client_mode: ["focus-existing", "auto"] };192 await writeManifest(project, manifest);193 const valid = await sourcePwaIssues(project);194 assert(valid.length === 0, `valid launch handler was rejected: ${JSON.stringify(valid)}`);195 } finally {196 await Deno.remove(root, { recursive: true });197 }198});199200Deno.test("source PWA validation checks window controls overlay with standalone fallback", async () => {201 const { root, project } = await makeProject();202 try {203 const manifest = await readManifest(project);204 manifest.display_override = ["tabbed", "window-controls-overlay", "window-controls-overlay"];205 await writeManifest(project, manifest);206 const issues = await sourcePwaIssues(project);207 assert(208 hasIssue(issues, "without a tested lofi recipe"),209 "tabbed mode was presented as shipped",210 );211 assert(hasIssue(issues, "must not repeat modes"), "duplicate display override passed");212213 manifest.display_override = ["window-controls-overlay"];214 await writeManifest(project, manifest);215 const valid = await sourcePwaIssues(project);216 assert(valid.length === 0, `valid window controls override failed: ${JSON.stringify(valid)}`);217 assert(manifest.display === "standalone", "fixture lost its standalone fallback");218 } finally {219 await Deno.remove(root, { recursive: true });220 }221});222223Deno.test("source PWA validation checks opt-in file handlers", async () => {224 const { root, project } = await makeProject();225 try {226 const manifest = await readManifest(project);227 manifest.file_handlers = [{228 action: "../outside?unsafe=1",229 accept: {230 "application/*": ["json"],231 "application/json": [".json", ".json"],232 },233 icons: [{234 src: "./missing-file-icon.png",235 sizes: "64x64",236 type: "image/png",237 }],238 extra: true,239 }];240 await writeManifest(project, manifest);241 const issues = await sourcePwaIssues(project, "/field-notes/");242 assert(hasIssue(issues, "action must stay inside"), "escaped file action was accepted");243 assert(hasIssue(issues, "must not contain a query"), "file action query was accepted");244 assert(hasIssue(issues, "explicit lowercase MIME"), "wildcard MIME type was accepted");245 assert(hasIssue(issues, "lowercase dot-extension"), "bare extension was accepted");246 assert(hasIssue(issues, "must not repeat"), "duplicate extension was accepted");247 assert(hasIssue(issues, "missing asset missing-file-icon.png"), "missing file icon passed");248 assert(hasIssue(issues, "contains unsupported members"), "unknown file member was accepted");249250 manifest.file_handlers = [{251 action: "./import/",252 accept: { "application/json": [".json"] },253 }];254 await writeManifest(project, manifest);255 const valid = await sourcePwaIssues(project, "/field-notes/");256 assert(valid.length === 0, `valid file handler was rejected: ${JSON.stringify(valid)}`);257 } finally {258 await Deno.remove(root, { recursive: true });259 }260});261262Deno.test("source PWA validation checks opt-in protocol handlers", async () => {263 const { root, project } = await makeProject();264 try {265 const manifest = await readManifest(project);266 manifest.protocol_handlers = [{267 protocol: "mailto",268 url: "../outside/%s?again=%s#fragment",269 extra: true,270 }, {271 protocol: "web+lofi",272 url: "./open/?url=prefix-%s",273 }, {274 protocol: "web+lofi",275 url: "./open/?url=%s",276 }];277 await writeManifest(project, manifest);278 const issues = await sourcePwaIssues(project, "/field-notes/");279 assert(hasIssue(issues, "custom lowercase web+"), "privileged protocol was accepted");280 assert(hasIssue(issues, "exactly one %s"), "duplicate placeholder was accepted");281 assert(hasIssue(issues, "complete value"), "prefixed placeholder was accepted");282 assert(hasIssue(issues, "must not repeat"), "duplicate protocol was accepted");283 assert(hasIssue(issues, "contains unsupported members"), "unknown protocol member passed");284285 manifest.protocol_handlers = [{ protocol: "web+lofi", url: "./open/?url=%s" }];286 await writeManifest(project, manifest);287 const valid = await sourcePwaIssues(project, "/field-notes/");288 assert(valid.length === 0, `valid protocol handler was rejected: ${JSON.stringify(valid)}`);289 } finally {290 await Deno.remove(root, { recursive: true });291 }292});293294Deno.test("source PWA validation checks opt-in related applications without preferring them", async () => {295 const { root, project } = await makeProject();296 try {297 const manifest = await readManifest(project);298 manifest.prefer_related_applications = true;299 manifest.related_applications = [{300 platform: "invented",301 url: "http://user:secret@store.invalid/app",302 extra: true,303 }];304 await writeManifest(project, manifest);305 const issues = await sourcePwaIssues(project);306 assert(hasIssue(issues, "preserve PWA installability"), "native preference was accepted");307 assert(hasIssue(issues, "platform is not supported"), "invented platform was accepted");308 assert(hasIssue(issues, "credential-free HTTPS"), "unsafe related URL was accepted");309 assert(hasIssue(issues, "contains unsupported members"), "unknown related member passed");310311 manifest.prefer_related_applications = false;312 manifest.related_applications = [{313 platform: "play",314 id: "com.example.companion",315 url: "https://play.google.com/store/apps/details?id=com.example.companion",316 }];317 await writeManifest(project, manifest);318 const valid = await sourcePwaIssues(project);319 assert(valid.length === 0, `valid related app was rejected: ${JSON.stringify(valid)}`);320 } finally {321 await Deno.remove(root, { recursive: true });322 }323});324325Deno.test("source PWA validation checks opt-in scope extensions", async () => {326 const { root, project } = await makeProject();327 try {328 const manifest = await readManifest(project);329 manifest.scope_extensions = [330 {331 type: "invented",332 origin: "http://user:secret@help.example.com/path?unsafe=1",333 authorization: true,334 },335 { type: "origin", origin: "https://help.example.com" },336 {337 type: "origin",338 origin: "https://help.example.com",339 },340 ];341 await writeManifest(project, manifest);342 const issues = await sourcePwaIssues(project);343 assert(hasIssue(issues, "type must be origin"), "invented extension type was accepted");344 assert(hasIssue(issues, "exact credential-free HTTPS origin"), "unsafe origin was accepted");345 assert(hasIssue(issues, "contains unsupported members"), "unknown extension member passed");346 assert(hasIssue(issues, "origin must not repeat"), "duplicate origin was accepted");347348 manifest.scope_extensions = [{ type: "origin", origin: "https://help.example.com" }];349 await writeManifest(project, manifest);350 const valid = await sourcePwaIssues(project);351 assert(valid.length === 0, `valid scope extension was rejected: ${JSON.stringify(valid)}`);352 } finally {353 await Deno.remove(root, { recursive: true });354 }355});356357Deno.test("source PWA validation permits replacement branding and optional members", async () => {358 const { root, project } = await makeProject();359 try {360 for (361 const [before, after] of [362 ["icon-192.png", "acme-192.png"],363 ["icon-512.png", "acme-512.png"],364 ["icon-maskable-512.png", "acme-maskable.png"],365 ["icon-monochrome.svg", "acme-symbol.svg"],366 ]367 ) {368 await Deno.rename(join(project, "public", before), join(project, "public", after));369 }370 const manifest = await readManifest(project);371 manifest.name = "Acme Field Notes";372 manifest.categories = ["productivity"];373 manifest.acme_optional_member = { enabled: true };374 const icons = manifest.icons as Array<Record<string, unknown>>;375 for (const icon of icons) {376 icon.src = String(icon.src)377 .replace("icon-192.png", "acme-192.png")378 .replace("icon-512.png", "acme-512.png")379 .replace("icon-maskable-512.png", "acme-maskable.png")380 .replace("icon-monochrome.svg", "acme-symbol.svg");381 }382 const monochrome = icons.find((icon) => icon.purpose === "monochrome");383 assert(monochrome, "starter fixture lost its monochrome icon");384 const shortcuts = manifest.shortcuts as Array<{ icons: Array<Record<string, unknown>> }>;385 shortcuts[0].icons[0].src = "./acme-192.png";386 await writeManifest(project, manifest);387 const issues = await sourcePwaIssues(project);388 assert(issues.length === 0, `replacement branding was rejected: ${JSON.stringify(issues)}`);389 } finally {390 await Deno.remove(root, { recursive: true });391 }392});393394async function makeProductionFixture(): Promise<string> {395 const root = await Deno.makeTempDir({ dir: ".", prefix: ".lofi-pwa-production-test-" });396 const dist = join(root, "dist");397 await Deno.mkdir(join(dist, "settings"), { recursive: true });398 const publicRoot = join(import.meta.dirname!, "../../apps/reference/public");399 const files: string[] = [];400 for await (const entry of Deno.readDir(publicRoot)) {401 if (!entry.isFile) continue;402 await Deno.copyFile(join(publicRoot, entry.name), join(dist, entry.name));403 files.push(entry.name);404 }405 const html =406 `<!doctype html><link rel="manifest" href="/field-notes/manifest.webmanifest"><link rel="apple-touch-icon" href="/field-notes/apple-touch-icon.png">`;407 await Deno.writeTextFile(join(dist, "index.html"), html);408 await Deno.writeTextFile(join(dist, "settings", "index.html"), html);409 await Deno.writeTextFile(410 join(dist, "lofi-build.json"),411 `${JSON.stringify({ sourceHash: "fixture-hash", basePath: "/field-notes/" })}\n`,412 );413 await Deno.writeTextFile(414 join(dist, "sw.js"),415 `const revision = "fixture-hash"; new URL("./lofi-precache.json", self.registration.scope);\n`,416 );417 files.push("index.html", "settings/index.html", "lofi-build.json", "sw.js");418 const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest")));419 await Deno.writeTextFile(420 join(dist, "lofi-precache.json"),421 `${422 JSON.stringify(expectedPrecacheUrls(files, screenshotAssetPaths(manifest, "/field-notes/")))423 }\n`,424 );425 return root;426}427428Deno.test("production PWA validation accepts one base-aware artifact set", async () => {429 const root = await makeProductionFixture();430 try {431 const issues = await productionPwaIssues(root, "/field-notes/");432 assert(issues.length === 0, `valid production fixture was rejected: ${JSON.stringify(issues)}`);433 } finally {434 await Deno.remove(root, { recursive: true });435 }436});437438Deno.test("production PWA validation catches nested links, precache drift, and worker drift", async () => {439 const root = await makeProductionFixture();440 try {441 const dist = join(root, "dist");442 await Deno.writeTextFile(443 join(dist, "settings", "index.html"),444 `<!doctype html><link rel="manifest" href="/manifest.webmanifest"><link rel="apple-touch-icon" href="/field-notes/apple-touch-icon.png">`,445 );446 await Deno.writeTextFile(join(dist, "lofi-precache.json"), `["./"]\n`);447 await Deno.writeTextFile(448 join(dist, "sw.js"),449 `new URL("./lofi-precache.json", self.registration.scope);\n`,450 );451 const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest")));452 manifest.shortcuts[0].url = "./missing/";453 manifest.share_target = {454 action: "./share/",455 method: "GET",456 enctype: "application/x-www-form-urlencoded",457 params: { title: "title", text: "text", url: "url" },458 };459 manifest.file_handlers = [{460 action: "./import/",461 accept: { "application/json": [".json"] },462 }];463 manifest.protocol_handlers = [{ protocol: "web+lofi", url: "./open/?url=%s" }];464 await Deno.writeTextFile(465 join(dist, "manifest.webmanifest"),466 `${JSON.stringify(manifest, null, 2)}\n`,467 );468 const issues = await productionPwaIssues(root, "/field-notes/");469 assert(hasIssue(issues, "settings/index.html: must link"), "nested manifest link drift passed");470 assert(hasIssue(issues, "entries do not match"), "precache drift passed");471 assert(hasIssue(issues, "build revision does not match"), "worker revision drift passed");472 assert(hasIssue(issues, "has no emitted offline route"), "missing shortcut route passed");473 assert(474 hasIssue(issues, "share_target.action has no emitted offline route"),475 "missing share-target route passed",476 );477 assert(478 hasIssue(issues, "file_handlers[0].action has no emitted offline route"),479 "missing file-handler route passed",480 );481 assert(482 hasIssue(issues, "protocol_handlers[0].url has no emitted offline route"),483 "missing protocol-handler route passed",484 );485 } finally {486 await Deno.remove(root, { recursive: true });487 }488});489490Deno.test("production preview maps install-critical content types", () => {491 assert(492 productionContentType("manifest.webmanifest") === "application/manifest+json",493 "manifest MIME drifted",494 );495 assert(496 productionContentType("sw.js") === "text/javascript; charset=utf-8",497 "worker MIME drifted",498 );499 assert(500 productionContentType("lofi-precache.json") === "application/json; charset=utf-8",501 "precache MIME drifted",502 );503 assert(productionContentType("icon.webp") === "image/webp", "WebP MIME drifted");504});505506Deno.test("a truncated PNG reports not decodable instead of throwing", async () => {507 const { root, project } = await makeProject();508 try {509 // A valid PNG signature and IHDR tag, truncated before the dimension bytes.510 const truncated = new Uint8Array([511 137,512 80,513 78,514 71,515 13,516 10,517 26,518 10,519 0,520 0,521 0,522 13,523 73,524 72,525 68,526 82,527 ]);528 await Deno.writeFile(join(project, "public", "icon-192.png"), truncated);529 const issues = await sourcePwaIssues(project);530 assert(531 hasIssue(issues, "not decodable as image/png"),532 "a truncated image must produce the documented diagnostic, not a RangeError",533 );534 } finally {535 await Deno.remove(root, { recursive: true });536 }537});538539Deno.test("encoded backslashes cannot escape the deployment scope", async () => {540 const { root, project } = await makeProject();541 try {542 const manifest = await readManifest(project);543 const icons = manifest.icons as Array<Record<string, unknown>>;544 icons[0] = { ...icons[0], src: "..%5C..%5Cicon-192.png" };545 await writeManifest(project, manifest);546 const issues = await sourcePwaIssues(project);547 assert(548 hasIssue(issues, "must stay inside deployment scope"),549 "an encoded-backslash path must be rejected as out of scope",550 );551 } finally {552 await Deno.remove(root, { recursive: true });553 }554});