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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2925_keys-0EA5E9" alt="i18n 19 locales — 2925 keys">
<img src="https://img.shields.io/badge/Tests-7138%2B_%2F_583_files-22C55E" alt="7138+ tests / 583 files">
<img src="https://img.shields.io/badge/Tests-7150%2B_%2F_585_files-22C55E" alt="7150+ tests / 585 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (7138+ tests / 583 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (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` |
Expand Down Expand Up @@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (7138+ tests, 583 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (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
Expand Down Expand Up @@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |

**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
- **7138+ unit tests** across **583 test files** — CI is authoritative for pass/fail
- **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)

Expand Down
7 changes: 6 additions & 1 deletion app/persistedStateFlush.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,10 @@ export async function flushPersistedState(state: RootState): Promise<void> {
),
);
}
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;
}
4 changes: 1 addition & 3 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,7 @@ async function offlineFallback(request) {
// INSTALL — Precache shell
// ════════════════════════════════════════════════════════════
self.addEventListener('install', (event) => {
// QNBS-v3: skipWaiting immediately so a new SW never sits in "waiting" state behind a stale
// active SW that serves cached v.old assets. The app auto-saves to IDB so a mid-session
// reload is safe. Paired with clients.claim() in activate this ensures all tabs get new code.
// QNBS-v3 (DA-02): activates immediately, no waiting — register-sw.ts owns the bounded pre-reload flush mitigation, residual risk tracked in #518.
self.skipWaiting();
// QNBS-v3: Never precache inside Tauri — the desktop app serves its shell from the bundle.
if (IS_TAURI) return;
Expand Down
66 changes: 62 additions & 4 deletions register-sw.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { RootState } from './app/store';
import { appStoreRef } from './app/storeRef';
import { flushPersistedState } from './app/persistedStateFlush';
import { logger as appLogger } from './services/logger';

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

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

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

// 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,
Comment thread
qnbs marked this conversation as resolved.
Resolved
settings: state.settings,
};
}

function persistedSlicesUnchanged(
a: ReturnType<typeof persistedSlices>,
b: ReturnType<typeof persistedSlices>,
): boolean {
return a.project === b.project && a.versionControl === b.versionControl && a.settings === b.settings;
}

async function flushLatestState(): Promise<void> {
const store = appStoreRef.current;
if (!store) return;
let snapshot = store.getState() as RootState;
let snapshotSlices = persistedSlices(snapshot);
for (let attempt = 0; attempt < MAX_FLUSH_ATTEMPTS; attempt++) {
await flushPersistedState(snapshot);
Comment thread
qnbs marked this conversation as resolved.
Resolved
const latest = store.getState() as RootState;
const latestSlices = persistedSlices(latest);
if (persistedSlicesUnchanged(snapshotSlices, latestSlices)) return;
snapshot = latest;
Comment thread
qnbs marked this conversation as resolved.
Resolved
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);
Comment thread
qnbs marked this conversation as resolved.
Resolved
}

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

if (typeof window !== 'undefined') {
window.addEventListener('load', registerServiceWorker);
}
Expand Down
6 changes: 5 additions & 1 deletion services/storage/idbProjectStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,12 @@ export class IdbProjectStore extends IdbAssetStore {
const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite');
return new Promise<void>((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'));
});
});
}
Expand Down
36 changes: 35 additions & 1 deletion tests/unit/persistedStateFlush.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void>((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']);
});
});
7 changes: 4 additions & 3 deletions tests/unit/registerSwCacheOwnership.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// QNBS-v3: proves the Tauri-teardown cache cleanup in register-sw.ts never deletes an unowned cache.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

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

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

Expand Down
Loading
Loading