Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/lib/pwa/serviceWorkerRecoveryBootstrap.test.ts
T

360 lines
12 KiB
TypeScript

// @vitest-environment node
import { runInNewContext } from "node:vm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SERVICE_WORKER_RECOVERY_BOOTSTRAP, SERVICE_WORKER_VERSION } from "./serviceWorkerRecoveryBootstrap";
type RecoveryHarnessOptions = {
controlled?: boolean;
controllerScriptUrl?: string;
existingMarker?: string;
registrationAvailable?: boolean;
registerError?: Error;
storageError?: Error;
unregisterError?: Error;
updateError?: Error;
updateThrows?: Error;
waitingWorker?: boolean;
};
type ServiceWorkerEvent = { data?: unknown; source?: unknown };
type ServiceWorkerListener = (event: ServiceWorkerEvent) => void;
function createRecoveryHarness(options: RecoveryHarnessOptions = {}) {
const storageValues = new Map<string, string>();
if (options.existingMarker) {
storageValues.set("service-worker-recovery:v4", options.existingMarker);
}
const listeners = new Map<string, Set<ServiceWorkerListener>>();
const reload = vi.fn();
const controller = {
postMessage: vi.fn(),
scriptURL: options.controllerScriptUrl ?? "https://app.example.test/sw.js",
};
const waitingWorker = { postMessage: vi.fn() };
const update = options.updateThrows
? vi.fn(() => {
throw options.updateThrows;
})
: options.updateError
? vi.fn().mockRejectedValue(options.updateError)
: vi.fn().mockResolvedValue(undefined);
const unregister = options.unregisterError ? vi.fn().mockRejectedValue(options.unregisterError) : vi.fn().mockResolvedValue(true);
const registration = {
update,
unregister,
waiting: options.waitingWorker ? waitingWorker : null,
};
const replacementRegistration = { waiting: null };
const getRegistration = vi.fn().mockResolvedValue(options.registrationAvailable === false ? undefined : registration);
const register = options.registerError ? vi.fn().mockRejectedValue(options.registerError) : vi.fn().mockResolvedValue(replacementRegistration);
const addEventListener = vi.fn((type: string, listener: ServiceWorkerListener) => {
const typedListeners = listeners.get(type) ?? new Set<ServiceWorkerListener>();
typedListeners.add(listener);
listeners.set(type, typedListeners);
});
const removeEventListener = vi.fn((type: string, listener: ServiceWorkerListener) => {
listeners.get(type)?.delete(listener);
});
const sessionStorage = {
getItem: vi.fn((key: string) => {
if (options.storageError) throw options.storageError;
return storageValues.get(key) ?? null;
}),
setItem: vi.fn((key: string, value: string) => {
if (options.storageError) throw options.storageError;
storageValues.set(key, value);
}),
removeItem: vi.fn((key: string) => {
if (options.storageError) throw options.storageError;
return storageValues.delete(key);
}),
};
const serviceWorker = {
controller: options.controlled === false ? null : controller,
addEventListener,
removeEventListener,
getRegistration,
register,
};
const context = {
Date,
JSON,
Math,
Promise,
clearTimeout,
encodeURIComponent,
location: { origin: "https://app.example.test", reload },
navigator: { serviceWorker },
sessionStorage,
setTimeout,
};
function emit(type: string, event: ServiceWorkerEvent = {}) {
for (const listener of [...(listeners.get(type) ?? [])]) listener(event);
}
function answerVersion(version = SERVICE_WORKER_VERSION) {
const request = controller.postMessage.mock.lastCall?.[0] as { requestId?: string } | undefined;
emit("message", {
data: {
type: "HIVEGRID_SW_VERSION_RESPONSE",
requestId: request?.requestId,
version,
},
source: controller,
});
}
return {
addEventListener,
answerVersion,
context,
controller,
emitControllerChange: () => emit("controllerchange"),
getRegistration,
register,
reload,
removeEventListener,
sessionStorage,
storageValues,
unregister,
update,
waitingWorker,
};
}
async function runBootstrap(harness: ReturnType<typeof createRecoveryHarness>) {
runInNewContext(SERVICE_WORKER_RECOVERY_BOOTSTRAP, harness.context);
await Promise.resolve();
}
describe("service-worker recovery bootstrap", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("uses the versioned controller URL as a synchronous healthy fast path", async () => {
const marker = JSON.stringify({ expiresAt: Date.now() + 60_000 });
const harness = createRecoveryHarness({
controllerScriptUrl: `https://app.example.test/sw.js?v=${encodeURIComponent(SERVICE_WORKER_VERSION)}`,
existingMarker: marker,
});
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(10_000);
expect(harness.controller.postMessage).not.toHaveBeenCalled();
expect(harness.getRegistration).not.toHaveBeenCalled();
expect(harness.update).not.toHaveBeenCalled();
expect(harness.unregister).not.toHaveBeenCalled();
expect(harness.register).not.toHaveBeenCalled();
expect(harness.reload).not.toHaveBeenCalled();
expect(harness.storageValues.size).toBe(0);
});
it("uses a version handshake and skips update checks for the current worker", async () => {
const harness = createRecoveryHarness();
await runBootstrap(harness);
expect(harness.controller.postMessage).toHaveBeenCalledWith({
type: "HIVEGRID_SW_VERSION_REQUEST",
requestId: expect.any(String),
});
harness.answerVersion();
await vi.advanceTimersByTimeAsync(10_000);
expect(harness.getRegistration).not.toHaveBeenCalled();
expect(harness.update).not.toHaveBeenCalled();
expect(harness.register).not.toHaveBeenCalled();
expect(harness.reload).not.toHaveBeenCalled();
});
it("clears an interrupted recovery marker after a healthy handshake", async () => {
const marker = JSON.stringify({ expiresAt: Date.now() + 60_000 });
const harness = createRecoveryHarness({ existingMarker: marker });
await runBootstrap(harness);
harness.answerVersion();
await Promise.resolve();
expect(harness.sessionStorage.removeItem).toHaveBeenCalledWith("service-worker-recovery:v4");
expect(harness.storageValues.size).toBe(0);
});
it("updates a legacy worker that does not answer the handshake", async () => {
const harness = createRecoveryHarness();
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(300);
expect(harness.getRegistration).toHaveBeenCalledTimes(1);
expect(harness.update).toHaveBeenCalledTimes(1);
expect(harness.storageValues.has("service-worker-recovery:v4")).toBe(true);
harness.emitControllerChange();
harness.emitControllerChange();
expect(harness.reload).toHaveBeenCalledTimes(1);
expect(harness.storageValues.size).toBe(0);
});
it("updates immediately when a worker reports an obsolete version", async () => {
const harness = createRecoveryHarness();
await runBootstrap(harness);
harness.answerVersion("v3-obsolete");
await Promise.resolve();
await Promise.resolve();
expect(harness.update).toHaveBeenCalledTimes(1);
});
it("accepts a late healthy response during recovery without unregistering or reloading", async () => {
const harness = createRecoveryHarness();
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(300);
expect(harness.update).toHaveBeenCalledTimes(1);
expect(harness.controller.postMessage).toHaveBeenCalledTimes(2);
harness.answerVersion();
await vi.advanceTimersByTimeAsync(10_000);
expect(harness.unregister).not.toHaveBeenCalled();
expect(harness.register).not.toHaveBeenCalled();
expect(harness.reload).not.toHaveBeenCalled();
expect(harness.storageValues.size).toBe(0);
});
it("nudges a waiting replacement after an update", async () => {
const harness = createRecoveryHarness({ waitingWorker: true });
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(300);
expect(harness.waitingWorker.postMessage).toHaveBeenCalledWith({ type: "HIVEGRID_SW_SKIP_WAITING" });
});
it("replaces a registration when a resolved update never changes controller", async () => {
const harness = createRecoveryHarness();
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(250 + 1_500 + 1_500);
expect(harness.update).toHaveBeenCalledTimes(1);
expect(harness.unregister).toHaveBeenCalledTimes(1);
expect(harness.register).toHaveBeenCalledWith(`/sw.js?v=${encodeURIComponent(SERVICE_WORKER_VERSION)}`, {
scope: "/",
updateViaCache: "none",
});
expect(harness.reload).toHaveBeenCalledTimes(1);
expect(harness.storageValues.size).toBe(0);
});
it("falls back immediately when the update is rejected", async () => {
const harness = createRecoveryHarness({ updateError: new Error("update failed") });
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(250 + 1_500);
expect(harness.update).toHaveBeenCalledTimes(1);
expect(harness.unregister).toHaveBeenCalledTimes(1);
expect(harness.register).toHaveBeenCalledTimes(1);
});
it("contains synchronous update errors and still reaches the bounded replacement", async () => {
const harness = createRecoveryHarness({ updateThrows: new Error("sync update failure") });
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(250 + 1_500);
expect(harness.update).toHaveBeenCalledTimes(1);
expect(harness.unregister).toHaveBeenCalledTimes(1);
expect(harness.register).toHaveBeenCalledTimes(1);
});
it("reloads through the network when re-registration fails after unregister", async () => {
const harness = createRecoveryHarness({
registerError: new Error("register failed"),
updateError: new Error("update failed"),
});
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(250 + 1_500);
expect(harness.unregister).toHaveBeenCalledTimes(1);
expect(harness.register).toHaveBeenCalledTimes(1);
expect(harness.reload).toHaveBeenCalledTimes(1);
expect(harness.storageValues.size).toBe(0);
});
it("bounds a replacement wait after unregister fails and clears the retry marker", async () => {
const harness = createRecoveryHarness({
unregisterError: new Error("unregister failed"),
updateError: new Error("update failed"),
});
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(250 + 1_500 + 1_500);
expect(harness.register).toHaveBeenCalledTimes(1);
expect(harness.reload).not.toHaveBeenCalled();
expect(harness.storageValues.size).toBe(0);
});
it("retries after an unexpired lock instead of treating it as permanent", async () => {
const harness = createRecoveryHarness({
existingMarker: JSON.stringify({ expiresAt: Date.now() + 1_000 }),
});
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(999);
expect(harness.update).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(20);
expect(harness.update).toHaveBeenCalledTimes(1);
});
it("re-registers when the legacy registration cannot be found", async () => {
const harness = createRecoveryHarness({ registrationAvailable: false });
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(300);
expect(harness.update).not.toHaveBeenCalled();
expect(harness.register).toHaveBeenCalledTimes(1);
});
it("does nothing without an active controller", async () => {
const harness = createRecoveryHarness({ controlled: false });
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(10_000);
expect(harness.getRegistration).not.toHaveBeenCalled();
expect(harness.storageValues.size).toBe(0);
});
it("still repairs a controlled page when session storage is unavailable", async () => {
const harness = createRecoveryHarness({ storageError: new Error("storage unavailable") });
await runBootstrap(harness);
await vi.advanceTimersByTimeAsync(300);
expect(harness.update).toHaveBeenCalledTimes(1);
});
it("contains no script-closing sequence", () => {
expect(SERVICE_WORKER_RECOVERY_BOOTSTRAP).not.toContain("</script>");
expect(SERVICE_WORKER_RECOVERY_BOOTSTRAP).not.toContain("${");
});
});