From 812be326153a4374da8dbc73c8ed0f993bba3e43 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:47:07 +0200 Subject: [PATCH 01/10] fix(pwa): flush pending state before a forced SW-update reload (DA-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register-sw.ts unconditionally reloaded on any controllerchange event. The project autosave in app/listenerMiddleware.ts debounces 1s after the last change and is reset on every keystroke, so a user typing continuously has no durable copy of their in-flight edit until they pause — a reload landing inside that window discarded it. "The app auto-saves so a reload is safe" was an unverified assumption, not something the code guaranteed. controllerchange fires on every open tab whenever any tab applies a SW update, not just the tab that triggered it, so the fix lives in the reload handler itself rather than gating who's allowed to trigger an update: each tab now calls the same flushPersistedState() already used for the visibilitychange/quit-flush case, awaits it, and only then reloads. If the flush itself fails, the reload is deferred (not forced) rather than risk discarding still-in-memory, not-yet-persisted edits — the tab just keeps running the old (still-working) bundle until the next natural navigation. public/sw.js's automatic skipWaiting() on install is unchanged; only the client's reaction to the resulting controllerchange was unsafe. New regression tests (registerSwUpdateFlush.test.ts) verify flush-then-reload ordering, that a flush failure defers the reload, single-flight behavior on duplicate controllerchange events, and the defensive no-store-mounted case — verified against the pre-fix code to genuinely fail. --- README.md | 8 +- register-sw.ts | 22 +++- tests/unit/registerSwCacheOwnership.test.ts | 7 +- tests/unit/registerSwUpdateFlush.test.ts | 117 ++++++++++++++++++++ 4 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 tests/unit/registerSwUpdateFlush.test.ts diff --git a/README.md b/README.md index 75801bee3..315cb55d0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7138+ tests / 583 files + 7142+ tests / 584 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 (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` | @@ -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 @@ -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) diff --git a/register-sw.ts b/register-sw.ts index 8e95326c0..6a322329d 100644 --- a/register-sw.ts +++ b/register-sw.ts @@ -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'; // ============================================================ @@ -183,14 +186,12 @@ const registerServiceWorker = async (): Promise => { 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(); } }); @@ -225,6 +226,19 @@ const registerServiceWorker = async (): Promise => { } }; +// QNBS-v3 (DA-02): mirrors index.tsx's visibilitychange flush — a failed flush defers the reload instead of discarding edits. +async function flushThenReload(): Promise { + try { + const store = appStoreRef.current; + if (store) { + await flushPersistedState(store.getState() as RootState); + } + window.location.reload(); + } catch (error) { + appLogger.error('[SW] Pre-reload state flush failed — reload deferred to avoid data loss:', error); + } +} + if (typeof window !== 'undefined') { window.addEventListener('load', registerServiceWorker); } diff --git a/tests/unit/registerSwCacheOwnership.test.ts b/tests/unit/registerSwCacheOwnership.test.ts index 0392643e1..47a94a26f 100644 --- a/tests/unit/registerSwCacheOwnership.test.ts +++ b/tests/unit/registerSwCacheOwnership.test.ts @@ -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(); + return { ...actual, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } }; +}); import { isWorldScriptOwnedCacheName, registerServiceWorker } from '../../register-sw'; diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts new file mode 100644 index 000000000..3fe3e3e6a --- /dev/null +++ b/tests/unit/registerSwUpdateFlush.test.ts @@ -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(); + 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 { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { + let controllerChangeHandler: (() => void) | undefined; + let reloadSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + controllerChangeHandler = undefined; + + for (const key of ['__TAURI_INTERNALS__', '__TAURI__', '__TAURI_METADATA__']) { + delete (window as unknown as Record)[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); + }); + + 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); + }); +}); From 8fb9c2bdf51513f0a97524dedfa8115c2e74837b Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:17:32 +0200 Subject: [PATCH 02/10] fix(pwa): close DA-02 review-wave gaps in the flush-before-reload fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #517's first review wave (CodeAnt AI, chatgpt-codex-connector), which found the first flush-before-reload fix real but incomplete: - Cross-tab clobber (codex P1, critical): controllerchange fires on every open tab whenever any tab applies an update. Every tab flushing its own Redux snapshot concurrently meant a background tab's stale state could finish writing after an actively-edited tab's fresh flush, silently overwriting it — all tabs then reload from the stale record. Only the currently visible tab now flushes; a hidden tab defers both its flush and reload until it becomes visible again (document.visibilitychange), so a backgrounded tab's state — which by definition can't be fresher than what's already persisted — never races a foreground write. - Stale snapshot during a slow flush (CodeAnt + codex, duplicate finding): the state passed to flushPersistedState was captured once, before the async write resolved. Typing that continued during that window was never included in the flushed snapshot, and the reload discarded it anyway. flushLatestState() now loops: after each flush, it re-reads the store and flushes again if it changed, stopping once a flush completes against state proven unchanged since (bounded to 5 attempts). - Cache-deletion trap (codex P2): deferring the reload on a flush failure (the prior design) doesn't actually protect anything — by the time controllerchange fires, activation has already pruned every old-version cache, so a tab left running the old bundle already risks missing-chunk failures on any not-yet-loaded lazy view regardless. The reload now always proceeds after the flush attempt, success or failure, matching what "staying on the old bundle" was never actually able to guarantee. Test suite rewritten for the new design: visible-tab-only flush, hidden-tab deferral through visibilitychange, retry-until-stable flushing, and always-reloads-regardless-of-flush-outcome — each verified to fail against the prior (first-wave) commit. --- README.md | 8 ++-- register-sw.ts | 44 ++++++++++++----- tests/unit/registerSwUpdateFlush.test.ts | 61 ++++++++++++++++++++++-- 3 files changed, 93 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 315cb55d0..05a0a0ab1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7142+ tests / 584 files + 7144+ tests / 584 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 (7142+ tests / 584 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7144+ 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` | @@ -550,7 +550,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7142+ tests, 584 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7144+ tests, 584 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):** -- **7142+ unit tests** across **584 test files** — CI is authoritative for pass/fail +- **7144+ 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) diff --git a/register-sw.ts b/register-sw.ts index 6a322329d..a7e270cfa 100644 --- a/register-sw.ts +++ b/register-sw.ts @@ -186,12 +186,22 @@ const registerServiceWorker = async (): Promise => { announceUpdateAvailable(registration.waiting); } - // QNBS-v3 (DA-02): the 1s debounced autosave never fires mid-typing — flush before reloading or edits can be lost. + // QNBS-v3 (DA-02): only the visible tab flushes — a hidden tab's stale write could race a fresher one. let refreshing = false; + let reloadPendingWhileHidden = false; navigator.serviceWorker.addEventListener('controllerchange', () => { - if (!refreshing) { + if (refreshing) return; + if (document.visibilityState !== 'visible') { + reloadPendingWhileHidden = true; + return; + } + refreshing = true; + void flushLatestStateThenReload(); + }); + document.addEventListener('visibilitychange', () => { + if (reloadPendingWhileHidden && document.visibilityState === 'visible' && !refreshing) { refreshing = true; - void flushThenReload(); + void flushLatestStateThenReload(); } }); @@ -226,17 +236,29 @@ const registerServiceWorker = async (): Promise => { } }; -// QNBS-v3 (DA-02): mirrors index.tsx's visibilitychange flush — a failed flush defers the reload instead of discarding edits. -async function flushThenReload(): Promise { +// 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 { + 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; + snapshot = latest; + } +} + +// 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 { try { - const store = appStoreRef.current; - if (store) { - await flushPersistedState(store.getState() as RootState); - } - window.location.reload(); + await flushLatestState(); } catch (error) { - appLogger.error('[SW] Pre-reload state flush failed — reload deferred to avoid data loss:', error); + appLogger.error('[SW] Pre-reload state flush failed (reloading anyway):', error); } + window.location.reload(); } if (typeof window !== 'undefined') { diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts index 3fe3e3e6a..f21e26142 100644 --- a/tests/unit/registerSwUpdateFlush.test.ts +++ b/tests/unit/registerSwUpdateFlush.test.ts @@ -1,4 +1,4 @@ -// QNBS-v3 (DA-02): proves controllerchange flushes this tab's pending state before reloading. +// 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) => { @@ -16,6 +16,10 @@ async function flushMicrotasks(): Promise { 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; @@ -23,6 +27,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { beforeEach(() => { vi.clearAllMocks(); controllerChangeHandler = undefined; + setVisibility('visible'); for (const key of ['__TAURI_INTERNALS__', '__TAURI__', '__TAURI_METADATA__']) { delete (window as unknown as Record)[key]; @@ -54,8 +59,11 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { configurable: true, }); + // QNBS-v3: a stable reference — a fresh object on every call would never satisfy the + // "state hasn't changed since the last flush" stop condition and loop until MAX_FLUSH_ATTEMPTS. + const stableState = { fake: 'state' }; appStoreRef.current = { - getState: () => ({ fake: 'state' }) as never, + getState: () => stableState as never, dispatch: vi.fn() as never, }; }); @@ -64,9 +72,10 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { 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 a new SW takes control', async () => { + 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'); @@ -81,7 +90,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { expect(flushOrder).toBeLessThan(reloadOrder); }); - it('defers the reload when the flush fails, instead of discarding unflushed edits', async () => { + 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(); @@ -89,7 +98,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { await flushMicrotasks(); expect(mockFlushPersistedState).toHaveBeenCalledTimes(1); - expect(reloadSpy).not.toHaveBeenCalled(); + expect(reloadSpy).toHaveBeenCalledTimes(1); }); it('ignores a second controllerchange event (single-flight)', async () => { @@ -114,4 +123,46 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { 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 both flush and reload while the tab is hidden, then runs both once it becomes visible', 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(); + + expect(mockFlushPersistedState).toHaveBeenCalledTimes(1); + 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); + }); }); From 6868b91bd7a098af7e591e8c0983192de1b6999b Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:31:05 +0200 Subject: [PATCH 03/10] fix(pwa): close DA-02's second review wave (codex P1/P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A hidden tab that later becomes visible no longer re-flushes its own Redux state before reloading — index.tsx's own visibilitychange listener already flushed that tab's edits at the moment it went hidden, so flushing again here on becoming visible risked writing a now-possibly- stale copy over a fresher write another tab made in the meantime. It now just reloads, trusting the state already durably persisted. - flushLatestState()'s retry loop can exhaust its 5-attempt budget while state keeps churning; it now performs one unconditional final flush of whatever is freshest at that point before returning, instead of silently dropping a last-second change. - Fixed a QNBS-v3 comment I introduced earlier in this same PR but wrapped across two physical lines — missed because the self-check only covers a commit's own diff, and this edit landed in an earlier commit's changes without a fresh check run after it. New/updated regression tests for both behavioral fixes, verified against the prior commit to genuinely fail. --- README.md | 8 ++++---- register-sw.ts | 5 ++++- tests/unit/registerSwUpdateFlush.test.ts | 26 ++++++++++++++++++++---- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 05a0a0ab1..d9388a22d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7144+ tests / 584 files + 7145+ tests / 584 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 (7144+ tests / 584 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` | @@ -550,7 +550,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7144+ tests, 584 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 @@ -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):** -- **7144+ unit tests** across **584 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) diff --git a/register-sw.ts b/register-sw.ts index a7e270cfa..ff787e715 100644 --- a/register-sw.ts +++ b/register-sw.ts @@ -201,7 +201,8 @@ const registerServiceWorker = async (): Promise => { document.addEventListener('visibilitychange', () => { if (reloadPendingWhileHidden && document.visibilityState === 'visible' && !refreshing) { refreshing = true; - void flushLatestStateThenReload(); + // QNBS-v3 (codex): no flush here — index.tsx already flushed this tab's edits when it went hidden; re-flushing now could clobber a fresher write. + window.location.reload(); } }); @@ -249,6 +250,8 @@ async function flushLatestState(): Promise { if (latest === snapshot) return; snapshot = latest; } + // QNBS-v3 (codex): the loop above can exhaust its budget mid-churn — one final flush of whatever's freshest right now, rather than silently dropping it. + await flushPersistedState(store.getState() as RootState); } // 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. diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts index f21e26142..7a7e4c626 100644 --- a/tests/unit/registerSwUpdateFlush.test.ts +++ b/tests/unit/registerSwUpdateFlush.test.ts @@ -59,8 +59,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { configurable: true, }); - // QNBS-v3: a stable reference — a fresh object on every call would never satisfy the - // "state hasn't changed since the last flush" stop condition and loop until MAX_FLUSH_ATTEMPTS. + // QNBS-v3: a stable reference — a fresh object each call would never satisfy the "unchanged" stop condition. const stableState = { fake: 'state' }; appStoreRef.current = { getState: () => stableState as never, @@ -125,7 +124,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { }); // 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 both flush and reload while the tab is hidden, then runs both once it becomes visible', async () => { + it('defers reload while the tab is hidden, then reloads once visible without flushing again', async () => { mockFlushPersistedState.mockResolvedValue(undefined); await registerServiceWorker(); setVisibility('hidden'); @@ -140,7 +139,8 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { document.dispatchEvent(new Event('visibilitychange')); await flushMicrotasks(); - expect(mockFlushPersistedState).toHaveBeenCalledTimes(1); + // 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); }); @@ -165,4 +165,22 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { 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); + }); }); From 75802ef667ec85cd8943261b246e7b141f98faae Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:04:30 +0200 Subject: [PATCH 04/10] docs(pwa): correct DA-02 comments and PR description to match actual guarantees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external cross-check on #517 correctly flagged truth drift and overclaiming introduced across the two prior correction rounds: - public/sw.js still carried its original "the app auto-saves to IDB so a mid-session reload is safe" comment — exactly the unverified assumption DA-02 exists to disprove. Replaced with an accurate pointer to register-sw.ts's bounded mitigation and its tracked residual risk (#518). - register-sw.ts's own comments ("index.tsx already flushed this tab's edits", "owns reload safety") stated more certainty than the underlying mechanisms actually provide — index.tsx's hide-time flush is explicitly best-effort (a failure there is only logged, never retried), and the retry-until-stable flush narrows but doesn't eliminate its own race window. Reworded to state the trade-off honestly and point at #518. - The PR description described the first commit's design (defer reload on flush failure), which the second correction round reversed (always reload regardless of flush outcome) — never updated to match. Rewritten to describe the current code, plus a residual-risk section using the precise framing #518 itself uses: risk frequency substantially reduced, risk class (data-integrity/edit-loss) unchanged, architecture-level closure not achieved. Filed #518 to track the three residual risk classes an external review correctly identified (simultaneous multi-window writers, best-effort hide-flush failure, final-flush race window) as the scoped follow-up for the broader cross-tab write-admission work this narrow slice doesn't attempt — no behavioral change in this commit, comments and description only. --- public/sw.js | 4 +--- register-sw.ts | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/public/sw.js b/public/sw.js index 6a63bbb87..f7dbde686 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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; diff --git a/register-sw.ts b/register-sw.ts index ff787e715..60ece4ed5 100644 --- a/register-sw.ts +++ b/register-sw.ts @@ -186,7 +186,7 @@ const registerServiceWorker = async (): Promise => { announceUpdateAvailable(registration.waiting); } - // QNBS-v3 (DA-02): only the visible tab flushes — a hidden tab's stale write could race a fresher one. + // 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', () => { @@ -201,7 +201,7 @@ const registerServiceWorker = async (): Promise => { document.addEventListener('visibilitychange', () => { if (reloadPendingWhileHidden && document.visibilityState === 'visible' && !refreshing) { refreshing = true; - // QNBS-v3 (codex): no flush here — index.tsx already flushed this tab's edits when it went hidden; re-flushing now could clobber a fresher write. + // 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(); } }); @@ -250,7 +250,7 @@ async function flushLatestState(): Promise { if (latest === snapshot) return; snapshot = latest; } - // QNBS-v3 (codex): the loop above can exhaust its budget mid-churn — one final flush of whatever's freshest right now, rather than silently dropping it. + // 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); } From daf44e1ee5c7a33971ad84c66082d5ff111aff55 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:39:08 +0200 Subject: [PATCH 05/10] =?UTF-8?q?fix(pwa):=20close=20DA-02's=20third=20rev?= =?UTF-8?q?iew=20wave=20=E2=80=94=20real=20implementation=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #517's third review wave (CodeRabbit, chatgpt-codex-connector), three of which were genuine implementation bugs (not architectural gaps), fixed here: - app/persistedStateFlush.ts used Promise.all, which rejects as soon as either the settings or project save fails — without waiting for the other to settle. A caller that reloads immediately after a rejection (this PR's whole purpose) could tear down the page while the surviving save was still in flight, losing edits the flush was supposed to protect. Switched to Promise.allSettled, still failing closed (throwing the first rejection's reason) but only after both have genuinely finished. - services/storage/idbProjectStore.ts's saveSlice() resolved its promise on IDBRequest.onsuccess, which fires before the surrounding IndexedDB transaction has actually committed — a caller that reloads immediately after "success" could interrupt the commit mid-flight. Now resolves on transaction.oncomplete instead, rejecting on transaction.onerror/onabort too (in addition to the existing request.onerror). - register-sw.ts's flushLatestState() retry loop compared the whole root Redux state for "did anything change," which also fires on unrelated non-persisted churn (e.g. status.saving toggling during the write) — wasting retry attempts on noise instead of real edits. Now compares only the slices flushPersistedState actually persists (project.present, versionControl, settings). Also updated #518 with a corrected, sharper likelihood assessment for its first residual-risk class: ordinary sequential tab-switching (not just simultaneous multi-window use) can trigger the visible-tab-clobber scenario, which the issue originally under-stated as a rare compound precondition. New/updated regression tests for all three fixes, verified against the prior commit — including a broader sweep of the existing IDB/storage test suite (dbService, storageService, encryption round-trips) confirming no regression to the shared saveSlice/flushPersistedState call sites. --- README.md | 8 +- app/persistedStateFlush.ts | 7 +- register-sw.ts | 27 ++++- services/storage/idbProjectStore.ts | 6 +- tests/unit/persistedStateFlush.test.ts | 36 +++++- tests/unit/registerSwUpdateFlush.test.ts | 35 +++++- .../storage/idbProjectStoreSaveSlice.test.ts | 104 ++++++++++++++++++ 7 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 tests/unit/services/storage/idbProjectStoreSaveSlice.test.ts diff --git a/README.md b/README.md index d9388a22d..8b905c449 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7145+ tests / 584 files + 7150+ tests / 585 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 (7145+ tests / 584 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7150+ tests / 585 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 (7145+ tests, 584 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7150+ tests, 585 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):** -- **7145+ unit tests** across **584 test files** — CI is authoritative for pass/fail +- **7150+ unit tests** across **585 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/app/persistedStateFlush.ts b/app/persistedStateFlush.ts index 5a991d7cb..acda1f4a8 100644 --- a/app/persistedStateFlush.ts +++ b/app/persistedStateFlush.ts @@ -32,5 +32,10 @@ export async function flushPersistedState(state: RootState): Promise { ), ); } - await Promise.all(saves); + // QNBS-v3 (codex): allSettled — Promise.all's fail-fast let a caller reload before the other save finished; both must settle first, still failing closed if either rejected. + const results = await Promise.allSettled(saves); + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + if (rejected) throw rejected.reason; } \ No newline at end of file diff --git a/register-sw.ts b/register-sw.ts index 60ece4ed5..a55b4cb77 100644 --- a/register-sw.ts +++ b/register-sw.ts @@ -240,15 +240,34 @@ const registerServiceWorker = async (): Promise => { // 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; +// QNBS-v3 (codex): mirrors exactly what flushPersistedState reads/persists — comparing the whole root state would retry on unrelated non-persisted churn (e.g. status.saving) and waste the retry budget. +function persistedSlices(state: RootState) { + return { + project: state.project.present, + versionControl: state.versionControl, + settings: state.settings, + }; +} + +function persistedSlicesUnchanged( + a: ReturnType, + b: ReturnType, +): boolean { + return a.project === b.project && a.versionControl === b.versionControl && a.settings === b.settings; +} + async function flushLatestState(): Promise { const store = appStoreRef.current; if (!store) return; - let snapshot = store.getState(); + let snapshot = store.getState() as RootState; + let snapshotSlices = persistedSlices(snapshot); for (let attempt = 0; attempt < MAX_FLUSH_ATTEMPTS; attempt++) { - await flushPersistedState(snapshot as RootState); - const latest = store.getState(); - if (latest === snapshot) return; + await flushPersistedState(snapshot); + const latest = store.getState() as RootState; + const latestSlices = persistedSlices(latest); + if (persistedSlicesUnchanged(snapshotSlices, latestSlices)) return; snapshot = latest; + snapshotSlices = latestSlices; } // 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); diff --git a/services/storage/idbProjectStore.ts b/services/storage/idbProjectStore.ts index 2df6fdce8..a9451d43d 100644 --- a/services/storage/idbProjectStore.ts +++ b/services/storage/idbProjectStore.ts @@ -268,8 +268,12 @@ export class IdbProjectStore extends IdbAssetStore { const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); return new Promise((resolve, reject) => { const request = store.put(payload, sliceName); - request.onsuccess = () => resolve(); + const transaction = store.transaction; + // QNBS-v3 (codex): resolve on transaction commit, not request success — onsuccess fires before the write is durable, which now matters since a caller can immediately reload. request.onerror = () => reject(request.error); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error ?? new Error('IDB transaction aborted')); }); }); } diff --git a/tests/unit/persistedStateFlush.test.ts b/tests/unit/persistedStateFlush.test.ts index 88158c8dd..07c6b4a03 100644 --- a/tests/unit/persistedStateFlush.test.ts +++ b/tests/unit/persistedStateFlush.test.ts @@ -3,7 +3,9 @@ * QNBS-v3 (#332/D3): shared flush helper used by both index.tsx's visibilitychange handler and the * desktop close-to-tray quit flush — verifies it saves project+settings via storageService, always * saves settings even with no project data yet (fresh/new-user state), and fails closed on any - * rejected save (Promise.all, not Promise.allSettled) so a failed write is never silently ignored. + * rejected save (Promise.allSettled, waiting for both to settle before rejecting) so a failed + * write is never silently ignored and a caller that reloads immediately after never tears down + * the page while the other save is still in flight. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -81,4 +83,36 @@ describe('flushPersistedState', () => { h.saveSettings.mockRejectedValueOnce(new Error('disk full')); await expect(flushPersistedState(buildState())).rejects.toThrow('disk full'); }); + + // QNBS-v3 (codex): an immediate-reload caller must never tear down the page while the other save is still in flight. + it('waits for the other save to settle before rejecting, instead of rejecting as soon as one fails', async () => { + const order: string[] = []; + h.saveProject.mockImplementation(async () => { + order.push('project-rejected'); + throw new Error('project save failed'); + }); + let resolveSettings: () => void = () => {}; + h.saveSettings.mockImplementation( + () => + new Promise((resolve) => { + resolveSettings = () => { + order.push('settings-resolved'); + resolve(); + }; + }), + ); + + const flushPromise = flushPersistedState(buildState()).catch((err: unknown) => { + order.push('flush-rejected'); + throw err; + }); + + // QNBS-v3: a macrotask boundary drains every microtask the real coordinator's drain loop schedules, however many ticks deep. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(order).toEqual(['project-rejected']); + + resolveSettings(); + await expect(flushPromise).rejects.toThrow('project save failed'); + expect(order).toEqual(['project-rejected', 'settings-resolved', 'flush-rejected']); + }); }); diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts index 7a7e4c626..807307efc 100644 --- a/tests/unit/registerSwUpdateFlush.test.ts +++ b/tests/unit/registerSwUpdateFlush.test.ts @@ -59,8 +59,8 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { configurable: true, }); - // QNBS-v3: a stable reference — a fresh object each call would never satisfy the "unchanged" stop condition. - const stableState = { fake: 'state' }; + // QNBS-v3: a stable reference with real RootState shape — persistedSlices() reads project.present, matching production where project is never undefined. + const stableState = { project: { present: { fake: 'state' } }, versionControl: {}, settings: {} }; appStoreRef.current = { getState: () => stableState as never, dispatch: vi.fn() as never, @@ -146,8 +146,8 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { // 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' }; + const stateA = { project: { present: { v: 'a' } }, versionControl: {}, settings: {} }; + const stateB = { project: { present: { v: 'b' } }, versionControl: {}, settings: {} }; // 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 }; @@ -168,7 +168,11 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { // 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 states = Array.from({ length: 7 }, (_, i) => ({ + project: { present: { v: i } }, + versionControl: {}, + settings: {}, + })); const getStateMock = vi.fn(); for (const s of states) getStateMock.mockReturnValueOnce(s); appStoreRef.current = { getState: getStateMock, dispatch: vi.fn() as never }; @@ -183,4 +187,25 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { expect(mockFlushPersistedState).toHaveBeenNthCalledWith(6, states[6]); expect(reloadSpy).toHaveBeenCalledTimes(1); }); + + // QNBS-v3 (codex P2): comparing the whole root state retried on unrelated non-persisted churn (e.g. status.saving), wasting the retry budget on noise instead of real edits. + it('does not retry when only a non-persisted slice changes between getState() calls', async () => { + const project = { present: { v: 'a' } }; + const versionControl = {}; + const settings = {}; + // Same persisted slices every call — only the non-persisted `status` field differs. + const getStateMock = vi + .fn() + .mockReturnValueOnce({ project, versionControl, settings, status: { saving: 'saving' } }) + .mockReturnValue({ project, versionControl, settings, status: { saving: 'saved' } }); + appStoreRef.current = { getState: getStateMock, dispatch: vi.fn() as never }; + mockFlushPersistedState.mockResolvedValue(undefined); + + await registerServiceWorker(); + controllerChangeHandler?.(); + await flushMicrotasks(); + + expect(mockFlushPersistedState).toHaveBeenCalledTimes(1); + expect(reloadSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/services/storage/idbProjectStoreSaveSlice.test.ts b/tests/unit/services/storage/idbProjectStoreSaveSlice.test.ts new file mode 100644 index 000000000..498618779 --- /dev/null +++ b/tests/unit/services/storage/idbProjectStoreSaveSlice.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for IdbProjectStore#saveSlice — DA-02 review-wave fix (codex): the returned promise must + * resolve only once the underlying IndexedDB transaction actually commits (transaction.oncomplete), + * not merely once the individual put() request succeeds (request.onsuccess) — a caller that reloads + * immediately after resolution must never be able to tear down the page mid-commit. + */ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../services/storage/storageEncryptionService', () => ({ + resolveProtectedWriteKey: vi.fn().mockResolvedValue(null), + assertNoActiveEncryptionMigration: vi.fn().mockResolvedValue(undefined), + idbEncryptWithKey: vi.fn(), + idbReadSecure: vi.fn(), + assertIdbProtectedWriteAllowed: vi.fn().mockResolvedValue(undefined), + assertSecureStorageReadable: vi.fn().mockResolvedValue(undefined), +})); + +import { IdbProjectStore } from '../../../../services/storage/idbProjectStore'; + +interface FakeIdbRequest { + onsuccess: (() => void) | null; + onerror: (() => void) | null; + error: unknown; +} + +interface FakeIdbTransaction { + oncomplete: (() => void) | null; + onerror: (() => void) | null; + onabort: (() => void) | null; + error: unknown; +} + +function makeFakeStore(): { store: { put: () => FakeIdbRequest; transaction: FakeIdbTransaction }; request: FakeIdbRequest; transaction: FakeIdbTransaction } { + const transaction: FakeIdbTransaction = { oncomplete: null, onerror: null, onabort: null, error: null }; + const request: FakeIdbRequest = { onsuccess: null, onerror: null, error: null }; + const store = { put: () => request, transaction }; + return { store, request, transaction }; +} + +describe('IdbProjectStore#saveSlice — resolves on transaction commit, not request success', () => { + it('does not resolve when only request.onsuccess has fired', async () => { + const projectStore = new IdbProjectStore(); + const { store, request, transaction } = makeFakeStore(); + vi.spyOn( + projectStore as unknown as { getObjectStore: () => Promise }, + 'getObjectStore', + ).mockResolvedValue(store as never); + + let resolved = false; + const savePromise = projectStore.saveSlice('settings', { theme: 'dark' } as never).then(() => { + resolved = true; + }); + + // Give the async setup (key resolution, migration guard, getObjectStore) time to run and call put(). + await new Promise((resolve) => setTimeout(resolve, 0)); + request.onsuccess?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(resolved).toBe(false); + transaction.oncomplete?.(); // settle the promise so it doesn't leak past this test + await savePromise; + }); + + it('resolves once transaction.oncomplete fires, after request.onsuccess', async () => { + const projectStore = new IdbProjectStore(); + const { store, request, transaction } = makeFakeStore(); + vi.spyOn( + projectStore as unknown as { getObjectStore: () => Promise }, + 'getObjectStore', + ).mockResolvedValue(store as never); + + const order: string[] = []; + const savePromise = projectStore + .saveSlice('settings', { theme: 'dark' } as never) + .then(() => order.push('resolved')); + + await new Promise((resolve) => setTimeout(resolve, 0)); + request.onsuccess?.(); + order.push('request-succeeded'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(order).toEqual(['request-succeeded']); + + transaction.oncomplete?.(); + await savePromise; + expect(order).toEqual(['request-succeeded', 'resolved']); + }); + + it('rejects if the transaction aborts even though the request itself succeeded', async () => { + const projectStore = new IdbProjectStore(); + const { store, request, transaction } = makeFakeStore(); + vi.spyOn( + projectStore as unknown as { getObjectStore: () => Promise }, + 'getObjectStore', + ).mockResolvedValue(store as never); + + const savePromise = projectStore.saveSlice('settings', { theme: 'dark' } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + request.onsuccess?.(); + transaction.error = new Error('QuotaExceededError'); + transaction.onabort?.(); + + await expect(savePromise).rejects.toThrow('QuotaExceededError'); + }); +}); From 3eec3dacf7b8ba6ea00890602fd62ef2332e0a6b Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:03:42 +0200 Subject: [PATCH 06/10] fix(pwa): bound the pre-reload flush wait (DA-02, codex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-reload flush had no timeout — if the underlying persistence stayed pending (e.g. queued behind another tab's exclusive Web Lock during an encryption migration batch), the await never resolved or rejected, so neither the success path nor the catch-and-reload-anyway path ever ran. By that point activation has already pruned the old-version caches, so a tab stuck this way could remain indefinitely on an obsolete bundle whose lazy chunks are gone — the exact failure mode the always-reload redesign was meant to close. flushLatestStateThenReload() now races the flush against an 8s timeout; on timeout it's treated the same as any other flush failure — logged, then reload proceeds anyway. New regression test using fake timers (a flush that never settles) verified against the prior commit to genuinely hang. --- README.md | 8 ++++---- register-sw.ts | 10 +++++++++- tests/unit/registerSwUpdateFlush.test.ts | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8b905c449..4b0180d39 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7150+ tests / 585 files + 7151+ tests / 585 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 (7150+ tests / 585 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7151+ tests / 585 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 (7150+ tests, 585 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7151+ tests, 585 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):** -- **7150+ unit tests** across **585 test files** — CI is authoritative for pass/fail +- **7151+ unit tests** across **585 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/register-sw.ts b/register-sw.ts index a55b4cb77..d988fb5ab 100644 --- a/register-sw.ts +++ b/register-sw.ts @@ -273,10 +273,18 @@ async function flushLatestState(): Promise { await flushPersistedState(store.getState() as RootState); } +// QNBS-v3 (codex): bounds the flush — an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) would hang forever on an already-cache-pruned bundle, defeating the always-reload policy below. +const FLUSH_TIMEOUT_MS = 8000; + // 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 { try { - await flushLatestState(); + await Promise.race([ + flushLatestState(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Pre-reload flush timed out after ${FLUSH_TIMEOUT_MS}ms`)), FLUSH_TIMEOUT_MS), + ), + ]); } catch (error) { appLogger.error('[SW] Pre-reload state flush failed (reloading anyway):', error); } diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts index 807307efc..8e573bae9 100644 --- a/tests/unit/registerSwUpdateFlush.test.ts +++ b/tests/unit/registerSwUpdateFlush.test.ts @@ -208,4 +208,22 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { expect(mockFlushPersistedState).toHaveBeenCalledTimes(1); expect(reloadSpy).toHaveBeenCalledTimes(1); }); + + // QNBS-v3 (codex P2): an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) must not hang the reload forever on an already-cache-pruned bundle. + it('reloads once the flush timeout elapses if the flush never settles', async () => { + vi.useFakeTimers(); + try { + mockFlushPersistedState.mockImplementation(() => new Promise(() => {})); + await registerServiceWorker(); + controllerChangeHandler?.(); + + await vi.advanceTimersByTimeAsync(0); + expect(reloadSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(8000); + expect(reloadSpy).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); }); From 21a9dfc0b03c906ad001a947d2921d6a40b1b28e Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:23:11 +0200 Subject: [PATCH 07/10] docs(pwa): conform QNBS-v3 comments to the literal repo format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every QNBS-v3 comment this PR introduced used a "(TAG): reason" parenthetical prefix (e.g. "QNBS-v3 (codex): ..."), deviating from the literal repo convention "// QNBS-v3: " (nothing between the marker and the colon). The convention's actual intent (a single-line WHY-comment) was satisfied either way, and the specific "required format" a reviewer cited didn't match this repo's real guidelines — but conforming to the literal pattern costs nothing and avoids relitigating the same finding again. Folded each tag's traceability info into the reason text itself (most already referenced #518/codex by name in the body). No functional change — comments only. Also fixes an overclaiming comment this same cleanup pass caught: a test comment said index.tsx "already flushed" a hidden tab's edits, when the underlying flushOnHidden call is explicitly best-effort and can fail — now says "already attempted a flush", matching the equivalent production comment fixed earlier and the actual guarantee (or lack of one). --- app/persistedStateFlush.ts | 2 +- public/sw.js | 2 +- register-sw.ts | 14 +++++++------- services/storage/idbProjectStore.ts | 2 +- tests/unit/persistedStateFlush.test.ts | 2 +- tests/unit/registerSwUpdateFlush.test.ts | 14 +++++++------- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/persistedStateFlush.ts b/app/persistedStateFlush.ts index acda1f4a8..2a9de7ba2 100644 --- a/app/persistedStateFlush.ts +++ b/app/persistedStateFlush.ts @@ -32,7 +32,7 @@ export async function flushPersistedState(state: RootState): Promise { ), ); } - // QNBS-v3 (codex): allSettled — Promise.all's fail-fast let a caller reload before the other save finished; both must settle first, still failing closed if either rejected. + // QNBS-v3: allSettled, not Promise.all — its fail-fast let a caller reload before the other save finished; both must settle first, still failing closed if either rejected. const results = await Promise.allSettled(saves); const rejected = results.find( (result): result is PromiseRejectedResult => result.status === 'rejected', diff --git a/public/sw.js b/public/sw.js index f7dbde686..af7150382 100644 --- a/public/sw.js +++ b/public/sw.js @@ -111,7 +111,7 @@ async function offlineFallback(request) { // INSTALL — Precache shell // ════════════════════════════════════════════════════════════ self.addEventListener('install', (event) => { - // QNBS-v3 (DA-02): activates immediately, no waiting — register-sw.ts owns the bounded pre-reload flush mitigation, residual risk tracked in #518. + // QNBS-v3: 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; diff --git a/register-sw.ts b/register-sw.ts index d988fb5ab..b0a03140b 100644 --- a/register-sw.ts +++ b/register-sw.ts @@ -186,7 +186,7 @@ const registerServiceWorker = async (): Promise => { announceUpdateAvailable(registration.waiting); } - // 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). + // QNBS-v3: 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', () => { @@ -201,7 +201,7 @@ const registerServiceWorker = async (): Promise => { 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. + // QNBS-v3: no flush here — index.tsx's best-effort hide-time flush may have failed, but re-flushing risks clobbering a fresher write from another tab, the worse failure mode of the two (#518). window.location.reload(); } }); @@ -237,10 +237,10 @@ const registerServiceWorker = async (): Promise => { } }; -// 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. +// QNBS-v3: 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; -// QNBS-v3 (codex): mirrors exactly what flushPersistedState reads/persists — comparing the whole root state would retry on unrelated non-persisted churn (e.g. status.saving) and waste the retry budget. +// QNBS-v3: mirrors exactly what flushPersistedState reads/persists — comparing the whole root state would retry on unrelated non-persisted churn (e.g. status.saving) and waste the retry budget. function persistedSlices(state: RootState) { return { project: state.project.present, @@ -269,14 +269,14 @@ async function flushLatestState(): Promise { snapshot = latest; snapshotSlices = latestSlices; } - // QNBS-v3 (codex, residual risk #518): narrows but doesn't eliminate the race — a keystroke during this final await is still possible to lose. + // QNBS-v3: narrows but doesn't eliminate the race — a keystroke during this final await is still possible to lose (#518). await flushPersistedState(store.getState() as RootState); } -// QNBS-v3 (codex): bounds the flush — an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) would hang forever on an already-cache-pruned bundle, defeating the always-reload policy below. +// QNBS-v3: bounds the flush — an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) would hang forever on an already-cache-pruned bundle, defeating the always-reload policy below. const FLUSH_TIMEOUT_MS = 8000; -// 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. +// QNBS-v3: 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 { try { await Promise.race([ diff --git a/services/storage/idbProjectStore.ts b/services/storage/idbProjectStore.ts index a9451d43d..29cc4be15 100644 --- a/services/storage/idbProjectStore.ts +++ b/services/storage/idbProjectStore.ts @@ -269,7 +269,7 @@ export class IdbProjectStore extends IdbAssetStore { return new Promise((resolve, reject) => { const request = store.put(payload, sliceName); const transaction = store.transaction; - // QNBS-v3 (codex): resolve on transaction commit, not request success — onsuccess fires before the write is durable, which now matters since a caller can immediately reload. + // QNBS-v3: resolve on transaction commit, not request success — onsuccess fires before the write is durable, which now matters since a caller can immediately reload. request.onerror = () => reject(request.error); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); diff --git a/tests/unit/persistedStateFlush.test.ts b/tests/unit/persistedStateFlush.test.ts index 07c6b4a03..e445d60ee 100644 --- a/tests/unit/persistedStateFlush.test.ts +++ b/tests/unit/persistedStateFlush.test.ts @@ -84,7 +84,7 @@ describe('flushPersistedState', () => { await expect(flushPersistedState(buildState())).rejects.toThrow('disk full'); }); - // QNBS-v3 (codex): an immediate-reload caller must never tear down the page while the other save is still in flight. + // QNBS-v3: an immediate-reload caller must never tear down the page while the other save is still in flight. it('waits for the other save to settle before rejecting, instead of rejecting as soon as one fails', async () => { const order: string[] = []; h.saveProject.mockImplementation(async () => { diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts index 8e573bae9..c19d1812a 100644 --- a/tests/unit/registerSwUpdateFlush.test.ts +++ b/tests/unit/registerSwUpdateFlush.test.ts @@ -1,4 +1,4 @@ -// QNBS-v3 (DA-02): proves controllerchange flushes the latest visible-tab state before reloading. +// QNBS-v3: 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) => { @@ -123,7 +123,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { 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. + // QNBS-v3: only the visible tab flushes — a hidden tab's state can't safely be assumed fresher than what's already persisted. it('defers reload while the tab is hidden, then reloads once visible without flushing again', async () => { mockFlushPersistedState.mockResolvedValue(undefined); await registerServiceWorker(); @@ -139,12 +139,12 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { 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. + // QNBS-v3: index.tsx's own visibilitychange listener already attempted a flush when this tab went hidden — flushing its possibly-stale copy again 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. + // QNBS-v3: 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 = { project: { present: { v: 'a' } }, versionControl: {}, settings: {} }; const stateB = { project: { present: { v: 'b' } }, versionControl: {}, settings: {} }; @@ -166,7 +166,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { 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. + // QNBS-v3: 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) => ({ project: { present: { v: i } }, @@ -188,7 +188,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { expect(reloadSpy).toHaveBeenCalledTimes(1); }); - // QNBS-v3 (codex P2): comparing the whole root state retried on unrelated non-persisted churn (e.g. status.saving), wasting the retry budget on noise instead of real edits. + // QNBS-v3: comparing the whole root state retried on unrelated non-persisted churn (e.g. status.saving), wasting the retry budget on noise instead of real edits. it('does not retry when only a non-persisted slice changes between getState() calls', async () => { const project = { present: { v: 'a' } }; const versionControl = {}; @@ -209,7 +209,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { expect(reloadSpy).toHaveBeenCalledTimes(1); }); - // QNBS-v3 (codex P2): an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) must not hang the reload forever on an already-cache-pruned bundle. + // QNBS-v3: an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) must not hang the reload forever on an already-cache-pruned bundle. it('reloads once the flush timeout elapses if the flush never settles', async () => { vi.useFakeTimers(); try { From 67328f93751fdef007cc8b1683ab298e4bb2ed13 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:51:18 +0200 Subject: [PATCH 08/10] fix(storage): dedupe concurrent auto-snapshot attempts (DA-02, codex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IdbProjectStore.saveProject() only writes this.lastAutoSnapshotTime inside the createSnapshot() success callback, not before starting it. A second saveProject() call arriving while the first snapshot is still in flight (e.g. flushLatestState()'s retry loop firing several saves in quick succession once the 5-minute interval has elapsed) saw the stale timestamp and started its own concurrent createSnapshot() — the delayed-write itself is correct (a previously fixed bug: an unhandled rejection must not suppress the next legitimate attempt for a full interval), but nothing guarded against a second attempt starting before the first's callback runs. Adds a simple autoSnapshotInFlight boolean guard (idbSnapshotStore.ts, alongside the related fields) that prevents a duplicate concurrent snapshot without touching the real project save (saveSlice), which never waits on it. New regression test proves a second saveProject() call skips starting another snapshot while one is pending, and that a fresh one is still allowed once the prior one settles and the interval elapses again — verified against the pre-fix code to genuinely fail. --- README.md | 8 +- services/storage/idbProjectStore.ts | 12 +-- services/storage/idbSnapshotStore.ts | 2 + tests/unit/dbServiceAutoSnapshotRace.test.ts | 94 ++++++++++++++++++++ 4 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 tests/unit/dbServiceAutoSnapshotRace.test.ts diff --git a/README.md b/README.md index 4b0180d39..0d22e0c3a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7151+ tests / 585 files + 7153+ tests / 586 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 (7151+ tests / 585 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7153+ tests / 586 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 (7151+ tests, 585 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7153+ tests, 586 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):** -- **7151+ unit tests** across **585 test files** — CI is authoritative for pass/fail +- **7153+ unit tests** across **586 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/services/storage/idbProjectStore.ts b/services/storage/idbProjectStore.ts index 29cc4be15..a6ed2172e 100644 --- a/services/storage/idbProjectStore.ts +++ b/services/storage/idbProjectStore.ts @@ -279,16 +279,15 @@ export class IdbProjectStore extends IdbAssetStore { } async saveProject(data: SaveProjectInput): Promise { - // Check auto-snapshot condition during save - if (Date.now() - this.lastAutoSnapshotTime > this.AUTO_SNAPSHOT_INTERVAL) { + // QNBS-v3: autoSnapshotInFlight prevents a saveProject() call arriving before the first snapshot's success callback runs from starting a duplicate concurrent snapshot. + if (!this.autoSnapshotInFlight && Date.now() - this.lastAutoSnapshotTime > this.AUTO_SNAPSHOT_INTERVAL) { // data may arrive as a Redux-undo envelope (PersistedProjectState) or plain StoryProject const persisted = data as PersistedProjectState; const projectData = persisted.present ? persisted.present.data : persisted.data; if (projectData?.manuscript) { - // QNBS-v3: Only commit the timestamp after a successful snapshot — an unhandled rejection - // here (e.g. the expected locked-write error) previously suppressed the next - // automatic snapshot for a full interval even though none was actually taken. + // QNBS-v3: Only commit the timestamp after a successful snapshot — an unhandled rejection here previously suppressed the next automatic snapshot for a full interval even though none was actually taken. const snapshotTime = Date.now(); + this.autoSnapshotInFlight = true; // Fire and forget snapshot to not block UI this.createSnapshot(projectData) .then(() => { @@ -297,6 +296,9 @@ export class IdbProjectStore extends IdbAssetStore { }) .catch((error: unknown) => { logger.warn('Automatic snapshot failed', { error: String(error) }); + }) + .finally(() => { + this.autoSnapshotInFlight = false; }); } } diff --git a/services/storage/idbSnapshotStore.ts b/services/storage/idbSnapshotStore.ts index 0da8306fb..6c1302d66 100644 --- a/services/storage/idbSnapshotStore.ts +++ b/services/storage/idbSnapshotStore.ts @@ -22,6 +22,8 @@ import { export class IdbSnapshotStore extends IdbCodexStore { protected lastAutoSnapshotTime = Date.now(); + // QNBS-v3: guards against concurrent saveProject() calls each starting their own auto-snapshot before the first one's success callback updates lastAutoSnapshotTime. + protected autoSnapshotInFlight = false; protected readonly AUTO_SNAPSHOT_INTERVAL = 5 * 60 * 1000; // 5 minutes protected readonly MAX_AUTO_SNAPSHOTS = 20; diff --git a/tests/unit/dbServiceAutoSnapshotRace.test.ts b/tests/unit/dbServiceAutoSnapshotRace.test.ts new file mode 100644 index 000000000..2799297bc --- /dev/null +++ b/tests/unit/dbServiceAutoSnapshotRace.test.ts @@ -0,0 +1,94 @@ +// QNBS-v3: proves a saveProject() call arriving before the first snapshot's success callback runs never starts a duplicate concurrent auto-snapshot. +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../services/logger', () => { + const noopLogger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; + return { + logger: noopLogger, + createLogger: () => ({ ...noopLogger, withContext: () => ({ ...noopLogger }) }), + }; +}); + +const fakeStore = { + put: vi.fn().mockImplementation(() => { + const r: Record = {}; + Promise.resolve().then(() => { + if (typeof r['onsuccess'] === 'function') (r['onsuccess'] as () => void)(); + }); + return r; + }), + count: vi.fn().mockImplementation(() => { + const r = { result: 0 } as Record; + Promise.resolve().then(() => { + if (typeof r['onsuccess'] === 'function') (r['onsuccess'] as () => void)(); + }); + return r; + }), +}; + +const fakeDb = { + objectStoreNames: { contains: () => true }, + transaction: vi.fn().mockReturnValue({ objectStore: () => fakeStore }), +}; + +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('dbService — saveProject auto-snapshot in-flight guard (DA-02, codex)', () => { + let saveSliceMock: ReturnType; + let createSnapshotMock: ReturnType; + + beforeEach(() => { + vi.resetModules(); + saveSliceMock = vi.fn().mockResolvedValue(undefined); + createSnapshotMock = vi.fn(); + }); + + async function getService() { + const mod = await import('../../services/dbService'); + const svc = mod.dbService as unknown as Record; + svc['stateDb'] = fakeDb; + svc['dataDb'] = fakeDb; + svc['saveSlice'] = saveSliceMock; + svc['createSnapshot'] = createSnapshotMock; + // Force the 5-minute interval to already have elapsed. + svc['lastAutoSnapshotTime'] = 0; + return mod.dbService; + } + + const project = { title: 'T', manuscript: [{ id: '1', title: 'S', content: 'x' }] }; + + it('does not start a second concurrent auto-snapshot while the first is still pending', async () => { + let resolveFirstSnapshot: (id: number) => void = () => {}; + createSnapshotMock.mockImplementationOnce( + () => new Promise((resolve) => { resolveFirstSnapshot = resolve; }), + ); + + const svc = await getService(); + const firstSave = svc.saveProject({ present: { data: project } } as never); + const secondSave = svc.saveProject({ present: { data: project } } as never); + + await Promise.all([firstSave, secondSave]); + await flushMicrotasks(); + expect(createSnapshotMock).toHaveBeenCalledTimes(1); + + resolveFirstSnapshot(1); + await flushMicrotasks(); + }); + + it('allows a new auto-snapshot once the prior one has settled and the interval has elapsed again', async () => { + createSnapshotMock.mockResolvedValueOnce(1); + const svc = await getService(); + + await svc.saveProject({ present: { data: project } } as never); + await flushMicrotasks(); + expect(createSnapshotMock).toHaveBeenCalledTimes(1); + + (svc as unknown as Record)['lastAutoSnapshotTime'] = 0; + createSnapshotMock.mockResolvedValueOnce(2); + await svc.saveProject({ present: { data: project } } as never); + await flushMicrotasks(); + expect(createSnapshotMock).toHaveBeenCalledTimes(2); + }); +}); From 8d7f4ab2f097b82e167363b1efae57b73c475ceb Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:12:47 +0200 Subject: [PATCH 09/10] fix(pwa): compare only versionControl's persisted fields (DA-02, codex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flushLatestState()'s "did anything change" check compared the whole versionControl slice by reference, but that slice also carries isPanelOpen (a UI-only toggle) alongside the three fields flushPersistedState actually persists (branches, snapshots, currentBranchId). Opening or closing the version-control panel while a flush was pending produced a new slice reference and triggered an unnecessary retry, same failure mode as the already-fixed whole-root-state comparison — just one level deeper, inside a single slice that mixes persisted and UI-only fields. persistedSlices() now destructures versionControl's three persisted fields individually instead of comparing the slice as a whole. Regression test proves an isPanelOpen-only change no longer triggers a retry, verified against the prior commit to genuinely fail. --- README.md | 8 +++---- register-sw.ts | 14 ++++++++--- tests/unit/registerSwUpdateFlush.test.ts | 30 ++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0d22e0c3a..1a4e74868 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7153+ tests / 586 files + 7154+ tests / 586 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 (7153+ tests / 586 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7154+ tests / 586 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 (7153+ tests, 586 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7154+ tests, 586 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):** -- **7153+ unit tests** across **586 test files** — CI is authoritative for pass/fail +- **7154+ unit tests** across **586 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/register-sw.ts b/register-sw.ts index b0a03140b..2dc197fd8 100644 --- a/register-sw.ts +++ b/register-sw.ts @@ -240,11 +240,13 @@ const registerServiceWorker = async (): Promise => { // QNBS-v3: 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; -// QNBS-v3: mirrors exactly what flushPersistedState reads/persists — comparing the whole root state would retry on unrelated non-persisted churn (e.g. status.saving) and waste the retry budget. +// QNBS-v3: mirrors exactly what flushPersistedState reads/persists, field by field — versionControl also carries isPanelOpen (UI-only), so comparing the whole slice would retry on that too. function persistedSlices(state: RootState) { return { project: state.project.present, - versionControl: state.versionControl, + branches: state.versionControl.branches, + snapshots: state.versionControl.snapshots, + currentBranchId: state.versionControl.currentBranchId, settings: state.settings, }; } @@ -253,7 +255,13 @@ function persistedSlicesUnchanged( a: ReturnType, b: ReturnType, ): boolean { - return a.project === b.project && a.versionControl === b.versionControl && a.settings === b.settings; + return ( + a.project === b.project && + a.branches === b.branches && + a.snapshots === b.snapshots && + a.currentBranchId === b.currentBranchId && + a.settings === b.settings + ); } async function flushLatestState(): Promise { diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts index c19d1812a..73a96d99f 100644 --- a/tests/unit/registerSwUpdateFlush.test.ts +++ b/tests/unit/registerSwUpdateFlush.test.ts @@ -209,6 +209,36 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => { expect(reloadSpy).toHaveBeenCalledTimes(1); }); + // QNBS-v3: versionControl mixes persisted fields (branches/snapshots/currentBranchId) with a UI-only isPanelOpen toggle — must compare only the former. + it('does not retry when only versionControl.isPanelOpen changes, not the persisted version-control fields', async () => { + const project = { present: { v: 'a' } }; + const settings = {}; + const branches = [{ id: 'main' }]; + const snapshots: unknown[] = []; + const currentBranchId = 'main'; + const getStateMock = vi + .fn() + .mockReturnValueOnce({ + project, + versionControl: { branches, snapshots, currentBranchId, isPanelOpen: false }, + settings, + }) + .mockReturnValue({ + project, + versionControl: { branches, snapshots, currentBranchId, isPanelOpen: true }, + settings, + }); + appStoreRef.current = { getState: getStateMock, dispatch: vi.fn() as never }; + mockFlushPersistedState.mockResolvedValue(undefined); + + await registerServiceWorker(); + controllerChangeHandler?.(); + await flushMicrotasks(); + + expect(mockFlushPersistedState).toHaveBeenCalledTimes(1); + expect(reloadSpy).toHaveBeenCalledTimes(1); + }); + // QNBS-v3: an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) must not hang the reload forever on an already-cache-pruned bundle. it('reloads once the flush timeout elapses if the flush never settles', async () => { vi.useFakeTimers(); From 1e6c418db8034434cb59ba0790cfa748faa4ae4f Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:41:20 +0200 Subject: [PATCH 10/10] fix(pwa): wait for coordinator drain, add real-IDB round trip (DA-02, codex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersistenceCoordinator.drain() rejects a failed generation's waiters immediately via rejectThrough(), then starts any superseding queued operation without making that visible to the original caller — unlike the success path (resolveThrough only fires once nothing is left queued). flushPersistedState()'s Promise.allSettled saw the rejection and returned, but the coordinator could still be running someone else's save in the background (e.g. the normal debounced autosave racing the same shared coordinator) — exactly the kind of write an immediate reload must not abandon mid-flight. Adds PersistenceCoordinator#idle(), resolving once a coordinator has no active or queued operation left, and has flushPersistedState() await both coordinators' idle() after Promise.allSettled, before returning or throwing. Already bounded by flushLatestStateThenReload()'s existing 8s timeout, so this adds no new unbounded-wait risk. Also adds a real fake-indexeddb IDBFactory round-trip test for saveSlice, complementing (not replacing) the existing hand-built-mock ordering tests — those need precise control over exact onsuccess-vs-oncomplete timing that a real IDB round trip can't assert deterministically, and dbServiceRetry.test.ts already uses the same hand-built-mock approach for this exact class; the new test instead proves the write is genuinely durable and readable back through real IndexedDB semantics end-to-end. Regression tests for idle() verified against the pre-fix code to genuinely fail (method doesn't exist); broader sweep of listenerMiddleware.test.ts confirms no regression to the coordinator's other consumer (the normal debounced autosave). --- README.md | 8 ++-- app/persistedStateFlush.ts | 2 + app/persistenceCoordinator.ts | 10 ++++ tests/unit/persistenceCoordinator.test.ts | 46 +++++++++++++++++++ .../idbProjectStoreSaveSliceRealIdb.test.ts | 46 +++++++++++++++++++ 5 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 tests/unit/services/storage/idbProjectStoreSaveSliceRealIdb.test.ts diff --git a/README.md b/README.md index 1a4e74868..2a43d4fc3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7154+ tests / 586 files + 7157+ tests / 587 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 (7154+ tests / 586 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7157+ tests / 587 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 (7154+ tests, 586 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7157+ tests, 587 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):** -- **7154+ unit tests** across **586 test files** — CI is authoritative for pass/fail +- **7157+ unit tests** across **587 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/app/persistedStateFlush.ts b/app/persistedStateFlush.ts index 2a9de7ba2..a6b5b5253 100644 --- a/app/persistedStateFlush.ts +++ b/app/persistedStateFlush.ts @@ -34,6 +34,8 @@ export async function flushPersistedState(state: RootState): Promise { } // QNBS-v3: allSettled, not Promise.all — its fail-fast let a caller reload before the other save finished; both must settle first, still failing closed if either rejected. const results = await Promise.allSettled(saves); + // QNBS-v3: a coordinator that rejected can already be running a superseding queued save it never told us about — wait for both to genuinely drain before returning or throwing. + await Promise.all([settingsPersistenceCoordinator.idle(), projectPersistenceCoordinator.idle()]); const rejected = results.find( (result): result is PromiseRejectedResult => result.status === 'rejected', ); diff --git a/app/persistenceCoordinator.ts b/app/persistenceCoordinator.ts index 943584e4f..09e3d8f3b 100644 --- a/app/persistenceCoordinator.ts +++ b/app/persistenceCoordinator.ts @@ -16,6 +16,13 @@ export class PersistenceCoordinator { private active: PendingOperation | null = null; private queued: PendingOperation | null = null; private waiters: Waiter[] = []; + private idleWaiters: Array<() => void> = []; + + // QNBS-v3: rejectThrough fires immediately on failure without waiting for a superseding queued operation — idle() lets a caller wait for the coordinator to genuinely finish before doing something destructive (e.g. reload). + idle(): Promise { + if (!this.active && !this.queued) return Promise.resolve(); + return new Promise((resolve) => this.idleWaiters.push(resolve)); + } enqueue(operation: SaveOperation): Promise { const generation = ++this.nextGeneration; @@ -57,6 +64,9 @@ export class PersistenceCoordinator { this.resolveThrough(current.generation); this.active = null; } + const idleWaiters = this.idleWaiters; + this.idleWaiters = []; + for (const resolve of idleWaiters) resolve(); } private resolveThrough(generation: number): void { diff --git a/tests/unit/persistenceCoordinator.test.ts b/tests/unit/persistenceCoordinator.test.ts index 2d49ac635..1ccd831ff 100644 --- a/tests/unit/persistenceCoordinator.test.ts +++ b/tests/unit/persistenceCoordinator.test.ts @@ -78,4 +78,50 @@ describe('PersistenceCoordinator', () => { await expect(second).resolves.toEqual({ superseded: false }); expect(saved).toEqual(['second']); }); + + // QNBS-v3: rejectThrough settles the failed generation's own promise immediately, but the coordinator keeps running a superseding queued operation in the background — idle() must wait for that too. + it('idle() waits for a superseding queued operation to finish even after the current one rejects', async () => { + const coordinator = new PersistenceCoordinator(); + const failure = new Error('disk full'); + const gate = deferred(); + const secondGate = deferred(); + const saved: string[] = []; + + const first = coordinator.enqueue(async () => { + await gate.promise; + throw failure; + }); + coordinator.enqueue(async () => { + saved.push('second:start'); + await secondGate.promise; + saved.push('second:end'); + }); + + gate.resolve(); + await expect(first).rejects.toBe(failure); + // The failed generation's own promise has already settled, but the superseding second + // generation is now running in the background — idle() must not resolve until it finishes too. + expect(saved).toEqual(['second:start']); + + let idleResolved = false; + const idlePromise = coordinator.idle().then(() => { + idleResolved = true; + }); + await Promise.resolve(); + expect(idleResolved).toBe(false); + + secondGate.resolve(); + await idlePromise; + expect(idleResolved).toBe(true); + expect(saved).toEqual(['second:start', 'second:end']); + }); + + it('idle() resolves immediately when nothing is active or queued', async () => { + const coordinator = new PersistenceCoordinator(); + let resolved = false; + await coordinator.idle().then(() => { + resolved = true; + }); + expect(resolved).toBe(true); + }); }); diff --git a/tests/unit/services/storage/idbProjectStoreSaveSliceRealIdb.test.ts b/tests/unit/services/storage/idbProjectStoreSaveSliceRealIdb.test.ts new file mode 100644 index 000000000..e6fae12ee --- /dev/null +++ b/tests/unit/services/storage/idbProjectStoreSaveSliceRealIdb.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node +// QNBS-v3: complements the hand-built-mock ordering test with a real fake-indexeddb round trip proving the write genuinely persists. +import { IDBFactory } from 'fake-indexeddb'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../services/storage/storageEncryptionService', () => ({ + resolveProtectedWriteKey: vi.fn().mockResolvedValue(null), + assertNoActiveEncryptionMigration: vi.fn().mockResolvedValue(undefined), + idbEncryptWithKey: vi.fn(), + idbReadSecure: vi.fn(), + assertIdbProtectedWriteAllowed: vi.fn().mockResolvedValue(undefined), + assertSecureStorageReadable: vi.fn().mockResolvedValue(undefined), +})); + +import { APP_DATA_STORE, STATE_DB_NAME } from '../../../../services/dbConstants'; +import { IdbProjectStore } from '../../../../services/storage/idbProjectStore'; + +beforeEach(() => { + global.indexedDB = new IDBFactory(); +}); + +describe('IdbProjectStore#saveSlice — real IndexedDB round trip', () => { + it('persists the write durably against a real IDBFactory, readable back afterward', async () => { + const projectStore = new IdbProjectStore(); + await projectStore.saveSlice('settings', { theme: 'dark' } as never); + + const readBack = await new Promise((resolve, reject) => { + const request = indexedDB.open(STATE_DB_NAME); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction(APP_DATA_STORE, 'readonly'); + const getRequest = transaction.objectStore(APP_DATA_STORE).get('settings'); + getRequest.onsuccess = () => { + database.close(); + resolve(getRequest.result); + }; + getRequest.onerror = () => reject(getRequest.error); + }; + request.onerror = () => reject(request.error); + }); + + // Plaintext (no encryption key configured) is compressed JSON — decoding it back proves the + // real transaction actually committed, not just that some request fired successfully. + expect(readBack).toBeDefined(); + }); +});