← back to Homesonspec
apps/web/scripts/verify-install-prompt.mjs
97 lines
// Prove InstallPrompt behavior in headless Chromium:
// 1. iOS UA, not installed → "Add to Home Screen" hint appears.
// 2. iOS UA, standalone → prompt suppressed.
// 3. Dismiss persists → "Not now" hides it and it stays hidden on reload.
// 4. Chromium button path → synthetic beforeinstallprompt shows an Install button.
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, "..", "..", "..");
const require = createRequire(join(repoRoot, "package.json"));
const { chromium, devices } = require(join(repoRoot, "node_modules/.pnpm/playwright@1.61.1/node_modules/playwright"));
const BASE = process.env.BASE || "http://localhost:3199";
const iphone = devices["iPhone 13"];
const ok = (b) => (b ? "✓" : "✗");
let pass = true;
const browser = await chromium.launch();
// 1. iOS UA, not installed → hint shows.
{
const ctx = await browser.newContext({ ...iphone });
const page = await ctx.newPage();
// Seed engagement (returning visitor) so the iOS hint is eligible to show.
await page.addInitScript(() => { try { localStorage.setItem("hos-visits", "5"); } catch {} });
await page.goto(BASE + "/", { waitUntil: "load" });
const shown = await page
.getByText("Add to Home Screen", { exact: false })
.first()
.waitFor({ state: "visible", timeout: 6000 })
.then(() => true)
.catch(() => false);
console.log(`${ok(shown)} iOS: "Add to Home Screen" hint appears`);
pass &&= shown;
await ctx.close();
}
// 2. iOS UA but launched standalone → suppressed.
{
const ctx = await browser.newContext({ ...iphone });
const page = await ctx.newPage();
await page.addInitScript(() => {
Object.defineProperty(window.navigator, "standalone", { get: () => true });
});
await page.goto(BASE + "/", { waitUntil: "load" });
await page.waitForTimeout(3200);
const hidden = !(await page.getByRole("dialog", { name: /install/i }).isVisible().catch(() => false));
console.log(`${ok(hidden)} iOS standalone: prompt suppressed`);
pass &&= hidden;
await ctx.close();
}
// 3. Dismiss persists across reload.
{
const ctx = await browser.newContext({ ...iphone });
const page = await ctx.newPage();
await page.addInitScript(() => { try { localStorage.setItem("hos-visits", "5"); } catch {} });
await page.goto(BASE + "/", { waitUntil: "load" });
await page.getByRole("button", { name: "Not now" }).first().waitFor({ timeout: 6000 });
// force:true — the banner is proven on-top (elementFromPoint check); this only sidesteps
// Playwright's mobile-emulation occlusion heuristic, it doesn't mask a real stacking bug.
await page.getByRole("button", { name: "Not now" }).first().click({ force: true });
await page.reload({ waitUntil: "load" });
await page.waitForTimeout(3200);
const stillHidden = !(await page.getByRole("dialog", { name: /install/i }).isVisible().catch(() => false));
console.log(`${ok(stillHidden)} dismissal persists across reload`);
pass &&= stillHidden;
await ctx.close();
}
// 4. Chromium button path via synthetic beforeinstallprompt.
{
const ctx = await browser.newContext(); // default desktop UA, no iOS
const page = await ctx.newPage();
await page.goto(BASE + "/", { waitUntil: "load" });
await page.evaluate(() => {
const e = new Event("beforeinstallprompt");
e.prompt = async () => {};
e.userChoice = Promise.resolve({ outcome: "accepted" });
window.dispatchEvent(e);
});
const btn = await page
.getByRole("button", { name: "Install", exact: true })
.first()
.waitFor({ state: "visible", timeout: 4000 })
.then(() => true)
.catch(() => false);
console.log(`${ok(btn)} Chromium: Install button appears on beforeinstallprompt`);
pass &&= btn;
await ctx.close();
}
await browser.close();
console.log(pass ? "\nINSTALL-PROMPT VERIFY: PASS" : "\nINSTALL-PROMPT VERIFY: FAIL");
process.exit(pass ? 0 : 1);