Files
kochwas/tests/unit/pwa-store.test.ts
hsiegeln 854af2fc34
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 29s
fix(pwa): Reload-Loop beim Zombie-Cleanup beseitigt
Der Commit 1bec054 hatte einen globalen controllerchange-Listener in
init(), der bei jedem Event location.reload() auslöste. In Kombination
mit der Zombie-Aufräumung (silent SKIP_WAITING) ergab das einen
Endlos-Loop: Seite lädt → Zombie erkannt → SKIP_WAITING → controller-
change → Reload → neue Seite mit frischem Zombie → usw.

Fix: Der controllerchange-Listener wird nur noch scoped aus reload()
heraus gesetzt ({ once: true }) — also genau dann, wenn der User auf
„Neu laden" geklickt hat und einen Reload tatsächlich will. Beim
silent Zombie-Cleanup gibt es keinen Listener, die Seite läuft
einfach nahtlos unter dem neuen (funktional identischen) SW weiter.

Regression-Test sichert ab, dass fireControllerChange() nach silent
SKIP_WAITING location.reload() NICHT aufruft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:12:19 +02:00

177 lines
5.6 KiB
TypeScript

// @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<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
function mountFakeSW(init: Partial<Reg>): {
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<void> {
await new Promise((r) => setTimeout(r, ms));
}
function stubLocationReload(): ReturnType<typeof vi.fn> {
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);
});
});