diff --git a/README.md b/README.md index 2f1c518b2..7f3b69304 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7114+ tests / 580 files + 7126+ tests / 582 files Codecov Coverage License MIT CI Status @@ -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); + }); +});