From 63a035bf71791e2f266521c2d03e0d07f6936a48 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:48:37 +0100 Subject: [PATCH 1/6] test(pwa): expose read-only offline readiness diagnostics --- src/main.ts | 52 ++++++++++++++++- src/services/offline-diagnostic.ts | 44 ++++++++++++++ tests/e2e/offline-diagnostic.spec.ts | 52 +++++++++++++++++ tests/unit/offline-diagnostic.test.ts | 82 +++++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 src/services/offline-diagnostic.ts create mode 100644 tests/e2e/offline-diagnostic.spec.ts create mode 100644 tests/unit/offline-diagnostic.test.ts diff --git a/src/main.ts b/src/main.ts index df43cfc..ad28bc9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,7 @@ import { securityView } from './views/security'; import { settingsView } from './views/settings'; import { driveView } from './views/drive'; import { connectedDrive } from './services/connected-drive'; +import { inspectOfflineElements, type OfflineDiagnostic } from './services/offline-diagnostic'; import type { View, ViewContext } from './views/types'; // ─── Routes ──────────────────────────────────────────────────────────────── @@ -63,6 +64,8 @@ const sideNav = h('nav', { class: 'side-nav', 'aria-label': 'Navigation principa const tabbar = h('nav', { class: 'tabbar', 'aria-label': 'Navigation principale' }); const sideFoot = h('div', { class: 'side-foot' }); const topbar = h('header', { class: 'topbar' }, h('span', { class: 'brand-mini mobile-only' }, logo(26)), topTitle, topStatus, searchMobile); +const testDiagnostic = import.meta.env.MODE === 'fortress-test'; +const offlineDiagnostic = h('div', { id: 'offline-diagnostic', class: 'banner banner-demo', role: 'status', 'aria-live': 'polite' }); const shell = h( 'div', @@ -75,7 +78,7 @@ const shell = h( sideNav, sideFoot, ), - h('div', { class: 'main-col' }, topbar, banners, viewEl, + h('div', { class: 'main-col' }, topbar, testDiagnostic && offlineDiagnostic, banners, viewEl, h('nav', { class: 'app-links', 'aria-label': 'Mes applications' }, h('strong', null, 'Mes applications'), h('div', null, @@ -308,6 +311,51 @@ boot() }); // Service worker : uniquement en production et hors aperçu intégré. -if ('serviceWorker' in navigator && import.meta.env.PROD && location.protocol === 'https:' && !location.hostname.endsWith('claude.ai') && !location.hostname.includes('claudeusercontent')) { +const canRegisterWorker = 'serviceWorker' in navigator && import.meta.env.PROD && location.protocol === 'https:' && !location.hostname.endsWith('claude.ai') && !location.hostname.includes('claudeusercontent'); +let diagnosticRevision = 0; +const diagnosticText: Record = { + 'unsupported': 'Contrôle hors ligne : navigateur non compatible ou stockage indisponible.', + 'not-registered': 'Contrôle hors ligne : préparation non confirmée.', + 'not-active': 'Contrôle hors ligne : préparation non terminée.', + 'not-controlling': 'Contrôle hors ligne : service non actif sur cette page.', + 'wrong-worker': 'Contrôle hors ligne : service inattendu.', + 'cache-missing': 'Contrôle hors ligne : cache de l’interface absent.', + 'shell-invalid': 'Contrôle hors ligne : page locale absente ou non vérifiée.', + 'assets-missing': 'Contrôle hors ligne : fichiers de l’interface manquants.', + 'local-elements-present': 'Contrôle local : service actif et fichiers de l’interface présents. Redémarrage hors ligne à vérifier.', + 'unavailable': 'Contrôle hors ligne : vérification indisponible.', +}; +async function refreshOfflineDiagnostic() { + if (!testDiagnostic) return; + const revision = ++diagnosticRevision; + const scope = new URL('./', location.href); + const assets = [...document.querySelectorAll('script[src], link[rel="stylesheet"][href]')] + .map((element) => new URL(element instanceof HTMLScriptElement ? element.src : element.href, scope)) + .filter((url) => url.origin === scope.origin && url.pathname.startsWith(scope.pathname + 'assets/')) + .map((url) => url.href); + const result = await inspectOfflineElements('serviceWorker' in navigator ? navigator.serviceWorker : undefined, 'caches' in window ? window.caches : undefined, scope.href, assets); + if (revision !== diagnosticRevision) return; + offlineDiagnostic.dataset.diagnostic = result; + replace(offlineDiagnostic, icon(result === 'local-elements-present' ? 'info' : 'alert'), h('p', null, diagnosticText[result])); +} +if (testDiagnostic) { + replace(offlineDiagnostic, icon('info'), h('p', null, 'Contrôle hors ligne : vérification en cours.')); + if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('controllerchange', () => void refreshOfflineDiagnostic()); + window.addEventListener('pageshow', () => void refreshOfflineDiagnostic()); + window.setTimeout(() => void refreshOfflineDiagnostic(), 12000); +} +if (canRegisterWorker && !testDiagnostic) { window.addEventListener('load', () => navigator.serviceWorker.register('./sw.js').catch(() => {})); +} else if (canRegisterWorker) { + const registerWorker = () => { + void navigator.serviceWorker.register('./sw.js').then((registration) => { + const watch = () => registration.installing?.addEventListener('statechange', () => void refreshOfflineDiagnostic()); + registration.addEventListener('updatefound', watch); + watch(); + void refreshOfflineDiagnostic(); + }).catch(() => void refreshOfflineDiagnostic()); + }; + window.addEventListener('load', registerWorker, { once: true }); +} else if (testDiagnostic) { + void refreshOfflineDiagnostic(); } diff --git a/src/services/offline-diagnostic.ts b/src/services/offline-diagnostic.ts new file mode 100644 index 0000000..1a372ba --- /dev/null +++ b/src/services/offline-diagnostic.ts @@ -0,0 +1,44 @@ +// Diagnostic TEST en lecture seule : aucune clé métier ni cache n'est modifié. +// Il décrit des éléments présents maintenant, pas la réussite d'un futur redémarrage. +export const CV_SHELL_CACHE = 'cv-shell-v4'; + +export type OfflineDiagnostic = + | 'unsupported' | 'not-registered' | 'not-active' | 'not-controlling' + | 'wrong-worker' | 'cache-missing' | 'shell-invalid' | 'assets-missing' + | 'local-elements-present' | 'unavailable'; + +export async function inspectOfflineElements( + workers: ServiceWorkerContainer | undefined, + storage: CacheStorage | undefined, + scope: string, + assets: string[], +): Promise { + if (!workers || !storage) return 'unsupported'; + try { + const root = new URL(scope); + if (root.search || root.hash || !root.pathname.endsWith('/')) return 'unavailable'; + const registration = await workers.getRegistration(root.href); + if (!registration) return 'not-registered'; + if (registration.scope !== root.href || !registration.active || registration.active.state !== 'activated') return 'not-active'; + const expectedWorker = new URL('sw.js', root).href; + const controller = workers.controller; + if (!controller) return 'not-controlling'; + if (controller.scriptURL !== expectedWorker || registration.active.scriptURL !== expectedWorker) return 'wrong-worker'; + + // CacheStorage.match est en lecture seule, même si le cache disparaît entre les opérations. + if (!(await storage.keys()).includes(CV_SHELL_CACHE)) return 'cache-missing'; + const match = (url: string) => storage.match(url, { cacheName: CV_SHELL_CACHE }); + const shell = await match(new URL('index.html', root).href); + if (!shell?.ok || shell.redirected || shell.url !== root.href || !/^text\/html(?:\s*;|$)/i.test(shell.headers.get('content-type') || '')) return 'shell-invalid'; + + const required = [...new Set(assets)]; + const parsed = required.map((url) => new URL(url, root)); + const allowed = parsed.every((url) => url.origin === root.origin && url.pathname.startsWith(root.pathname + 'assets/') && !url.search && !url.hash); + if (!allowed || !parsed.some((url) => url.pathname.endsWith('.js')) || !parsed.some((url) => url.pathname.endsWith('.css'))) return 'assets-missing'; + const responses = await Promise.all(parsed.map((url) => match(url.href))); + if (responses.some((response) => !response?.ok || response.redirected)) return 'assets-missing'; + return 'local-elements-present'; + } catch { + return 'unavailable'; + } +} diff --git a/tests/e2e/offline-diagnostic.spec.ts b/tests/e2e/offline-diagnostic.spec.ts new file mode 100644 index 0000000..517ade4 --- /dev/null +++ b/tests/e2e/offline-diagnostic.spec.ts @@ -0,0 +1,52 @@ +// Rendu de la version TEST avec stockage fictif ; aucun Google ni donnée réelle. +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { extname, join, resolve } from 'node:path'; +import { expect, test } from '@playwright/test'; + +let root: string; +test.beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'cv-offline-diagnostic-fixture-')); + execFileSync(process.execPath, ['node_modules/vite/bin/vite.js', 'build', '--mode', 'fortress-test'], { + env: { ...process.env, VAULT_OUT: root, GOOGLE_DRIVE_CONFIG: '' }, timeout: 90000, stdio: 'pipe', + }); +}); + +for (const scenario of ['registration-refused', 'cache-missing', 'local-elements-present'] as const) { + test(`diagnostic hors ligne TEST : ${scenario}`, async ({ page }) => { + await page.addInitScript((mode) => { + const scope = 'https://cv.fixture.test/'; + const worker = { state: 'activated', scriptURL: scope + 'sw.js', addEventListener() {} }; + const registration = { scope, active: worker, installing: null, addEventListener() {} }; + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { + controller: mode === 'registration-refused' ? null : worker, + getRegistration: async () => mode === 'registration-refused' ? undefined : registration, + register: async () => { if (mode === 'registration-refused') throw Error('fixture refused'); return registration; }, + addEventListener() {}, + } }); + Object.defineProperty(window, 'caches', { configurable: true, value: { + keys: async () => mode === 'cache-missing' ? [] : ['cv-shell-v4'], + match: async (key: string) => key.endsWith('/index.html') + ? { ok: true, redirected: false, url: scope, headers: { get: () => 'text/html' } } + : { ok: true, redirected: false }, + } }); + }, scenario); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'https://cv.fixture.test') return route.abort(); + const filename = resolve(root, '.' + (url.pathname === '/' ? '/index.html' : url.pathname)); + if (!filename.startsWith(root + '/')) return route.abort(); + const types: Record = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; + try { await route.fulfill({ status: 200, contentType: types[extname(filename)] || 'application/octet-stream', body: readFileSync(filename) }); } + catch { await route.fulfill({ status: 404, body: 'fixture absente' }); } + }); + await page.goto('https://cv.fixture.test/'); + await expect(page.locator('#hero-title')).toBeVisible(); + const diagnostic = page.locator('#offline-diagnostic'); + const expected = scenario === 'registration-refused' ? 'not-registered' : scenario; + await expect(diagnostic).toHaveAttribute('data-diagnostic', expected); + if (scenario === 'local-elements-present') await expect(diagnostic).toContainText('Redémarrage hors ligne à vérifier.'); + else await expect(diagnostic).not.toContainText('service actif et fichiers'); + }); +} diff --git a/tests/unit/offline-diagnostic.test.ts b/tests/unit/offline-diagnostic.test.ts new file mode 100644 index 0000000..4cc7210 --- /dev/null +++ b/tests/unit/offline-diagnostic.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { CV_SHELL_CACHE, inspectOfflineElements } from '../../src/services/offline-diagnostic'; + +const origin = 'https://cv.fixture.test/'; +const js = origin + 'assets/app-123.js'; +const css = origin + 'assets/app-456.css'; +const asset = () => ({ ok: true, redirected: false }) as Response; +const shell = () => ({ ok: true, redirected: false, url: origin, headers: { get: () => 'text/html; charset=utf-8' } }) as unknown as Response; +function fixture() { + const worker = { state: 'activated', scriptURL: origin + 'sw.js' } as ServiceWorker; + const registration = { scope: origin, active: worker } as ServiceWorkerRegistration; + const getRegistration = vi.fn(async () => registration as ServiceWorkerRegistration | undefined); + const workers = { getRegistration, controller: worker } as unknown as ServiceWorkerContainer; + const entries = new Map([[origin + 'index.html', shell()], [js, asset()], [css, asset()]]); + const match = vi.fn(async (key: string, _options: MultiCacheQueryOptions) => entries.get(key)); + const keys = vi.fn(async () => [CV_SHELL_CACHE, 'other-application']); + const open = vi.fn(); + const storage = { keys, open, match } as unknown as CacheStorage; + const check = () => inspectOfflineElements(workers, storage, origin, [js, css]); + return { worker, registration, getRegistration, workers, storage, keys, open, match, entries, check }; +} + +describe('diagnostic de préparation hors ligne TEST, en lecture seule', () => { + it('signale seulement les éléments contrôlés sans déclarer la recette iPhone réussie', async () => { + const f = fixture(); + expect(await f.check()).toBe('local-elements-present'); + expect(f.keys).toHaveBeenCalledOnce(); + expect(f.open).not.toHaveBeenCalled(); + expect(f.match.mock.calls.map(([key]) => key).sort()).toEqual([origin + 'index.html', js, css].sort()); + expect(f.match.mock.calls.every(([, options]) => options.cacheName === CV_SHELL_CACHE)).toBe(true); + expect(readFileSync('src/main.ts', 'utf8')).toContain('Redémarrage hors ligne à vérifier.'); + }); + it('le nom du cache contrôlé correspond au Service Worker du candidat', () => { + const worker = readFileSync('public/sw.js', 'utf8'); + expect(worker).toContain("const CACHE_PREFIX = 'cv-shell-';"); + expect(worker).toContain('const VERSION = `${CACHE_PREFIX}v4`;'); + expect(CV_SHELL_CACHE).toBe('cv-shell-v4'); + }); + it('repère l’absence de l’API, de registration, de contrôle ou de service actif', async () => { + const f = fixture(); + expect(await inspectOfflineElements(undefined, f.storage, origin, [js, css])).toBe('unsupported'); + expect(await inspectOfflineElements(f.workers, undefined, origin, [js, css])).toBe('unsupported'); + f.getRegistration.mockResolvedValueOnce(undefined); + expect(await f.check()).toBe('not-registered'); + Object.defineProperty(f.registration, 'active', { value: null, configurable: true }); + expect(await f.check()).toBe('not-active'); + Object.defineProperty(f.registration, 'active', { value: f.worker, configurable: true }); + Object.defineProperty(f.workers, 'controller', { value: null, configurable: true }); + expect(await f.check()).toBe('not-controlling'); + expect(f.open).not.toHaveBeenCalled(); + }); + it('rejette une mauvaise origine de service sans toucher au cache', async () => { + const f = fixture(); + Object.defineProperty(f.worker, 'scriptURL', { value: 'https://elsewhere.fixture.test/sw.js' }); + expect(await f.check()).toBe('wrong-worker'); + expect(f.open).not.toHaveBeenCalled(); + }); + it('ne crée pas le cache manquant', async () => { + const f = fixture(); f.keys.mockResolvedValueOnce(['other-application']); + expect(await f.check()).toBe('cache-missing'); expect(f.open).not.toHaveBeenCalled(); + }); + it('refuse une page absente, redirigée, externe ou qui n’est pas du HTML', async () => { + for (const bad of [undefined, { ...shell(), redirected: true }, { ...shell(), url: 'https://outside.fixture.test/' }, { ...shell(), headers: { get: () => 'application/json' } }]) { + const f = fixture(); + if (bad) f.entries.set(origin + 'index.html', bad as Response); else f.entries.delete(origin + 'index.html'); + expect(await f.check()).toBe('shell-invalid'); + } + }); + it('refuse les scripts et styles manquants, redirigés ou hors origine', async () => { + const f = fixture(); f.entries.delete(css); + expect(await f.check()).toBe('assets-missing'); + f.entries.set(css, { ...asset(), redirected: true } as Response); + expect(await f.check()).toBe('assets-missing'); + expect(await inspectOfflineElements(f.workers, f.storage, origin, [js, 'https://outside.fixture.test/assets/a.css'])).toBe('assets-missing'); + expect(await inspectOfflineElements(f.workers, f.storage, origin, [js])).toBe('assets-missing'); + }); + it('signale les erreurs de lecture sans suppression ni correction automatique', async () => { + const f = fixture(); f.keys.mockRejectedValueOnce(new Error('fixture stockage indisponible')); + expect(await f.check()).toBe('unavailable'); expect(f.open).not.toHaveBeenCalled(); + }); +}); From f8a6143c92e658515590c3dbd030e53642b3a9f6 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:53:27 +0100 Subject: [PATCH 2/6] test(pwa): exercise diagnostic with native worker and offline cache --- tests/e2e/offline-diagnostic.spec.ts | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/e2e/offline-diagnostic.spec.ts b/tests/e2e/offline-diagnostic.spec.ts index 517ade4..03a2fc7 100644 --- a/tests/e2e/offline-diagnostic.spec.ts +++ b/tests/e2e/offline-diagnostic.spec.ts @@ -1,6 +1,7 @@ // Rendu de la version TEST avec stockage fictif ; aucun Google ni donnée réelle. import { execFileSync } from 'node:child_process'; import { mkdtempSync, readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { extname, join, resolve } from 'node:path'; import { expect, test } from '@playwright/test'; @@ -50,3 +51,42 @@ for (const scenario of ['registration-refused', 'cache-missing', 'local-elements else await expect(diagnostic).not.toContainText('service actif et fichiers'); }); } + +test('diagnostic : Service Worker et CacheStorage natifs, puis rechargement sans réseau', async ({ page }) => { + test.setTimeout(60000); + const types: Record = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; + const server = createServer((req, res) => { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + const filename = resolve(root, '.' + (url.pathname === '/' ? '/index.html' : url.pathname)); + if (!filename.startsWith(root + '/')) { res.writeHead(403); res.end(); return; } + try { res.writeHead(200, { 'Content-Type': types[extname(filename)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); res.end(readFileSync(filename)); } + catch { res.writeHead(404); res.end(); } + }); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + const address = server.address(); + if (!address || typeof address === 'string') throw Error('fixture server'); + const origin = `http://127.0.0.1:${address.port}`; + let closed = false; + try { + await page.goto(origin + '/'); + await expect(page.locator('#hero-title')).toBeVisible(); + // Sur loopback HTTP, l'application ne s'auto-enregistre pas. C'est le vrai + // fichier du build ; enregistrer ici explicitement pour tester le cache natif. + await page.evaluate(async () => { + await navigator.serviceWorker.register('./sw.js'); + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) await new Promise((done) => navigator.serviceWorker.addEventListener('controllerchange', () => done(), { once: true })); + localStorage.setItem('fixture-cv-diagnostic', 'fictif'); + }); + await expect(page.locator('#offline-diagnostic')).toHaveAttribute('data-diagnostic', 'local-elements-present'); + await new Promise((done) => { server.close(() => done()); server.closeAllConnections(); }); + closed = true; + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(page.locator('#hero-title')).toBeVisible(); + await expect(page.locator('#offline-diagnostic')).toHaveAttribute('data-diagnostic', 'local-elements-present'); + await expect(page.locator('#offline-diagnostic')).toContainText('Redémarrage hors ligne à vérifier.'); + expect(await page.evaluate(() => localStorage.getItem('fixture-cv-diagnostic'))).toBe('fictif'); + } finally { + if (!closed) await new Promise((done) => { server.close(() => done()); server.closeAllConnections(); }); + } +}); From a3395ec4e92f8563a36641ad67f489e27602e147 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:58:49 +0100 Subject: [PATCH 3/6] test(pwa): bound diagnostic failures and blocked storage --- src/main.ts | 18 ++++++++++++------ tests/e2e/offline-diagnostic.spec.ts | 14 ++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main.ts b/src/main.ts index ad28bc9..910e148 100644 --- a/src/main.ts +++ b/src/main.ts @@ -328,12 +328,18 @@ const diagnosticText: Record = { async function refreshOfflineDiagnostic() { if (!testDiagnostic) return; const revision = ++diagnosticRevision; - const scope = new URL('./', location.href); - const assets = [...document.querySelectorAll('script[src], link[rel="stylesheet"][href]')] - .map((element) => new URL(element instanceof HTMLScriptElement ? element.src : element.href, scope)) - .filter((url) => url.origin === scope.origin && url.pathname.startsWith(scope.pathname + 'assets/')) - .map((url) => url.href); - const result = await inspectOfflineElements('serviceWorker' in navigator ? navigator.serviceWorker : undefined, 'caches' in window ? window.caches : undefined, scope.href, assets); + let timer = 0; + const check = Promise.resolve().then(async () => { + const scope = new URL('./', location.href); + const assets = [...document.querySelectorAll('script[src], link[rel="stylesheet"][href]')] + .map((element) => new URL(element instanceof HTMLScriptElement ? element.src : element.href, scope)) + .filter((url) => url.origin === scope.origin && url.pathname.startsWith(scope.pathname + 'assets/')) + .map((url) => url.href); + return inspectOfflineElements('serviceWorker' in navigator ? navigator.serviceWorker : undefined, 'caches' in window ? window.caches : undefined, scope.href, assets); + }).catch((): OfflineDiagnostic => 'unavailable'); + const timeout = new Promise((done) => { timer = window.setTimeout(() => done('unavailable'), 6000); }); + const result = await Promise.race([check, timeout]); + window.clearTimeout(timer); if (revision !== diagnosticRevision) return; offlineDiagnostic.dataset.diagnostic = result; replace(offlineDiagnostic, icon(result === 'local-elements-present' ? 'info' : 'alert'), h('p', null, diagnosticText[result])); diff --git a/tests/e2e/offline-diagnostic.spec.ts b/tests/e2e/offline-diagnostic.spec.ts index 03a2fc7..cec0adc 100644 --- a/tests/e2e/offline-diagnostic.spec.ts +++ b/tests/e2e/offline-diagnostic.spec.ts @@ -14,7 +14,7 @@ test.beforeAll(() => { }); }); -for (const scenario of ['registration-refused', 'cache-missing', 'local-elements-present'] as const) { +for (const scenario of ['registration-refused', 'cache-missing', 'local-elements-present', 'storage-denied', 'registration-stalled'] as const) { test(`diagnostic hors ligne TEST : ${scenario}`, async ({ page }) => { await page.addInitScript((mode) => { const scope = 'https://cv.fixture.test/'; @@ -22,16 +22,18 @@ for (const scenario of ['registration-refused', 'cache-missing', 'local-elements const registration = { scope, active: worker, installing: null, addEventListener() {} }; Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { controller: mode === 'registration-refused' ? null : worker, - getRegistration: async () => mode === 'registration-refused' ? undefined : registration, + getRegistration: async () => mode === 'registration-stalled' ? new Promise(() => {}) : mode === 'registration-refused' ? undefined : registration, register: async () => { if (mode === 'registration-refused') throw Error('fixture refused'); return registration; }, addEventListener() {}, } }); - Object.defineProperty(window, 'caches', { configurable: true, value: { + const cacheFixture = { keys: async () => mode === 'cache-missing' ? [] : ['cv-shell-v4'], match: async (key: string) => key.endsWith('/index.html') ? { ok: true, redirected: false, url: scope, headers: { get: () => 'text/html' } } : { ok: true, redirected: false }, - } }); + }; + if (mode === 'storage-denied') Object.defineProperty(window, 'caches', { configurable: true, get() { throw Error('fixture denied'); } }); + else Object.defineProperty(window, 'caches', { configurable: true, value: cacheFixture }); }, scenario); await page.route('**/*', async (route) => { const url = new URL(route.request().url()); @@ -45,8 +47,8 @@ for (const scenario of ['registration-refused', 'cache-missing', 'local-elements await page.goto('https://cv.fixture.test/'); await expect(page.locator('#hero-title')).toBeVisible(); const diagnostic = page.locator('#offline-diagnostic'); - const expected = scenario === 'registration-refused' ? 'not-registered' : scenario; - await expect(diagnostic).toHaveAttribute('data-diagnostic', expected); + const expected = scenario === 'registration-refused' ? 'not-registered' : (scenario === 'storage-denied' || scenario === 'registration-stalled') ? 'unavailable' : scenario; + await expect(diagnostic).toHaveAttribute('data-diagnostic', expected, { timeout: 10000 }); if (scenario === 'local-elements-present') await expect(diagnostic).toContainText('Redémarrage hors ligne à vérifier.'); else await expect(diagnostic).not.toContainText('service actif et fichiers'); }); From 7855f40a3831c605e9b7030abcdc2dbc6c779f5a Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:01:56 +0100 Subject: [PATCH 4/6] test(pwa): track native WebKit activation state --- .../workflows/fortress-offline-diagnostic.yml | 42 +++++++++++++++++++ src/main.ts | 14 ++++++- tests/e2e/offline-diagnostic.spec.ts | 8 +++- 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/fortress-offline-diagnostic.yml diff --git a/.github/workflows/fortress-offline-diagnostic.yml b/.github/workflows/fortress-offline-diagnostic.yml new file mode 100644 index 0000000..2912ecd --- /dev/null +++ b/.github/workflows/fortress-offline-diagnostic.yml @@ -0,0 +1,42 @@ +name: FORTRESS TEST - diagnostic hors ligne WebKit +on: + pull_request: + paths: + - 'src/main.ts' + - 'src/services/offline-diagnostic.ts' + - 'tests/e2e/offline-diagnostic.spec.ts' + - '.github/workflows/fortress-offline-diagnostic.yml' + +permissions: + contents: read + +concurrency: + group: fortress-offline-diagnostic-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + native-webkit: + if: github.event.pull_request.head.ref == 'test/fortress-final-acceptance-ios-offline-diagnostic-20261027' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + container: + image: mcr.microsoft.com/playwright:v1.56.1-noble + options: --ipc=host + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 22 + cache: npm + - name: Dépendances verrouillées + run: npm ci --no-audit --no-fund + - name: Build de fixture + run: npm run build + - name: Deux profils WebKit avec service worker et cache natifs + run: | + npx playwright test tests/e2e/offline-diagnostic.spec.ts -g 'diagnostic : Service Worker et CacheStorage natifs' --project=webkit-iphone-11-pro-max --project=webkit-desktop + env: + CI: '1' diff --git a/src/main.ts b/src/main.ts index 910e148..7ed5d61 100644 --- a/src/main.ts +++ b/src/main.ts @@ -346,7 +346,19 @@ async function refreshOfflineDiagnostic() { } if (testDiagnostic) { replace(offlineDiagnostic, icon('info'), h('p', null, 'Contrôle hors ligne : vérification en cours.')); - if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('controllerchange', () => void refreshOfflineDiagnostic()); + if ('serviceWorker' in navigator) { + let watchedController: ServiceWorker | null = null; + const watchController = () => { + const controller = navigator.serviceWorker.controller; + if (controller && controller !== watchedController) { + watchedController = controller; + controller.addEventListener('statechange', () => void refreshOfflineDiagnostic()); + } + void refreshOfflineDiagnostic(); + }; + navigator.serviceWorker.addEventListener('controllerchange', watchController); + watchController(); + } window.addEventListener('pageshow', () => void refreshOfflineDiagnostic()); window.setTimeout(() => void refreshOfflineDiagnostic(), 12000); } diff --git a/tests/e2e/offline-diagnostic.spec.ts b/tests/e2e/offline-diagnostic.spec.ts index cec0adc..d074c73 100644 --- a/tests/e2e/offline-diagnostic.spec.ts +++ b/tests/e2e/offline-diagnostic.spec.ts @@ -80,7 +80,13 @@ test('diagnostic : Service Worker et CacheStorage natifs, puis rechargement sans if (!navigator.serviceWorker.controller) await new Promise((done) => navigator.serviceWorker.addEventListener('controllerchange', () => done(), { once: true })); localStorage.setItem('fixture-cv-diagnostic', 'fictif'); }); - await expect(page.locator('#offline-diagnostic')).toHaveAttribute('data-diagnostic', 'local-elements-present'); + const nativeState = await page.evaluate(async () => { + const reg = await navigator.serviceWorker.getRegistration(new URL('./', location.href).href); + return { expectedScope: new URL('./', location.href).href, actualScope: reg?.scope, activeState: reg?.active?.state, + activeScript: reg?.active?.scriptURL, controllerState: navigator.serviceWorker.controller?.state, controllerScript: navigator.serviceWorker.controller?.scriptURL }; + }); + console.log('NATIVE_WORKER_DIAGNOSTIC', JSON.stringify(nativeState)); + await expect(page.locator('#offline-diagnostic')).toHaveAttribute('data-diagnostic', 'local-elements-present', { timeout: 15000 }); await new Promise((done) => { server.close(() => done()); server.closeAllConnections(); }); closed = true; await page.reload({ waitUntil: 'domcontentloaded' }); From 08c1ae828a639c24cbc9b94093536a28076b087d Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:03:20 +0100 Subject: [PATCH 5/6] test(pwa): restore single-workflow repository invariant --- .../workflows/fortress-offline-diagnostic.yml | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/fortress-offline-diagnostic.yml diff --git a/.github/workflows/fortress-offline-diagnostic.yml b/.github/workflows/fortress-offline-diagnostic.yml deleted file mode 100644 index 2912ecd..0000000 --- a/.github/workflows/fortress-offline-diagnostic.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: FORTRESS TEST - diagnostic hors ligne WebKit -on: - pull_request: - paths: - - 'src/main.ts' - - 'src/services/offline-diagnostic.ts' - - 'tests/e2e/offline-diagnostic.spec.ts' - - '.github/workflows/fortress-offline-diagnostic.yml' - -permissions: - contents: read - -concurrency: - group: fortress-offline-diagnostic-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - native-webkit: - if: github.event.pull_request.head.ref == 'test/fortress-final-acceptance-ios-offline-diagnostic-20261027' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - container: - image: mcr.microsoft.com/playwright:v1.56.1-noble - options: --ipc=host - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 22 - cache: npm - - name: Dépendances verrouillées - run: npm ci --no-audit --no-fund - - name: Build de fixture - run: npm run build - - name: Deux profils WebKit avec service worker et cache natifs - run: | - npx playwright test tests/e2e/offline-diagnostic.spec.ts -g 'diagnostic : Service Worker et CacheStorage natifs' --project=webkit-iphone-11-pro-max --project=webkit-desktop - env: - CI: '1' From f87834b581532de507a1eb3ca7b5b0ce5616c3c8 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:06:35 +0100 Subject: [PATCH 6/6] fix(test-pwa): keep diagnostic failures from blocking startup --- src/main.ts | 41 ++++++++++++++++------------ tests/e2e/offline-diagnostic.spec.ts | 10 ++++--- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/main.ts b/src/main.ts index 7ed5d61..d4e8cf1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -347,17 +347,22 @@ async function refreshOfflineDiagnostic() { if (testDiagnostic) { replace(offlineDiagnostic, icon('info'), h('p', null, 'Contrôle hors ligne : vérification en cours.')); if ('serviceWorker' in navigator) { - let watchedController: ServiceWorker | null = null; - const watchController = () => { - const controller = navigator.serviceWorker.controller; - if (controller && controller !== watchedController) { - watchedController = controller; - controller.addEventListener('statechange', () => void refreshOfflineDiagnostic()); - } - void refreshOfflineDiagnostic(); - }; - navigator.serviceWorker.addEventListener('controllerchange', watchController); - watchController(); + try { + const workers = navigator.serviceWorker; + let watchedController: ServiceWorker | null = null; + const watchController = () => { + try { + const controller = workers.controller; + if (controller && controller !== watchedController) { + watchedController = controller; + controller.addEventListener('statechange', () => void refreshOfflineDiagnostic()); + } + } catch { /* Un diagnostic ne doit jamais empêcher l'ouverture de l'application. */ } + void refreshOfflineDiagnostic(); + }; + workers.addEventListener('controllerchange', watchController); + watchController(); + } catch { void refreshOfflineDiagnostic(); } } window.addEventListener('pageshow', () => void refreshOfflineDiagnostic()); window.setTimeout(() => void refreshOfflineDiagnostic(), 12000); @@ -366,12 +371,14 @@ if (canRegisterWorker && !testDiagnostic) { window.addEventListener('load', () => navigator.serviceWorker.register('./sw.js').catch(() => {})); } else if (canRegisterWorker) { const registerWorker = () => { - void navigator.serviceWorker.register('./sw.js').then((registration) => { - const watch = () => registration.installing?.addEventListener('statechange', () => void refreshOfflineDiagnostic()); - registration.addEventListener('updatefound', watch); - watch(); - void refreshOfflineDiagnostic(); - }).catch(() => void refreshOfflineDiagnostic()); + try { + void navigator.serviceWorker.register('./sw.js').then((registration) => { + const watch = () => registration.installing?.addEventListener('statechange', () => void refreshOfflineDiagnostic()); + registration.addEventListener('updatefound', watch); + watch(); + void refreshOfflineDiagnostic(); + }).catch(() => void refreshOfflineDiagnostic()); + } catch { void refreshOfflineDiagnostic(); } }; window.addEventListener('load', registerWorker, { once: true }); } else if (testDiagnostic) { diff --git a/tests/e2e/offline-diagnostic.spec.ts b/tests/e2e/offline-diagnostic.spec.ts index d074c73..7240319 100644 --- a/tests/e2e/offline-diagnostic.spec.ts +++ b/tests/e2e/offline-diagnostic.spec.ts @@ -14,18 +14,20 @@ test.beforeAll(() => { }); }); -for (const scenario of ['registration-refused', 'cache-missing', 'local-elements-present', 'storage-denied', 'registration-stalled'] as const) { +for (const scenario of ['registration-refused', 'cache-missing', 'local-elements-present', 'storage-denied', 'worker-denied', 'registration-stalled'] as const) { test(`diagnostic hors ligne TEST : ${scenario}`, async ({ page }) => { await page.addInitScript((mode) => { const scope = 'https://cv.fixture.test/'; const worker = { state: 'activated', scriptURL: scope + 'sw.js', addEventListener() {} }; const registration = { scope, active: worker, installing: null, addEventListener() {} }; - Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { + const workerFixture = { controller: mode === 'registration-refused' ? null : worker, getRegistration: async () => mode === 'registration-stalled' ? new Promise(() => {}) : mode === 'registration-refused' ? undefined : registration, register: async () => { if (mode === 'registration-refused') throw Error('fixture refused'); return registration; }, addEventListener() {}, - } }); + }; + if (mode === 'worker-denied') Object.defineProperty(navigator, 'serviceWorker', { configurable: true, get() { throw Error('fixture denied'); } }); + else Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: workerFixture }); const cacheFixture = { keys: async () => mode === 'cache-missing' ? [] : ['cv-shell-v4'], match: async (key: string) => key.endsWith('/index.html') @@ -47,7 +49,7 @@ for (const scenario of ['registration-refused', 'cache-missing', 'local-elements await page.goto('https://cv.fixture.test/'); await expect(page.locator('#hero-title')).toBeVisible(); const diagnostic = page.locator('#offline-diagnostic'); - const expected = scenario === 'registration-refused' ? 'not-registered' : (scenario === 'storage-denied' || scenario === 'registration-stalled') ? 'unavailable' : scenario; + const expected = scenario === 'registration-refused' ? 'not-registered' : (scenario === 'storage-denied' || scenario === 'worker-denied' || scenario === 'registration-stalled') ? 'unavailable' : scenario; await expect(diagnostic).toHaveAttribute('data-diagnostic', expected, { timeout: 10000 }); if (scenario === 'local-elements-present') await expect(diagnostic).toContainText('Redémarrage hors ligne à vérifier.'); else await expect(diagnostic).not.toContainText('service actif et fichiers');