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
8 changes: 7 additions & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
// CacheStorage est partagé par toutes les applications d'une même origine GitHub Pages.
// Ne jamais supprimer les caches Race Control ou les caches tiers.
const CACHE_PREFIX = 'cv-shell-';
const VERSION = `${CACHE_PREFIX}v3`;
// Renouveler le namespace protège contre un ancien shell déjà remplacé par une page annexe.
// La migration efface uniquement les anciens caches cv-shell-, jamais les autres applications.
const VERSION = `${CACHE_PREFIX}v4`;
const APP_PATH = new URL(self.registration.scope).pathname;
const cached = (request) => caches.open(VERSION).then((cache) => cache.match(request));
async function remember(request, response) {
Expand Down Expand Up @@ -37,6 +39,10 @@ self.addEventListener('fetch', (event) => {

// Navigation : réseau d'abord (toujours la dernière version), cache en secours hors ligne.
if (req.mode === 'navigate') {
// Une page de reprise, une configuration ou tout autre document HTML ne doit
// jamais écraser ./index.html dans CacheStorage. Seuls les alias du shell
// bénéficient du repli hors ligne.
if (![APP_PATH, APP_PATH + 'index', APP_PATH + 'index.html'].includes(url.pathname)) return;
event.respondWith(
fetch(req)
.then((res) => {
Expand Down
59 changes: 47 additions & 12 deletions tests/unit/service-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type TestEvent = {
request?: { method: string; mode: string; url: string };
};

function harness(initial: string[], options: { fetch?: () => Promise<unknown>; hit?: boolean; failPut?: boolean } = {}) {
function harness(initial: string[], options: { fetch?: () => Promise<unknown>; hit?: boolean; failPut?: boolean; origin?: string; scope?: string } = {}) {
const names = new Set(initial);
const deleted: string[] = [];
const listeners = new Map<string, (event: TestEvent) => void>();
Expand All @@ -20,8 +20,8 @@ function harness(initial: string[], options: { fetch?: () => Promise<unknown>; h
let claimed = false;

const self = {
location: { origin: 'https://ipoower.github.io' },
registration: { scope: 'https://ipoower.github.io/Control-Vault/' },
location: { origin: options.origin ?? 'https://ipoower.github.io' },
registration: { scope: options.scope ?? 'https://ipoower.github.io/Control-Vault/' },
addEventListener: (type: string, fn: (event: TestEvent) => void) => listeners.set(type, fn),
skipWaiting: () => {},
clients: { claim: async () => { claimed = true; } },
Expand Down Expand Up @@ -57,16 +57,16 @@ function harness(initial: string[], options: { fetch?: () => Promise<unknown>; h

describe('Service Worker : isolation interprojets', () => {
it('ne supprime que les ANCIENS caches Control Vault et préserve Race Control et les autres applications', async () => {
const h = harness(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache']);
const h = harness(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3', 'cv-shell-v4', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache']);
const activate = h.listeners.get('activate');
expect(activate).toBeTypeOf('function');
let pending: Promise<unknown> | undefined;
activate?.({ waitUntil: (promise) => { pending = promise; } });
expect(pending).toBeDefined();
await pending;

expect(h.deleted.sort()).toEqual(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2']);
expect([...h.names].sort()).toEqual(['cv-shell-v3', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache'].sort());
expect(h.deleted.sort()).toEqual(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3']);
expect([...h.names].sort()).toEqual(['cv-shell-v4', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache'].sort());
expect(h.isClaimed()).toBe(true);
});

Expand All @@ -80,20 +80,20 @@ describe('Service Worker : isolation interprojets', () => {
});

it('utilise encore la page hors ligne en cas d’échec réseau', async () => {
const h = harness(['cv-shell-v3', 'twrc-shell-v17']);
const h = harness(['cv-shell-v4', 'twrc-shell-v17']);
let response: Promise<unknown> | undefined;
h.listeners.get('fetch')?.({
request: { method: 'GET', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' },
respondWith: (promise) => { response = promise; },
});
expect(response).toBeDefined();
await expect(response).resolves.toEqual({ offlineShell: true });
expect(h.matched).toEqual(['cv-shell-v3:./index.html']);
expect(h.matched).toEqual(['cv-shell-v4:./index.html']);
expect(h.deleted).toEqual([]);
});

it('ignore les requêtes externes et non GET', () => {
const h = harness(['cv-shell-v3', 'twrc-shell-v17']);
const h = harness(['cv-shell-v4', 'twrc-shell-v17']);
let responded = false;
for (const request of [
{ method: 'POST', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' },
Expand All @@ -117,7 +117,7 @@ describe('Service Worker : isolation interprojets', () => {
it('attend la mise en cache de l’interface réseau dans son seul cache', async () => {
const res = response(), h = harness(['twrc-shell-v17'], { fetch: async () => res });
expect(await request(h)).toBe(res);
expect(h.written).toEqual([{ cache: 'cv-shell-v3', request: './index.html' }]);
expect(h.written).toEqual([{ cache: 'cv-shell-v4', request: './index.html' }]);
expect(h.deleted).toEqual([]);
});
it('ne remplace pas son interface hors ligne par une erreur HTTP ou une réponse JSON', async () => {
Expand All @@ -136,7 +136,42 @@ describe('Service Worker : isolation interprojets', () => {
const res = response(true, 'text/javascript'), h = harness(['twrc-shell-v17'], { fetch: async () => res, hit: false });
const asset = { ...navigation, mode: 'cors', url: 'https://ipoower.github.io/Control-Vault/assets/fiction.js' };
expect(await request(h, asset)).toBe(res);
expect(h.written).toEqual([{ cache: 'cv-shell-v3', request: asset }]);
expect(h.matched.every(k => k.startsWith('cv-shell-v3:'))).toBe(true);
expect(h.written).toEqual([{ cache: 'cv-shell-v4', request: asset }]);
expect(h.matched.every(k => k.startsWith('cv-shell-v4:'))).toBe(true);
});
it('ne remplace jamais le shell par une navigation vers /recovery ou un JSON de configuration', () => {
const h = harness(['cv-shell-v4'], { fetch: async () => response(true, 'text/html') });
const paths = ['recovery', 'recovery.html', 'drive-config.json', 'fixture-manifest.json'];
for (const path of paths) {
let intercepted = false;
h.listeners.get('fetch')?.({
request: { method: 'GET', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' + path },
respondWith: () => { intercepted = true; },
});
expect(intercepted, 'le navigateur doit aller directement au réseau pour ' + path).toBe(false);
}
expect(h.written).toEqual([]);
});

it('applique aussi les frontières TEST Cloudflare à la racine, sans bloquer le shell', () => {
const origin = 'https://cv-drive-test-ipoower.pages.dev';
const h = harness(['cv-shell-v3'], { origin, scope: origin + '/' });
for (const pathname of ['/recovery', '/recovery.html', '/drive-config.json', '/fixture-manifest.json', '/autre-page.html']) {
let intercepted = false;
h.listeners.get('fetch')?.({
request: { method: 'GET', mode: 'navigate', url: origin + pathname },
respondWith: () => { intercepted = true; },
});
expect(intercepted, 'ne pas intercepter la route secondaire ' + pathname).toBe(false);
}
for (const pathname of ['/', '/index', '/index.html']) {
let intercepted = false;
h.listeners.get('fetch')?.({
request: { method: 'GET', mode: 'navigate', url: origin + pathname },
respondWith: () => { intercepted = true; },
});
expect(intercepted, 'la route shell doit rester interceptée ' + pathname).toBe(true);
}
});

});
Loading