Files
suaveplan/packages/foundation/data/pwa/src/install-prompt/install-prompt.test.ts
2026-05-27 23:47:53 +01:00

303 lines
11 KiB
TypeScript

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<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
platforms: string[];
}
class FakeWindow implements WindowLike {
private listeners = new Map<string, ((event: Event) => 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<string, string>();
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();
});
});