Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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-7138%2B_%2F_583_files-22C55E" alt="7138+ tests / 583 files">
<img src="https://img.shields.io/badge/Tests-7145%2B_%2F_584_files-22C55E" alt="7145+ tests / 584 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 (7138+ tests / 583 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (7145+ tests / 584 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 (7138+ tests, 583 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (7145+ tests, 584 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):**
- **7138+ unit tests** across **583 test files** — CI is authoritative for pass/fail
- **7145+ unit tests** across **584 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
4 changes: 1 addition & 3 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,7 @@ async function offlineFallback(request) {
// INSTALL — Precache shell
// ════════════════════════════════════════════════════════════
self.addEventListener('install', (event) => {
// QNBS-v3: skipWaiting immediately so a new SW never sits in "waiting" state behind a stale
// active SW that serves cached v.old assets. The app auto-saves to IDB so a mid-session
// reload is safe. Paired with clients.claim() in activate this ensures all tabs get new code.
// QNBS-v3 (DA-02): activates immediately, no waiting — register-sw.ts owns the bounded pre-reload flush mitigation, residual risk tracked in #518.
self.skipWaiting();
// QNBS-v3: Never precache inside Tauri — the desktop app serves its shell from the bundle.
if (IS_TAURI) return;
Expand Down
47 changes: 43 additions & 4 deletions register-sw.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { RootState } from './app/store';
import { appStoreRef } from './app/storeRef';
import { flushPersistedState } from './app/persistedStateFlush';
import { logger as appLogger } from './services/logger';

// ============================================================
Expand Down Expand Up @@ -183,13 +186,22 @@ const registerServiceWorker = async (): Promise<void> => {
announceUpdateAvailable(registration.waiting);
}

// QNBS-v3: Reload on any SW controller change — install now calls skipWaiting() automatically,
// so controllerchange fires whenever a new SW activates (not just on user-initiated updates).
// The app auto-saves to IDB so a mid-session reload is safe and always serves fresh assets.
// QNBS-v3 (DA-02): only the visible tab flushes — a hidden tab's stale write could race a fresher one (residual multi-window gap: #518).
let refreshing = false;
let reloadPendingWhileHidden = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!refreshing) {
if (refreshing) return;
if (document.visibilityState !== 'visible') {
Comment thread
qnbs marked this conversation as resolved.
Resolved
reloadPendingWhileHidden = true;
return;
}
refreshing = true;
void flushLatestStateThenReload();
});
document.addEventListener('visibilitychange', () => {
if (reloadPendingWhileHidden && document.visibilityState === 'visible' && !refreshing) {
refreshing = true;
// QNBS-v3 (DA-02, residual risk tracked in #518): no flush here — index.tsx's best-effort hide-time flush may have failed, but re-flushing this possibly-stale copy risks clobbering a fresher write from another tab, which is the worse failure mode of the two.
window.location.reload();
Comment thread
qnbs marked this conversation as resolved.
Resolved
}
});
Expand Down Expand Up @@ -225,6 +237,33 @@ const registerServiceWorker = async (): Promise<void> => {
}
};

// QNBS-v3 (DA-02): loops until a flush completes against state that provably hasn't changed since — a single snapshot could miss edits made while the async write was still in flight.
const MAX_FLUSH_ATTEMPTS = 5;

async function flushLatestState(): Promise<void> {
const store = appStoreRef.current;
if (!store) return;
let snapshot = store.getState();
for (let attempt = 0; attempt < MAX_FLUSH_ATTEMPTS; attempt++) {
await flushPersistedState(snapshot as RootState);
const latest = store.getState();
if (latest === snapshot) return;
Comment thread
qnbs marked this conversation as resolved.
Resolved
snapshot = latest;
Comment thread
qnbs marked this conversation as resolved.
Resolved
}
// QNBS-v3 (codex, residual risk #518): narrows but doesn't eliminate the race — a keystroke during this final await is still possible to lose.
await flushPersistedState(store.getState() as RootState);
Comment thread
qnbs marked this conversation as resolved.
Resolved
}

// QNBS-v3 (DA-02): the reload always proceeds — activation already pruned old-version caches by the time controllerchange fires, so staying on the old bundle risks missing-chunk failures too.
async function flushLatestStateThenReload(): Promise<void> {
try {
await flushLatestState();
Comment thread
qnbs marked this conversation as resolved.
Resolved
Comment thread
qnbs marked this conversation as resolved.
Resolved
} catch (error) {
appLogger.error('[SW] Pre-reload state flush failed (reloading anyway):', error);
}
window.location.reload();
Comment thread
qnbs marked this conversation as resolved.
Resolved
Comment thread
qnbs marked this conversation as resolved.
Resolved
}

if (typeof window !== 'undefined') {
window.addEventListener('load', registerServiceWorker);
}
Expand Down
7 changes: 4 additions & 3 deletions tests/unit/registerSwCacheOwnership.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// 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() },
}));
vi.mock('../../services/logger', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/logger')>();
return { ...actual, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } };
});

import { isWorldScriptOwnedCacheName, registerServiceWorker } from '../../register-sw';

Expand Down
186 changes: 186 additions & 0 deletions tests/unit/registerSwUpdateFlush.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// QNBS-v3 (DA-02): proves controllerchange flushes the latest visible-tab state before reloading.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('../../services/logger', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/logger')>();
return { ...actual, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } };
});

const { mockFlushPersistedState } = vi.hoisted(() => ({ mockFlushPersistedState: vi.fn() }));
vi.mock('../../app/persistedStateFlush', () => ({ flushPersistedState: mockFlushPersistedState }));

import { appStoreRef } from '../../app/storeRef';
import { registerServiceWorker } from '../../register-sw';

async function flushMicrotasks(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}

function setVisibility(value: 'visible' | 'hidden'): void {
Object.defineProperty(document, 'visibilityState', { value, configurable: true });
}

describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
let controllerChangeHandler: (() => void) | undefined;
let reloadSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
vi.clearAllMocks();
controllerChangeHandler = undefined;
setVisibility('visible');

for (const key of ['__TAURI_INTERNALS__', '__TAURI__', '__TAURI_METADATA__']) {
delete (window as unknown as Record<string, unknown>)[key];
}

reloadSpy = vi.fn();
Object.defineProperty(window, 'location', {
value: { ...window.location, reload: reloadSpy },
writable: true,
configurable: true,
});

const fakeRegistration = {
scope: '/',
installing: null,
waiting: null,
addEventListener: vi.fn(),
};

Object.defineProperty(navigator, 'serviceWorker', {
value: {
register: vi.fn().mockResolvedValue(fakeRegistration),
controller: {},
addEventListener: vi.fn((type: string, handler: () => void) => {
if (type === 'controllerchange') controllerChangeHandler = handler;
}),
},
writable: true,
configurable: true,
});

// QNBS-v3: a stable reference — a fresh object each call would never satisfy the "unchanged" stop condition.
const stableState = { fake: 'state' };
appStoreRef.current = {
Comment thread
qnbs marked this conversation as resolved.
Resolved
getState: () => stableState as never,
dispatch: vi.fn() as never,
};
});

afterEach(() => {
appStoreRef.current = null;
// @ts-expect-error — test-only cleanup of a property this suite defines itself.
delete navigator.serviceWorker;
setVisibility('visible');
});

it('flushes pending state and reloads, in that order, when the visible tab takes control', async () => {
mockFlushPersistedState.mockResolvedValue(undefined);
await registerServiceWorker();
expect(controllerChangeHandler).toBeTypeOf('function');

controllerChangeHandler?.();
await flushMicrotasks();

expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
expect(reloadSpy).toHaveBeenCalledTimes(1);
const flushOrder = mockFlushPersistedState.mock.invocationCallOrder[0] as number;
const reloadOrder = reloadSpy.mock.invocationCallOrder[0] as number;
expect(flushOrder).toBeLessThan(reloadOrder);
Comment thread
qnbs marked this conversation as resolved.
Resolved
});

it('reloads even when the flush fails, rather than staying on a bundle whose old cache is already pruned', async () => {
mockFlushPersistedState.mockRejectedValue(new Error('IDB write failed'));
await registerServiceWorker();

controllerChangeHandler?.();
await flushMicrotasks();

expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
expect(reloadSpy).toHaveBeenCalledTimes(1);
});

it('ignores a second controllerchange event (single-flight)', async () => {
mockFlushPersistedState.mockResolvedValue(undefined);
await registerServiceWorker();

controllerChangeHandler?.();
controllerChangeHandler?.();
await flushMicrotasks();

expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
expect(reloadSpy).toHaveBeenCalledTimes(1);
});

it('still reloads when no store is mounted yet (defensive null-guard, nothing to flush)', async () => {
appStoreRef.current = null;
await registerServiceWorker();

controllerChangeHandler?.();
await flushMicrotasks();

expect(mockFlushPersistedState).not.toHaveBeenCalled();
expect(reloadSpy).toHaveBeenCalledTimes(1);
});

// QNBS-v3 (codex P1): a hidden tab's state can't be fresher than what's already persisted — only the visible tab flushes.
it('defers reload while the tab is hidden, then reloads once visible without flushing again', async () => {
mockFlushPersistedState.mockResolvedValue(undefined);
await registerServiceWorker();
setVisibility('hidden');

controllerChangeHandler?.();
await flushMicrotasks();

expect(mockFlushPersistedState).not.toHaveBeenCalled();
expect(reloadSpy).not.toHaveBeenCalled();

setVisibility('visible');
document.dispatchEvent(new Event('visibilitychange'));
await flushMicrotasks();

// QNBS-v3 (codex): index.tsx's own visibilitychange listener already flushed this tab when it went hidden — flushing its possibly-stale copy again here could clobber a fresher write from another tab.
expect(mockFlushPersistedState).not.toHaveBeenCalled();
expect(reloadSpy).toHaveBeenCalledTimes(1);
});

// QNBS-v3 (CodeAnt/codex): a single snapshot could miss an edit made while the async write is still in flight.
it('re-flushes with the latest state when it changes during the pending flush, before reloading', async () => {
const stateA = { v: 'a' };
const stateB = { v: 'b' };
// 1st getState(): stateA. 2nd (after flush #1): stateB (changed — retry). 3rd (after flush #2): stateB (stable — stop).
const getStateMock = vi.fn().mockReturnValueOnce(stateA).mockReturnValueOnce(stateB).mockReturnValue(stateB);
appStoreRef.current = { getState: getStateMock, dispatch: vi.fn() as never };
mockFlushPersistedState.mockResolvedValue(undefined);

await registerServiceWorker();
controllerChangeHandler?.();
await flushMicrotasks();

expect(mockFlushPersistedState).toHaveBeenCalledTimes(2);
expect(mockFlushPersistedState).toHaveBeenNthCalledWith(1, stateA);
expect(mockFlushPersistedState).toHaveBeenNthCalledWith(2, stateB);
expect(reloadSpy).toHaveBeenCalledTimes(1);
const lastFlushOrder = mockFlushPersistedState.mock.invocationCallOrder[1] as number;
const reloadOrder = reloadSpy.mock.invocationCallOrder[0] as number;
expect(lastFlushOrder).toBeLessThan(reloadOrder);
});

// QNBS-v3 (codex P2): the retry loop can exhaust its budget while state keeps changing — one guaranteed final flush must still capture whatever's freshest, not silently drop it.
it('performs one final guaranteed flush of the freshest state after exhausting the retry budget', async () => {
const states = Array.from({ length: 7 }, (_, i) => ({ v: i }));
const getStateMock = vi.fn();
for (const s of states) getStateMock.mockReturnValueOnce(s);
appStoreRef.current = { getState: getStateMock, dispatch: vi.fn() as never };
mockFlushPersistedState.mockResolvedValue(undefined);

await registerServiceWorker();
controllerChangeHandler?.();
await flushMicrotasks();

// 5 in-loop attempts (states[0..4]) + 1 guaranteed final flush of the freshest state (states[6]).
expect(mockFlushPersistedState).toHaveBeenCalledTimes(6);
expect(mockFlushPersistedState).toHaveBeenNthCalledWith(6, states[6]);
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
});
Loading