Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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-7142%2B_%2F_584_files-22C55E" alt="7142+ 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 (7142+ 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 (7142+ 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
- **7142+ 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
22 changes: 18 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,14 +186,12 @@ 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): the 1s debounced autosave never fires mid-typing — flush before reloading or edits can be lost.
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!refreshing) {
refreshing = true;
window.location.reload();
void flushThenReload();
}
});

Expand Down Expand Up @@ -225,6 +226,19 @@ const registerServiceWorker = async (): Promise<void> => {
}
};

// QNBS-v3 (DA-02): mirrors index.tsx's visibilitychange flush — a failed flush defers the reload instead of discarding edits.
async function flushThenReload(): Promise<void> {
try {
const store = appStoreRef.current;
if (store) {
await flushPersistedState(store.getState() as RootState);
Comment thread
qnbs marked this conversation as resolved.
Resolved
Comment thread
qnbs marked this conversation as resolved.
Resolved
}
window.location.reload();
Comment thread
qnbs marked this conversation as resolved.
Resolved
} catch (error) {
appLogger.error('[SW] Pre-reload state flush failed — reload deferred to avoid data loss:', error);
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
117 changes: 117 additions & 0 deletions tests/unit/registerSwUpdateFlush.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// QNBS-v3 (DA-02): proves controllerchange flushes this tab's pending 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));
}

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

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

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

appStoreRef.current = {
getState: () => ({ fake: 'state' }) 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;
});

it('flushes pending state and reloads, in that order, when a new SW 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('defers the reload when the flush fails, instead of discarding unflushed edits', async () => {
mockFlushPersistedState.mockRejectedValue(new Error('IDB write failed'));
await registerServiceWorker();

controllerChangeHandler?.();
await flushMicrotasks();

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

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