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 @@ -13,7 +13,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-2940_keys-0EA5E9" alt="i18n 19 locales — 2940 keys">
<img src="https://img.shields.io/badge/Tests-7433%2B_%2F_597_files-22C55E" alt="7433+ tests / 597 files">
<img src="https://img.shields.io/badge/Tests-7437%2B_%2F_598_files-22C55E" alt="7437+ tests / 598 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 @@ -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 (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` |
Expand Down Expand Up @@ -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 (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
Expand Down Expand Up @@ -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
- **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)

Expand Down
11 changes: 7 additions & 4 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!doctype html><title>Offline</title><p>WorldScript Studio ist offline.</p>', {
headers: { 'Content-Type': 'text/html' },
status: 503,
Expand Down Expand Up @@ -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()));
Expand Down Expand Up @@ -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)
)
);
Expand Down
81 changes: 81 additions & 0 deletions tests/unit/swCacheMatchScoping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// @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
Comment thread
qnbs marked this conversation as resolved.
// 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, 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')
.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].replace(/\$\{BASE\}/, '<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 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).toBe(3);
});

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(`<BASE>index.html`, { cacheName: CACHE_STATIC })',
]);
});

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).toEqual(['caches.match(`<BASE>offline.html`, { cacheName: CACHE_STATIC })']);
});
});
Loading