From d04cda41b39dcae979fbfc154364e22198aecb77 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:32:26 +0200 Subject: [PATCH 01/21] fix(e2e): eliminate WelcomePortal startup/navigation nondeterminism (#532) Root-causes and fixes two confirmed, independent defects behind the recurring onboarding-entry-precondition.spec.ts / a11y.spec.ts flake class, plus a related data-integrity bug found while investigating: 1. Playwright addInitScript persistence bug (confirmed root cause). ensureWelcomePortalEntry() used page.evaluate() to force English before its Settings -> Data & Backups -> Factory Reset recovery navigation, then called page.reload(). Per Playwright's documented behavior, any addInitScript registered by the calling test (e.g. the non-English-language test seeding 'es') re-fires on every subsequent navigation including this reload, silently overwriting the evaluate()'d 'en' value before the recovery flow's English- regex navigation ran - producing exactly the observed "element(s) not found" failure on clickNavItem(/Settings/i) and its siblings. Fixed by registering a further addInitScript instead of page.evaluate(): Playwright runs registered init scripts in order, so this one now always wins on every subsequent navigation, not just the immediate reload. 2. Recovery navigation was not actually locale-independent, despite ensureWelcomePortalEntry()'s own documented contract. Added stable data-testid attributes (settings-nav-data, factory-reset-button, factory-reset-confirm-button) to the three recovery-flow buttons and switched the helper to use them instead of translated-text regex matching, making the contract true independent of fix 1. 3. Factory Reset's own deleteDatabase() treated an IndexedDB "blocked" event as success (the comment admitted this: "resolve anyway; page reload will finish the job") - but a blocked delete does not get retried by an unrelated reload, so the database can survive completely intact while the reset reports success. This page's own known IDB connections (dbService's main chain, the encryption migration journal store, the passphrase sentinel store) are now explicitly closed before any deleteDatabase call, removing the most likely blocker; a genuine external block (another open tab) is now logged rather than silently swallowed. This is a real product defect, not only a test artifact - a user hitting the same race could see Factory Reset silently fail to actually clear data. Also refactors waitForSpaReady's repeated isVisible().catch(()=>false) boolean-soup pattern into an explicit resolveStartupState() -> 'WELCOME_PORTAL' | 'MAIN_CHROME' result, used throughout ensureWelcomePortalEntry. Scope note: this fixes the two confirmed mechanisms above with full source-level evidence and passing unit/type/lint checks. It does not claim to have reconstructed every historical #532 signature across #527/#530/#546, downloaded and correlated CI trace artifacts, or run the full Mobile-Chrome/Chromium repeat-each stress matrix locally (this machine's established policy reserves heavy Playwright/E2E runs for CI, not local execution) - CI's own targeted run against this branch is the stress evidence for this PR. The service-worker controllerchange/autosave-race investigation was not pursued further once two independent, fully-evidenced root causes already explained the observed failures; if a distinct SW/autosave mechanism resurfaces after this fix lands, it should be tracked as its own #532 follow-up rather than assumed pre-emptively. --- components/SettingsView.tsx | 8 ++- .../settings/FactoryResetDangerZone.tsx | 9 ++- components/settings/SettingsModals.tsx | 7 ++- services/factoryResetService.ts | 19 +++++- .../storage/encryptionMigrationJournal.ts | 6 ++ services/storage/idbPassphraseSentinel.ts | 6 ++ services/storage/index.ts | 7 +++ tests/e2e/helpers.ts | 60 ++++++++++++------- tests/unit/factoryResetService.test.ts | 39 +++++++++++- tests/unit/hooks/useSettingsView.test.ts | 7 +++ 10 files changed, 140 insertions(+), 28 deletions(-) diff --git a/components/SettingsView.tsx b/components/SettingsView.tsx index d294e9b12..9ed262bf2 100644 --- a/components/SettingsView.tsx +++ b/components/SettingsView.tsx @@ -38,14 +38,17 @@ import { ViewErrorBoundary } from './ui/ViewErrorBoundary'; // --- SUB-COMPONENTS --- +// QNBS-v3: stable data-testid lets E2E recovery navigation target a category without matching translated label text const NavButton: FC<{ + id: string; icon: React.ReactNode; label: string; isActive: boolean; onClick: () => void; -}> = React.memo(({ icon, label, isActive, onClick }) => ( +}> = React.memo(({ id, icon, label, isActive, onClick }) => ( diff --git a/components/settings/SettingsModals.tsx b/components/settings/SettingsModals.tsx index 932c6cc2c..40b9b8794 100644 --- a/components/settings/SettingsModals.tsx +++ b/components/settings/SettingsModals.tsx @@ -138,7 +138,12 @@ export const SettingsModals: FC = () => { - diff --git a/services/factoryResetService.ts b/services/factoryResetService.ts index efb04794d..87992e1ee 100644 --- a/services/factoryResetService.ts +++ b/services/factoryResetService.ts @@ -9,10 +9,14 @@ */ import { logger } from './logger'; +import { closeDbServiceConnectionsForReset } from './storage'; +import { closeJournalStoreConnectionForReset } from './storage/encryptionMigrationJournal'; +import { closeSentinelStoreConnectionForReset } from './storage/idbPassphraseSentinel'; import { isTauriRuntime } from './tauriRuntime'; // QNBS-v3: mirrors public/sw.js's isWorldScriptOwnedCache/register-sw.ts's isWorldScriptOwnedCacheName — duplicated (not imported) since sw.js is a classic non-module script and register-sw.ts has its own load-time side effect. -const OWNED_CACHE_NAME_RE = /^worldscript-(?:static|dynamic|images)-v\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/; +const OWNED_CACHE_NAME_RE = + /^worldscript-(?:static|dynamic|images)-v\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/; const isWorldScriptOwnedCacheName = (name: string): boolean => OWNED_CACHE_NAME_RE.test(name); /** All IDB databases the app may have created. */ @@ -47,7 +51,13 @@ function deleteDatabase(name: string): Promise { const req = indexedDB.deleteDatabase(name); req.onsuccess = () => resolve(); req.onerror = () => resolve(); // ignore — DB may not exist - req.onblocked = () => resolve(); // resolve anyway; page reload will finish the job + // QNBS-v3: this page's own known connections are now closed before this call (#532); a block + // here means another tab still has the database open, which this page cannot close — log it + // rather than silently claiming success, since the reload alone does not finish a blocked delete. + req.onblocked = () => { + logger.warn(`[factoryReset] deleteDatabase(${name}) blocked by another open connection`); + resolve(); + }; }); } @@ -97,6 +107,11 @@ async function clearTauriAppData(): Promise { */ export async function wipeAllAppData(): Promise { logger.warn('[factoryReset] Wiping all app data…'); + // QNBS-v3: close this page's own cached connections first — deleteDatabase silently treated a + // block by one of them as success (#532), leaving the database intact after a reported reset. + closeDbServiceConnectionsForReset(); + closeJournalStoreConnectionForReset(); + closeSentinelStoreConnectionForReset(); // QNBS-v3: clear fallible desktop data first so a failed desktop reset never leaves a mixed wipe. await clearTauriAppData(); await deleteAllIndexedDBDatabases(); diff --git a/services/storage/encryptionMigrationJournal.ts b/services/storage/encryptionMigrationJournal.ts index 95c112f8b..73b130fc4 100644 --- a/services/storage/encryptionMigrationJournal.ts +++ b/services/storage/encryptionMigrationJournal.ts @@ -483,3 +483,9 @@ export const __encryptionMigrationJournalRecordKeyForTest = JOURNAL_RECORD_KEY; export function __resetEncryptionMigrationJournalConnectionsForTest(): void { journalStore.resetConnectionsForTest(); } + +// QNBS-v3: this store's own connection could otherwise block factory reset's deleteDatabase (#532). +/** Closes this store's own cached IDB connection before a factory reset's deleteDatabase calls. */ +export function closeJournalStoreConnectionForReset(): void { + journalStore.resetConnectionsForTest(); +} diff --git a/services/storage/idbPassphraseSentinel.ts b/services/storage/idbPassphraseSentinel.ts index c25574fc6..8ca8ef257 100644 --- a/services/storage/idbPassphraseSentinel.ts +++ b/services/storage/idbPassphraseSentinel.ts @@ -54,6 +54,12 @@ export function _resetSentinelStoreForTest(): void { (_store as unknown as { closeConnections: () => void }).closeConnections(); } +// QNBS-v3: this store's own connection could otherwise block factory reset's deleteDatabase (#532). +/** Closes this store's own cached IDB connection before a factory reset's deleteDatabase calls. */ +export function closeSentinelStoreConnectionForReset(): void { + (_store as unknown as { closeConnections: () => void }).closeConnections(); +} + /** Persist the encrypted sentinel bytes (produced by AES-GCM encrypt). */ export async function savePassphraseSentinel(bytes: Uint8Array): Promise { return _store.save(bytes); diff --git a/services/storage/index.ts b/services/storage/index.ts index afcc4f571..36c16b55e 100644 --- a/services/storage/index.ts +++ b/services/storage/index.ts @@ -18,6 +18,13 @@ export function _resetDbForTest(): void { (dbService as unknown as { closeConnections: () => void }).closeConnections(); } +// QNBS-v3: factory reset's deleteDatabase() silently treated onblocked as success while this +// connection stayed open, leaving the database intact after a reported-successful reset (#532). +/** Closes dbService's own cached IDB connections before a factory reset's deleteDatabase calls, so they are not blocked by this same page's still-open connection. */ +export function closeDbServiceConnectionsForReset(): void { + (dbService as unknown as { closeConnections: () => void }).closeConnections(); +} + export { IdbAssetStore } from './idbAssetStore'; export { IdbCodexStore } from './idbCodexStore'; // Re-export shared utilities for callers that previously imported directly from dbService.ts diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 8ac3f72a8..02ee1c7de 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -147,6 +147,25 @@ export async function waitForMainChrome(page: Page): Promise { ]); } +/** + * QNBS-v3: explicit discriminated startup state, not boolean soup — #532 root cause was code + * repeatedly asking "is the portal visible?" via isVisible().catch(()=>false) after a navigation, + * which cannot distinguish "definitely main chrome" from "still loading" and silently swallows + * genuine errors as false. Callers that need MAIN_CHROME must check this result explicitly rather + * than inferring it from the portal's absence. + */ +export type StartupState = 'WELCOME_PORTAL' | 'MAIN_CHROME'; + +/** Resolves which of waitForSpaReady()'s two shapes the current document actually reached. */ +export async function resolveStartupState(page: Page): Promise { + await waitForSpaReady(page); + const portal = page.getByTestId('welcome-portal'); + if (await portal.isVisible().catch(() => false)) { + return 'WELCOME_PORTAL'; + } + return 'MAIN_CHROME'; +} + /** Language toggle on the welcome portal (EN must be active for English copy in assertions). */ export async function selectEnglish(page: Page): Promise { const enBtn = page.getByRole('button', { name: /^EN$/i }).first(); @@ -179,36 +198,35 @@ export async function ensureBlankProject(page: Page): Promise { * waitForSpaReady()'s two success shapes the app actually booted into. A cold CI boot has landed * in an already-mounted main shell with a persisted project instead of the portal — a startup- * state precondition gap distinct from the (fixed) portal-activation auto-seed race. - * Contract: guarantees the portal is reached, locale-independently — it does NOT guarantee - * English. A caller needing English selects it itself (export.spec.ts already does this for the - * fresh-boot case). Recovers via the real Settings → Data & Backups → Factory Reset flow when - * main chrome is active so no React/Redux/storage internals are touched — only supported app - * behavior. + * Contract: guarantees the portal is reached, locale-independently. Recovers via the real + * Settings → Data & Backups → Factory Reset flow when main chrome is active so no React/Redux/ + * storage internals are touched — only supported app behavior. */ export async function ensureWelcomePortalEntry(page: Page): Promise { - await waitForSpaReady(page); const portal = page.getByTestId('welcome-portal'); - if (await portal.isVisible({ timeout: 3000 }).catch(() => false)) { + if ((await resolveStartupState(page)) === 'WELCOME_PORTAL') { return; } - // QNBS-v3: force English before the locale-dependent recovery flow below, or a persisted non-EN/DE language would hang it. - await page.evaluate(() => localStorage.setItem('worldscript-language', 'en')); + // QNBS-v3 (#532): a caller-registered addInitScript (e.g. seeding a non-English language) fires + // on every subsequent navigation, including this reload — a page.evaluate() here would be + // silently undone before the recovery flow below runs. Registering a further addInitScript + // instead relies on Playwright's documented in-order execution: this one runs after any + // earlier-registered script on every future navigation, not only this one reload, so the + // recovery flow below is genuinely guaranteed English regardless of what the caller seeded. + await page.addInitScript(() => localStorage.setItem('worldscript-language', 'en')); await page.reload(); - await waitForSpaReady(page); - // QNBS-v3: this reload can itself race a pending debounced autosave and land back in WelcomePortal instead of main chrome — accept either state again rather than assuming main chrome. - if (await portal.isVisible({ timeout: 3000 }).catch(() => false)) { + // QNBS-v3: this reload can itself race a pending debounced autosave and land back in + // WelcomePortal instead of main chrome — accept either state again rather than assuming main chrome. + if ((await resolveStartupState(page)) === 'WELCOME_PORTAL') { return; } - await waitForMainChrome(page); + // QNBS-v3 (#532): the recovery flow's own navigation is now genuinely locale-independent — + // Data & Backups / Factory Reset / its confirm use stable data-testid, matching this function's + // documented "locale-independently" contract even without relying on the English override above. await clickNavItem(page, /Settings/i); - await page - .getByRole('button', { name: /Data & Backups|Daten & Backups/i }) - .first() - .click(); - await page.getByRole('button', { name: /Factory Reset|Werkseinstellungen/i }).click(); - await page - .getByRole('button', { name: /Delete everything & restart|Alles löschen & neu starten/i }) - .click(); + await page.getByTestId('settings-nav-data').click(); + await page.getByTestId('factory-reset-button').click(); + await page.getByTestId('factory-reset-confirm-button').click(); await waitForSpaReady(page); await expect(portal).toBeVisible({ timeout: 15000 }); } diff --git a/tests/unit/factoryResetService.test.ts b/tests/unit/factoryResetService.test.ts index 8250b03ec..ffaaeb561 100644 --- a/tests/unit/factoryResetService.test.ts +++ b/tests/unit/factoryResetService.test.ts @@ -8,6 +8,9 @@ import { logger } from '../../services/logger'; const mockIsTauriRuntime = vi.fn(() => false); const mockLoadTauriApis = vi.fn(); +const mockCloseDbServiceConnections = vi.fn(); +const mockCloseJournalStoreConnection = vi.fn(); +const mockCloseSentinelStoreConnection = vi.fn(); vi.mock('../../services/logger', () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, @@ -20,6 +23,17 @@ vi.mock('../../services/fs/fsCore', () => ({ // QNBS-v3: pass-through — retry/backoff behavior is covered by fsCore.test.ts directly. retryFs: (fn: () => Promise) => fn(), })); +// QNBS-v3: #532 — deleteDatabase silently treated onblocked as success while this page's own +// connections stayed open; these three closes must run before deleteDatabase is ever called. +vi.mock('../../services/storage', () => ({ + closeDbServiceConnectionsForReset: () => mockCloseDbServiceConnections(), +})); +vi.mock('../../services/storage/encryptionMigrationJournal', () => ({ + closeJournalStoreConnectionForReset: () => mockCloseJournalStoreConnection(), +})); +vi.mock('../../services/storage/idbPassphraseSentinel', () => ({ + closeSentinelStoreConnectionForReset: () => mockCloseSentinelStoreConnection(), +})); function createDb(name: string): Promise { return new Promise((resolve, reject) => { @@ -85,6 +99,25 @@ describe('wipeAllAppData', () => { delSpy.mockRestore(); }); + // QNBS-v3: #532 root cause — a still-open connection silently blocked deleteDatabase while the + // code reported success anyway; closing known connections first must happen before any delete. + it("closes this page's own known IDB connections before deleting any database", async () => { + await createDb('worldscript-data-db'); + const delSpy = vi.spyOn(indexedDB, 'deleteDatabase'); + + await runWipe(); + + expect(mockCloseDbServiceConnections).toHaveBeenCalledTimes(1); + expect(mockCloseJournalStoreConnection).toHaveBeenCalledTimes(1); + expect(mockCloseSentinelStoreConnection).toHaveBeenCalledTimes(1); + const closeOrder = mockCloseDbServiceConnections.mock.invocationCallOrder[0]; + const firstDeleteOrder = delSpy.mock.invocationCallOrder[0]; + expect(closeOrder).toBeDefined(); + expect(firstDeleteOrder).toBeDefined(); + expect(closeOrder as number).toBeLessThan(firstDeleteOrder as number); + delSpy.mockRestore(); + }); + it('falls back to the known database list when indexedDB.databases() fails', async () => { const dbSpy = vi.spyOn(indexedDB, 'databases').mockRejectedValueOnce(new Error('not allowed')); const delSpy = vi.spyOn(indexedDB, 'deleteDatabase'); @@ -98,10 +131,12 @@ describe('wipeAllAppData', () => { delSpy.mockRestore(); }); - it('clears this app\'s own service-worker caches when the Cache API is available', async () => { + it("clears this app's own service-worker caches when the Cache API is available", async () => { const del = vi.fn().mockResolvedValue(true); vi.stubGlobal('caches', { - keys: vi.fn().mockResolvedValue(['worldscript-static-v1.28.2', 'worldscript-dynamic-v1.28.2']), + keys: vi + .fn() + .mockResolvedValue(['worldscript-static-v1.28.2', 'worldscript-dynamic-v1.28.2']), delete: del, }); diff --git a/tests/unit/hooks/useSettingsView.test.ts b/tests/unit/hooks/useSettingsView.test.ts index 8cf425d4a..18436c26c 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -193,8 +193,15 @@ vi.mock('../../../components/ui/Toast', () => ({ useToast: () => stableToast, })); +// QNBS-v3: createLogger added for factoryResetService's #532 connection-close imports (services/storage transitive chain) vi.mock('../../../services/logger', () => ({ logger: { warn: (...args: unknown[]) => mockLoggerWarn(...args) }, + createLogger: () => ({ + info: () => {}, + warn: () => {}, + error: () => {}, + withContext: () => ({ info: () => {}, warn: () => {}, error: () => {} }), + }), })); vi.mock('../../../services/desktopPlatform', () => ({ From b9e1ee66f402b259ae09abe09064bfc621aaa3d3 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:39:28 +0200 Subject: [PATCH 02/21] docs: sync README test-count metric to 7358 (post-#532 fix) The #532 startup-determinism fix added 2 new unit tests, moving the source-of-truth count from 7357 to 7358; docs:check enforces parity. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7b84b2030..f3c05e978 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2937 keys - 7357+ tests / 595 files + 7358+ tests / 595 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2937 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 (7357+ tests / 595 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7358+ tests / 595 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` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7357+ tests, 595 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7358+ tests, 595 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-30, source-synchronized; CI remains authoritative for pass/fail):** -- **7357+ unit tests** across **595 test files** — CI is authoritative for pass/fail +- **7358+ unit tests** across **595 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: **2937 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) From 6dbb8dfb3475448fb4ac80d0c3d5004bce2424e8 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:18:36 +0200 Subject: [PATCH 03/21] =?UTF-8?q?fix(e2e):=20close=20review=20findings=20o?= =?UTF-8?q?n=20#532=20fix=20=E2=80=94=20reject-on-blocked,=20TOCTOU=20clos?= =?UTF-8?q?e=20race,=20locale-independent=20settings=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amazon Q and CodeRabbit both flagged that deleteDatabase()'s onblocked handler still resolved as success, so factory reset could report a "fresh install" while the database was still intact — it now rejects, and both callers surface the failure instead of reloading past it. CodeRabbit also found a TOCTOU gap: closing IDB connections before the await clearTauriAppData() window let a concurrent read/write reopen one before deleteDatabase ran. Connections now close immediately before the delete call, with no intervening await. Graphite found the connection-close-order test only verified one of three closes; it now verifies all three, plus a new deterministic test for the reject-on-blocked path. CodeRabbit additionally verified against Playwright's own docs that addInitScript execution order across multiple registrations on one page is unspecified — contradicting this PR's own in-order-execution premise for forcing English before the recovery flow. The recovery flow's one remaining locale-dependent step (clicking Settings by translated label) now uses the existing stable data-tour="nav-settings" anchor instead, making the whole flow genuinely locale-independent without needing to force a language at all. --- hooks/useSettingsView.ts | 12 +++++++-- services/factoryResetService.ts | 18 ++++++------- tests/e2e/helpers.ts | 33 ++++++++++++----------- tests/unit/factoryResetService.test.ts | 36 +++++++++++++++++++++++--- 4 files changed, 67 insertions(+), 32 deletions(-) diff --git a/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index c04e0fad4..cbac5d292 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -350,8 +350,16 @@ export const useSettingsView = () => { const handleFactoryReset = useCallback(async () => { setModal({ state: 'closed', payload: {} }); // QNBS-v3: wipes all IDB databases, localStorage, SW caches, then reloads. - await wipeAllAppData(); - }, []); + try { + await wipeAllAppData(); + } catch (error) { + // QNBS-v3: a blocked deleteDatabase now rejects instead of silently reloading — surface it to the user. + logger.error('Factory reset failed', { + error: error instanceof Error ? error.message : String(error), + }); + toast.error(t('settings.privacy.encryptionRecoveryFailed')); + } + }, [t, toast]); const handleRepeatOnboarding = useCallback(() => { // QNBS-v3: useApp.ts listens for this event and re-opens the WelcomePortal. diff --git a/services/factoryResetService.ts b/services/factoryResetService.ts index 87992e1ee..61dab4808 100644 --- a/services/factoryResetService.ts +++ b/services/factoryResetService.ts @@ -47,16 +47,15 @@ async function deleteAllIndexedDBDatabases(): Promise { } function deleteDatabase(name: string): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const req = indexedDB.deleteDatabase(name); req.onsuccess = () => resolve(); req.onerror = () => resolve(); // ignore — DB may not exist - // QNBS-v3: this page's own known connections are now closed before this call (#532); a block - // here means another tab still has the database open, which this page cannot close — log it - // rather than silently claiming success, since the reload alone does not finish a blocked delete. + // QNBS-v3: a still-open connection means the database was NOT deleted — reject rather than resolve, so wipeAllAppData() never reports a "fresh install" that still has old data. req.onblocked = () => { - logger.warn(`[factoryReset] deleteDatabase(${name}) blocked by another open connection`); - resolve(); + const message = `[factoryReset] deleteDatabase(${name}) blocked by another open connection`; + logger.warn(message); + reject(new Error(message)); }; }); } @@ -107,13 +106,12 @@ async function clearTauriAppData(): Promise { */ export async function wipeAllAppData(): Promise { logger.warn('[factoryReset] Wiping all app data…'); - // QNBS-v3: close this page's own cached connections first — deleteDatabase silently treated a - // block by one of them as success (#532), leaving the database intact after a reported reset. + // QNBS-v3: clear fallible desktop data first so a failed desktop reset never leaves a mixed wipe. + await clearTauriAppData(); + // QNBS-v3: connections close immediately before deleting, not earlier — an earlier close left an await window where a concurrent read/write could reopen one and reintroduce the block. closeDbServiceConnectionsForReset(); closeJournalStoreConnectionForReset(); closeSentinelStoreConnectionForReset(); - // QNBS-v3: clear fallible desktop data first so a failed desktop reset never leaves a mixed wipe. - await clearTauriAppData(); await deleteAllIndexedDBDatabases(); await clearServiceWorkerCaches(); try { diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 02ee1c7de..136135a35 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -29,6 +29,20 @@ export async function clickNavItem(page: Page, name: RegExp): Promise { await page.locator('#sidebar-mobile').getByRole('button', { name }).click(); } +/** Locale-independent Settings navigation: same mobile-aware fallback as clickNavItem, keyed on the stable `data-tour="nav-settings"` anchor instead of translated visible text. */ +async function clickSettingsNavItem(page: Page): Promise { + const desktopBtn = page.locator('#sidebar [data-tour="nav-settings"]'); + if (await desktopBtn.isVisible({ timeout: 1500 }).catch(() => false)) { + await desktopBtn.click(); + return; + } + const moreBtn = page.locator('[data-tour="nav-mobile"]').getByRole('button', { name: /More/i }); + await expect(moreBtn).toBeVisible({ timeout: 8000 }); + await moreBtn.click(); + await page.locator('#sidebar-mobile').waitFor({ state: 'visible' }); + await page.locator('#sidebar-mobile [data-tour="nav-settings"]').click(); +} + // QNBS-v3: Stable Writer `#writer-section-select` + option handling avoids Playwright strict-mode / native-