From f476821e8ee89445f38c09dd6f734d8465ba57ed Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 19:42:27 -0400 Subject: [PATCH] Task 96ab3c0b: PWA manifest and service worker (#189) --- admin/index.html | 3 + admin/public/icons/icon-192.png | Bin 0 -> 2580 bytes admin/public/icons/icon-512.png | Bin 0 -> 8815 bytes admin/public/icons/icon-maskable-512.png | Bin 0 -> 8371 bytes admin/public/manifest.webmanifest | 14 + admin/public/sw.js | 88 +++++ admin/scripts/generate-icons.mjs | 80 +++++ admin/src/main.tsx | 5 +- admin/src/pwa/pwa.test.ts | 396 +++++++++++++++++++++++ admin/src/pwa/register.ts | 13 + admin/vite-sw-plugin.ts | 93 ++++++ admin/vite.config.ts | 5 +- 12 files changed, 694 insertions(+), 3 deletions(-) create mode 100644 admin/public/icons/icon-192.png create mode 100644 admin/public/icons/icon-512.png create mode 100644 admin/public/icons/icon-maskable-512.png create mode 100644 admin/public/manifest.webmanifest create mode 100644 admin/public/sw.js create mode 100644 admin/scripts/generate-icons.mjs create mode 100644 admin/src/pwa/pwa.test.ts create mode 100644 admin/src/pwa/register.ts create mode 100644 admin/vite-sw-plugin.ts diff --git a/admin/index.html b/admin/index.html index 2ecc421..8f95fb8 100644 --- a/admin/index.html +++ b/admin/index.html @@ -3,6 +3,9 @@ + + + NestQuest Admin diff --git a/admin/public/icons/icon-192.png b/admin/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..4435870c8891af50b9eff2d5fd05a1aed156abe4 GIT binary patch literal 2580 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 h1DF)UO+@U)|e`|4713pZaR_@yScK{tuRSZ{q*} literal 0 HcmV?d00001 diff --git a/admin/public/icons/icon-maskable-512.png b/admin/public/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..ae98c76043bc1c19dde093220bf2e8289683a0be GIT binary patch literal 8371 zcmc&)dt6l2_CF&CCemp5el(OBCfVepuuRYRL{mUb1tpz)T}Ul56H>H8X<>2&6N}7j zc#4WRmKq4*>7fzVYr{0)h)Sd3V;;#|<&2YOI%ogZ+K2J>&+mS&-p}p+GtBI>&wA~( zzU#4he@1L{Xy?A28HNd+I%VQ4hUoxbJ20K_PkP@8-y*YDTf9u-X zMHhAio^aH7KdY>pJMght#RnFj+VSbA*i~CzfKTtT;>5s{-D|x^)&^C7y*TLb;m6Oe zKEC?6H(icDwDFp>=<9ci+`s?8#@$+wUTH|U5cfoD>8-1M%Xi|JvBxTV zP4>O?McmL%_}33l)>KvY%c?$BnP}O)@aGj-)isr)BTA!%QdV-r zkxioQnFoV2Wct%Ek<4W5ahzx|$ZG{#kbn7k660TP5;Prjbb}DLmwd+58|bU)jof2Q zJIQR1tq+4Fgi_8Xn*79VHS!2mJ~HLRqL)o>&~)^3iYBl4m-h*_K>uA`&$$CGPW)~ag7bZw@$NKlj6_UYzlFx}L0e+oI$fN${ zm7>~DF`|LA-uP}{yHextnr+OzvgP9Ziqtsy?tVpTMRym!4N|0Qe&O8<$xi=)jIfjM zAMtDQh?wISz*Iz@_Ak#CQ|>J9Azl5{$4g$n;caE|G`|hf4v-)H)O}~Tv3@G89)tft z*p0Gbr~lnQ)0s~HFPzEt$vvz3tlxNVi2R!0FLhJ=nJxHttj+nB`+cI1V7L>be`4tF zzGrKj?gYAyMEV`;T0Pg_?|XeE?VT-CNt@r_avh}U4g#$I6TNxYJsLr$9wayNm-v_65%`2}8&bduXB6Im=8FOPKLY-#mTf7u38g2h!#?%~6UYoyIVQ>mUDj+}j|U@PN4W zQn&t>@D&S@71Z-3!*f2K<}*^;psD<31X9SB&!b#9MeNcRFa!Cx9Rk@20r0vn7ayj3 zqL&Bv0X}Y4$%bAtxF?ar&@+RRHPM`C;|g)-gDd*GRNa1($*oZZH8NBwAh0ko(>EqGh&tSbu>iunL35v&G$F#~fw#5TL_jUc-%SJr=wN*Tv<T6^)EoQAT=Y6W3;0V4sw1#U$6sxh}s^^P4G8zV1XF zQpS^<_rL^(N#xPKI+E%!pgp)4!9wPFxa*W=jJ>_)FbgSVDj{VOp{b(Wu6c$L*iG^` z{dAPB)Edih(1=h7p4s zNKu5Xn3a+|dTm2qFaQO$J|KpLF8~M4miaM|-Iq8uLl_}vA9n=Sj>|>p{6flnauHl& zq*MX?6Ql{if1F3%529@-Q?!`9Z7&T8UZ7+Y2A} zWKw+tsErc2RX`}%OqR@~=dSL-h?zg3$JCnH@eDdkQhIMlDLH#cF9VBb7)KU*^hU8) z52?t(gPao_nkV7B7mznWy$uSy3Q%HI*Q(tem&0>AAHfJ(BjI>Uj*}j?yrH&cd?^^uqE!&A!kjD^&3I#e1K&E0kybd+dRBVgoD`ZIL!w;ZV?8{2 zXh|9cquizu^H%B-B-;Tin6QCNGZAk$NOpJ-+NO+)Vsr>2h1fArdRh3z%r*9mCcw6KEyVogK^VCh?eBWjy%}PPt79fw z@Vp?sLbszBh^q?Vi%*kn(l#jh-Sk#GCUPl4pE5xoJ z!z}XfA3V&A^Kh=;zUu@G`(Roj!$oVTZmAR&7=BRh2EP}Jh8X`#N-;K`OG@_0^LDP!m)#feb;@ea38RQutrWCG26D(sCV zC7@$I=%^bhjR+3UmgfL^6w(m%S-1x>GQ`qQ-C)UfP>53@dvIPbNxWJ&U0NL!{)yZO z+d)UU5wy#Ikm)*??7EHWRMAr{rG|+N3jacG>d^HCvITxG!gS6Jlk|JLgUgh5Mu*x5 zk(y`4$jobC|HABd86ubu@bpE^6neY~5UHE7QlrGY+#e%5k0m&G(abVa+~*&^228SAgHV(+ePvXJ!Deq>wt2aII4=OkN=_5UZ?lNToa#9Hq|8Z=tE{EBRA5)&Z~s z5JfIwHAset@u9k8$#=i*HK_=kIk*u^La>FaV|bN3AQ)V!0JcqVA*gC*<5}+|<+&E? zIvQ|bn-DwZWcCR7CJ3`qwYiAfMuQXT3NS@k6i6>Y{t?uBsjAaSoHazZUdjy&-$DI( zyU4ADM3L73PWeeaUy9g+czy_T9DJ$*x;{_F_5}b!!vOZs&*%`HPD&32r-=k(&!K~g zIfAiAMia*CttPwebGfW>3q&WXIbW<@d*9{*Ty<#jG-8?q28(oZPzf0GxlPOoE58f8 zpM41wPnNW>{aJL!FtOrn##JGwCtylFHvr(t!nbMyMiz64q4{J4a9t*ln{8zEM6yNX z6ST}}2j{yi#D;_>&!osPjE3)acxF@$b#m9<>@67=kWnlt0B|C&q0UzJ+%&NKKqx;< zN+$8=u21UOmx4a=^)smMC=E*7r%M!TU)H%Of|S`YM@Bj|#gzPSg6HtNg;*3!!cmR! zAXsr^IaeMe6i|B>%IToma528i*UOJOb>!PV$yX|>C!Rso0K5?^_mF8b1L{ALI}We^ zo7=Nty5s^a8|f@5uYr{854BGx)p@B4H0FM_Y8%vxGpY5VRRIV)<~%P3dKF!J8inIcK15HOK>8Y%e(1Msg)L7!w;fDHJL3R)Rp7NSvw!I2eIVX6%}A zWEFhsNO&l%m}+G1D+svn?3%}c_&72Oi1(LFz;%kqufKWm!@xY`ze0c(Jm_cSJZ>Bb7mo8N5-#$u-@MqkO(FL-O_qZpx);Jt7Wp{}c+%WvSV6xr>J}Oa+~broGo zY)5p5*G~q$xHSB*e0t1qF|7#4r0xmC`s0_&|M9xh_DgOd7l-pw_^dde(bPrQ&lRaR z%ex&s3ru23YQU3qx;!yTO2)z%!Jwzdov(7arjq*~43;1{0|o%wSNlG>>+LTEcgth- z{4|=&%3b7Ko#Xn#zONk%EVtV%5m=~W%=_v|YFcQ5eyQa19dQ8UVnr4wde3E)4cohy ztJP@1&cp<5H&`*vmT@Y6GI>Pvs8nsmLfX5Z+aEN&waH_;easAlORP0hq!h!aKi8dO zpv5;i5#UDH5jvY;;DSbIvl~^}FmAX2ds*qJuVGT+18XnzSn&-lc=ZMs-GSo41XgT1 zd(Cc`OFbz^?t9U0m`7Lx_)U|LrxGk|Id%DRxi7Ri5e?|<5mwsN;6l<*w2LCGw||=g zkIl3l=hCV&^QTW@rPWUCK1}<_x)ad8fj@!yOb2+3f@T+OwnPWNi+lM8XNYH^e zNpJaJhb*|`ly1qJ+GSs;8JyGINVIe~;U1b@4!$0x4A`_T^8M}bemmcS;m0r!y$#@| zOUuHCOAuErE;NsW6pK*5JD`&X{J{AOS7Z7?7b9xGG;!t1{Xn{h@131x0Y+@DG8fS3 zxjutqA@s-rr8|P5&newwBJ^M@Xn#~n4%^Aq+uJtu#x<9jHl^gKT^o=Iy}Q6aE%DDH zd#*-|{KnGktIhe@Kp(^^vlVqINxZhv*HRBmA%B8laU_*@>mgTCGu(S*(FV@}MQK*e z?&`oWFEH7zO#2}n*xcvu$owZ>`H@%u7cR{s$}30pCtl2G?)g}`r(h&Qqo}(WcgKYa z6~x0QVG7Dohj>D=-tiL)(HMNU{&|6QcTTK~0Kq4{bZVhE^Z+G)QE84V^HqG{A9SOq z!}yE6JJ9&URW7}Epg~e|*LSL0@7aa9mSZV|Em`%~_bM#AV&I< { + event.waitUntil( + caches + .open(SHELL_CACHE) + .then((cache) => cache.addAll(SHELL_URLS)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key)) + .map((key) => caches.delete(key)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + if (new URL(request.url).origin !== self.location.origin) return; + + if (request.mode === "navigate") { + event.respondWith( + fetch(request).catch(() => + caches.match("/index.html", { cacheName: SHELL_CACHE }).then( + (cached) => cached || Response.error(), + ), + ), + ); + return; + } + + if (STATIC_DESTINATIONS.includes(request.destination)) { + event.respondWith(cacheFirst(request)); + } +}); + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok && response.type === "basic") { + const cache = await caches.open(RUNTIME_CACHE); + await cache.put(request, response.clone()); + } + return response; +} diff --git a/admin/scripts/generate-icons.mjs b/admin/scripts/generate-icons.mjs new file mode 100644 index 0000000..89cd01f --- /dev/null +++ b/admin/scripts/generate-icons.mjs @@ -0,0 +1,80 @@ +// Generates the committed PWA icons under public/icons/ as genuine PNGs. +// Pure Node (zlib only) — no network, no image dependencies. +// Run: node scripts/generate-icons.mjs +import { writeFileSync } from "node:fs"; +import { deflateSync } from "node:zlib"; +import { fileURLToPath } from "node:url"; + +// Brand tokens (design/tokens/nestquest-admin-tokens.css). +const BLUE = [0x3b, 0x3a, 0xb8]; +const PURPLE = [0xa0, 0x35, 0xcc]; +const WHITE = [0xff, 0xff, 0xff]; + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(buf) { + let c = 0xffffffff; + for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +function encodePng(size, pixel) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // colour type: RGB + const raw = Buffer.alloc(size * (size * 3 + 1)); + for (let y = 0; y < size; y++) { + const row = y * (size * 3 + 1); + raw[row] = 0; // filter: none + for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3); + } + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest") +// around a white dot ("quest"). `scale` shrinks the mark for the maskable +// safe zone (inner 80% circle). +function brandMark(size, scale) { + const c = size / 2; + const outer = size * 0.34 * scale; + const inner = size * 0.24 * scale; + const dot = size * 0.1 * scale; + return (x, y) => { + const d = Math.hypot(x + 0.5 - c, y + 0.5 - c); + if ((d <= outer && d >= inner) || d <= dot) return WHITE; + const t = (x + y) / (2 * (size - 1)); + return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t)); + }; +} + +const outDir = new URL("../public/icons/", import.meta.url); +const icons = [ + ["icon-192.png", 192, 1], + ["icon-512.png", 512, 1], + ["icon-maskable-512.png", 512, 0.75], +]; +for (const [name, size, scale] of icons) { + const path = fileURLToPath(new URL(name, outDir)); + writeFileSync(path, encodePng(size, brandMark(size, scale))); + console.log(`wrote ${path}`); +} diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 35e1ff7..5f81c94 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import AppRoutes from "./AppRoutes"; import "./index.css"; +import { registerServiceWorker } from "./pwa/register"; createRoot(document.getElementById("root")!).render( , -); \ No newline at end of file +); + +void registerServiceWorker(); diff --git a/admin/src/pwa/pwa.test.ts b/admin/src/pwa/pwa.test.ts new file mode 100644 index 0000000..4089ab4 --- /dev/null +++ b/admin/src/pwa/pwa.test.ts @@ -0,0 +1,396 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + REVISION_MARKER, + STATIC_PRECACHE_FILES, + injectServiceWorker, +} from "../../vite-sw-plugin"; +import { registerServiceWorker } from "./register"; + +const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const adminPath = (rel: string) => resolve(adminRoot, rel); +const publicPath = (urlPath: string) => adminPath(`public${urlPath}`); + +interface ManifestIcon { + src: string; + sizes: string; + type: string; + purpose?: string; +} + +const manifest = JSON.parse(readFileSync(publicPath("/manifest.webmanifest"), "utf8")); +const icons: ManifestIcon[] = manifest.icons; +const swSource = readFileSync(publicPath("/sw.js"), "utf8"); + +const sizeOf = (icon: ManifestIcon) => Number(icon.sizes.split("x")[0]); +const purposes = (icon: ManifestIcon) => (icon.purpose ?? "any").split(/\s+/); + +describe("web app manifest", () => { + it("meets the installability criteria", () => { + expect(manifest.name).toBe("NestQuest Admin"); + expect(manifest.short_name).toBe("NestQuest"); + expect(manifest.start_url).toBe("/"); + expect(manifest.scope).toBe("/"); + expect(manifest.display).toBe("standalone"); + expect(manifest.background_color).toMatch(/^#[0-9a-f]{6}$/i); + expect(manifest.theme_color).toMatch(/^#[0-9a-f]{6}$/i); + + const any = icons.filter((i) => purposes(i).includes("any")); + expect(any.some((i) => sizeOf(i) >= 192)).toBe(true); + expect(any.some((i) => sizeOf(i) >= 512)).toBe(true); + expect(icons.some((i) => purposes(i).includes("maskable"))).toBe(true); + }); + + it("uses the admin design token colours", () => { + const css = readFileSync(adminPath("src/index.css"), "utf8"); + expect(css).toContain(`--nq-brand-blue: ${manifest.theme_color};`); + expect(css).toContain(`--nq-a-page: ${manifest.background_color};`); + }); + + it.each(icons.map((i) => [i.src, i] as const))( + "icon %s is a real PNG of its declared size", + (_src, icon) => { + const path = publicPath(icon.src); + expect(existsSync(path)).toBe(true); + const png = readFileSync(path); + expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect(png.toString("ascii", 12, 16)).toBe("IHDR"); + const [w, h] = icon.sizes.split("x").map(Number); + expect(png.readUInt32BE(16)).toBe(w); + expect(png.readUInt32BE(20)).toBe(h); + expect(icon.type).toBe("image/png"); + }, + ); +}); + +describe("index.html and entry point", () => { + const html = readFileSync(adminPath("index.html"), "utf8"); + + it("links the manifest, theme colour and apple touch icon", () => { + expect(html).toContain(''); + expect(html).toContain(``); + expect(html).toContain(''); + }); + + it("main.tsx registers the service worker", () => { + const main = readFileSync(adminPath("src/main.tsx"), "utf8"); + expect(main).toMatch(/import \{ registerServiceWorker \} from "\.\/pwa\/register"/); + expect(main).toMatch(/registerServiceWorker\(\)/); + }); +}); + +// Minimal service-worker global scope: runs public/sw.js against in-memory +// caches and a stubbed network, so handler behaviour is checked without I/O. +function loadServiceWorker( + network: (url: string) => Promise, + source: string = swSource, +) { + const listeners: Record void> = {}; + const stores = new Map>(); + const openStore = (name: string) => { + if (!stores.has(name)) stores.set(name, new Map()); + return stores.get(name)!; + }; + const keyOf = (req: string | { url: string }) => + new URL(typeof req === "string" ? req : req.url, "https://admin.test").href; + const fetchFn = vi.fn((req: string | { url: string }) => network(keyOf(req))); + + const caches = { + open: async (name: string) => { + const store = openStore(name); + return { + addAll: async (urls: string[]) => { + for (const u of urls) store.set(keyOf(u), await network(keyOf(u))); + }, + put: async (req: { url: string }, res: FakeResponse) => void store.set(keyOf(req), res), + }; + }, + keys: async () => [...stores.keys()], + delete: async (name: string) => stores.delete(name), + match: async (req: string | { url: string }, opts?: { cacheName?: string }) => { + const names = opts?.cacheName ? [opts.cacheName] : [...stores.keys()]; + for (const n of names) { + const hit = stores.get(n)?.get(keyOf(req)); + if (hit) return hit; + } + return undefined; + }, + }; + const self = { + location: new URL("https://admin.test/"), + addEventListener: (type: string, fn: (event: any) => void) => (listeners[type] = fn), + skipWaiting: vi.fn(async () => undefined), + clients: { claim: vi.fn(async () => undefined) }, + }; + const ResponseStub = { error: () => new FakeResponse("", false, "error") }; + new Function("self", "caches", "fetch", "Response", source)(self, caches, fetchFn, ResponseStub); + + const dispatch = async (type: string, init: object = {}) => { + let pending: Promise | undefined; + const event = { + ...init, + waitUntil: (p: Promise) => (pending = p), + respondWith: (p: Promise) => (pending = p), + }; + listeners[type](event); + return { responded: pending !== undefined, result: await pending }; + }; + return { listeners, stores, self, fetchFn, dispatch, openStore }; +} + +class FakeResponse { + constructor( + readonly body: string, + readonly ok = true, + readonly type = "basic", + ) {} + clone() { + return new FakeResponse(this.body, this.ok, this.type); + } +} + +const request = (url: string, extra: object = {}) => ({ + url: new URL(url, "https://admin.test").href, + method: "GET", + mode: "cors", + destination: "", + ...extra, +}); + +describe("service worker (public/sw.js)", () => { + it("is a static asset served from the site root", () => { + expect(existsSync(publicPath("/sw.js"))).toBe(true); + expect(swSource).toMatch(/const SHELL_CACHE = /); + expect(swSource).toMatch(/addEventListener\("install"/); + expect(swSource).toMatch(/addEventListener\("activate"/); + expect(swSource).toMatch(/addEventListener\("fetch"/); + }); + + it("precaches the shell on install, prunes stale caches on activate", async () => { + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`)); + sw.openStore("nestquest-admin-shell-v0"); + + await sw.dispatch("install"); + expect(sw.self.skipWaiting).toHaveBeenCalled(); + const precached = [...sw.stores.values()].flatMap((s) => [...s.keys()]); + for (const path of ["/", "/index.html", "/manifest.webmanifest", ...icons.map((i) => i.src)]) { + expect(precached).toContain(`https://admin.test${path}`); + } + + await sw.dispatch("activate"); + expect(sw.stores.has("nestquest-admin-shell-v0")).toBe(false); + expect(sw.self.clients.claim).toHaveBeenCalled(); + }); + + it("serves the cached shell for navigations when offline", async () => { + let online = true; + const sw = loadServiceWorker(async (url) => { + if (!online) throw new TypeError("Failed to fetch"); + return new FakeResponse(`net:${url}`); + }); + await sw.dispatch("install"); + online = false; + + const { result } = await sw.dispatch("fetch", { + request: request("/children/42", { mode: "navigate", destination: "document" }), + }); + expect((result as FakeResponse).body).toBe("net:https://admin.test/index.html"); + }); + + it("serves static assets cache-first and fills the runtime cache", async () => { + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`)); + const asset = request("/assets/index-abc123.js", { destination: "script" }); + + const first = await sw.dispatch("fetch", { request: asset }); + expect((first.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js"); + expect(sw.fetchFn).toHaveBeenCalledTimes(1); + + const second = await sw.dispatch("fetch", { request: asset }); + expect((second.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js"); + expect(sw.fetchFn).toHaveBeenCalledTimes(1); + }); + + it("never handles cross-origin, non-GET or non-static requests", async () => { + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`)); + const cases = [ + request("https://auth.example.com/logo.png", { destination: "image" }), + request("/assets/app.js", { destination: "script", method: "POST" }), + request("/api/admin/children"), + ]; + for (const req of cases) { + const { responded } = await sw.dispatch("fetch", { request: req }); + expect(responded).toBe(false); + } + expect(sw.fetchFn).not.toHaveBeenCalled(); + }); +}); + +// A Vite-style built index.html referencing hashed bundles. +const builtIndexHtml = (hash: string) => ` + + + + + + + + + +
+`; + +// Same-origin script/link URLs of an HTML document, parsed independently of +// the build plugin so the regression check does not trust its own extractor. +const referencedAssets = (html: string) => + [...new DOMParser().parseFromString(html, "text/html").querySelectorAll("script[src], link[href]")] + .map((el) => new URL(el.getAttribute("src") ?? el.getAttribute("href")!, "https://admin.test/")) + .filter((url) => url.origin === "https://admin.test") + .map((url) => url.href); + +// The unhashed files the build hashes into the revision, read from public/. +const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({ + path, + bytes: readFileSync(publicPath(`/${path}`)), +})); +const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1]; + +describe("build-time service worker injection", () => { + it("precaches every asset the cached index.html references on install (PWA-001)", async () => { + const html = builtIndexHtml("Ab12Cd34"); + const built = injectServiceWorker(html, swSource, staticFiles); + const sw = loadServiceWorker(async (url) => + new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`), + built); + + await sw.dispatch("install"); + const shell = [...sw.stores.entries()].find(([name]) => name.startsWith("nestquest-admin-shell-")); + expect(shell).toBeDefined(); + const [, shellStore] = shell!; + + const cachedHtml = shellStore.get("https://admin.test/index.html")!.body; + const assets = referencedAssets(cachedHtml); + expect(assets).toEqual( + expect.arrayContaining([ + "https://admin.test/assets/index-Ab12Cd34.js", + "https://admin.test/assets/vendor-Ab12Cd34.js", + "https://admin.test/assets/index-Ab12Cd34.css", + ]), + ); + for (const asset of assets) expect([...shellStore.keys()]).toContain(asset); + expect([...shellStore.keys()]).not.toContain("https://auth.example.com/"); + }); + + it("derives a new revision for different index.html content (PWA-002)", () => { + const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles); + const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource, staticFiles); + + expect(a).not.toContain(REVISION_MARKER); + expect(b).not.toContain(REVISION_MARKER); + expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/); + expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/); + expect(revisionOf(a)).not.toBe(revisionOf(b)); + expect(injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles)).toBe(a); + }); + + it("derives a new revision when only a precached static file changes (PWA-002)", () => { + const html = builtIndexHtml("aaaa1111"); + const base = revisionOf(injectServiceWorker(html, swSource, staticFiles)); + const withChanged = (path: string) => + staticFiles.map((f) => + f.path === path ? { path, bytes: Buffer.concat([f.bytes, Buffer.from([0])]) } : f, + ); + + expect(revisionOf(injectServiceWorker(html, swSource, [...staticFiles].reverse()))).toBe(base); + for (const path of STATIC_PRECACHE_FILES) { + const changed = revisionOf(injectServiceWorker(html, swSource, withChanged(path))); + expect(changed, path).toMatch(/^[0-9a-f]{12}$/); + expect(changed, path).not.toBe(base); + } + }); + + it("hashes every unhashed URL sw.js precaches (PWA-002)", () => { + const shellUrls = [...swSource.matchAll(/^\s*"(\/[^"]*)",$/gm)].map((m) => m[1]); + const fixed = shellUrls.filter((url) => url !== "/" && url !== "/index.html"); + expect(fixed.map((url) => url.slice(1)).sort()).toEqual([...STATIC_PRECACHE_FILES].sort()); + }); + + it("names both caches after the build revision (PWA-002)", async () => { + const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles); + const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1]; + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built); + + await sw.dispatch("install"); + await sw.dispatch("fetch", { request: request("/assets/late.js", { destination: "script" }) }); + expect([...sw.stores.keys()].sort()).toEqual([ + `nestquest-admin-runtime-${revision}`, + `nestquest-admin-shell-${revision}`, + ]); + }); + + it("fails fast when sw.js lacks the placeholders", () => { + expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];", staticFiles)).toThrow(); + }); + + it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => { + const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles); + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built); + for (const name of [ + "nestquest-admin-shell-old", + "nestquest-admin-runtime-old", + "some-other-app-cache", + ]) { + sw.openStore(name); + } + + await sw.dispatch("install"); + await sw.dispatch("activate"); + const names = [...sw.stores.keys()]; + expect(names).not.toContain("nestquest-admin-shell-old"); + expect(names).not.toContain("nestquest-admin-runtime-old"); + expect(names).toContain("some-other-app-cache"); + expect(names.some((n) => n.startsWith("nestquest-admin-shell-") && n !== "nestquest-admin-shell-old")).toBe(true); + }); +}); + +describe("registerServiceWorker", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const stubServiceWorker = (register: ReturnType) => + vi.stubGlobal("navigator", { ...navigator, serviceWorker: { register } }); + + it("does not register outside production builds", async () => { + const register = vi.fn(); + stubServiceWorker(register); + expect(import.meta.env.PROD).toBe(false); + await registerServiceWorker(); + expect(register).not.toHaveBeenCalled(); + }); + + it("registers /sw.js in production", async () => { + vi.stubEnv("PROD", true); + const register = vi.fn().mockResolvedValue({}); + stubServiceWorker(register); + await registerServiceWorker(); + expect(register).toHaveBeenCalledWith("/sw.js", { scope: "/" }); + }); + + it("logs instead of throwing when registration fails", async () => { + vi.stubEnv("PROD", true); + stubServiceWorker(vi.fn().mockRejectedValue(new Error("boom"))); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + await expect(registerServiceWorker()).resolves.toBeUndefined(); + expect(error).toHaveBeenCalled(); + }); + + it("does nothing when the browser lacks service workers", async () => { + vi.stubEnv("PROD", true); + vi.stubGlobal("navigator", {}); + await expect(registerServiceWorker()).resolves.toBeUndefined(); + }); +}); diff --git a/admin/src/pwa/register.ts b/admin/src/pwa/register.ts new file mode 100644 index 0000000..14f00f6 --- /dev/null +++ b/admin/src/pwa/register.ts @@ -0,0 +1,13 @@ +/** + * Service worker registration for the installable admin PWA. + * Registers /sw.js only in production builds, so dev servers and tests never + * run a worker. Failures are logged, never thrown — the app works without it. + */ +export async function registerServiceWorker(): Promise { + if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return; + try { + await navigator.serviceWorker.register("/sw.js", { scope: "/" }); + } catch (error) { + console.error("Service worker registration failed:", error); + } +} diff --git a/admin/vite-sw-plugin.ts b/admin/vite-sw-plugin.ts new file mode 100644 index 0000000..4db9f9e --- /dev/null +++ b/admin/vite-sw-plugin.ts @@ -0,0 +1,93 @@ +/* Build-time service worker injection. After Vite writes the bundle, the + hashed asset URLs referenced by dist/index.html and a content-derived + revision are written into dist/sw.js, so the offline shell is complete right + after install and every release ships a new worker with fresh caches. + Build tooling only — never imported by the app bundle. */ +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { Plugin } from "vite"; + +export const REVISION_MARKER = "__NQ_BUILD_REVISION__"; +const PRECACHE_PLACEHOLDER = "const PRECACHE_ASSETS = [];"; +// Unhashed files sw.js precaches by fixed URL, relative to the build output. +export const STATIC_PRECACHE_FILES = [ + "manifest.webmanifest", + "icons/icon-192.png", + "icons/icon-512.png", + "icons/icon-maskable-512.png", +]; + +export interface PrecacheFile { + path: string; + bytes: string | Uint8Array; +} + +// Every same-origin URL an index.html loads via