Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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<OfflineDiagnostic, string> = {
'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<HTMLScriptElement | HTMLLinkElement>('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<OfflineDiagnostic>((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();
}
44 changes: 44 additions & 0 deletions src/services/offline-diagnostic.ts
Original file line number Diff line number Diff line change
@@ -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<OfflineDiagnostic> {
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';
}
}
102 changes: 102 additions & 0 deletions tests/e2e/offline-diagnostic.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { '.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<string, string> = { '.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<void>((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<void>((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<void>((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<void>((done) => { server.close(() => done()); server.closeAllConnections(); });
}
});
Loading
Loading