import { describe, expect, it } from "@suaveplan/testing/runner"; import type { StorageLike, WindowLike } from "../types/common"; import { InstallPromptManager } from "./install-prompt"; interface BIPEvent extends Event { prompt: () => Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; platforms: string[]; } class FakeWindow implements WindowLike { private listeners = new Map void)[]>(); public addEventListener( type: string, listener: (event: Event) => void ): void { const arr = this.listeners.get(type) ?? []; arr.push(listener); this.listeners.set(type, arr); } public removeEventListener( type: string, listener: (event: Event) => void ): void { const arr = (this.listeners.get(type) ?? []).filter( (l) => l !== listener ); this.listeners.set(type, arr); } public dispatch(type: string, event: Event): void { for (const l of [...(this.listeners.get(type) ?? [])]) l(event); } public listenerCount(type: string): number { return (this.listeners.get(type) ?? []).length; } } function makeStorage(): StorageLike { const data = new Map(); return { getItem: (k) => data.get(k) ?? null, setItem: (k, v) => { data.set(k, v); }, removeItem: (k) => { data.delete(k); }, }; } function makeBIPEvent(outcome: "accepted" | "dismissed"): BIPEvent { const event = new Event("beforeinstallprompt") as BIPEvent; event.platforms = ["web"]; event.userChoice = Promise.resolve({ outcome }); event.prompt = async () => {}; return event; } describe("InstallPromptManager", () => { it("captures beforeinstallprompt and exposes available", { timeout: 5000, }, () => { const window = new FakeWindow(); const storage = makeStorage(); const ipm = new InstallPromptManager({ window, storage }); ipm.listen(); const states: string[] = []; ipm.onStateChange((s) => states.push(s)); window.dispatch("beforeinstallprompt", makeBIPEvent("accepted")); expect(ipm.isAvailable).toBe(true); expect(states).toEqual(["available"]); ipm.unlisten(); expect(window.listenerCount("beforeinstallprompt")).toBe(0); }); it("prompt() resolves accepted and updates state", { timeout: 5000, }, async () => { const window = new FakeWindow(); const ipm = new InstallPromptManager({ window, storage: makeStorage(), }); ipm.listen(); window.dispatch("beforeinstallprompt", makeBIPEvent("accepted")); expect(await ipm.prompt()).toBe("accepted"); expect(ipm.state).toBe("accepted"); }); it("persists dismissal across instances", { timeout: 5000 }, async () => { const window = new FakeWindow(); const storage = makeStorage(); const ipm = new InstallPromptManager({ window, storage }); ipm.listen(); window.dispatch("beforeinstallprompt", makeBIPEvent("dismissed")); expect(await ipm.prompt()).toBe("dismissed"); const ipm2 = new InstallPromptManager({ window: new FakeWindow(), storage, }); expect(ipm2.wasDismissed).toBe(true); ipm2.clearDismissed(); expect(ipm2.wasDismissed).toBe(false); }); it("dismiss() clears the deferred prompt and persists", { timeout: 5000, }, () => { const window = new FakeWindow(); const storage = makeStorage(); const ipm = new InstallPromptManager({ window, storage }); ipm.listen(); window.dispatch("beforeinstallprompt", makeBIPEvent("accepted")); ipm.dismiss(); expect(ipm.state).toBe("dismissed"); expect(storage.getItem("__pwa_install_dismissed__")).toBe("true"); }); it("appinstalled transitions to accepted", { timeout: 5000 }, () => { const window = new FakeWindow(); const ipm = new InstallPromptManager({ window, storage: makeStorage(), }); ipm.listen(); window.dispatch("beforeinstallprompt", makeBIPEvent("accepted")); window.dispatch("appinstalled", new Event("appinstalled")); expect(ipm.state).toBe("accepted"); }); it("prompt() with no deferred event returns current state", { timeout: 5000, }, async () => { const ipm = new InstallPromptManager({ window: new FakeWindow(), storage: makeStorage(), }); expect(await ipm.prompt()).toBe("idle"); }); it("listen() is a no-op when no window seam is available", { timeout: 5000, }, () => { const ipm = new InstallPromptManager({ window: undefined, storage: makeStorage(), }); expect(() => ipm.listen()).not.toThrow(); expect(() => ipm.unlisten()).not.toThrow(); }); it("reset() returns to idle and unlistens", { timeout: 5000 }, () => { const window = new FakeWindow(); const ipm = new InstallPromptManager({ window, storage: makeStorage(), }); ipm.listen(); window.dispatch("beforeinstallprompt", makeBIPEvent("accepted")); ipm.reset(); expect(ipm.state).toBe("idle"); expect(window.listenerCount("beforeinstallprompt")).toBe(0); }); it("onStateChange unsubscribe stops further notifications", { timeout: 5000, }, () => { const window = new FakeWindow(); const ipm = new InstallPromptManager({ window, storage: makeStorage(), }); const states: string[] = []; const off = ipm.onStateChange((s) => states.push(s)); ipm.listen(); window.dispatch("beforeinstallprompt", makeBIPEvent("accepted")); off(); ipm.dismiss(); expect(states).toEqual(["available"]); }); it("unlisten() is a no-op when listen() was never called", { timeout: 5000, }, () => { const ipm = new InstallPromptManager({ window: new FakeWindow(), storage: makeStorage(), }); // No listen() → _boundHandler / _boundAppInstalled stay null. expect(() => ipm.unlisten()).not.toThrow(); }); it("onStateChange unsubscribe is idempotent", { timeout: 5000 }, () => { const ipm = new InstallPromptManager({ window: new FakeWindow(), storage: makeStorage(), }); const off = ipm.onStateChange(() => {}); off(); // Second invocation: indexOf returns -1, splice is skipped. expect(() => off()).not.toThrow(); }); it("threads an injected observability bundle through the constructor", { timeout: 5000, }, async () => { const { createPwaObservability } = await import( "../observability/observability" ); const observability = createPwaObservability(); const ipm = new InstallPromptManager({ window: new FakeWindow(), storage: makeStorage(), observability, }); expect(ipm.state).toBe("idle"); }); it("derives window + storage from globals when no options are passed", { timeout: 5000, }, () => { // happy-dom provides globalThis.addEventListener + localStorage, so the // bare constructor exercises the truthy globalThis-fallback branches // for both the window seam and the storage seam. const ipm = new InstallPromptManager(); expect(ipm.state).toBe("idle"); expect(() => ipm.listen()).not.toThrow(); ipm.reset(); }); it("resolves both seams to undefined fallback when globals are stripped", { timeout: 5000, }, () => { const originalLocal = (globalThis as { localStorage?: StorageLike }) .localStorage; const originalAdd = (globalThis as { addEventListener?: unknown }) .addEventListener; // happy-dom installs addEventListener on globalThis — strip it so the // window-detection falls to undefined and exercises the falsy branch. Reflect.deleteProperty(globalThis as object, "addEventListener"); (globalThis as { localStorage?: StorageLike }).localStorage = undefined; try { const ipm = new InstallPromptManager(); // listen() is a no-op when window is undefined. expect(() => ipm.listen()).not.toThrow(); expect(() => ipm.unlisten()).not.toThrow(); // wasDismissed routes through NULL_STORAGE.getItem returning null. expect(ipm.wasDismissed).toBe(false); } finally { if (originalAdd !== undefined) { ( globalThis as { addEventListener?: unknown } ).addEventListener = originalAdd; } (globalThis as { localStorage?: StorageLike }).localStorage = originalLocal; } }); it("falls back to NULL_STORAGE when no storage is injected and globalThis.localStorage is absent", { timeout: 5000, }, () => { const original = (globalThis as { localStorage?: StorageLike }) .localStorage; (globalThis as { localStorage?: StorageLike }).localStorage = undefined; try { const ipm = new InstallPromptManager({ window: new FakeWindow(), }); // Drives the NULL_STORAGE.getItem path — returns null → wasDismissed false. expect(ipm.wasDismissed).toBe(false); // Drives the NULL_STORAGE.setItem path — no-op, must not throw. expect(() => ipm.dismiss()).not.toThrow(); // Drives the NULL_STORAGE.removeItem path. expect(() => ipm.clearDismissed()).not.toThrow(); } finally { (globalThis as { localStorage?: StorageLike }).localStorage = original; } }); it("wasDismissed tolerates storage throwing", { timeout: 5000 }, () => { const throwing: StorageLike = { getItem: () => { throw new Error("blocked"); }, setItem: () => { throw new Error("blocked"); }, removeItem: () => { throw new Error("blocked"); }, }; const ipm = new InstallPromptManager({ window: new FakeWindow(), storage: throwing, }); expect(ipm.wasDismissed).toBe(false); expect(() => ipm.dismiss()).not.toThrow(); expect(() => ipm.clearDismissed()).not.toThrow(); }); });