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 await Deno.writeTextFile(418 join(dist, "lofi-schema.json"),419 `${JSON.stringify({ v: 1, revision: "fixture-hash", head: "v1:aa", lineage: ["v1:aa"] })}\n`,420 );421 files.push("index.html", "settings/index.html", "lofi-build.json", "lofi-schema.json", "sw.js");422 const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest")));423 await Deno.writeTextFile(424 join(dist, "lofi-precache.json"),425 `${426 JSON.stringify(expectedPrecacheUrls(files, screenshotAssetPaths(manifest, "/field-notes/")))427 }\n`,428 );429 return root;430}431432Deno.test("production PWA validation accepts one base-aware artifact set", async () => {433 const root = await makeProductionFixture();434 try {435 const issues = await productionPwaIssues(root, "/field-notes/");436 assert(issues.length === 0, `valid production fixture was rejected: ${JSON.stringify(issues)}`);437 } finally {438 await Deno.remove(root, { recursive: true });439 }440});441442Deno.test("production PWA validation catches nested links, precache drift, and worker drift", async () => {443 const root = await makeProductionFixture();444 try {445 const dist = join(root, "dist");446 await Deno.writeTextFile(447 join(dist, "settings", "index.html"),448 `<!doctype html><link rel="manifest" href="/manifest.webmanifest"><link rel="apple-touch-icon" href="/field-notes/apple-touch-icon.png">`,449 );450 await Deno.writeTextFile(join(dist, "lofi-precache.json"), `["./"]\n`);451 await Deno.writeTextFile(452 join(dist, "sw.js"),453 `new URL("./lofi-precache.json", self.registration.scope);\n`,454 );455 await Deno.writeTextFile(456 join(dist, "lofi-schema.json"),457 `${JSON.stringify({ v: 1, revision: "other-hash", head: "v1:aa", lineage: ["v1:aa"] })}\n`,458 );459 const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest")));460 manifest.shortcuts[0].url = "./missing/";461 manifest.share_target = {462 action: "./share/",463 method: "GET",464 enctype: "application/x-www-form-urlencoded",465 params: { title: "title", text: "text", url: "url" },466 };467 manifest.file_handlers = [{468 action: "./import/",469 accept: { "application/json": [".json"] },470 }];471 manifest.protocol_handlers = [{ protocol: "web+lofi", url: "./open/?url=%s" }];472 await Deno.writeTextFile(473 join(dist, "manifest.webmanifest"),474 `${JSON.stringify(manifest, null, 2)}\n`,475 );476 const issues = await productionPwaIssues(root, "/field-notes/");477 assert(hasIssue(issues, "settings/index.html: must link"), "nested manifest link drift passed");478 assert(hasIssue(issues, "entries do not match"), "precache drift passed");479 assert(hasIssue(issues, "build revision does not match"), "worker revision drift passed");480 assert(481 hasIssue(issues, "lofi-schema.json: schema compatibility manifest"),482 "schema manifest revision drift passed",483 );484 assert(hasIssue(issues, "has no emitted offline route"), "missing shortcut route passed");485 assert(486 hasIssue(issues, "share_target.action has no emitted offline route"),487 "missing share-target route passed",488 );489 assert(490 hasIssue(issues, "file_handlers[0].action has no emitted offline route"),491 "missing file-handler route passed",492 );493 assert(494 hasIssue(issues, "protocol_handlers[0].url has no emitted offline route"),495 "missing protocol-handler route passed",496 );497 } finally {498 await Deno.remove(root, { recursive: true });499 }500});501502Deno.test("production preview maps install-critical content types", () => {503 assert(504 productionContentType("manifest.webmanifest") === "application/manifest+json",505 "manifest MIME drifted",506 );507 assert(508 productionContentType("sw.js") === "text/javascript; charset=utf-8",509 "worker MIME drifted",510 );511 assert(512 productionContentType("lofi-precache.json") === "application/json; charset=utf-8",513 "precache MIME drifted",514 );515 assert(productionContentType("icon.webp") === "image/webp", "WebP MIME drifted");516});517518Deno.test("a truncated PNG reports not decodable instead of throwing", async () => {519 const { root, project } = await makeProject();520 try {521 // A valid PNG signature and IHDR tag, truncated before the dimension bytes.522 const truncated = new Uint8Array([523 137,524 80,525 78,526 71,527 13,528 10,529 26,530 10,531 0,532 0,533 0,534 13,535 73,536 72,537 68,538 82,539 ]);540 await Deno.writeFile(join(project, "public", "icon-192.png"), truncated);541 const issues = await sourcePwaIssues(project);542 assert(543 hasIssue(issues, "not decodable as image/png"),544 "a truncated image must produce the documented diagnostic, not a RangeError",545 );546 } finally {547 await Deno.remove(root, { recursive: true });548 }549});550551Deno.test("encoded backslashes cannot escape the deployment scope", async () => {552 const { root, project } = await makeProject();553 try {554 const manifest = await readManifest(project);555 const icons = manifest.icons as Array<Record<string, unknown>>;556 icons[0] = { ...icons[0], src: "..%5C..%5Cicon-192.png" };557 await writeManifest(project, manifest);558 const issues = await sourcePwaIssues(project);559 assert(560 hasIssue(issues, "must stay inside deployment scope"),561 "an encoded-backslash path must be rejected as out of scope",562 );563 } finally {564 await Deno.remove(root, { recursive: true });565 }566});