Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2925_keys-0EA5E9" alt="i18n 19 locales — 2925 keys">
<img src="https://img.shields.io/badge/Tests-7114%2B_%2F_580_files-22C55E" alt="7114+ tests / 580 files">
<img src="https://img.shields.io/badge/Tests-7126%2B_%2F_582_files-22C55E" alt="7126+ tests / 582 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
16 changes: 13 additions & 3 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)));
Comment thread
qnbs marked this conversation as resolved.
} catch (err) {
swLogger.warn('Tauri cache cleanup failed (non-fatal):', err);
}
Expand All @@ -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))
Comment thread
qnbs marked this conversation as resolved.
.map((name) => {
swLogger.log('Pruning old cache:', name);
return caches.delete(name);
Expand Down Expand Up @@ -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' }));
}

Expand Down
7 changes: 6 additions & 1 deletion register-sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
// QNBS-v3: In Tauri, never register — and proactively tear down any SW + caches a prior build
Expand All @@ -114,7 +119,7 @@ const registerServiceWorker = async (): Promise<void> => {
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(
Expand Down
82 changes: 82 additions & 0 deletions tests/unit/registerSwCacheOwnership.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>;

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);
});
});
Loading
Loading