// @vitest-environment jsdom import { describe, it, expect, beforeEach, vi } from 'vitest'; class FakeSW extends EventTarget { scriptURL = '/service-worker.js'; state: 'installed' | 'activated' = 'activated'; version: string | null; postMessage = vi.fn((msg: unknown, transfer?: Transferable[]) => { if ((msg as { type?: string } | null)?.type === 'GET_VERSION') { const port = transfer?.[0] as MessagePort | undefined; if (port && this.version !== null) port.postMessage({ version: this.version }); } }); constructor(version: string | null = null) { super(); this.version = version; } } type Reg = { active: FakeSW | null; waiting: FakeSW | null; installing: FakeSW | null; addEventListener: ReturnType; update: ReturnType; }; function mountFakeSW(init: Partial): { registration: Reg; fireControllerChange: () => void; } { const registration: Reg = { active: init.active ?? null, waiting: init.waiting ?? null, installing: init.installing ?? null, addEventListener: vi.fn(), update: vi.fn().mockResolvedValue(undefined) }; type Entry = { fn: (e: Event) => void; once: boolean }; const listeners: Entry[] = []; Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { ready: Promise.resolve(registration), controller: registration.active, addEventListener: vi.fn( (type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions | boolean) => { if (type !== 'controllerchange') return; const once = typeof opts === 'object' ? !!opts.once : false; listeners.push({ fn, once }); } ) } }); const fireControllerChange = () => { const snap = [...listeners]; for (const e of snap) { e.fn(new Event('controllerchange')); if (e.once) { const i = listeners.indexOf(e); if (i >= 0) listeners.splice(i, 1); } } }; return { registration, fireControllerChange }; } async function flush(ms = 20): Promise { await new Promise((r) => setTimeout(r, ms)); } function stubLocationReload(): ReturnType { const reload = vi.fn(); Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, reload } }); return reload; } describe('pwa store', () => { beforeEach(() => { vi.resetModules(); }); it('zombie-waiter (gleiche Version): kein Toast, silent SKIP_WAITING, KEIN Reload', async () => { const active = new FakeSW('1776527907402'); const waiting = new FakeSW('1776527907402'); waiting.state = 'installed'; const { fireControllerChange } = mountFakeSW({ active, waiting }); const reload = stubLocationReload(); const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); await pwaStore.init(); await flush(); expect(pwaStore.updateAvailable).toBe(false); expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' }); // Kritisch: selbst wenn der Browser nach dem silent SKIP_WAITING // controllerchange feuert, darf kein Auto-Reload passieren — sonst // Endlos-Loop, weil der nächste Seitenaufruf erneut einen Zombie // bekommt. fireControllerChange(); expect(reload).not.toHaveBeenCalled(); }); it('echtes Update (unterschiedliche Version): Toast', async () => { const active = new FakeSW('1776526292782'); const waiting = new FakeSW('1776527907402'); waiting.state = 'installed'; mountFakeSW({ active, waiting }); const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); await pwaStore.init(); await flush(); expect(pwaStore.updateAvailable).toBe(true); expect(waiting.postMessage).not.toHaveBeenCalledWith({ type: 'SKIP_WAITING' }); }); it('alter active-SW ohne GET_VERSION (Fallback): Toast', async () => { const active = new FakeSW(null); const waiting = new FakeSW('1776527907402'); waiting.state = 'installed'; mountFakeSW({ active, waiting }); const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); await pwaStore.init(); await flush(); expect(pwaStore.updateAvailable).toBe(true); }); it('kein waiting-SW: kein Toast', async () => { mountFakeSW({ active: new FakeSW('1776527907402') }); const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); await pwaStore.init(); await flush(); expect(pwaStore.updateAvailable).toBe(false); }); it('reload() postet SKIP_WAITING, reload einmalig bei controllerchange', async () => { const active = new FakeSW('v1'); const waiting = new FakeSW('v2'); waiting.state = 'installed'; const { fireControllerChange } = mountFakeSW({ active, waiting }); const reload = stubLocationReload(); const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); await pwaStore.init(); await flush(); pwaStore.reload(); expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' }); expect(reload).not.toHaveBeenCalled(); fireControllerChange(); expect(reload).toHaveBeenCalledTimes(1); // Nochmal controllerchange → wegen { once: true } kein zweiter Reload. fireControllerChange(); expect(reload).toHaveBeenCalledTimes(1); }); it('reload() ohne waiting-SW ruft location.reload() sofort auf', async () => { mountFakeSW({ active: new FakeSW('v1') }); const reload = stubLocationReload(); const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); await pwaStore.init(); await flush(); pwaStore.reload(); expect(reload).toHaveBeenCalledTimes(1); }); });