2026-04-18 17:48:55 +02:00
|
|
|
// @vitest-environment jsdom
|
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
|
|
|
|
|
|
class FakeSW extends EventTarget {
|
|
|
|
|
scriptURL = '/service-worker.js';
|
|
|
|
|
state: 'installed' | 'activated' = 'activated';
|
2026-04-18 18:06:36 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-18 17:48:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Reg = {
|
|
|
|
|
active: FakeSW | null;
|
|
|
|
|
waiting: FakeSW | null;
|
|
|
|
|
installing: FakeSW | null;
|
|
|
|
|
addEventListener: ReturnType<typeof vi.fn>;
|
|
|
|
|
update: ReturnType<typeof vi.fn>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-18 17:57:51 +02:00
|
|
|
function mountFakeSW(init: Partial<Reg>): {
|
|
|
|
|
registration: Reg;
|
2026-04-18 18:12:19 +02:00
|
|
|
fireControllerChange: () => void;
|
2026-04-18 17:57:51 +02:00
|
|
|
} {
|
2026-04-18 17:48:55 +02:00
|
|
|
const registration: Reg = {
|
|
|
|
|
active: init.active ?? null,
|
|
|
|
|
waiting: init.waiting ?? null,
|
|
|
|
|
installing: init.installing ?? null,
|
|
|
|
|
addEventListener: vi.fn(),
|
|
|
|
|
update: vi.fn().mockResolvedValue(undefined)
|
|
|
|
|
};
|
2026-04-18 18:12:19 +02:00
|
|
|
type Entry = { fn: (e: Event) => void; once: boolean };
|
|
|
|
|
const listeners: Entry[] = [];
|
2026-04-18 17:48:55 +02:00
|
|
|
Object.defineProperty(navigator, 'serviceWorker', {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: {
|
|
|
|
|
ready: Promise.resolve(registration),
|
|
|
|
|
controller: registration.active,
|
2026-04-18 18:12:19 +02:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-04-18 17:48:55 +02:00
|
|
|
}
|
|
|
|
|
});
|
2026-04-18 18:12:19 +02:00
|
|
|
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 };
|
2026-04-18 17:48:55 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-18 18:06:36 +02:00
|
|
|
async function flush(ms = 20): Promise<void> {
|
2026-04-18 17:48:55 +02:00
|
|
|
await new Promise((r) => setTimeout(r, ms));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 18:12:19 +02:00
|
|
|
function stubLocationReload(): ReturnType<typeof vi.fn> {
|
|
|
|
|
const reload = vi.fn();
|
|
|
|
|
Object.defineProperty(window, 'location', {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: { ...window.location, reload }
|
|
|
|
|
});
|
|
|
|
|
return reload;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 17:48:55 +02:00
|
|
|
describe('pwa store', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.resetModules();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-18 18:12:19 +02:00
|
|
|
it('zombie-waiter (gleiche Version): kein Toast, silent SKIP_WAITING, KEIN Reload', async () => {
|
2026-04-18 18:06:36 +02:00
|
|
|
const active = new FakeSW('1776527907402');
|
|
|
|
|
const waiting = new FakeSW('1776527907402');
|
|
|
|
|
waiting.state = 'installed';
|
2026-04-18 18:12:19 +02:00
|
|
|
const { fireControllerChange } = mountFakeSW({ active, waiting });
|
|
|
|
|
const reload = stubLocationReload();
|
2026-04-18 18:06:36 +02:00
|
|
|
|
|
|
|
|
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' });
|
2026-04-18 18:12:19 +02:00
|
|
|
// 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();
|
2026-04-18 18:06:36 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('echtes Update (unterschiedliche Version): Toast', async () => {
|
|
|
|
|
const active = new FakeSW('1776526292782');
|
|
|
|
|
const waiting = new FakeSW('1776527907402');
|
2026-04-18 17:48:55 +02:00
|
|
|
waiting.state = 'installed';
|
|
|
|
|
mountFakeSW({ active, waiting });
|
|
|
|
|
|
|
|
|
|
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
|
|
|
|
await pwaStore.init();
|
|
|
|
|
await flush();
|
|
|
|
|
|
2026-04-18 17:57:51 +02:00
|
|
|
expect(pwaStore.updateAvailable).toBe(true);
|
2026-04-18 18:06:36 +02:00
|
|
|
expect(waiting.postMessage).not.toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
|
2026-04-18 17:48:55 +02:00
|
|
|
});
|
|
|
|
|
|
2026-04-18 18:06:36 +02:00
|
|
|
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 });
|
2026-04-18 17:48:55 +02:00
|
|
|
|
|
|
|
|
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
|
|
|
|
await pwaStore.init();
|
|
|
|
|
await flush();
|
|
|
|
|
|
2026-04-18 18:06:36 +02:00
|
|
|
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();
|
2026-04-18 17:57:51 +02:00
|
|
|
expect(pwaStore.updateAvailable).toBe(false);
|
2026-04-18 17:48:55 +02:00
|
|
|
});
|
|
|
|
|
|
2026-04-18 18:12:19 +02:00
|
|
|
it('reload() postet SKIP_WAITING, reload einmalig bei controllerchange', async () => {
|
2026-04-18 18:06:36 +02:00
|
|
|
const active = new FakeSW('v1');
|
|
|
|
|
const waiting = new FakeSW('v2');
|
2026-04-18 17:48:55 +02:00
|
|
|
waiting.state = 'installed';
|
2026-04-18 18:12:19 +02:00
|
|
|
const { fireControllerChange } = mountFakeSW({ active, waiting });
|
|
|
|
|
const reload = stubLocationReload();
|
2026-04-18 17:48:55 +02:00
|
|
|
|
|
|
|
|
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
|
|
|
|
await pwaStore.init();
|
|
|
|
|
await flush();
|
|
|
|
|
|
2026-04-18 17:57:51 +02:00
|
|
|
pwaStore.reload();
|
|
|
|
|
expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
|
|
|
|
|
expect(reload).not.toHaveBeenCalled();
|
|
|
|
|
|
2026-04-18 18:12:19 +02:00
|
|
|
fireControllerChange();
|
2026-04-18 17:57:51 +02:00
|
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
2026-04-18 17:48:55 +02:00
|
|
|
|
2026-04-18 18:12:19 +02:00
|
|
|
// Nochmal controllerchange → wegen { once: true } kein zweiter Reload.
|
|
|
|
|
fireControllerChange();
|
2026-04-18 17:57:51 +02:00
|
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-18 18:06:36 +02:00
|
|
|
it('reload() ohne waiting-SW ruft location.reload() sofort auf', async () => {
|
|
|
|
|
mountFakeSW({ active: new FakeSW('v1') });
|
2026-04-18 18:12:19 +02:00
|
|
|
const reload = stubLocationReload();
|
2026-04-18 17:48:55 +02:00
|
|
|
|
2026-04-18 17:57:51 +02:00
|
|
|
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
|
|
|
|
await pwaStore.init();
|
|
|
|
|
await flush();
|
|
|
|
|
|
2026-04-18 17:48:55 +02:00
|
|
|
pwaStore.reload();
|
2026-04-18 17:57:51 +02:00
|
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
2026-04-18 17:48:55 +02:00
|
|
|
});
|
|
|
|
|
});
|