← 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);