303 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|