From 8f68d63dafc029d52e33ccd74d02285ec9467d53 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Wed, 26 Aug 2026 20:22:16 +0200
Subject: [PATCH 1/2] fix(pwa): never delete a CacheStorage entry this app
doesn't own
DA-01 of the post-#512 deep audit: public/sw.js deleted any cache not
exactly matching one of the 3 current-version names in activate(), and
deleted every cache unconditionally in both the IS_TAURI branch of
activate() and the CLEAR_CACHE message handler. On the app's actual
shared-origin GitHub Pages deployment (qnbs.github.io/WorldScript-Studio/),
CacheStorage is origin-scoped, not path-scoped, so any other app hosted
under the same qnbs.github.io origin could have its own caches deleted
by a WorldScript Studio service-worker activation or a user-triggered
"clear cache" action.
Added isWorldScriptOwnedCache(), matching the exact closed set of cache
name families this SW actually creates (not a broad "worldscript-"
prefix, which could still false-positive-match an unrelated cache from
some other tool), and applied it to all three deletion sites:
- non-Tauri activate(): prune only owned-and-stale (unchanged current-
generation behavior for owned caches, but foreign caches now always
survive)
- IS_TAURI branch of activate(): no evidence the Tauri WebView origin
is exclusive to this app, so apply the same predicate rather than
assuming and deleting everything
- CLEAR_CACHE message handler: clear owned caches of any generation,
never anything foreign
New tests/unit/serviceWorkerCacheOwnership.test.ts uses a Node vm-based
harness that loads the real public/sw.js source and executes its real
activate/message handlers against a mocked caches/self, proving (for
both the browser and Tauri code paths): current owned caches survive
activation, stale owned generations are pruned, foreign caches always
survive both activate and CLEAR_CACHE, owned caches are fully cleared
by CLEAR_CACHE, and a failed owned-cache deletion never causes a
foreign cache to be deleted as a side effect. Verified all 7 assertions
fail against the pre-fix code before restoring the fix, confirming the
tests are genuine regression proof, not vacuous.
---
README.md | 8 +-
public/sw.js | 16 +-
.../unit/serviceWorkerCacheOwnership.test.ts | 198 ++++++++++++++++++
3 files changed, 215 insertions(+), 7 deletions(-)
create mode 100644 tests/unit/serviceWorkerCacheOwnership.test.ts
diff --git a/README.md b/README.md
index 2f1c518b2..e9384a5b6 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,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`) | 2925 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 (7114+ tests / 580 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7121+ tests / 581 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` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7114+ tests, 580 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7121+ tests, 581 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,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-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **7114+ unit tests** across **580 test files** — CI is authoritative for pass/fail
+- **7121+ unit tests** across **581 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: **2925 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 482c552f4..315f5b3a1 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -12,6 +12,13 @@ const CACHE_DYNAMIC = `worldscript-dynamic-v${APP_VERSION}`;
const CACHE_IMAGES = `worldscript-images-v${APP_VERSION}`;
const ALL_CACHES = [CACHE_STATIC, CACHE_DYNAMIC, CACHE_IMAGES];
+// QNBS-v3: exact owned families — a shared origin (qnbs.github.io) can host other apps' caches too.
+const OWNED_CACHE_FAMILIES = ['worldscript-static-v', 'worldscript-dynamic-v', 'worldscript-images-v'];
+
+function isWorldScriptOwnedCache(name) {
+ return OWNED_CACHE_FAMILIES.some((family) => name.startsWith(family));
+}
+
const BASE = self.location.pathname.replace(/sw\.js$/, '');
// QNBS-v3: Detect the Tauri desktop WebView (served from tauri://localhost or https://tauri.localhost).
@@ -131,8 +138,9 @@ self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
try {
+ // QNBS-v3: Tauri origin exclusivity is unproven here — apply the same ownership predicate.
const keys = await caches.keys();
- await Promise.all(keys.map((name) => caches.delete(name)));
+ await Promise.all(keys.filter(isWorldScriptOwnedCache).map((name) => caches.delete(name)));
} catch (err) {
swLogger.warn('Tauri cache cleanup failed (non-fatal):', err);
}
@@ -151,7 +159,8 @@ self.addEventListener('activate', (event) => {
.then((cacheNames) =>
Promise.all(
cacheNames
- .filter((name) => !ALL_CACHES.includes(name))
+ // QNBS-v3: prune only owned-and-stale — never delete a cache we don't positively own.
+ .filter((name) => isWorldScriptOwnedCache(name) && !ALL_CACHES.includes(name))
.map((name) => {
swLogger.log('Pruning old cache:', name);
return caches.delete(name);
@@ -319,8 +328,9 @@ self.addEventListener('message', (event) => {
}
if (type === 'CLEAR_CACHE') {
+ // QNBS-v3: clear only owned caches — a shared origin can host unrelated apps' caches too.
caches.keys()
- .then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
+ .then((keys) => Promise.all(keys.filter(isWorldScriptOwnedCache).map((k) => caches.delete(k))))
.then(() => event.source?.postMessage({ type: 'CACHE_CLEARED' }));
}
diff --git a/tests/unit/serviceWorkerCacheOwnership.test.ts b/tests/unit/serviceWorkerCacheOwnership.test.ts
new file mode 100644
index 000000000..b4a1dffd7
--- /dev/null
+++ b/tests/unit/serviceWorkerCacheOwnership.test.ts
@@ -0,0 +1,198 @@
+// @vitest-environment node
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import vm from 'node:vm';
+import { beforeAll, describe, expect, it } from 'vitest';
+
+// QNBS-v3: proves the real activate/message handlers never delete a cache this app doesn't own.
+const swPath = fileURLToPath(new URL('../../public/sw.js', import.meta.url));
+const swSource = readFileSync(swPath, 'utf8');
+
+const appVersionMatch = swSource.match(/const APP_VERSION\s*=\s*'([^']+)'/);
+const extractedVersion = appVersionMatch?.[1];
+if (!extractedVersion) throw new Error('Could not extract APP_VERSION from public/sw.js');
+const APP_VERSION = extractedVersion;
+
+const CURRENT_STATIC = `worldscript-static-v${APP_VERSION}`;
+const CURRENT_DYNAMIC = `worldscript-dynamic-v${APP_VERSION}`;
+const CURRENT_IMAGES = `worldscript-images-v${APP_VERSION}`;
+const STALE_STATIC = 'worldscript-static-v0.0.0-stale-test';
+const FOREIGN_CACHE = 'some-other-github-pages-app-cache-v1';
+
+interface FakeCaches {
+ keys(): Promise;
+ open(name: string): Promise<{
+ addAll: () => Promise;
+ match: () => Promise;
+ put: () => Promise;
+ keys: () => Promise;
+ }>;
+ delete(name: string): Promise;
+ match(): Promise;
+ attemptedDeletes: string[];
+ names(): string[];
+}
+
+function createFakeCaches(initialNames: string[], rejectOnDelete?: string): FakeCaches {
+ const store = new Set(initialNames);
+ const attemptedDeletes: string[] = [];
+ return {
+ async keys() {
+ return [...store];
+ },
+ async open() {
+ return {
+ addAll: async () => {},
+ match: async () => undefined,
+ put: async () => {},
+ keys: async () => [],
+ };
+ },
+ async delete(name: string) {
+ attemptedDeletes.push(name);
+ if (name === rejectOnDelete) throw new Error(`simulated delete failure for ${name}`);
+ return store.delete(name);
+ },
+ async match() {
+ return undefined;
+ },
+ attemptedDeletes,
+ names: () => [...store],
+ };
+}
+
+type SwHandler = (event: Record) => unknown;
+
+function loadServiceWorker(opts: {
+ protocol: string;
+ hostname: string;
+ initialCacheNames: string[];
+ rejectOnDelete?: string;
+}) {
+ const handlers: Record = {};
+ const fakeCaches = createFakeCaches(opts.initialCacheNames, opts.rejectOnDelete);
+ const selfMock = {
+ location: { protocol: opts.protocol, hostname: opts.hostname, pathname: '/WorldScript-Studio/sw.js' },
+ console: { log: () => {}, warn: () => {}, error: () => {} },
+ addEventListener: (type: string, handler: SwHandler) => {
+ handlers[type] = handler;
+ },
+ clients: { claim: async () => {} },
+ registration: { unregister: async () => {} },
+ skipWaiting: () => {},
+ __WB_MANIFEST: [],
+ };
+ const context = vm.createContext({ self: selfMock, caches: fakeCaches, console: selfMock.console });
+ vm.runInContext(swSource, context);
+ // QNBS-v3: bracket-index + explicit throw satisfies noUncheckedIndexedAccess and yields non-optional SwHandler.
+ const getHandler = (type: 'activate' | 'message'): SwHandler => {
+ const handler = handlers[type];
+ if (!handler) throw new Error(`sw.js never registered a "${type}" listener`);
+ return handler;
+ };
+ return { getHandler, fakeCaches };
+}
+
+async function runWaitUntil(handler: SwHandler, event: Record = {}) {
+ let captured: unknown;
+ await handler({ ...event, waitUntil: (p: unknown) => { captured = p; } });
+ await captured;
+}
+
+describe('service worker — cache ownership (activate / CLEAR_CACHE never delete unowned caches)', () => {
+ beforeAll(() => {
+ expect(APP_VERSION.length).toBeGreaterThan(0);
+ });
+
+ it('non-Tauri activate: prunes stale owned generations, keeps current owned and foreign caches', async () => {
+ const { getHandler, fakeCaches } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [STALE_STATIC, CURRENT_STATIC, CURRENT_DYNAMIC, CURRENT_IMAGES, FOREIGN_CACHE],
+ });
+ await runWaitUntil(getHandler('activate'));
+ const remaining = fakeCaches.names();
+ expect(remaining).not.toContain(STALE_STATIC);
+ expect(remaining).toContain(CURRENT_STATIC);
+ expect(remaining).toContain(CURRENT_DYNAMIC);
+ expect(remaining).toContain(CURRENT_IMAGES);
+ expect(remaining).toContain(FOREIGN_CACHE);
+ });
+
+ it('non-Tauri activate: never attempts to delete a foreign cache', async () => {
+ const { getHandler, fakeCaches } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [STALE_STATIC, CURRENT_STATIC, FOREIGN_CACHE],
+ });
+ await runWaitUntil(getHandler('activate'));
+ expect(fakeCaches.attemptedDeletes).not.toContain(FOREIGN_CACHE);
+ });
+
+ it('Tauri activate: deletes owned caches of any generation, never a foreign cache', async () => {
+ const { getHandler, fakeCaches } = loadServiceWorker({
+ protocol: 'tauri:',
+ hostname: 'localhost',
+ initialCacheNames: [STALE_STATIC, CURRENT_STATIC, CURRENT_DYNAMIC, FOREIGN_CACHE],
+ });
+ await runWaitUntil(getHandler('activate'));
+ const remaining = fakeCaches.names();
+ expect(remaining).not.toContain(STALE_STATIC);
+ expect(remaining).not.toContain(CURRENT_STATIC);
+ expect(remaining).not.toContain(CURRENT_DYNAMIC);
+ expect(remaining).toContain(FOREIGN_CACHE);
+ expect(fakeCaches.attemptedDeletes).not.toContain(FOREIGN_CACHE);
+ });
+
+ it('CLEAR_CACHE: deletes owned caches of any generation, never a foreign cache', async () => {
+ const { getHandler, fakeCaches } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [STALE_STATIC, CURRENT_STATIC, CURRENT_IMAGES, FOREIGN_CACHE],
+ });
+ await getHandler('message')({ data: { type: 'CLEAR_CACHE' }, source: { postMessage: () => {} } });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const remaining = fakeCaches.names();
+ expect(remaining).not.toContain(STALE_STATIC);
+ expect(remaining).not.toContain(CURRENT_STATIC);
+ expect(remaining).not.toContain(CURRENT_IMAGES);
+ expect(remaining).toContain(FOREIGN_CACHE);
+ });
+
+ it('CLEAR_CACHE: never attempts to delete a foreign cache', async () => {
+ const { getHandler, fakeCaches } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [CURRENT_STATIC, FOREIGN_CACHE],
+ });
+ await getHandler('message')({ data: { type: 'CLEAR_CACHE' }, source: { postMessage: () => {} } });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(fakeCaches.attemptedDeletes).not.toContain(FOREIGN_CACHE);
+ });
+
+ it('non-Tauri activate: a failed owned-cache delete never causes a foreign cache to be deleted', async () => {
+ const { getHandler, fakeCaches } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [STALE_STATIC, CURRENT_STATIC, FOREIGN_CACHE],
+ rejectOnDelete: STALE_STATIC,
+ });
+ // QNBS-v3: Promise.all rejects on the simulated failure — activate's own promise chain rejects too.
+ await expect(runWaitUntil(getHandler('activate'))).rejects.toThrow();
+ expect(fakeCaches.attemptedDeletes).not.toContain(FOREIGN_CACHE);
+ expect(fakeCaches.names()).toContain(FOREIGN_CACHE);
+ });
+
+ it('Tauri activate: a failed owned-cache delete is caught and never causes a foreign cache delete', async () => {
+ const { getHandler, fakeCaches } = loadServiceWorker({
+ protocol: 'tauri:',
+ hostname: 'localhost',
+ initialCacheNames: [STALE_STATIC, FOREIGN_CACHE],
+ rejectOnDelete: STALE_STATIC,
+ });
+ // QNBS-v3: the Tauri branch wraps cleanup in try/catch, so this must resolve, not reject.
+ await runWaitUntil(getHandler('activate'));
+ expect(fakeCaches.attemptedDeletes).not.toContain(FOREIGN_CACHE);
+ expect(fakeCaches.names()).toContain(FOREIGN_CACHE);
+ });
+});
From bfd738da5eac0af57ac7fcc2c0c57939330e8ed1 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Wed, 26 Aug 2026 20:53:48 +0200
Subject: [PATCH 2/2] fix(pwa): close the DA-03 cache-ownership predicate's
boundary gap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The startsWith('worldscript-static-v')-style family check let a foreign
cache like worldscript-static-vendor-cache or worldscript-images-vendor-cache
false-positive-match and be wrongly treated as owned — the exact collision
class DA-03 exists to prevent. Replace it with an anchored regex requiring
a semver-shaped version suffix (^worldscript-(static|dynamic|images)-v
\d+\.\d+\.\d+...$), verified against every real and adversarial cache name.
register-sw.ts's independent Tauri-boot teardown had the same class of bug,
one step broader: a bare startsWith('worldscript-') matched any foreign
cache sharing that prefix at all. It now uses the same anchored predicate
(duplicated, not shared, since public/sw.js is a dependency-free classic
worker script and can't import a module).
Adds adversarial regression tests for both call sites, verified to fail
against the pre-fix code and pass against the fix.
---
README.md | 8 +-
public/sw.js | 6 +-
register-sw.ts | 7 +-
tests/unit/registerSwCacheOwnership.test.ts | 82 +++++++++++++++++++
.../unit/serviceWorkerCacheOwnership.test.ts | 25 ++++++
5 files changed, 120 insertions(+), 8 deletions(-)
create mode 100644 tests/unit/registerSwCacheOwnership.test.ts
diff --git a/README.md b/README.md
index e9384a5b6..7f3b69304 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,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`) | 2925 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 (7121+ tests / 581 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7126+ tests / 582 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` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7121+ tests, 581 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7126+ tests, 582 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,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-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **7121+ unit tests** across **581 test files** — CI is authoritative for pass/fail
+- **7126+ unit tests** across **582 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: **2925 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 315f5b3a1..6a63bbb87 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -12,11 +12,11 @@ const CACHE_DYNAMIC = `worldscript-dynamic-v${APP_VERSION}`;
const CACHE_IMAGES = `worldscript-images-v${APP_VERSION}`;
const ALL_CACHES = [CACHE_STATIC, CACHE_DYNAMIC, CACHE_IMAGES];
-// QNBS-v3: exact owned families — a shared origin (qnbs.github.io) can host other apps' caches too.
-const OWNED_CACHE_FAMILIES = ['worldscript-static-v', 'worldscript-dynamic-v', 'worldscript-images-v'];
+// QNBS-v3: anchored regex — startsWith('worldscript-static-v') also matched 'worldscript-static-vendor-cache'.
+const OWNED_CACHE_NAME_RE = /^worldscript-(?:static|dynamic|images)-v\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
function isWorldScriptOwnedCache(name) {
- return OWNED_CACHE_FAMILIES.some((family) => name.startsWith(family));
+ return OWNED_CACHE_NAME_RE.test(name);
}
const BASE = self.location.pathname.replace(/sw\.js$/, '');
diff --git a/register-sw.ts b/register-sw.ts
index 2f54013dd..8e95326c0 100644
--- a/register-sw.ts
+++ b/register-sw.ts
@@ -102,6 +102,11 @@ const isTauriEnvironment = (): boolean => {
);
};
+// QNBS-v3: mirrors public/sw.js's isWorldScriptOwnedCache — duplicated since sw.js is a classic (non-module) script.
+const OWNED_CACHE_NAME_RE = /^worldscript-(?:static|dynamic|images)-v\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
+
+export const isWorldScriptOwnedCacheName = (name: string): boolean => OWNED_CACHE_NAME_RE.test(name);
+
// ── Core registration ─────────────────────────────────────────
const registerServiceWorker = async (): Promise => {
// QNBS-v3: In Tauri, never register — and proactively tear down any SW + caches a prior build
@@ -114,7 +119,7 @@ const registerServiceWorker = async (): Promise => {
if (typeof caches !== 'undefined') {
const keys = await caches.keys();
await Promise.all(
- keys.filter((k) => k.startsWith('worldscript-')).map((k) => caches.delete(k)),
+ keys.filter(isWorldScriptOwnedCacheName).map((k) => caches.delete(k)),
);
}
appLogger.info(
diff --git a/tests/unit/registerSwCacheOwnership.test.ts b/tests/unit/registerSwCacheOwnership.test.ts
new file mode 100644
index 000000000..0392643e1
--- /dev/null
+++ b/tests/unit/registerSwCacheOwnership.test.ts
@@ -0,0 +1,82 @@
+// QNBS-v3: proves the Tauri-teardown cache cleanup in register-sw.ts never deletes an unowned cache.
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../services/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+import { isWorldScriptOwnedCacheName, registerServiceWorker } from '../../register-sw';
+
+const CURRENT_STATIC = 'worldscript-static-v1.28.1';
+const FOREIGN_CACHE = 'some-other-github-pages-app-cache-v1';
+const COLLIDING_FOREIGN_CACHE = 'worldscript-static-vendor-cache';
+
+describe('register-sw — isWorldScriptOwnedCacheName', () => {
+ it('accepts real owned cache names', () => {
+ expect(isWorldScriptOwnedCacheName(CURRENT_STATIC)).toBe(true);
+ expect(isWorldScriptOwnedCacheName('worldscript-dynamic-v1.28.1')).toBe(true);
+ expect(isWorldScriptOwnedCacheName('worldscript-images-v1.28.1')).toBe(true);
+ });
+
+ it('rejects a foreign cache whose name merely shares the owned prefix', () => {
+ expect(isWorldScriptOwnedCacheName(COLLIDING_FOREIGN_CACHE)).toBe(false);
+ expect(isWorldScriptOwnedCacheName(FOREIGN_CACHE)).toBe(false);
+ });
+});
+
+describe('register-sw — Tauri teardown never deletes an unowned cache', () => {
+ let deletedNames: string[];
+ let cacheStore: Set;
+
+ beforeEach(() => {
+ deletedNames = [];
+ cacheStore = new Set([CURRENT_STATIC, FOREIGN_CACHE, COLLIDING_FOREIGN_CACHE]);
+
+ Object.defineProperty(window, '__TAURI_INTERNALS__', {
+ value: {},
+ writable: true,
+ configurable: true,
+ enumerable: true,
+ });
+
+ Object.defineProperty(navigator, 'serviceWorker', {
+ value: {
+ getRegistrations: async () => [],
+ },
+ writable: true,
+ configurable: true,
+ enumerable: true,
+ });
+
+ Object.defineProperty(globalThis, 'caches', {
+ value: {
+ keys: async () => [...cacheStore],
+ delete: async (name: string) => {
+ deletedNames.push(name);
+ return cacheStore.delete(name);
+ },
+ },
+ writable: true,
+ configurable: true,
+ enumerable: true,
+ });
+ });
+
+ afterEach(() => {
+ // @ts-expect-error — test-only cleanup of a property this suite defines itself.
+ delete window.__TAURI_INTERNALS__;
+ // @ts-expect-error — jsdom's Navigator normally lacks serviceWorker; restore that absence.
+ delete navigator.serviceWorker;
+ // @ts-expect-error — jsdom lacks a global caches object by default; restore that absence.
+ delete globalThis.caches;
+ });
+
+ it('deletes the owned cache but never the foreign caches, including the colliding-prefix one', async () => {
+ await registerServiceWorker();
+ expect(deletedNames).toContain(CURRENT_STATIC);
+ expect(deletedNames).not.toContain(FOREIGN_CACHE);
+ expect(deletedNames).not.toContain(COLLIDING_FOREIGN_CACHE);
+ expect(cacheStore.has(FOREIGN_CACHE)).toBe(true);
+ expect(cacheStore.has(COLLIDING_FOREIGN_CACHE)).toBe(true);
+ });
+});
diff --git a/tests/unit/serviceWorkerCacheOwnership.test.ts b/tests/unit/serviceWorkerCacheOwnership.test.ts
index b4a1dffd7..6376223d4 100644
--- a/tests/unit/serviceWorkerCacheOwnership.test.ts
+++ b/tests/unit/serviceWorkerCacheOwnership.test.ts
@@ -18,6 +18,8 @@ const CURRENT_DYNAMIC = `worldscript-dynamic-v${APP_VERSION}`;
const CURRENT_IMAGES = `worldscript-images-v${APP_VERSION}`;
const STALE_STATIC = 'worldscript-static-v0.0.0-stale-test';
const FOREIGN_CACHE = 'some-other-github-pages-app-cache-v1';
+// QNBS-v3: a startsWith('worldscript-static-v') predicate would wrongly treat this as owned.
+const COLLIDING_FOREIGN_CACHE = 'worldscript-static-vendor-cache';
interface FakeCaches {
keys(): Promise;
@@ -195,4 +197,27 @@ describe('service worker — cache ownership (activate / CLEAR_CACHE never delet
expect(fakeCaches.attemptedDeletes).not.toContain(FOREIGN_CACHE);
expect(fakeCaches.names()).toContain(FOREIGN_CACHE);
});
+
+ it('non-Tauri activate: never deletes a foreign cache whose name shares the owned prefix', async () => {
+ const { getHandler, fakeCaches } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [CURRENT_STATIC, COLLIDING_FOREIGN_CACHE],
+ });
+ await runWaitUntil(getHandler('activate'));
+ expect(fakeCaches.attemptedDeletes).not.toContain(COLLIDING_FOREIGN_CACHE);
+ expect(fakeCaches.names()).toContain(COLLIDING_FOREIGN_CACHE);
+ });
+
+ it('CLEAR_CACHE: never deletes a foreign cache whose name shares the owned prefix', async () => {
+ const { getHandler, fakeCaches } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [CURRENT_STATIC, COLLIDING_FOREIGN_CACHE],
+ });
+ await getHandler('message')({ data: { type: 'CLEAR_CACHE' }, source: { postMessage: () => {} } });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(fakeCaches.attemptedDeletes).not.toContain(COLLIDING_FOREIGN_CACHE);
+ expect(fakeCaches.names()).toContain(COLLIDING_FOREIGN_CACHE);
+ });
});