diff --git a/src/main.ts b/src/main.ts index df43cfc..d4e8cf1 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,76 @@ 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; + 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])); +} +if (testDiagnostic) { + replace(offlineDiagnostic, icon('info'), h('p', null, 'Contrôle hors ligne : vérification en cours.')); + if ('serviceWorker' in navigator) { + 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); +} +if (canRegisterWorker && !testDiagnostic) { window.addEventListener('load', () => navigator.serviceWorker.register('./sw.js').catch(() => {})); +} else if (canRegisterWorker) { + const registerWorker = () => { + 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) { + 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..7240319 --- /dev/null +++ b/tests/e2e/offline-diagnostic.spec.ts @@ -0,0 +1,102 @@ +// 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'; + +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', '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() {} }; + 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') + ? { 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()); + 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 === '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'); + }); +} + +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'); + }); + 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' }); + 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(); }); + } +}); 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(); + }); +});