Initial Commit
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user