diff --git a/README.md b/README.md index 73923ccd..c3943233 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ Release v1.28.3 IndexedDB v8 PWA v3.0 - i18n 19 locales — 2938 keys - 7424+ tests / 597 files + i18n 19 locales — 2940 keys + 7427+ tests / 597 files Codecov Coverage License MIT CI Status @@ -401,7 +401,7 @@ Infrastructure-level features that keep the app fast and extensible as projects ### 🌐 Full Multi-Language Support -Shipped UI locales with **2938 i18n keys** across all 19 languages — zero hardcoded user-facing strings: +Shipped UI locales with **2940 i18n keys** across all 19 languages — zero hardcoded user-facing strings: - 🇩🇪 **German** (Deutsch) - 🇬🇧 **English** @@ -510,8 +510,8 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **PDF Export** | jsPDF | Client-side, configurable PDF document generation | | **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`) | 2938 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 (7424+ tests / 597 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **i18n** | Custom React Context (`I18nContext.tsx`) | 2940 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 (7427+ tests / 597 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 (7424+ tests, 597 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7427+ tests, 597 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -710,10 +710,10 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `deploy` | `main` only | GitHub Pages after **`ci-success`** succeeds | | `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):** -- **7424+ unit tests** across **597 test files** — CI is authoritative for pass/fail +**Current test metrics (2026-09-04, source-synchronized; CI remains authoritative for pass/fail):** +- **7427+ unit tests** across **597 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: **2938 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) +- i18n: **2940 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) **CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist. diff --git a/components/StorageErrorScreen.tsx b/components/StorageErrorScreen.tsx index c398c9b8..182ef787 100644 --- a/components/StorageErrorScreen.tsx +++ b/components/StorageErrorScreen.tsx @@ -2,12 +2,15 @@ import React from 'react'; import { ProjectQuarantineError } from '../services/fs/projectFsStore'; import { getStaticTranslation } from '../services/i18n/staticTranslate'; import { logger } from '../services/logger'; +import type { StartupRecoveryFailureKind } from '../services/startupRecoveryPolicy'; export interface StorageErrorCopy { description: string; storageUnavailable: string; projectUnavailable: string; + projectIoUnavailable: string; reload: string; + retry: string; recover: string; recovering: string; reset: string; @@ -22,7 +25,10 @@ export const STARTUP_COPY_FALLBACKS: StorageErrorCopy = { description: 'The local project or database could not be opened. Reload and try again.', storageUnavailable: 'Local storage could not be opened. Reload and try again.', projectUnavailable: 'A local project could not be opened. Reload and try again.', + projectIoUnavailable: + 'The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.', reload: 'Reload', + retry: 'Retry', recover: 'Quarantine project and reload', recovering: 'Preserving project…', reset: 'Reset database and reload', @@ -47,7 +53,9 @@ export async function loadStorageErrorCopy(): Promise { description, storageUnavailable, projectUnavailable, + projectIoUnavailable, reload, + retry, recover, recovering, reset, @@ -66,7 +74,12 @@ export async function loadStorageErrorCopy(): Promise { 'error.startup.projectUnavailable', STARTUP_COPY_FALLBACKS.projectUnavailable, ), + startupTranslation( + 'error.startup.projectIoUnavailable', + STARTUP_COPY_FALLBACKS.projectIoUnavailable, + ), startupTranslation('error.startup.reload', STARTUP_COPY_FALLBACKS.reload), + startupTranslation('error.startup.retry', STARTUP_COPY_FALLBACKS.retry), startupTranslation('error.startup.recover', STARTUP_COPY_FALLBACKS.recover), startupTranslation('error.startup.recovering', STARTUP_COPY_FALLBACKS.recovering), startupTranslation('error.startup.reset', STARTUP_COPY_FALLBACKS.reset), @@ -83,7 +96,9 @@ export async function loadStorageErrorCopy(): Promise { description, storageUnavailable, projectUnavailable, + projectIoUnavailable, reload, + retry, recover, recovering, reset, @@ -95,23 +110,208 @@ export async function loadStorageErrorCopy(): Promise { }; } -// QNBS-v3: show stable localized recovery actions while keeping technical filesystem diagnostics in the logger. +function getFailureMessage( + failureKind: StartupRecoveryFailureKind, + copy: StorageErrorCopy, +): string { + const messages: Record = { + 'project-corrupt': copy.projectUnavailable, + 'project-io': copy.projectIoUnavailable, + storage: copy.storageUnavailable, + }; + return messages[failureKind]; +} + +type RecoveryStatus = 'failed' | 'already-preserved' | 'source-missing' | null; + +function mapQuarantineErrorReason(error: unknown): Exclude { + if (error instanceof ProjectQuarantineError && error.reason === 'already-preserved') { + return 'already-preserved'; + } + if (error instanceof ProjectQuarantineError && error.reason === 'source-missing') { + return 'source-missing'; + } + return 'failed'; +} + +function getRecoveryStatusText( + isRecovering: boolean, + recoveryStatus: RecoveryStatus, + copy: StorageErrorCopy, +): string { + if (isRecovering) return copy.recovering; + if (!recoveryStatus) return ''; + const messages: Record, string> = { + 'already-preserved': copy.recoveryAlreadyPreserved, + 'source-missing': copy.recoveryUnknown, + failed: copy.recoveryFailed, + }; + return messages[recoveryStatus]; +} + +const buttonBaseStyle: React.CSSProperties = { + padding: '0.5rem 1.25rem', + borderRadius: '0.5rem', + fontFamily: 'inherit', +}; + +function RetryButton({ + isProjectIo, + copy, + onClick, +}: { + isProjectIo: boolean; + copy: StorageErrorCopy; + onClick: () => void; +}) { + return ( + + ); +} + +function RecoverButton({ + failureKind, + onRecover, + isRecovering, + copy, + onClick, +}: { + failureKind: StartupRecoveryFailureKind; + onRecover: (() => Promise) | undefined; + isRecovering: boolean; + copy: StorageErrorCopy; + onClick: () => void; +}) { + if (failureKind !== 'project-corrupt' || !onRecover) return null; + return ( + + ); +} + +function RecoveryNotice({ + failureKind, + onRecover, + isRecovering, + recoveryStatus, + copy, +}: { + failureKind: StartupRecoveryFailureKind; + onRecover: (() => Promise) | undefined; + isRecovering: boolean; + recoveryStatus: RecoveryStatus; + copy: StorageErrorCopy; +}) { + if (failureKind !== 'project-corrupt' || !onRecover) return null; + return ( + <> +

+ {copy.quarantineNotice} +

+

+ {getRecoveryStatusText(isRecovering, recoveryStatus, copy)} +

+ + ); +} + +function ResetButton({ + failureKind, + onReset, + isRecovering, + copy, +}: { + failureKind: StartupRecoveryFailureKind; + onReset: (() => void) | undefined; + isRecovering: boolean; + copy: StorageErrorCopy; +}) { + if (failureKind !== 'storage' || !onReset) return null; + return ( + + ); +} + +function ResetWarning({ + failureKind, + onReset, + copy, +}: { + failureKind: StartupRecoveryFailureKind; + onReset: (() => void) | undefined; + copy: StorageErrorCopy; +}) { + if (failureKind !== 'storage' || !onReset) return null; + return

{copy.resetWarning}

; +} + +// QNBS-v3: use explicit failure kinds so I/O retry, quarantine, and database reset cannot be inferred from callbacks. export function StorageErrorScreen({ copy, onReset, onRecover, + onRetry, + failureKind, }: { copy: StorageErrorCopy; + failureKind: StartupRecoveryFailureKind; onReset?: () => void; onRecover?: () => Promise; + onRetry?: () => void; }) { - const [recoveryStatus, setRecoveryStatus] = React.useState< - 'failed' | 'already-preserved' | 'source-missing' | null - >(null); + const [recoveryStatus, setRecoveryStatus] = React.useState(null); const [isRecovering, setIsRecovering] = React.useState(false); const handleRecover = async () => { - if (!onRecover) return; + if (!onRecover || failureKind !== 'project-corrupt') return; setRecoveryStatus(null); setIsRecovering(true); try { @@ -121,17 +321,19 @@ export function StorageErrorScreen({ logger.error('Project quarantine failed', { error: error instanceof Error ? error.message : String(error), }); - setRecoveryStatus( - error instanceof ProjectQuarantineError && error.reason === 'already-preserved' - ? 'already-preserved' - : error instanceof ProjectQuarantineError && error.reason === 'source-missing' - ? 'source-missing' - : 'failed', - ); + setRecoveryStatus(mapQuarantineErrorReason(error)); setIsRecovering(false); } }; + const handleRetry = () => { + if (onRetry) { + onRetry(); + return; + } + window.location.reload(); + }; + return (
- {onRecover ? copy.projectUnavailable : copy.storageUnavailable} + {getFailureMessage(failureKind, copy)}

- - {onRecover && ( - - )} - {onReset && ( - - )} + + void handleRecover()} + /> +
- {onRecover && ( -

- {copy.quarantineNotice} -

- )} - {onRecover && ( -

- {isRecovering - ? copy.recovering - : recoveryStatus === 'already-preserved' - ? copy.recoveryAlreadyPreserved - : recoveryStatus === 'source-missing' - ? copy.recoveryUnknown - : recoveryStatus === 'failed' - ? copy.recoveryFailed - : ''} -

- )} - {onReset &&

{copy.resetWarning}

} + +
); } diff --git a/locales/ar/common.json b/locales/ar/common.json index 3658decd..af538d74 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/de/common.json b/locales/de/common.json index 3331dbbf..2bdc82ab 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -699,7 +699,9 @@ "error.startup.description": "Das lokale Projekt oder die lokale Datenbank konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", "error.startup.storageUnavailable": "Der lokale Speicher konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", "error.startup.projectUnavailable": "Ein lokales Projekt konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", + "error.startup.projectIoUnavailable": "Das Projekt konnte derzeit aufgrund eines Speicher- oder Dateizugriffsproblems nicht gelesen werden. Es wurde nicht als beschädigt oder verändert eingestuft. Prüfen Sie die Verfügbarkeit des Speichers und die Dateiberechtigungen und versuchen Sie es erneut.", "error.startup.reload": "Neu laden", + "error.startup.retry": "Erneut versuchen", "error.startup.recover": "Projekt unter Quarantäne stellen und neu laden", "error.startup.recovering": "Projekt wird gesichert …", "error.startup.reset": "Datenbank zurücksetzen und neu laden", diff --git a/locales/el/common.json b/locales/el/common.json index e5dbcb63..9691e6d4 100644 --- a/locales/el/common.json +++ b/locales/el/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/en/common.json b/locales/en/common.json index 9494cd40..c2237d54 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/es/common.json b/locales/es/common.json index 41117663..fef6f375 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -699,7 +699,9 @@ "error.startup.description": "No se pudo abrir el proyecto local o la base de datos. Recarga la aplicación e inténtalo de nuevo.", "error.startup.storageUnavailable": "No se pudo abrir el almacenamiento local. Recarga la aplicación e inténtalo de nuevo.", "error.startup.projectUnavailable": "No se pudo abrir un proyecto local. Recarga la aplicación e inténtalo de nuevo.", + "error.startup.projectIoUnavailable": "No se pudo leer el proyecto en este momento debido a un problema de almacenamiento o acceso a los archivos. El proyecto no se ha clasificado como dañado ni como modificado. Comprueba la disponibilidad del dispositivo de almacenamiento y los permisos de los archivos, y vuelve a intentarlo.", "error.startup.reload": "Recargar", + "error.startup.retry": "Reintentar", "error.startup.recover": "Poner el proyecto en cuarentena y recargar", "error.startup.recovering": "Preservando el proyecto…", "error.startup.reset": "Restablecer la base de datos y recargar", diff --git a/locales/eu/common.json b/locales/eu/common.json index 921244f1..040c48d7 100644 --- a/locales/eu/common.json +++ b/locales/eu/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/fa/common.json b/locales/fa/common.json index 775617ef..3206f093 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/fi/common.json b/locales/fi/common.json index bdd59edc..7c89c142 100644 --- a/locales/fi/common.json +++ b/locales/fi/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/fr/common.json b/locales/fr/common.json index 1c0fc7cd..022e7b69 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -699,7 +699,9 @@ "error.startup.description": "Le projet local ou la base de données n’a pas pu être ouvert. Rechargez l’application et réessayez.", "error.startup.storageUnavailable": "Le stockage local n’a pas pu être ouvert. Rechargez l’application et réessayez.", "error.startup.projectUnavailable": "Un projet local n’a pas pu être ouvert. Rechargez l’application et réessayez.", + "error.startup.projectIoUnavailable": "Le projet n’a pas pu être lu pour le moment en raison d’un problème de stockage ou d’accès aux fichiers. Il n’a pas été considéré comme corrompu ni modifié. Vérifiez la disponibilité du support de stockage et les autorisations des fichiers, puis réessayez.", "error.startup.reload": "Recharger", + "error.startup.retry": "Réessayer", "error.startup.recover": "Mettre le projet en quarantaine et recharger", "error.startup.recovering": "Préservation du projet…", "error.startup.reset": "Réinitialiser la base de données et recharger", diff --git a/locales/he/common.json b/locales/he/common.json index c0fce188..4194ac1d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/hu/common.json b/locales/hu/common.json index 3519d768..1a456ddb 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/is/common.json b/locales/is/common.json index c8925c40..070eebc5 100644 --- a/locales/is/common.json +++ b/locales/is/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/it/common.json b/locales/it/common.json index a4197f98..1c1536fa 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -699,7 +699,9 @@ "error.startup.description": "Non è stato possibile aprire il progetto locale o il database. Ricarica l’applicazione e riprova.", "error.startup.storageUnavailable": "Non è stato possibile aprire l’archiviazione locale. Ricarica l’applicazione e riprova.", "error.startup.projectUnavailable": "Non è stato possibile aprire un progetto locale. Ricarica l’applicazione e riprova.", + "error.startup.projectIoUnavailable": "Non è stato possibile leggere il progetto in questo momento a causa di un problema di archiviazione o di accesso ai file. Il progetto non è stato classificato come danneggiato né modificato. Verifica la disponibilità del dispositivo di archiviazione e i permessi dei file, quindi riprova.", "error.startup.reload": "Ricarica", + "error.startup.retry": "Riprova", "error.startup.recover": "Metti il progetto in quarantena e ricarica", "error.startup.recovering": "Conservazione del progetto…", "error.startup.reset": "Reimposta il database e ricarica", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3a39a6f9..991fcd8f 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/ko/common.json b/locales/ko/common.json index 3ae366b8..905afeed 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/pt/common.json b/locales/pt/common.json index 9e5a0d51..2552ac3e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/ru/common.json b/locales/ru/common.json index 25ea3864..e2eefebd 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/sv/common.json b/locales/sv/common.json index fa5a3ddf..21ddf5e7 100644 --- a/locales/sv/common.json +++ b/locales/sv/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/locales/zh/common.json b/locales/zh/common.json index cca1db16..5aaf609e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -699,7 +699,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/ar/bundle.json b/public/locales/ar/bundle.json index 91d3dcb6..59a33d21 100644 --- a/public/locales/ar/bundle.json +++ b/public/locales/ar/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/de/bundle.json b/public/locales/de/bundle.json index 2c9fbb5b..a68564a1 100644 --- a/public/locales/de/bundle.json +++ b/public/locales/de/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "Das lokale Projekt oder die lokale Datenbank konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", "error.startup.storageUnavailable": "Der lokale Speicher konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", "error.startup.projectUnavailable": "Ein lokales Projekt konnte nicht geöffnet werden. Laden Sie die Anwendung neu und versuchen Sie es erneut.", + "error.startup.projectIoUnavailable": "Das Projekt konnte derzeit aufgrund eines Speicher- oder Dateizugriffsproblems nicht gelesen werden. Es wurde nicht als beschädigt oder verändert eingestuft. Prüfen Sie die Verfügbarkeit des Speichers und die Dateiberechtigungen und versuchen Sie es erneut.", "error.startup.reload": "Neu laden", + "error.startup.retry": "Erneut versuchen", "error.startup.recover": "Projekt unter Quarantäne stellen und neu laden", "error.startup.recovering": "Projekt wird gesichert …", "error.startup.reset": "Datenbank zurücksetzen und neu laden", diff --git a/public/locales/el/bundle.json b/public/locales/el/bundle.json index 5858a1ca..45b52868 100644 --- a/public/locales/el/bundle.json +++ b/public/locales/el/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/en/bundle.json b/public/locales/en/bundle.json index b1030f22..80e2b7a2 100644 --- a/public/locales/en/bundle.json +++ b/public/locales/en/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/es/bundle.json b/public/locales/es/bundle.json index 31e0b055..3cf25795 100644 --- a/public/locales/es/bundle.json +++ b/public/locales/es/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "No se pudo abrir el proyecto local o la base de datos. Recarga la aplicación e inténtalo de nuevo.", "error.startup.storageUnavailable": "No se pudo abrir el almacenamiento local. Recarga la aplicación e inténtalo de nuevo.", "error.startup.projectUnavailable": "No se pudo abrir un proyecto local. Recarga la aplicación e inténtalo de nuevo.", + "error.startup.projectIoUnavailable": "No se pudo leer el proyecto en este momento debido a un problema de almacenamiento o acceso a los archivos. El proyecto no se ha clasificado como dañado ni como modificado. Comprueba la disponibilidad del dispositivo de almacenamiento y los permisos de los archivos, y vuelve a intentarlo.", "error.startup.reload": "Recargar", + "error.startup.retry": "Reintentar", "error.startup.recover": "Poner el proyecto en cuarentena y recargar", "error.startup.recovering": "Preservando el proyecto…", "error.startup.reset": "Restablecer la base de datos y recargar", diff --git a/public/locales/eu/bundle.json b/public/locales/eu/bundle.json index fe818f77..12e7193c 100644 --- a/public/locales/eu/bundle.json +++ b/public/locales/eu/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/fa/bundle.json b/public/locales/fa/bundle.json index 6815ca0f..5aa28248 100644 --- a/public/locales/fa/bundle.json +++ b/public/locales/fa/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/fi/bundle.json b/public/locales/fi/bundle.json index 08c4c26b..3bbeaf29 100644 --- a/public/locales/fi/bundle.json +++ b/public/locales/fi/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/fr/bundle.json b/public/locales/fr/bundle.json index 7692136c..ef932fe0 100644 --- a/public/locales/fr/bundle.json +++ b/public/locales/fr/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "Le projet local ou la base de données n’a pas pu être ouvert. Rechargez l’application et réessayez.", "error.startup.storageUnavailable": "Le stockage local n’a pas pu être ouvert. Rechargez l’application et réessayez.", "error.startup.projectUnavailable": "Un projet local n’a pas pu être ouvert. Rechargez l’application et réessayez.", + "error.startup.projectIoUnavailable": "Le projet n’a pas pu être lu pour le moment en raison d’un problème de stockage ou d’accès aux fichiers. Il n’a pas été considéré comme corrompu ni modifié. Vérifiez la disponibilité du support de stockage et les autorisations des fichiers, puis réessayez.", "error.startup.reload": "Recharger", + "error.startup.retry": "Réessayer", "error.startup.recover": "Mettre le projet en quarantaine et recharger", "error.startup.recovering": "Préservation du projet…", "error.startup.reset": "Réinitialiser la base de données et recharger", diff --git a/public/locales/he/bundle.json b/public/locales/he/bundle.json index ccb1a043..fc2fa899 100644 --- a/public/locales/he/bundle.json +++ b/public/locales/he/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/hu/bundle.json b/public/locales/hu/bundle.json index 21594ab6..d89a42c3 100644 --- a/public/locales/hu/bundle.json +++ b/public/locales/hu/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/is/bundle.json b/public/locales/is/bundle.json index 1b3bdcb7..86582bae 100644 --- a/public/locales/is/bundle.json +++ b/public/locales/is/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/it/bundle.json b/public/locales/it/bundle.json index 39707cbd..0b451e1b 100644 --- a/public/locales/it/bundle.json +++ b/public/locales/it/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "Non è stato possibile aprire il progetto locale o il database. Ricarica l’applicazione e riprova.", "error.startup.storageUnavailable": "Non è stato possibile aprire l’archiviazione locale. Ricarica l’applicazione e riprova.", "error.startup.projectUnavailable": "Non è stato possibile aprire un progetto locale. Ricarica l’applicazione e riprova.", + "error.startup.projectIoUnavailable": "Non è stato possibile leggere il progetto in questo momento a causa di un problema di archiviazione o di accesso ai file. Il progetto non è stato classificato come danneggiato né modificato. Verifica la disponibilità del dispositivo di archiviazione e i permessi dei file, quindi riprova.", "error.startup.reload": "Ricarica", + "error.startup.retry": "Riprova", "error.startup.recover": "Metti il progetto in quarantena e ricarica", "error.startup.recovering": "Conservazione del progetto…", "error.startup.reset": "Reimposta il database e ricarica", diff --git a/public/locales/ja/bundle.json b/public/locales/ja/bundle.json index 8b51d8a6..d601bb28 100644 --- a/public/locales/ja/bundle.json +++ b/public/locales/ja/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/ko/bundle.json b/public/locales/ko/bundle.json index 5e6f518a..a358d699 100644 --- a/public/locales/ko/bundle.json +++ b/public/locales/ko/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/pt/bundle.json b/public/locales/pt/bundle.json index 5fb9722e..384e122e 100644 --- a/public/locales/pt/bundle.json +++ b/public/locales/pt/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/ru/bundle.json b/public/locales/ru/bundle.json index d8805ad8..5164eb8b 100644 --- a/public/locales/ru/bundle.json +++ b/public/locales/ru/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/sv/bundle.json b/public/locales/sv/bundle.json index b1e3665a..f1258662 100644 --- a/public/locales/sv/bundle.json +++ b/public/locales/sv/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/public/locales/zh/bundle.json b/public/locales/zh/bundle.json index c4e7ed4b..5d8d4558 100644 --- a/public/locales/zh/bundle.json +++ b/public/locales/zh/bundle.json @@ -815,7 +815,9 @@ "error.startup.description": "The local project or database could not be opened. Reload and try again.", "error.startup.storageUnavailable": "Local storage could not be opened. Reload and try again.", "error.startup.projectUnavailable": "A local project could not be opened. Reload and try again.", + "error.startup.projectIoUnavailable": "The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.", "error.startup.reload": "Reload", + "error.startup.retry": "Retry", "error.startup.recover": "Quarantine project and reload", "error.startup.recovering": "Preserving project…", "error.startup.reset": "Reset database and reload", diff --git a/services/startupRecovery.tsx b/services/startupRecovery.tsx index d0283a3d..ef6ce9d2 100644 --- a/services/startupRecovery.tsx +++ b/services/startupRecovery.tsx @@ -14,6 +14,7 @@ export async function renderStorageInitializationFailure(root: Root): Promise { await resetAllDatabases(); window.location.reload(); @@ -32,13 +33,14 @@ export async function renderProjectInitializationFailure( const projectLoadError = error instanceof ProjectLoadError ? error : null; const backendKind = await storageService.getStorageBackendKind(); const copy = await loadStorageErrorCopy(); - const { canQuarantine, canReset } = getStartupRecoveryActions(error, backendKind); + const { canQuarantine, canReset, failureKind } = getStartupRecoveryActions(error, backendKind); const corruptProjectId = projectLoadError?.reason === 'corrupt' ? projectLoadError.projectId : null; root.render( { @@ -49,6 +51,7 @@ export async function renderProjectInitializationFailure( }, } : {})} + {...(failureKind === 'project-io' ? { onRetry: () => window.location.reload() } : {})} {...(canReset ? { onReset: async () => { diff --git a/services/startupRecoveryPolicy.ts b/services/startupRecoveryPolicy.ts index 4fede0c9..cfb4b013 100644 --- a/services/startupRecoveryPolicy.ts +++ b/services/startupRecoveryPolicy.ts @@ -1,15 +1,29 @@ import { ProjectLoadError } from './fs/projectFsStore'; export type StartupStorageBackend = 'indexeddb' | 'filesystem'; +export type StartupRecoveryFailureKind = 'storage' | 'project-corrupt' | 'project-io'; -// QNBS-v3: bind destructive reset to IndexedDB provenance so desktop filesystem failures stay preserve-first. +export interface StartupRecoveryActions { + failureKind: StartupRecoveryFailureKind; + canQuarantine: boolean; + canReset: boolean; +} + +// QNBS-v3: separate project I/O retry UX from corruption so non-destructive failures never gain quarantine authority. export function getStartupRecoveryActions( error: unknown, backend: StartupStorageBackend, -): { canQuarantine: boolean; canReset: boolean } { +): StartupRecoveryActions { const projectLoadError = error instanceof ProjectLoadError ? error : null; + const failureKind: StartupRecoveryFailureKind = + projectLoadError?.reason === 'corrupt' + ? 'project-corrupt' + : projectLoadError || backend === 'filesystem' + ? 'project-io' + : 'storage'; return { - canQuarantine: projectLoadError?.reason === 'corrupt' && backend === 'filesystem', - canReset: !projectLoadError && backend === 'indexeddb', + failureKind, + canQuarantine: failureKind === 'project-corrupt' && backend === 'filesystem', + canReset: failureKind === 'storage' && backend === 'indexeddb', }; } diff --git a/tests/unit/StorageErrorScreen.test.tsx b/tests/unit/StorageErrorScreen.test.tsx index b32627f1..10812e99 100644 --- a/tests/unit/StorageErrorScreen.test.tsx +++ b/tests/unit/StorageErrorScreen.test.tsx @@ -46,11 +46,12 @@ describe('StorageErrorScreen', () => { ...STARTUP_COPY_FALLBACKS, description: 'Localized description', projectUnavailable: 'Localized project failure', + projectIoUnavailable: 'Localized project I/O failure', recover: 'Localized preserve', recoveryFailed: 'Localized safe failure', }; - render(); + render(); expect(screen.getByText('Localized description')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Localized preserve' })).toBeInTheDocument(); @@ -64,7 +65,7 @@ describe('StorageErrorScreen', () => { ); }); - it('announces recovery and blocks destructive reset while preservation is pending', async () => { + it('announces recovery without offering a destructive reset while preservation is pending', async () => { const user = userEvent.setup(); let resolveRecovery: () => void = () => undefined; const onRecover = vi.fn( @@ -73,18 +74,20 @@ describe('StorageErrorScreen', () => { resolveRecovery = resolve; }), ); - const onReset = vi.fn(); - render( - , + , ); await user.click(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.recover })); expect(screen.getByRole('status')).toHaveTextContent(STARTUP_COPY_FALLBACKS.recovering); - expect(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.reset })).toBeDisabled(); - await user.click(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.reset })); - expect(onReset).not.toHaveBeenCalled(); + expect( + screen.queryByRole('button', { name: STARTUP_COPY_FALLBACKS.reset }), + ).not.toBeInTheDocument(); resolveRecovery(); await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('')); @@ -94,7 +97,13 @@ describe('StorageErrorScreen', () => { const user = userEvent.setup(); const onRecover = vi.fn().mockRejectedValue(new ProjectQuarantineError('source-missing')); - render(); + render( + , + ); await user.click(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.recover })); @@ -102,12 +111,15 @@ describe('StorageErrorScreen', () => { expect(screen.queryByText(STARTUP_COPY_FALLBACKS.recoveryFailed)).not.toBeInTheDocument(); }); - it('reports the typed already-preserved outcome and supports successful recovery and reset actions', async () => { + it('reports the typed already-preserved outcome and supports successful recovery', async () => { const user = userEvent.setup(); const onRecover = vi.fn().mockRejectedValue(new ProjectQuarantineError('already-preserved')); - const onReset = vi.fn(); const firstRender = render( - , + , ); await user.click(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.recover })); @@ -115,12 +127,19 @@ describe('StorageErrorScreen', () => { await screen.findByText(STARTUP_COPY_FALLBACKS.recoveryAlreadyPreserved), ).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.reset })); - expect(onReset).toHaveBeenCalledOnce(); + expect( + screen.queryByRole('button', { name: STARTUP_COPY_FALLBACKS.reset }), + ).not.toBeInTheDocument(); firstRender.unmount(); const successfulRecover = vi.fn().mockResolvedValue(undefined); - render(); + render( + , + ); await user.click(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.recover })); await waitFor(() => expect(successfulRecover).toHaveBeenCalledOnce()); expect( @@ -129,7 +148,7 @@ describe('StorageErrorScreen', () => { }); it('omits unsupported recovery actions when no handlers are provided', () => { - render(); + render(); expect( screen.queryByRole('button', { name: STARTUP_COPY_FALLBACKS.recover }), @@ -139,4 +158,50 @@ describe('StorageErrorScreen', () => { ).not.toBeInTheDocument(); expect(screen.getByText(STARTUP_COPY_FALLBACKS.storageUnavailable)).toBeInTheDocument(); }); + + it('offers database reset only for generic storage failures', async () => { + const user = userEvent.setup(); + const onReset = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.reset })); + + expect(onReset).toHaveBeenCalledOnce(); + }); + + it('shows a non-destructive retry for project I/O failures without corruption or reset actions', async () => { + const user = userEvent.setup(); + const onRetry = vi.fn(); + const onRecover = vi.fn(); + const onReset = vi.fn(); + + render( + , + ); + + expect(screen.getByText(STARTUP_COPY_FALLBACKS.projectIoUnavailable)).toBeInTheDocument(); + expect(screen.queryByText(STARTUP_COPY_FALLBACKS.projectUnavailable)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.retry })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: STARTUP_COPY_FALLBACKS.recover }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: STARTUP_COPY_FALLBACKS.reset }), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: STARTUP_COPY_FALLBACKS.retry })); + + expect(onRetry).toHaveBeenCalledOnce(); + expect(onRecover).not.toHaveBeenCalled(); + expect(onReset).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/startupRecovery.test.tsx b/tests/unit/startupRecovery.test.tsx index 19f65792..340aef92 100644 --- a/tests/unit/startupRecovery.test.tsx +++ b/tests/unit/startupRecovery.test.tsx @@ -11,7 +11,9 @@ const { mockRoot, mockReset, mockBackendKind, mockQuarantine, mockCopy, loggerEr description: 'description', storageUnavailable: 'storage unavailable', projectUnavailable: 'project unavailable', + projectIoUnavailable: 'project io unavailable', reload: 'reload', + retry: 'retry', recover: 'recover', recovering: 'recovering', reset: 'reset', @@ -70,8 +72,10 @@ import { type RecoveryScreenProps = { copy: typeof mockCopy; + failureKind: 'storage' | 'project-corrupt' | 'project-io'; onReset?: () => Promise; onRecover?: () => Promise; + onRetry?: () => void; }; function renderedScreenProps(): RecoveryScreenProps { @@ -92,6 +96,7 @@ describe('startup recovery rendering', () => { expect(mockRoot.render).toHaveBeenCalledOnce(); expect(renderedScreenProps().copy).toBe(mockCopy); + expect(renderedScreenProps().failureKind).toBe('storage'); expect(renderedScreenProps().onReset).toEqual(expect.any(Function)); }); @@ -104,6 +109,7 @@ describe('startup recovery rendering', () => { ); const props = renderedScreenProps(); + expect(props.failureKind).toBe('project-corrupt'); expect(props.onRecover).toEqual(expect.any(Function)); expect(props.onReset).toBeUndefined(); await (props.onRecover as () => Promise)(); @@ -114,13 +120,17 @@ describe('startup recovery rendering', () => { mockBackendKind.mockResolvedValue('filesystem'); await renderProjectInitializationFailure(mockRoot as never, new Error('EACCES /projects/p1')); + expect(renderedScreenProps().failureKind).toBe('project-io'); expect(renderedScreenProps().onReset).toBeUndefined(); await renderProjectInitializationFailure( mockRoot as never, new ProjectLoadError('io-error', 'io', 'p1'), ); + expect(renderedScreenProps().failureKind).toBe('project-io'); expect(renderedScreenProps().onReset).toBeUndefined(); + expect(renderedScreenProps().onRecover).toBeUndefined(); + expect(renderedScreenProps().onRetry).toEqual(expect.any(Function)); expect(loggerError).toHaveBeenCalled(); }); @@ -129,6 +139,7 @@ describe('startup recovery rendering', () => { await renderProjectInitializationFailure(mockRoot as never, new Error('QuotaExceededError')); expect(renderedScreenProps().onRecover).toBeUndefined(); + expect(renderedScreenProps().failureKind).toBe('storage'); expect(renderedScreenProps().onReset).toEqual(expect.any(Function)); }); }); diff --git a/tests/unit/startupRecoveryPolicy.test.ts b/tests/unit/startupRecoveryPolicy.test.ts index a264d83c..e16517dc 100644 --- a/tests/unit/startupRecoveryPolicy.test.ts +++ b/tests/unit/startupRecoveryPolicy.test.ts @@ -8,10 +8,12 @@ describe('startup recovery action policy', () => { const error = new ProjectLoadError('corrupt', 'corrupt', 'project-1'); expect(getStartupRecoveryActions(error, 'filesystem')).toEqual({ + failureKind: 'project-corrupt', canQuarantine: true, canReset: false, }); expect(getStartupRecoveryActions(error, 'indexeddb')).toEqual({ + failureKind: 'project-corrupt', canQuarantine: false, canReset: false, }); @@ -19,12 +21,14 @@ describe('startup recovery action policy', () => { it('never offers destructive reset for filesystem-origin failures, including raw errors', () => { expect(getStartupRecoveryActions(new Error('EACCES /projects/p1'), 'filesystem')).toEqual({ + failureKind: 'project-io', canQuarantine: false, canReset: false, }); expect( getStartupRecoveryActions(new ProjectLoadError('io-error', 'io', 'project-1'), 'filesystem'), ).toEqual({ + failureKind: 'project-io', canQuarantine: false, canReset: false, }); @@ -32,8 +36,19 @@ describe('startup recovery action policy', () => { it('retains database reset for non-project failures from IndexedDB', () => { expect(getStartupRecoveryActions(new Error('QuotaExceededError'), 'indexeddb')).toEqual({ + failureKind: 'storage', canQuarantine: false, canReset: true, }); }); + + it('does not offer database reset for project-specific IndexedDB failures', () => { + expect( + getStartupRecoveryActions(new ProjectLoadError('io-error', 'io', 'project-1'), 'indexeddb'), + ).toEqual({ + failureKind: 'project-io', + canQuarantine: false, + canReset: false, + }); + }); });