diff --git a/README.md b/README.md
index 3cf9908b3..501ca0e1c 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -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('
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\}/, '