diff --git a/README.md b/README.md index 3cf9908b3..501ca0e1c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2940 keys - 7433+ tests / 597 files + 7437+ tests / 598 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2940 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7433+ tests / 597 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7437+ tests / 598 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7433+ tests, 597 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7437+ tests, 598 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-09-04, source-synchronized; CI remains authoritative for pass/fail):** -- **7433+ unit tests** across **597 test files** — CI is authoritative for pass/fail +- **7437+ unit tests** across **598 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2940 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/public/sw.js b/public/sw.js index b18e9d72c..1556f55ab 100644 --- a/public/sw.js +++ b/public/sw.js @@ -88,7 +88,8 @@ async function trimCache(cacheName, maxEntries) { // ── Offline fallback per resource type ─────────────────────── async function offlineFallback(request) { if (request.destination === 'document') { - const cached = await caches.match(`${BASE}offline.html`); + // QNBS-v3: scoped to CACHE_STATIC (where offline.html is precached) — an unscoped caches.match() searches every cache on the shared qnbs.github.io origin, not just this app's own. + const cached = await caches.match(`${BASE}offline.html`, { cacheName: CACHE_STATIC }); return cached || new Response('Offline

WorldScript Studio ist offline.

', { headers: { 'Content-Type': 'text/html' }, status: 503, @@ -241,7 +242,8 @@ self.addEventListener('fetch', (event) => { (request.destination === 'script' || request.destination === 'style') ) { event.respondWith( - caches.match(request).then((cached) => { + // QNBS-v3: scoped to CACHE_STATIC (where the network path below stores it) — same shared-origin rationale as offlineFallback's fix above. + caches.match(request, { cacheName: CACHE_STATIC }).then((cached) => { const networkFetch = fetch(request).then((response) => { if (response.ok) { caches.open(CACHE_STATIC).then((c) => c.put(request, response.clone())); @@ -290,8 +292,9 @@ self.addEventListener('fetch', (event) => { return response; }) .catch(async () => - (await caches.match(request)) || - (await caches.match(`${BASE}index.html`)) || + // QNBS-v3: each lookup scoped to the cache it's actually written to (CACHE_DYNAMIC for the navigated URL, CACHE_STATIC for the precached SPA shell) — same shared-origin rationale as above. + (await caches.match(request, { cacheName: CACHE_DYNAMIC })) || + (await caches.match(`${BASE}index.html`, { cacheName: CACHE_STATIC })) || offlineFallback(request) ) ); diff --git a/tests/unit/swCacheMatchScoping.test.ts b/tests/unit/swCacheMatchScoping.test.ts new file mode 100644 index 000000000..c0c709729 --- /dev/null +++ b/tests/unit/swCacheMatchScoping.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +// QNBS-v3: regression guard for #514 — CacheStorage is origin-scoped, not path-scoped, so a bare +// caches.match(request) on a shared origin like qnbs.github.io searches every cache on the origin, +// not just this app's own. sw.js is a classic service worker (uses `self`, not importable), so we +// assert its source contract instead of executing it, mirroring swLocaleStrategy.test.ts's pattern. +const swSource = readFileSync( + fileURLToPath(new URL('../../public/sw.js', import.meta.url)), + 'utf8', +); + +/** Extract the body of the `self.addEventListener('fetch', ...)` handler. */ +function fetchHandlerBlock(src: string): string { + const start = src.indexOf("self.addEventListener('fetch'"); + expect(start).toBeGreaterThan(-1); + const end = src.indexOf("self.addEventListener('message'", start); + expect(end).toBeGreaterThan(start); + return src.slice(start, end); +} + +/** Extract the body of the `offlineFallback` helper, called from every fetch-handler catch path. */ +function offlineFallbackBlock(src: string): string { + const start = src.indexOf('async function offlineFallback'); + expect(start).toBeGreaterThan(-1); + const end = src.indexOf('\n}', start); + expect(end).toBeGreaterThan(start); + return src.slice(start, end); +} + +/** Every top-level `caches.match(...)` call found in a source block (not `cache.match(...)` on an already-opened, already-scoped handle). Strips `//` comments first so prose mentioning `caches.match()` can't masquerade as a real call site, and normalizes the `${BASE}` interpolation to a plain placeholder so expected-value strings in this file never need to embed a real template-literal placeholder themselves. */ +function cachesDotMatchCalls(block: string): string[] { + const codeOnly = block + .split('\n') + .map((line) => line.replace(/\/\/.*$/, '')) + .join('\n'); + const calls: string[] = []; + const re = /\bcaches\.match\([^;]*?\)/g; + let m: RegExpExecArray | null = re.exec(codeOnly); + while (m !== null) { + calls.push(m[0].replace(/\$\{BASE\}/, '')); + m = re.exec(codeOnly); + } + return calls; +} + +describe('service worker — caches.match() is always scoped to an owned cache (#514)', () => { + it('the fetch handler contains exactly the 3 known caches.match() call sites', () => { + // QNBS-v3: exact count, not a lower bound — a lower bound would let a call site silently disappear (e.g. an accidental merge/refactor) without this regression test failing. + const calls = cachesDotMatchCalls(fetchHandlerBlock(swSource)); + expect(calls.length).toBe(3); + }); + + it('the JS/CSS Cache-First lookup reads from CACHE_STATIC, where the network path writes it', () => { + const start = swSource.indexOf('JS / CSS bundles'); + expect(start).toBeGreaterThan(-1); + const end = swSource.indexOf('Locale JSON', start); + expect(end).toBeGreaterThan(start); + const calls = cachesDotMatchCalls(swSource.slice(start, end)); + expect(calls).toEqual(['caches.match(request, { cacheName: CACHE_STATIC })']); + }); + + it("the navigation fallback reads the navigated URL from CACHE_DYNAMIC and the SPA shell from CACHE_STATIC — not each other's cache", () => { + const start = swSource.indexOf('Navigation — Network First'); + expect(start).toBeGreaterThan(-1); + const end = swSource.indexOf('Everything else', start); + expect(end).toBeGreaterThan(start); + const calls = cachesDotMatchCalls(swSource.slice(start, end)); + expect(calls).toEqual([ + 'caches.match(request, { cacheName: CACHE_DYNAMIC })', + 'caches.match(`index.html`, { cacheName: CACHE_STATIC })', + ]); + }); + + it('offlineFallback (reachable from every fetch-handler catch path) reads offline.html from CACHE_STATIC, where it is precached', () => { + const calls = cachesDotMatchCalls(offlineFallbackBlock(swSource)); + expect(calls).toEqual(['caches.match(`offline.html`, { cacheName: CACHE_STATIC })']); + }); +});