From efb4ad238266ca4ff59a4757fb395d8ac8b84e5b Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:48:30 +0200 Subject: [PATCH 1/4] fix(sw): scope every fetch-handler caches.match() to its owned cache (#514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CacheStorage is origin-scoped, not path-scoped. On a shared origin like qnbs.github.io (hosting multiple independent GitHub Pages projects), a bare caches.match(request) searches every cache on the origin, not just this app's own CACHE_STATIC/CACHE_DYNAMIC/CACHE_IMAGES — in principle a different project's cached response for a coincidentally-identical full URL could be served here. This is the read-path counterpart to DA-03 (#513), which fixed the same shared-origin invariant for cache deletion. Scopes all 4 unscoped caches.match() call sites (JS/CSS Cache-First, navigation fallback's two lookups, and the offlineFallback helper reachable from every fetch-handler catch path) to the explicit cache each one's value actually lives in, via the standard { cacheName } match option — the same pattern already used elsewhere in this file for reads via an opened cache handle. Regression test mirrors the existing source-contract style for this classic (non-importable) worker script: asserts every caches.match() call in the fetch handler and in offlineFallback carries an explicit cacheName, and is confirmed to fail against the pre-fix source. --- public/sw.js | 11 ++-- tests/unit/swCacheMatchScoping.test.ts | 69 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 tests/unit/swCacheMatchScoping.test.ts 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..6e49e2d48 --- /dev/null +++ b/tests/unit/swCacheMatchScoping.test.ts @@ -0,0 +1,69 @@ +// @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. */ +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]); + m = re.exec(codeOnly); + } + return calls; +} + +describe('service worker — caches.match() is always scoped to an owned cache (#514)', () => { + it('the fetch handler contains at least the known caches.match() call sites', () => { + const calls = cachesDotMatchCalls(fetchHandlerBlock(swSource)); + expect(calls.length).toBeGreaterThanOrEqual(2); + }); + + it('every caches.match() call in the fetch handler passes an explicit cacheName', () => { + const calls = cachesDotMatchCalls(fetchHandlerBlock(swSource)); + for (const call of calls) { + expect(call).toMatch(/cacheName:\s*CACHE_(STATIC|DYNAMIC|IMAGES)/); + } + }); + + it('offlineFallback (reachable from every fetch-handler catch path) also scopes its caches.match()', () => { + const calls = cachesDotMatchCalls(offlineFallbackBlock(swSource)); + expect(calls.length).toBeGreaterThanOrEqual(1); + for (const call of calls) { + expect(call).toMatch(/cacheName:\s*CACHE_(STATIC|DYNAMIC|IMAGES)/); + } + }); +}); From 1bc02756984bd9ca5f82a670e644552f788ce328 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:49:51 +0200 Subject: [PATCH 2/4] docs: sync README test-count metrics for the new SW cache-scoping regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-doc-metrics.mjs computes its expected count from the actual Vitest source set — adding tests/unit/swCacheMatchScoping.test.ts (3 tests) shifted 597→598 files and 7433→7436 tests. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3cf9908b3..6a3367e6f 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 + 7436+ 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 (7436+ 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 (7436+ 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 +- **7436+ 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) From 26cc61e000ccbd40a0d7d61acc71f7a1ede53388 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:09:17 +0200 Subject: [PATCH 3/4] test(sw): tighten cache-scoping regression assertions per review Two real gaps: (1) the fetch-handler call-count assertion used a lower bound, so a call site could silently disappear without failing; (2) the cacheName assertion accepted any of the three owned caches, so a lookup scoped to the wrong cache (e.g. reading CACHE_IMAGES for a value written to CACHE_STATIC) would still pass. Verified the fix by injecting a wrong-cache mistake locally and confirming it now fails, then reverting. Each call site is now checked against its exact expected cache name; the ${BASE} interpolation is normalized to a plain placeholder in the extracted call text so the expected-value strings don't need to embed a real template-literal placeholder (avoids fighting biome's noTemplateCurlyInString on a literal string, without a suppression). --- tests/unit/swCacheMatchScoping.test.ts | 40 +++++++++++++++++--------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/tests/unit/swCacheMatchScoping.test.ts b/tests/unit/swCacheMatchScoping.test.ts index 6e49e2d48..c0c709729 100644 --- a/tests/unit/swCacheMatchScoping.test.ts +++ b/tests/unit/swCacheMatchScoping.test.ts @@ -30,7 +30,7 @@ function offlineFallbackBlock(src: string): string { 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. */ +/** 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') @@ -40,30 +40,42 @@ function cachesDotMatchCalls(block: string): string[] { const re = /\bcaches\.match\([^;]*?\)/g; let m: RegExpExecArray | null = re.exec(codeOnly); while (m !== null) { - calls.push(m[0]); + 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 at least the known caches.match() call sites', () => { + 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).toBeGreaterThanOrEqual(2); + expect(calls.length).toBe(3); }); - it('every caches.match() call in the fetch handler passes an explicit cacheName', () => { - const calls = cachesDotMatchCalls(fetchHandlerBlock(swSource)); - for (const call of calls) { - expect(call).toMatch(/cacheName:\s*CACHE_(STATIC|DYNAMIC|IMAGES)/); - } + 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) also scopes its caches.match()', () => { + 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.length).toBeGreaterThanOrEqual(1); - for (const call of calls) { - expect(call).toMatch(/cacheName:\s*CACHE_(STATIC|DYNAMIC|IMAGES)/); - } + expect(calls).toEqual(['caches.match(`offline.html`, { cacheName: CACHE_STATIC })']); }); }); From 1db048dea8ab6329474420a30b80d61a7bb324a5 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:09:56 +0200 Subject: [PATCH 4/4] docs: sync README test-count metrics for the tightened SW cache-scoping test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review-driven tightening split one assertion into two, adding a 4th test (7436→7437) without changing the file count. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6a3367e6f..501ca0e1c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2940 keys - 7436+ tests / 598 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 (7436+ tests / 598 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 (7436+ tests, 598 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):** -- **7436+ unit tests** across **598 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)