diff --git a/docs/decisions-log.md b/docs/decisions-log.md index cb1a41e..cde7cb0 100644 --- a/docs/decisions-log.md +++ b/docs/decisions-log.md @@ -2,6 +2,18 @@ Record of architectural and technical decisions made during development. +## 2026-07-26 — Request persistent storage on startup (Step 29) + +**Decision:** `main.js` now calls `navigator.storage.persist()` once at startup, fire-and-forget (guarded so browsers without the API skip it, result only logged). No UI, no i18n strings, no blocking: whether the browser grants, denies, or prompts, startup proceeds unchanged. + +**Rationale:** User report (2026-07-26): after not using the app for a while, the Bootstrap Icons occasionally render as placeholder boxes. Without `persist()`, all site storage is "best-effort" and the browser may evict Cache Storage (the Workbox precache holding the icon font) after long inactivity or under disk pressure — a stale HTTP-cached page then references an old content-hashed font file that no longer exists on the server after a deploy → 404 → tofu glyphs. More importantly, the OPFS SQLite database lives in the same evictable bucket, so the same cleanup could delete user data. `persist()` asks the browser to exempt the origin from automatic eviction (Chromium decides silently by engagement heuristics; Firefox may show a permission doorhanger). Placed in `main.js` rather than the re-callable `bootstrapDatabase()` so a retry after a failed first load doesn't re-prompt. Tests stub `StorageManager.prototype.persist` since the real outcome depends on browser heuristics/permission UI. + +## 2026-07-26 — First-load network failure: re-callable init + in-app retry (Step 28) + +**Decision:** A failed database startup is no longer final. `Database.init()` now resets itself when the worker never became usable (`db.info` still null): the dead worker is terminated and the memoized promise cleared, so a later `init()` call starts from scratch — required because the OPFS SAH pool only allows one connection per origin. The startup sequence (init + persisted language/theme + DOM attributes) moved from `main.js` into a re-callable `bootstrapDatabase()` (`src/db/bootstrap.js`), which App.vue's new **Try again** button on the error overlay reruns in place. The worker tags `sqlite3InitModule` failures as `DB_LOAD_FAILED` — that call is the only network-touching step (downloading `sqlite3.wasm` on a not-yet-cached first load) — and App.vue maps it to a "check your connection and try again" message instead of the misleading `browserNotCompatible`. `_handleError` only dispatches `db-connection-lost` after a successful init, so startup failures consistently land on the retryable error overlay rather than the reload-only connection-lost overlay. + +**Rationale:** User report (2026-07-26): a network timeout on the very first load left the app permanently stuck on "your browser is not compatible" — the init promise cached its rejection, nothing ever retried, and the overlay offered no way out. There is no timeout value to extend: the wasm download is a plain browser `fetch()` with no application-level timeout, so the fix is recoverability, not tuning. The reset deliberately does **not** trigger for failures after the DB opened (`DB_NEWER_THAN_APP`): that worker stays alive for export/diagnostics and the retry button is hidden since retrying cannot help (this also preserves the Step 16 test relying on a live worker after that error). Retry-in-place was chosen over a "reload the page" hint because it keeps the SPA state and proves recovery works without depending on cache behavior across reloads. Chromium-only tests for the wasm-fetch block (Playwright cannot intercept dedicated-worker requests on Firefox); a cross-browser test breaks worker creation instead so the retry machinery is verified on both browsers. + ## 2026-07-24 — Check Updates section moved before Credits; shows last manual check date **Decision:** Renamed the Settings "Application" card to **Check Updates** and moved it to sit directly above **Credits** (previously between Sound and the restore-overlay/report block). Added a `last_update_check_at` settings key (ISO-8601 UTC, in-place addition) stamped by `SettingsView.onCheckForUpdate()` right after `checkForUpdate()` resolves; the section displays it via `formatDateTime` ("Last manual check: {{when}}") or a "Never checked manually" fallback. diff --git a/docs/plan.md b/docs/plan.md index 914a4dd..b007128 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,7 +1,7 @@ # TrainUs — Implementation Plan (v3) -> **Last updated:** 2026-07-24 -> **Status:** Step 24 (app-update modal), Step 25 (headset media controls), Step 26 (voice announcements), and Step 27 (manual "Check for updates" button) all manually validated by the user. Code-review remediation F1–F12 (see `docs/code-review-remediation-plan.md`) manually validated by the user. +> **Last updated:** 2026-07-26 +> **Status:** Step 24 (app-update modal), Step 25 (headset media controls), Step 26 (voice announcements), and Step 27 (manual "Check for updates" button) all manually validated by the user. Code-review remediation F1–F12 (see `docs/code-review-remediation-plan.md`) manually validated by the user. Step 28 (first-load network failure: clear error + in-app retry) and Step 29 (request persistent storage) implemented, awaiting manual validation. ## Overview @@ -942,6 +942,65 @@ Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Depl --- +### Step 28 — First-load network failure: clear error + in-app retry + +**Status:** ✅ Done (2026-07-26) — 4 Playwright tests passing (1 on Firefox + Chromium, 3 Chromium-only: Playwright cannot intercept dedicated-worker requests on Firefox). + +**Requested:** 2026-07-26, user report — an exception on the very first load (bad network, likely a timeout downloading `sqlite3.wasm`); once it failed, it was never attempted again. + +**Why:** On a first load nothing is cached yet, so the DB worker downloads `sqlite3.wasm` over the network. Three compounding problems made a transient network failure final: `Database.init()` memoized its promise even when rejected (no retry possible), the error overlay had no retry button, and a network failure fell through to the misleading "browser not compatible" message. There is no timeout value to extend — the wasm download is a plain browser `fetch()` with no application-level timeout. + +#### Decisions + +| Decision | Choice | Rationale | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Retry mechanism | `init()` resets itself (terminate worker, clear state) when the worker never became usable, so a later call starts fresh | The OPFS SAH pool allows one connection per origin — the dead worker must be terminated before a new one can attach | +| Reset condition | Only when `db.info` is still null (worker never initialized) | `DB_NEWER_THAN_APP` fails _after_ the DB opened; its worker stays alive for export/diagnostics, and retry won't help | +| Error tagging | Worker wraps `sqlite3InitModule` failures as `DB_LOAD_FAILED: …` | That call is the only network-touching step, so it cleanly separates connectivity from storage/compat failures | +| Retry UX | "Try again" button on the error overlay reruns `bootstrapDatabase()` in place (extracted from `main.js` to `src/db/bootstrap.js`) | Recovers without a page reload; hidden for `DB_NEWER_THAN_APP` / `DB_MIGRATION_FAILED` where retrying is pointless | +| Connection-lost event | `_handleError` only dispatches `db-connection-lost` after a successful init | During startup the failure surfaces through `init()`'s rejection (with retry); the overlay is for post-init crashes | + +#### Files created / modified + +- `src/db/bootstrap.js` — new: full startup sequence (init + persisted language/theme + DOM attributes), re-callable +- `src/db/database.js` — `init()` resets on pre-usable failure; guarded `db-connection-lost` dispatch +- `src/db/worker.js` — tag `sqlite3InitModule` failures as `DB_LOAD_FAILED` +- `src/main.js` — startup sequence moved to `bootstrapDatabase()` +- `src/App.vue` — `Try again` button (`data-testid="db-retry"`), `DB_LOAD_FAILED` message mapping, `onRetryInit()` +- `src/i18n/locales/{en,fr,da}.json` — `error.dbLoadFailed`, `error.retry` +- `tests/test_step28_init_retry.py` +- `docs/decisions-log.md`, `website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.{en,fr}.md` + +**Verification:** Playwright tests — blocking `**/sqlite3.wasm` (route abort, service workers blocked) shows the `db-error` overlay tagged `DB_LOAD_FAILED` with the retry button; unblocking + "Try again" reaches `data-db-ready` in place (marker proves no reload); retrying while still blocked shows the overlay again. Cross-browser (Firefox + Chromium): breaking worker creation via a wrapped `window.Worker`, then fixing it and retrying, recovers in place. Existing step 16/19 suites still green. + +--- + +### Step 29 — Request persistent storage on startup + +**Status:** ✅ Done (2026-07-26) — 3 Playwright tests passing on Firefox + Chromium. + +**Requested:** 2026-07-26, user report — Bootstrap Icons occasionally render as placeholder boxes after not using the app for a while. + +**Why:** Without `navigator.storage.persist()`, all site storage is "best-effort": the browser may evict Cache Storage (the Workbox precache, including the icon font) and the OPFS SQLite database after long inactivity or under disk pressure. Evicted precache + a stale HTTP-cached page referencing an old content-hashed font file that no longer exists on the server → 404 → placeholder glyphs. The user-data risk (OPFS in the same evictable bucket) is the bigger reason to fix it. + +#### Decisions + +| Decision | Choice | Rationale | +| --------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Placement | `main.js`, not the re-callable `bootstrapDatabase()` | Runs exactly once per page load; a retry after a failed first load must not re-prompt (Firefox) | +| Behaviour | Fire-and-forget, guarded (`navigator.storage?.persist`), result only logged | Grant/denial must never block startup; no UI or i18n strings needed | +| Testing | Stub `StorageManager.prototype.persist` via init script | The real outcome depends on browser heuristics / permission UI — not reproducible in CI | + +#### Files created / modified + +- `src/main.js` — `navigator.storage.persist()` request at startup +- `tests/test_step29_persistent_storage.py` +- `docs/decisions-log.md`, `website/content/developers/technical.{en,fr}.md`, `website/content/docs/user-guide/index.{en,fr}.md` + +**Verification:** Playwright tests (Firefox + Chromium) — startup calls `persist()` exactly once and the app boots when granted; a denied request doesn't block startup; a browser without the API still boots with no call attempted. + +--- + ## Data Model ```mermaid diff --git a/src/App.vue b/src/App.vue index 9e58325..8d6b241 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,6 +8,7 @@ import FirstLoadWarning from './components/FirstLoadWarning.vue' import InstallPrompt from './components/InstallPrompt.vue' import SaveFileDialog from './components/SaveFileDialog.vue' import { db } from './db/database.js' +import { bootstrapDatabase } from './db/bootstrap.js' import { useBackupDownload } from './composables/useBackupDownload.js' const { t } = useTranslation() @@ -44,9 +45,31 @@ if (window.__dbReady) { const dbErrorBody = computed(() => { if (dbError.value === 'DB_NEWER_THAN_APP') return t('error.dbNewerThanApp') if (dbError.value === 'DB_MIGRATION_FAILED') return t('error.dbMigrationFailed') + if (dbError.value && dbError.value.startsWith('DB_LOAD_FAILED')) return t('error.dbLoadFailed') return t('error.browserNotCompatible') }) +// Retrying makes no sense when the data itself is ahead of the app or a +// migration just failed; every other init error (network, storage) may be +// transient. +const dbErrorRetryable = computed( + () => dbError.value !== 'DB_NEWER_THAN_APP' && dbError.value !== 'DB_MIGRATION_FAILED', +) + +async function onRetryInit() { + dbError.value = null // back to the loading overlay while the retry runs + try { + const status = await bootstrapDatabase() + if (status && status.migrationPending) { + migrationPending.value = status.migrationPending + } else { + dbReady.value = true + } + } catch (err) { + dbError.value = err.message || String(err) + } +} + // Blocking migration screen: backup is mandatory before the update can run const backupDownloaded = ref(false) const migrating = ref(false) @@ -127,6 +150,15 @@ const pageTitle = computed(() => {
{{ dbErrorBody }}
+ diff --git a/src/db/bootstrap.js b/src/db/bootstrap.js new file mode 100644 index 0000000..8b19872 --- /dev/null +++ b/src/db/bootstrap.js @@ -0,0 +1,39 @@ +import { db } from './database.js' +import { settingsRepository } from './repositories/settingsRepository.js' +import { i18next } from '../i18n' + +// Full startup sequence: DB init plus persisted settings applied before the +// first render. Re-callable: database.init() resets itself on failure, so +// calling this again (App.vue's Retry button) restarts from scratch once the +// network is back. +export async function bootstrapDatabase() { + try { + const info = await db.init() + console.log('Database initialized:', info) + + // Apply persisted settings before first render + const lang = await settingsRepository.get('language') + if (lang) { + i18next.changeLanguage(lang) + } + const theme = await settingsRepository.get('theme') + if (theme) { + document.documentElement.setAttribute('data-theme', theme) + } + + document.documentElement.removeAttribute('data-db-error') + + if (db.migrationPending) { + // App stays paused: App.vue shows the blocking migration screen + document.documentElement.setAttribute('data-db-migration', 'pending') + return { migrationPending: db.migrationPending } + } + + document.documentElement.setAttribute('data-db-ready', 'true') + return {} + } catch (err) { + console.error('Database initialization failed:', err) + document.documentElement.setAttribute('data-db-error', err.message) + throw err + } +} diff --git a/src/db/database.js b/src/db/database.js index da39c6c..f24a59d 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -13,7 +13,23 @@ class Database { async init() { if (this._initPromise) return this._initPromise - this._initPromise = this._doInit() + this._initPromise = this._doInit().catch((err) => { + // If the worker never became usable (e.g. the sqlite3.wasm download + // failed on a not-yet-cached first load), tear it down so a later + // init() call retries from scratch. When init failed *after* the DB + // opened (e.g. DB_NEWER_THAN_APP) the worker stays alive: the app may + // still need it (export, diagnostics) and a retry would not help. + if (!this.info) { + if (this._worker) { + this._worker.terminate() + this._worker = null + } + this._pending.clear() + this._broken = false + this._initPromise = null + } + throw err + }) return this._initPromise } @@ -109,7 +125,12 @@ class Database { reject(new Error('Worker crashed')) } this._pending.clear() - window.dispatchEvent(new CustomEvent('db-connection-lost')) + // During startup the failure surfaces through init()'s rejection (which + // offers a retry); the connection-lost overlay is only for a worker that + // dies after a successful init. + if (this.info) { + window.dispatchEvent(new CustomEvent('db-connection-lost')) + } } _send(type, args = {}) { diff --git a/src/db/worker.js b/src/db/worker.js index 837e996..b3b6317 100644 --- a/src/db/worker.js +++ b/src/db/worker.js @@ -8,9 +8,17 @@ let sqlite3 = null async function initDatabase(config) { const { dbName, wasmBaseUrl } = config - sqlite3 = await sqlite3InitModule({ - locateFile: (file) => new URL(file, wasmBaseUrl).href, - }) + try { + sqlite3 = await sqlite3InitModule({ + locateFile: (file) => new URL(file, wasmBaseUrl).href, + }) + } catch (e) { + // This is the only step that touches the network (downloading + // sqlite3.wasm on a not-yet-cached first load), so tag the failure as a + // connectivity problem — the UI offers a retry instead of the generic + // "browser not compatible" message. + throw new Error(`DB_LOAD_FAILED: ${e?.message || e}`, { cause: e }) + } const poolUtil = await sqlite3.installOpfsSAHPoolVfs({ directory: `.${dbName}`, diff --git a/src/i18n/locales/da.json b/src/i18n/locales/da.json index d9724df..ecf8ca3 100644 --- a/src/i18n/locales/da.json +++ b/src/i18n/locales/da.json @@ -14,7 +14,9 @@ "dbNewerThanApp": "Dine data blev oprettet af en nyere version af appen. Dine data er ikke blevet ændret. Genindlæs siden for at få den nyeste version af appen.", "dbMigrationFailed": "Databaseopdateringen mislykkedes, og dine data blev ikke ændret. Genindlæs siden for at prøve igen, eller gendan den sikkerhedskopi, du lige har downloadet, hvis problemet fortsætter.", "dbConnectionLost": "Databaseforbindelsen gik tabt på grund af en uventet fejl. Genindlæs siden for at genoprette forbindelsen.", - "reload": "Genindlæs" + "dbLoadFailed": "Applikationen kunne ikke hentes færdig, sandsynligvis på grund af et netværksproblem. Tjek din internetforbindelse og prøv igen.", + "reload": "Genindlæs", + "retry": "Prøv igen" }, "migration": { "title": "Databaseopdatering påkrævet", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c499c28..9fbd99a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -14,7 +14,9 @@ "dbNewerThanApp": "Your data was created by a newer version of the app. Your data has not been modified. Please reload the page to get the latest version of the app.", "dbMigrationFailed": "The database update failed and your data was left unchanged. Reload the page to try again, or restore the backup you just downloaded if the problem persists.", "dbConnectionLost": "The database connection was lost due to an unexpected error. Reload the page to reconnect.", - "reload": "Reload" + "dbLoadFailed": "The application could not finish downloading, probably because of a network problem. Check your internet connection and try again.", + "reload": "Reload", + "retry": "Try again" }, "migration": { "title": "Database update required", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 91beb1f..3f1092f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -14,7 +14,9 @@ "dbNewerThanApp": "Vos données ont été créées par une version plus récente de l'application. Vos données n'ont pas été modifiées. Veuillez recharger la page pour obtenir la dernière version de l'application.", "dbMigrationFailed": "La mise à jour de la base de données a échoué et vos données n'ont pas été modifiées. Rechargez la page pour réessayer, ou restaurez la sauvegarde que vous venez de télécharger si le problème persiste.", "dbConnectionLost": "La connexion à la base de données a été perdue suite à une erreur inattendue. Rechargez la page pour vous reconnecter.", - "reload": "Recharger" + "dbLoadFailed": "Le téléchargement de l'application n'a pas pu se terminer, probablement à cause d'un problème réseau. Vérifiez votre connexion Internet et réessayez.", + "reload": "Recharger", + "retry": "Réessayer" }, "migration": { "title": "Mise à jour de la base de données requise", diff --git a/src/main.js b/src/main.js index 49d6ccf..8b9bed7 100644 --- a/src/main.js +++ b/src/main.js @@ -2,6 +2,7 @@ import { createApp } from 'vue' import { i18next, I18NextVue } from './i18n' import router from './router' import { db } from './db/database.js' +import { bootstrapDatabase } from './db/bootstrap.js' import { settingsRepository } from './db/repositories/settingsRepository.js' import { exerciseRepository } from './db/repositories/exerciseRepository.js' import { comboRepository } from './db/repositories/comboRepository.js' @@ -18,36 +19,21 @@ const app = createApp(App) app.use(I18NextVue, { i18next }) app.use(router) -// Initialize database (before mount so App.vue can subscribe to the promise) -const dbReady = db - .init() - .then(async (info) => { - console.log('Database initialized:', info) +// Ask the browser to exempt this origin's storage (OPFS database, PWA +// precache — including the icon font) from automatic eviction after long +// periods of inactivity. Fire-and-forget: the app works either way, denial +// just leaves storage best-effort. +if (navigator.storage?.persist) { + navigator.storage + .persist() + .then((granted) => console.log('Persistent storage:', granted ? 'granted' : 'not granted')) + .catch(() => {}) +} - // Apply persisted settings before first render - const lang = await settingsRepository.get('language') - if (lang) { - i18next.changeLanguage(lang) - } - const theme = await settingsRepository.get('theme') - if (theme) { - document.documentElement.setAttribute('data-theme', theme) - } - - if (db.migrationPending) { - // App stays paused: App.vue shows the blocking migration screen - document.documentElement.setAttribute('data-db-migration', 'pending') - return { migrationPending: db.migrationPending } - } - - document.documentElement.setAttribute('data-db-ready', 'true') - return {} - }) - .catch((err) => { - console.error('Database initialization failed:', err) - document.documentElement.setAttribute('data-db-error', err.message) - throw err - }) +// Initialize database (before mount so App.vue can subscribe to the promise). +// The sequence lives in bootstrapDatabase() so App.vue can rerun it from the +// Retry button after a failed first load. +const dbReady = bootstrapDatabase() // Expose for testing window.__db = db diff --git a/tests/test_step28_init_retry.py b/tests/test_step28_init_retry.py new file mode 100644 index 0000000..e597a4a --- /dev/null +++ b/tests/test_step28_init_retry.py @@ -0,0 +1,149 @@ +""" +Step 28 — First-load network failure: clear error + in-app retry + +On a first load nothing is cached yet, so the DB worker downloads sqlite3.wasm +over the network. These tests simulate that download failing and verify the +app shows a network-specific error with a working "Try again" button instead +of the misleading "browser not compatible" dead end. + +The wasm-route tests are Chromium-only: Playwright cannot intercept requests +made inside a dedicated worker on Firefox, so the download failure cannot be +simulated there. The retry machinery itself is covered on both browsers by +test_worker_load_failure_retry, which breaks worker creation instead. + +- test_wasm_fetch_failure_shows_network_error (chromium): blocking + sqlite3.wasm shows the db-error overlay tagged DB_LOAD_FAILED with a retry + button +- test_retry_recovers_once_network_is_back (chromium): unblocking the request + and clicking "Try again" initializes the app in place, without a page reload +- test_retry_fails_again_while_still_offline (chromium): retrying with the + network still down shows the error overlay again (retry stays available) +- test_worker_load_failure_retry (both browsers): a DB worker that fails to + load shows the error overlay with retry; fixing the worker and clicking + "Try again" brings the app up in place +""" +import pytest +from playwright.sync_api import Browser, Page, expect + +WASM_ROUTE = "**/sqlite3.wasm" + +# Wrap window.Worker so the DB worker initially points at a 404 URL (the +# worker fails to load, like on a broken network). window.__fixWorker() +# restores normal behaviour for the retry. +_BREAK_WORKER = """ +(() => { + const RealWorker = window.Worker; + let broken = true; + window.__fixWorker = () => { broken = false; }; + window.Worker = function (url, opts) { + let target = url; + if (broken) { + // Corrupt the pathname (not the raw string: Vite dev appends a query + // string, and appending there would leave the path resolvable) + const u = new URL(url, window.location.href); + u.pathname += '.broken-by-test'; + target = u; + } + return new RealWorker(target, opts); + }; +})(); +""" + + +@pytest.fixture +def wasm_blocked_page(browser: Browser): + """A page in its own context (service workers blocked so route interception + reliably sees the worker's wasm fetch) where sqlite3.wasm requests fail.""" + context = browser.new_context( + viewport={"width": 1280, "height": 720}, + service_workers="block", + ) + page = context.new_page() + page.route(WASM_ROUTE, lambda route: route.abort()) + yield page + context.close() + + +def _wait_for_error_overlay(page: Page): + overlay = page.locator('[data-testid="db-error"]') + expect(overlay).to_be_visible(timeout=20000) + return overlay + + +@pytest.mark.only_browser("chromium") +def test_wasm_fetch_failure_shows_network_error(wasm_blocked_page, app_url: str): + """A failed sqlite3.wasm download shows the error overlay with the + network-specific message (DB_LOAD_FAILED) and a retry button.""" + page = wasm_blocked_page + page.goto(app_url) + + _wait_for_error_overlay(page) + + err = page.evaluate("document.documentElement.getAttribute('data-db-error')") + assert err and err.startswith("DB_LOAD_FAILED"), ( + f"Expected a DB_LOAD_FAILED tagged error, got: {err!r}" + ) + expect(page.locator('[data-testid="db-retry"]')).to_be_visible() + + +@pytest.mark.only_browser("chromium") +def test_retry_recovers_once_network_is_back(wasm_blocked_page, app_url: str): + """Unblocking the wasm request and clicking 'Try again' brings the app up + in place — no page reload involved.""" + page = wasm_blocked_page + page.goto(app_url) + _wait_for_error_overlay(page) + + # Marker survives only if no navigation/reload happens during the retry + page.evaluate("window.__retryMarker = true") + + page.unroute(WASM_ROUTE) + page.locator('[data-testid="db-retry"]').click() + + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=20000, + ) + expect(page.locator('[data-testid="db-error"]')).not_to_be_visible() + assert page.evaluate("window.__retryMarker") is True, ( + "The retry must recover in place, not through a page reload" + ) + + +@pytest.mark.only_browser("chromium") +def test_retry_fails_again_while_still_offline(wasm_blocked_page, app_url: str): + """Retrying while the network is still down fails again and keeps the + retry button available.""" + page = wasm_blocked_page + page.goto(app_url) + _wait_for_error_overlay(page) + + page.locator('[data-testid="db-retry"]').click() + + # The overlay disappears (loading spinner) then comes back with the button + _wait_for_error_overlay(page) + expect(page.locator('[data-testid="db-retry"]')).to_be_visible() + + +def test_worker_load_failure_retry(page: Page, app_url: str): + """Cross-browser: a DB worker that fails to load shows the error overlay + with a retry button, and retrying after the 'network' recovers initializes + the app in place.""" + page.add_init_script(_BREAK_WORKER) + page.goto(app_url) + + _wait_for_error_overlay(page) + expect(page.locator('[data-testid="db-retry"]')).to_be_visible() + + page.evaluate("window.__retryMarker = true") + page.evaluate("window.__fixWorker()") + page.locator('[data-testid="db-retry"]').click() + + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=20000, + ) + expect(page.locator('[data-testid="db-error"]')).not_to_be_visible() + assert page.evaluate("window.__retryMarker") is True, ( + "The retry must recover in place, not through a page reload" + ) diff --git a/tests/test_step29_persistent_storage.py b/tests/test_step29_persistent_storage.py new file mode 100644 index 0000000..20af9c3 --- /dev/null +++ b/tests/test_step29_persistent_storage.py @@ -0,0 +1,69 @@ +""" +Step 29 — Persistent storage request + +Without navigator.storage.persist(), the origin's storage (OPFS database, +PWA precache — including the Bootstrap Icons font) is "best-effort" and the +browser may evict it after long periods of inactivity or under disk +pressure. On startup the app now requests persistent storage once, +fire-and-forget. + +The real persist() outcome depends on browser heuristics / a permission +prompt, so these tests stub StorageManager.prototype.persist via an init +script and verify the app's behaviour around it: + +- test_persist_requested_on_startup: startup calls persist() exactly once + and the app boots normally when the request is granted +- test_app_boots_when_persist_denied: a denied request does not block + startup (red path) +- test_app_boots_without_persist_api: browsers without the API still boot + and no call is attempted (red path) +""" + +from playwright.sync_api import Page + +_DB_READY = "document.documentElement.getAttribute('data-db-ready') === 'true'" + +# Stub persist() to count calls and resolve with a fixed grant result. +_STUB_PERSIST_TEMPLATE = """ +(() => { + window.__persistCalls = 0; + StorageManager.prototype.persist = function () { + window.__persistCalls += 1; + return Promise.resolve(%s); + }; +})(); +""" + +_REMOVE_PERSIST = """ +(() => { + window.__persistCalls = 0; + StorageManager.prototype.persist = function () { + window.__persistCalls += 1; + }; + delete StorageManager.prototype.persist; +})(); +""" + + +def test_persist_requested_on_startup(page: Page, app_url: str): + """Startup requests persistent storage exactly once and the app boots.""" + page.add_init_script(_STUB_PERSIST_TEMPLATE % "true") + page.goto(app_url) + page.wait_for_function(_DB_READY, timeout=30000) + assert page.evaluate("window.__persistCalls") == 1 + + +def test_app_boots_when_persist_denied(page: Page, app_url: str): + """A denied persistent-storage request does not block startup.""" + page.add_init_script(_STUB_PERSIST_TEMPLATE % "false") + page.goto(app_url) + page.wait_for_function(_DB_READY, timeout=30000) + assert page.evaluate("window.__persistCalls") == 1 + + +def test_app_boots_without_persist_api(page: Page, app_url: str): + """Browsers without navigator.storage.persist still boot normally.""" + page.add_init_script(_REMOVE_PERSIST) + page.goto(app_url) + page.wait_for_function(_DB_READY, timeout=30000) + assert page.evaluate("window.__persistCalls") == 0 diff --git a/website/content/developers/technical.en.md b/website/content/developers/technical.en.md index 9c3c919..268b046 100644 --- a/website/content/developers/technical.en.md +++ b/website/content/developers/technical.en.md @@ -1041,6 +1041,10 @@ The optional `lang` parameter allows Vue computed properties to pass the reactiv Workbox handles content-hash-based precaching of the entire app shell at install time, enabling full offline support. The WASM binary is included in the precache manifest via the 10 MB limit. +#### Persistent storage + +At startup, `main.js` calls `navigator.storage.persist()` once, fire-and-forget. Without it, all site storage is "best-effort": the browser may evict Cache Storage (the Workbox precache, including the icon font) and the OPFS SQLite database after long periods of inactivity or under disk pressure. A granted request exempts the origin from automatic eviction. Chromium decides silently based on engagement heuristics; Firefox may show a permission prompt; a denial (or a browser without the API) never blocks startup — the result is only logged. + #### App manifest `public/manifest.json` declares `name`, `short_name`, `display: standalone`, `theme_color`, `background_color`, and two icons (192×192 and 512×512) with both `maskable` and `any` purposes. @@ -1119,6 +1123,20 @@ The modal is suppressed while the current route is the execution view (`route.na **Key files:** `vite.config.js`, `src/composables/useAppUpdate.js`, `src/components/AppUpdateDialog.vue`, `src/App.vue`, `src/views/SettingsView.vue` +### 3.15 First-Load Failure Recovery (Retry) + +On a very first load nothing is precached yet, so the DB worker downloads `sqlite3.wasm` (~8 MB) over the network. Three compounding problems used to make a transient network failure permanent: `Database.init()` memoized its promise even when rejected, the `db-error` overlay had no action button, and any unrecognized error fell through to the "browser not compatible" message — telling a user with a flaky connection that their browser was the problem. There is no timeout to tune: the wasm download is a plain browser `fetch()` with no application-level timeout, so the failure comes from the network stack and the only sound fix is recoverability. + +**Error classification:** `worker.js` wraps the `sqlite3InitModule()` call — the only network-touching step of startup — and rethrows failures as `DB_LOAD_FAILED: