Fix some issue at first load and cache issue
This commit is contained in:
parent
056ca9abcc
commit
270ff24c60
|
|
@ -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.
|
||||
|
|
|
|||
63
docs/plan.md
63
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
|
||||
|
|
|
|||
32
src/App.vue
32
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(() => {
|
|||
<i class="bi bi-exclamation-triangle db-error-icon"></i>
|
||||
<h1>{{ $t('error.dbInitFailed') }}</h1>
|
||||
<p>{{ dbErrorBody }}</p>
|
||||
<button
|
||||
v-if="dbErrorRetryable"
|
||||
class="btn btn-primary"
|
||||
style="margin-top: 1rem"
|
||||
data-testid="db-retry"
|
||||
@click="onRetryInit"
|
||||
>
|
||||
{{ $t('error.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
39
src/db/bootstrap.js
vendored
Normal file
39
src/db/bootstrap.js
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
44
src/main.js
44
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
|
||||
|
|
|
|||
149
tests/test_step28_init_retry.py
Normal file
149
tests/test_step28_init_retry.py
Normal file
|
|
@ -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"
|
||||
)
|
||||
69
tests/test_step29_persistent_storage.py
Normal file
69
tests/test_step29_persistent_storage.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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: <original message>`. `App.vue` maps that prefix to a "check your internet connection and try again" message; storage/compat failures keep their existing messages.
|
||||
|
||||
**Re-callable init:** `Database.init()` attaches a reset handler to its memoized promise. If init fails while `db.info` is still null (the worker never became usable), the worker is terminated, pending calls are cleared, and `_initPromise` is nulled so the next `init()` call starts from scratch — terminating the dead worker first matters because the OPFS SAH pool VFS (§2) allows only one connection per origin. Failures _after_ the DB opened (e.g. `DB_NEWER_THAN_APP`) deliberately skip the reset: that worker stays alive for export/diagnostics, and a retry could not change the outcome. For the same reason, `_handleError` only dispatches `db-connection-lost` once init has succeeded — startup failures always land on the retryable error overlay, never on the reload-only connection-lost overlay.
|
||||
|
||||
**Retry UX:** the startup sequence (DB init, persisted language/theme, `data-db-*` attributes) moved from `main.js` into `bootstrapDatabase()` (`src/db/bootstrap.js`). `main.js` calls it once at startup; the **Try again** button on the error overlay calls it again in place — no page reload, so recovery does not depend on cache behavior across navigations. The button is hidden for `DB_NEWER_THAN_APP` and `DB_MIGRATION_FAILED`, where retrying is pointless. After one successful load the service worker precaches the wasm, so this path only concerns first loads (or cleared site data).
|
||||
|
||||
**Testing:** the wasm-download failure is simulated with a Playwright route abort on `**/sqlite3.wasm` in a context with service workers blocked — Chromium-only, because Playwright cannot intercept requests issued inside a dedicated worker on Firefox. A cross-browser test covers the retry machinery instead by wrapping `window.Worker` to break (then fix) the worker URL.
|
||||
|
||||
**Key files:** `src/db/database.js`, `src/db/worker.js`, `src/db/bootstrap.js`, `src/main.js`, `src/App.vue`
|
||||
|
||||
---
|
||||
|
||||
## 4. Routing
|
||||
|
|
|
|||
|
|
@ -983,6 +983,10 @@ Le paramètre optionnel `lang` permet aux propriétés calculées (`computed`) d
|
|||
|
||||
Workbox gère la précache, basée sur le hash du contenu, de toute la coquille de l'application au moment de l'installation, ce qui permet un support hors ligne complet. Le binaire WASM est inclus dans le manifeste de précache grâce à la limite de 10 Mo.
|
||||
|
||||
#### Stockage persistant
|
||||
|
||||
Au démarrage, `main.js` appelle `navigator.storage.persist()` une seule fois, sans attendre le résultat. Sans cet appel, tout le stockage du site est « au mieux » (best-effort) : le navigateur peut évincer le Cache Storage (la précache Workbox, y compris la police d'icônes) et la base SQLite sur OPFS après une longue période d'inactivité ou en cas de pression sur le disque. Une demande accordée exempte l'origine de l'éviction automatique. Chromium décide silencieusement selon des heuristiques d'engagement ; Firefox peut afficher une demande de permission ; un refus (ou un navigateur sans cette API) ne bloque jamais le démarrage — le résultat est seulement journalisé.
|
||||
|
||||
#### Manifeste de l'application
|
||||
|
||||
`public/manifest.json` déclare `name`, `short_name`, `display: standalone`, `theme_color`, `background_color`, et deux icônes (192×192 et 512×512) avec les usages `maskable` et `any`.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ The very first time you open TrainUs, a one-time warning modal explains two impo
|
|||
|
||||
Click **Got it** to dismiss the modal — it will not appear again.
|
||||
|
||||
The first launch also downloads the app's database engine, which needs a working internet connection. If the download fails (bad network, timeout), TrainUs shows an error screen explaining the problem with a **Try again** button — once your connection is back, click it to finish loading without reloading the page. After one successful launch the app is fully cached and works offline.
|
||||
|
||||
On startup TrainUs also asks the browser to keep its data stored permanently, so your training data and the offline cache are not deleted automatically if you don't use the app for a long time. Some browsers (e.g. Firefox) may show a permission prompt for this — allowing it is recommended; if you decline, the app still works, but the browser may reclaim its storage after long inactivity.
|
||||
|
||||
## Home
|
||||
|
||||
The Home page (`#/home`) is the default landing page. It provides an overview of your recent training activity.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ La toute première fois que vous ouvrez TrainUs, une fenêtre d'avertissement un
|
|||
|
||||
Cliquez sur **J'ai compris** pour fermer la fenêtre — elle ne réapparaîtra plus.
|
||||
|
||||
Le premier lancement télécharge aussi le moteur de base de données de l'application, ce qui nécessite une connexion Internet. Si le téléchargement échoue (réseau instable, délai dépassé), TrainUs affiche un écran d'erreur expliquant le problème avec un bouton **Réessayer** — une fois la connexion rétablie, cliquez dessus pour terminer le chargement sans recharger la page. Après un premier lancement réussi, l'application est entièrement mise en cache et fonctionne hors ligne.
|
||||
|
||||
Au démarrage, TrainUs demande aussi au navigateur de conserver ses données de façon permanente, afin que vos données d'entraînement et le cache hors ligne ne soient pas supprimés automatiquement si vous n'utilisez pas l'application pendant longtemps. Certains navigateurs (par exemple Firefox) peuvent afficher une demande de permission — il est recommandé de l'accepter ; si vous refusez, l'application fonctionne quand même, mais le navigateur peut récupérer son stockage après une longue inactivité.
|
||||
|
||||
## Accueil
|
||||
|
||||
La page d'accueil (`#/home`) est la page par défaut. Elle donne un aperçu de votre activité récente.
|
||||
|
|
|
|||
Loading…
Reference in a new issue