diff --git a/README.md b/README.md
index 2f1c518b2..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 (7114+ tests / 580 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 (7114+ tests, 580 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):**
-- **7114+ unit tests** across **580 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 482c552f4..6a63bbb87 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: 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_NAME_RE.test(name);
+}
+
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/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
new file mode 100644
index 000000000..6376223d4
--- /dev/null
+++ b/tests/unit/serviceWorkerCacheOwnership.test.ts
@@ -0,0 +1,223 @@
+// @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';
+// 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;
+ 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);
+ });
+
+ 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);
+ });
+});