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('
OfflineWorldScript 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 @@
-
+
@@ -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 @@
-
+
@@ -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)