Testland
Browse all skills & agents

add-to-homescreen-flow-tests

The single install-flow skill: the reference contract for the PWA install flow (installability gate fields, `beforeinstallprompt` handshake, per-platform paths - Android WebAPK / iOS Share menu / Firefox no-op - and the `display-mode` post-install signal, in references/install-flow-reference.md) plus the build-an-X workflow that emits the Add-to-Home-Screen suite. Walks the four-stage timeline, emitting one test per gate cell per [web.dev/articles/install-criteria][install-criteria], the deferred-prompt → `prompt()` → `userChoice` chain per [web.dev/articles/customize-install][customize-install], the iOS Safari manual-metadata branch (`apple-touch-icon`) per [web.dev/learn/pwa/installation][learn-pwa], and the post-install `(display-mode: standalone)` MQ assertion. Output: a Playwright spec with per-stage cells plus a coverage matrix. Use when a PWA's manifest, icons, or install handler change, when install conversion drops at an unknown stage, or when triaging a flaky install assertion.

Install with skills.sh (any agent)

npx skills add testland/qa --skill add-to-homescreen-flow-tests
View source

add-to-homescreen-flow-tests

Overview

The PWA install flow is a four-stage test surface per references/install-flow-reference.md: installability gate → beforeinstallprompt handshake → per-platform install path → post-install display-mode signal. Every team's install regression looks slightly different - a missing start_url, an icon resolution drift, a prompt() called without a user gesture - but the test surface is the same.

This builder produces the per-PWA install suite. Output is a Playwright spec file plus a coverage YAML matrix mapping each install criterion from install-criteria (opens in new window) to its assertion. The contract itself - gate cells, event lifecycle, per-platform paths, and the full event timeline - lives in references/install-flow-reference.md; the workflow consumes it by emitting verification cells.

This builder generates the per-PWA suite from the project's actual manifest + SW + page handler - the artifact you check into the repo. Standalone generic recipes (single-test manifest validation, prompt capture, iOS metadata, display-mode MQ) are in references/install-flow-tests.md.

Composes with:

  • references/install-flow-reference.md - the four-stage timeline this builder follows step-by-step.
  • service-worker-lifecycle-tests - Stage 1 of the install gate requires an active SW; pair this builder's output with the lifecycle spec for full coverage.

When to use

  • New PWA, pre-launch - generate the install suite alongside the manifest.
  • Manifest changed (renamed name, new icon, dropped display field) - re-run the builder to catch criteria regressions.
  • Install conversion is dropping in analytics - author the per-stage cells to localize where users fall out (gate fails → prompt never fires; prompt fires → user dismisses; install succeeds but display-mode flips wrong).
  • Adding iOS support to a Chromium-first PWA - author the manual- metadata branch.

Workflow

Step 1 - Read the manifest + page handler

# Inventory
cat public/manifest.webmanifest > tests/install-snapshot/manifest.json
grep -E "beforeinstallprompt|appinstalled" src/ -rn > tests/install-snapshot/handlers.txt

Capture for the test:

FactUsed in
Manifest fields (name, short_name, display, start_url, icons sizes)Step 2
Whether the page binds beforeinstallpromptStep 3
The selector of the in-app "Install" buttonStep 3
Whether appinstalled is bound (analytics)Step 4
Whether apple-touch-icon + apple-mobile-web-app-capable meta are presentStep 5

Step 2 - Emit the gate-cell tests

Per install-criteria (opens in new window), every gate cell is independently assertable. Emit one test per cell:

// tests/install-gate.spec.ts
import { test, expect } from '@playwright/test';

// Fetch + parse the linked manifest once - reused by every field cell below.
async function readManifest(page, request) {
  const href = await page.locator('link[rel="manifest"]').getAttribute('href');
  return (await request.get(new URL(href!, page.url()).toString())).json();
}

test.describe('PWA install gate (per web.dev/articles/install-criteria)', () => {
  test('manifest link present', async ({ page }) => {
    await page.goto('https://localhost:3000/');
    await expect(page.locator('link[rel="manifest"]')).toHaveCount(1);
  });

  test('manifest declares name or short_name', async ({ page, request }) => {
    await page.goto('https://localhost:3000/');
    const m = await readManifest(page, request);
    expect(m.name || m.short_name).toBeTruthy();
  });

  test('manifest icons include 192px and 512px (per install-criteria)', async ({ page, request }) => {
    await page.goto('https://localhost:3000/');
    const m = await readManifest(page, request);
    const has192 = (m.icons ?? []).some((i: any) => /(^|\s)192x192(\s|$)/.test(i.sizes ?? ''));
    const has512 = (m.icons ?? []).some((i: any) => /(^|\s)512x512(\s|$)/.test(i.sizes ?? ''));
    expect(has192 && has512).toBe(true);
  });

  test('manifest start_url present', async ({ page, request }) => {
    await page.goto('https://localhost:3000/');
    const m = await readManifest(page, request);
    expect(m.start_url).toBeTruthy();
  });

  test('manifest display is installable value', async ({ page, request }) => {
    await page.goto('https://localhost:3000/');
    const m = await readManifest(page, request);
    // Per install-criteria: must be fullscreen, standalone, minimal-ui, or window-controls-overlay
    expect(['fullscreen', 'standalone', 'minimal-ui', 'window-controls-overlay']).toContain(m.display);
  });

  test('manifest does not opt out via prefer_related_applications', async ({ page, request }) => {
    await page.goto('https://localhost:3000/');
    const m = await readManifest(page, request);
    // Per install-criteria: "must not be present or be false"
    expect(m.prefer_related_applications === undefined || m.prefer_related_applications === false).toBe(true);
  });

  test('site served over HTTPS', async ({ page }) => {
    await page.goto('https://localhost:3000/');
    // Allow localhost http for dev; production check enforces https
    const url = page.url();
    expect(url.startsWith('https://') || url.startsWith('http://localhost')).toBe(true);
  });

  test('service worker is registered (install prerequisite)', async ({ page, context }) => {
    await page.goto('https://localhost:3000/');
    let [sw] = context.serviceWorkers();
    if (!sw) sw = await context.waitForEvent('serviceworker');
    expect(sw.url()).toBeTruthy();
  });
});

Step 3 - Emit the beforeinstallprompt handshake test

Per customize-install (opens in new window), the canonical lifecycle is preventDefault → stash → prompt() on user gesture → userChoice. Emit the test:

test('beforeinstallprompt: deferred prompt + click → userChoice resolves', async ({ page }) => {
  await page.goto('https://localhost:3000/');

  // Simulate engagement gate - per install-criteria, "Users must click/tap the page
  // at least once and spend minimum 30 seconds viewing it"
  await page.click('body');
  await page.waitForTimeout(31_000);

  // Wait for the deferred prompt to land on window.__deferredPrompt
  const deferred = await page.waitForFunction(
    () => (window as any).__deferredPrompt !== undefined,
    null,
    { timeout: 10_000 }
  );
  expect(deferred).toBeTruthy();

  // Click the in-app Install button
  await page.click('[data-testid="install-pwa"]');

  // userChoice resolves to { outcome: 'accepted' | 'dismissed' }
  const outcome = await page.evaluate(async () => {
    const p = (window as any).__lastUserChoice;
    return p?.outcome ?? null;
  });
  expect(['accepted', 'dismissed']).toContain(outcome);
});

This requires the page bundle to expose window.__deferredPrompt and window.__lastUserChoice for the test (or use a Playwright init script that hooks the event). The 31-second engagement wait is the engagement-gate cell from install-criteria (opens in new window).

Per customize-install (opens in new window): "You can only call prompt() on the deferred event once." - emit a second-call assertion:

test('beforeinstallprompt: second prompt() call rejects', async ({ page }) => {
  await page.goto('https://localhost:3000/');
  // ... engagement + prompt as above ...

  const error = await page.evaluate(async () => {
    try {
      await (window as any).__deferredPrompt.prompt();
      return null;
    } catch (e: any) {
      return e.message;
    }
  });
  expect(error).not.toBeNull();
});

Step 4 - Emit the appinstalled analytics test

Per customize-install (opens in new window): appinstalled "fires whenever installation succeeds, regardless of the trigger mechanism." Test the listener binds and fires:

test('appinstalled fires after install acceptance', async ({ page }) => {
  await page.goto('https://localhost:3000/');

  const fired = await page.evaluate(() => new Promise<boolean>((resolve) => {
    window.addEventListener('appinstalled', () => resolve(true));
    // Trigger install (test fixture mocks the prompt to auto-accept)
    (window as any).__triggerInstall?.();
    setTimeout(() => resolve(false), 5_000);
  }));
  expect(fired).toBe(true);
});

The Playwright environment does not surface a real install (no WebAPK minting headlessly), so the fixture either (a) mocks the prompt resolver, or (b) dispatches a synthetic appinstalled event in test mode.

Step 5 - Emit the iOS-metadata branch

Per learn-pwa (opens in new window), iOS Safari does not implement beforeinstallprompt. The test surface is metadata + manual smoke:

test('iOS install metadata: apple-touch-icon present', async ({ page }) => {
  await page.goto('https://localhost:3000/');
  await expect(page.locator('link[rel="apple-touch-icon"]')).toHaveCount(1);
});

test('iOS install metadata: apple-mobile-web-app-capable yes', async ({ page }) => {
  await page.goto('https://localhost:3000/');
  await expect(
    page.locator('meta[name="apple-mobile-web-app-capable"][content="yes"]')
  ).toHaveCount(1);
});

test('iOS install metadata: apple-touch-icon resolves', async ({ page, request }) => {
  await page.goto('https://localhost:3000/');
  const href = await page.locator('link[rel="apple-touch-icon"]').getAttribute('href');
  const r = await request.get(new URL(href!, page.url()).toString());
  expect(r.status()).toBe(200);
  // Icon must be PNG for iOS
  expect(r.headers()['content-type']).toMatch(/png/i);
});

Per learn-pwa (opens in new window): iOS install "requires apple-touch-icon tag" - omitting this means installed PWAs get a generic icon, a regression invisible until users file a bug.

Step 6 - Emit the post-install display-mode test

Post-install, the PWA detects its installed state via the display-mode MQ. Playwright doesn't auto-simulate installation; launch with --app= for the standalone path:

import { chromium, expect, test } from '@playwright/test';

// Launch the standalone app context once - shared by both runtime cells.
async function launchInstalled() {
  const ctx = await chromium.launchPersistentContext('./tmp/installed-app', {
    args: ['--app=https://localhost:3000/'],
  });
  const page = await ctx.newPage();
  await page.goto('https://localhost:3000/');
  return { ctx, page };
}

test('display-mode: standalone in launched-as-app context', async () => {
  const { ctx, page } = await launchInstalled();
  const standalone = await page.evaluate(() =>
    matchMedia('(display-mode: standalone)').matches
  );
  expect(standalone).toBe(true);
  await ctx.close();
});

test('display-mode: hides Install button when standalone', async () => {
  const { ctx, page } = await launchInstalled();
  // Per references/install-flow-reference.md Stage 4: apps hide the Install button when already installed
  await expect(page.locator('[data-testid="install-pwa"]')).not.toBeVisible();
  await ctx.close();
});

Step 7 - Emit the coverage matrix

Write tests/install-coverage.yaml, mapping every gate, handshake, iOS-metadata, and runtime cell to its spec and source criterion. The full matrix is in references/install-suite.md; its per-stage shape:

# tests/install-coverage.yaml
matrix:
  stage_1_gate:
    - cell: manifest_link
      spec: install-gate.spec.ts > "manifest link present"
      source: install-criteria
    # ... 7 more gate cells
  stage_2_handshake: # beforeinstallprompt userChoice + second-prompt reject
  stage_2_appinstalled: # appinstalled fires
  stage_3_ios: # apple-touch-icon + apple-mobile-web-app-capable
  stage_4_runtime: # display-mode standalone + install button hidden

CI gates on every matrix cell having a passing spec.

Worked example: a 14-cell install suite

For a PWA with manifest { name, short_name, display: 'standalone', start_url: '/', icons: [192, 512] }, SW at /sw.js, install button [data-testid="install-pwa"], and iOS support:

StageCells emitted
Stage 1 (gate)8 cells (Step 2)
Stage 2 (handshake)2 cells (Step 3)
Stage 2 (analytics)1 cell (Step 4)
Stage 3 (iOS)3 cells (Step 5)
Stage 4 (runtime)2 cells (Step 6)

Total: 16 cells across four spec files. Runs in ~45 seconds (dominated by the 31-second engagement-wait test cell). Catches the four classes of install regression most teams hit:

  1. Manifest field drift breaking the gate.
  2. prompt() called outside a user gesture.
  3. iOS icon omitted, install renders generic.
  4. In-app Install button visible after install (looks broken).

Anti-patterns

Anti-patternWhy it failsFix
One test asserting "install works"Per-cell regressions invisibleOne spec per gate cell (Step 2)
Skip the 30s engagement waitbeforeinstallprompt never fires; test falsely fails on a gate cellStep 3 explicit wait
Test prompt() on page loadBrowser blocks per customize-install (opens in new window); test never reaches userChoiceAlways bind to a user-gesture click
Pin a specific display valuePer install-criteria (opens in new window), four values are installabletoContain not toBe (Step 2)
Mock beforeinstallprompt with dispatchEvent(new Event())Real BeforeInstallPromptEvent has .prompt() + .userChoice methods; synthetic event lacks themHook a real listener in an init script
Skip iOS metadata tests because "we'll add it later"Existing PWAs lose iOS users silently when icon resolves to 404Always include Step 5
Assert display-mode: standalone from a normal Playwright pageA normal page is display-mode: browser; need --app= launchStep 6 launches with --app=
Assume appinstalled fires the same sessionReal installs may fire post-close; test plan must bind earlyBind listener at page load (Step 4)

Limitations

  • The 31-second engagement wait is real. Per install-criteria (opens in new window) "Users must click/tap the page at least once and spend minimum 30 seconds viewing it." The test cell from Step 3 sleeps the wait literally - slow tests by ~30s. Run only on release branches if PR-CI time pressure is high.
  • Headless WebAPK minting can't be asserted. Step 4's appinstalled test verifies the event fires; the actual Android WebAPK creation is opaque per references/install-flow-reference.md Stage 3.
  • iOS install is fully manual. Step 5's tests cover the metadata; the actual Share → Add to Home Screen flow needs a real device. Document the manual smoke step in the PR template.
  • --app= launch is Chromium-only. Firefox / WebKit cannot be driven into a display-mode: standalone context from headless Playwright; Step 6 is Chromium-only by design.
  • prefer_related_applications: true suppresses the gate per install-criteria (opens in new window); the Step 2 cell asserts the field is absent or false, but a project that intentionally sets it must skip this cell (and the install flow as a whole).
  • Test fixture needs hooks. Steps 3, 4 require window.__deferredPrompt and window.__triggerInstall to be surfaced by the page bundle in test mode. Document the hooks alongside the spec files.

References

PWA install flow - the reference contract

View source (opens in new window)

PWA install flow - the reference contract

Companion reference for add-to-homescreen-flow-tests. Consult when a beforeinstallprompt test is flaking and you need the contract to separate "gate not met" from "test setup wrong", or when you need the gate fields, the event contract, and the per-platform expectations in one place instead of re-reading three vendor docs.

The PWA install flow is a four-stage test surface: installability gate → install prompt handshake → platform-specific install path → post-install runtime signal. Each stage has a documented contract the test suite can assert against; the SKILL.md workflow emits one verification cell per contract row.

Stage 1 - Installability gate

Per install-criteria (opens in new window), a page becomes installable on Chromium when every cell below is satisfied. Failures are silent: the beforeinstallprompt event simply never fires. Tests must assert each cell independently to localize gate failures.

CellRequirementSource
Manifest name"short_name" or "name" presentinstall-criteria (opens in new window)
Manifest icons"icons" array must include both 192px and 512px iconsinstall-criteria (opens in new window)
Manifest start"start_url" presentinstall-criteria (opens in new window)
Manifest display"display" must be fullscreen, standalone, minimal-ui, or window-controls-overlayinstall-criteria (opens in new window)
Manifest related-apps gate"prefer_related_applications" must not be present or be falseinstall-criteria (opens in new window)
Transport"Be served over HTTPS"install-criteria (opens in new window)
User engagement"Users must click/tap the page at least once and spend minimum 30 seconds viewing it"install-criteria (opens in new window)
Not pre-installed"The web app is not already installed"install-criteria (opens in new window)

Per install-criteria (opens in new window), when every cell passes "Chrome fires the beforeinstallprompt event and displays an install promotion in the browser UI (address bar button or overflow menu)."

Per learn-pwa (opens in new window): "As a minimum requirement for installability, most browsers that support it use the Web App Manifest file and certain properties such as the name of the app, and configuration of the installed experience." Edge, Samsung Internet, and Opera follow the Chromium criteria; Firefox desktop does not implement beforeinstallprompt; Safari uses a manual flow (Stage 3).

Stage 2 - The beforeinstallprompt handshake

Per customize-install (opens in new window), the canonical lifecycle:

CallPurpose
event.preventDefault()"Prevent the mini-infobar from appearing on mobile"
Stash event referenceSave the deferred prompt for the app's own "Install" button
event.prompt()Show the prompt; must be called from a user-gesture handler. "You can only call prompt() on the deferred event once"
await event.userChoiceResolves to { outcome: 'accepted' | 'dismissed' }
appinstalled eventFires "whenever installation succeeds, regardless of the trigger mechanism" - covers both custom-button and browser-driven installs

The BeforeInstallPromptEvent instance also exposes a platforms property - the array of install targets the browser would offer (typically ['web'] on desktop Chromium); tests can assert against this to detect WebView vs full Chromium environments.

Stage 3 - Per-platform install path

The install path diverges by platform; tests must branch:

PlatformPathTriggerTest posture
Chromium desktop (Chrome, Edge, Brave)Install badge in URL bar; "Install" item in overflow menubeforeinstallprompt fires when Stage 1 passesDrive prompt() from a user-gesture click; assert userChoice.outcome and appinstalled
Android ChromeWebAPK minting (a real APK signed by Google Play services and registered with the launcher) per learn-pwa (opens in new window)beforeinstallprompt fires; user accepts via mini-infobar or app-driven promptSmoke on a real device farm; Playwright on Android Chrome works for the prompt itself but cannot assert WebAPK minting completion
Android Chrome (alternate)Shortcuts or QuickApp formats per learn-pwa (opens in new window)Same as WebAPK pathWebAPK is the canonical path; shortcut path is a fallback
iOS / iPadOS Safari"Open the Share menu... Click Add to Home Screen... Confirm the name of the app... Click Add" per learn-pwa (opens in new window)Manual user gesture only; no beforeinstallprompt eventTest the metadata (apple-touch-icon, apple-mobile-web-app-capable meta) statically; assert installed runtime via the display-mode MQ; the actual install step is manual smoke
Desktop SafariApp-driven install on macOS Sonoma+ via the "Add to Dock" Share menuManual user gesture onlySame posture as iOS - static metadata + post-install MQ
Firefox desktopInstall UI not exposedn/aNo beforeinstallprompt; no install assertion path

Per learn-pwa (opens in new window): iOS install "requires apple-touch-icon tag" - a test that omits this assertion misses a class of icon-missing install regressions invisible until a user files a bug.

Per-platform caveats:

  • WebAPK minting completion is opaque. The test surface ends at appinstalled; the minting is a background operation, not a DOM-observable event.
  • iOS Safari has no programmatic install API. iOS cells need manual smoke or device-farm Appium drivers; no headless path exists.

Stage 4 - Post-install runtime signal

After install, the running PWA detects its installed state via the display-mode media query. The query matches standalone, minimal-ui, fullscreen, or window-controls-overlay per the manifest's display field - the same values Stage 1 enumerates. Tests use this signal to:

  • Hide the in-app "Install" button (the prompt won't fire when already installed per Stage 1's "Not pre-installed" cell).
  • Branch analytics: a session in display-mode: standalone is a PWA-launch session.
  • Assert that a --app= launched Chromium instance reports standalone.

The signal can be polled (matchMedia('(display-mode: standalone)').matches) or observed (mql.addEventListener('change', ...)); both are fair game.

The full event timeline

1. Page loads.                                  (page load)
2. Service worker registers.                    (Stage 1 prerequisite)
3. User engagement reaches the threshold.       (Stage 1 prerequisite)
4. Manifest gate passes.                        (Stage 1)
5. browser fires beforeinstallprompt.           (Stage 2)
6. App calls event.preventDefault() + stashes.  (Stage 2)
7. User clicks the app's "Install" button.      (Stage 2 - user gesture)
8. App calls stashedEvent.prompt().             (Stage 2)
9. User accepts → userChoice resolves accepted. (Stage 2)
10. Browser installs (WebAPK / shortcut / desktop bundle).  (Stage 3)
11. Browser fires appinstalled.                 (Stage 2)
12. Next session: PWA launches in display-mode standalone.  (Stage 4)

A test plan covers each step with an assertion or a documented gap ("step 10 not assertable in headless").

Common test-setup anti-patterns

Anti-patternWhy it failsBetter posture
Calling prompt() without user gestureBrowser blocks; Stage 2 contract violated per customize-install (opens in new window)Always tie to a user click handler
Calling prompt() twice on the same event"You can only call prompt() on the deferred event once" per customize-install (opens in new window)Re-bind a fresh beforeinstallprompt listener for the next attempt
Asserting installability without 30s+ engagementStage 1 user-engagement cell fails silently per install-criteria (opens in new window)Simulate engagement (scroll, click) before asserting beforeinstallprompt
Asserting appinstalled then immediately closing the pageThe event may fire post-close; race on visibilityBind the listener at page load, not at click-time
Treating iOS the same as ChromiumStage 2 doesn't apply on Safari; the install path is manual per learn-pwa (opens in new window)Branch test paths; assert metadata + display-mode only
Manifest in subdirectory without scopestart_url outside scope invalidates the manifest gateSet scope to the parent path of start_url

Limitations

  • The 30-second engagement timer is browser-internal. Tests can only assert that beforeinstallprompt did fire after sufficient engagement, not the precise threshold.
  • prefer_related_applications: true suppresses install entirely per install-criteria (opens in new window); a manifest that also defines a native counterpart will assert installability incorrectly if this field is missed.
  • display-mode MQ does not survive deep-link launches in all browsers. Some launchers open the URL in the system browser; the MQ then reports browser, which is correct but easy to misread as "install regressed."

Sources

Generic install-flow test recipes

View source (opens in new window)

Generic install-flow test recipes

Reference for add-to-homescreen-flow-tests: standalone, copy-paste install-flow test recipes (manifest validation, beforeinstallprompt capture, appinstalled analytics, iOS metadata, display-mode MQ) usable without running the full per-PWA suite builder in the main skill.

Per the PWA installation guide (opens in new window), installability requires a Web App Manifest with display: standalone | minimal-ui, start_url, icons, and name - plus a registered service worker (most browsers) and HTTPS.

When to use

  • Pre-release: validate the manifest meets installability criteria on Chrome/Edge/Android.
  • Regression: ensure a manifest change didn't break install eligibility.
  • UX testing: the in-app install prompt fires when expected and records outcome for analytics.

Step 1 - Validate manifest fields

import { test, expect } from '@playwright/test';

test('manifest meets installability criteria', async ({ page, request }) => {
  await page.goto('https://localhost:3000');

  const manifestHref = await page.locator('link[rel="manifest"]').getAttribute('href');
  expect(manifestHref).toBeTruthy();

  const manifestUrl = new URL(manifestHref!, page.url()).toString();
  const manifest = await (await request.get(manifestUrl)).json();

  // Per https://web.dev/learn/pwa/installation requirements
  expect(manifest.name).toBeTruthy();
  expect(manifest.short_name).toBeTruthy();
  expect(['standalone', 'minimal-ui', 'fullscreen']).toContain(manifest.display);
  expect(manifest.start_url).toBeTruthy();

  // At least one icon ≥ 192x192 (PNG); Android WebAPK requires 512x512 maskable
  const has192 = manifest.icons?.some((i: any) => /(^|\s)192x192(\s|$)/.test(i.sizes ?? ''));
  const has512 = manifest.icons?.some((i: any) => /(^|\s)512x512(\s|$)/.test(i.sizes ?? ''));
  expect(has192 && has512).toBe(true);
});

Per the PWA installation guide (opens in new window): manifest fields drive desktop install badge + Android WebAPK minting + iOS home-screen icon.

Step 2 - Validate service worker registered

test('service worker registered (installability prerequisite)', async ({ page, context }) => {
  await page.goto('https://localhost:3000');

  let [sw] = context.serviceWorkers();
  if (!sw) sw = await context.waitForEvent('serviceworker');

  expect(sw.url()).toBeTruthy();
});

Cross-ref service-worker-lifecycle-tests for SW lifecycle testing.

Step 3 - Trigger and capture beforeinstallprompt

test('beforeinstallprompt fires; user accept resolves', async ({ page }) => {
  await page.goto('https://localhost:3000');

  const prompt = await page.evaluate(() => {
    return new Promise<{ platforms: string[] }>((resolve) => {
      window.addEventListener('beforeinstallprompt', (e: any) => {
        e.preventDefault();
        // Stash for app's "Install" button handler
        (window as any).__deferredPrompt = e;
        resolve({ platforms: e.platforms });
      });
    });
  });

  expect(prompt.platforms).toContain('web');

  // Click app's Install button → triggers stored prompt.prompt()
  await page.click('[data-testid="install-pwa"]');

  const outcome = await page.evaluate(async () => {
    const p = (window as any).__deferredPrompt;
    p.prompt();
    const choice = await p.userChoice;
    return choice.outcome; // 'accepted' | 'dismissed'
  });

  expect(['accepted', 'dismissed']).toContain(outcome);
});

Note: beforeinstallprompt only fires when Chromium's heuristics + Step 1 + Step 2 criteria pass + the user has not already installed. Test environments may need --enable-features=InstallPromptForApp.

Step 4 - Verify appinstalled event analytics

test('appinstalled fires after acceptance', async ({ page }) => {
  await page.goto('https://localhost:3000');
  // ... trigger prompt as Step 3 ...

  const installed = await page.evaluate(() => {
    return new Promise<boolean>((resolve) => {
      window.addEventListener('appinstalled', () => resolve(true));
      // Wait up to 5s
      setTimeout(() => resolve(false), 5000);
    });
  });
  expect(installed).toBe(true);
});

Useful for analytics: increment install counter on this event.

Step 5 - iOS path (manual / advisory)

Per the PWA installation guide (opens in new window): iOS/iPadOS requires manual install via Share menu → "Add to Home Screen". Cannot be triggered programmatically. Test by:

  • Manual smoke test on real iOS Safari + iPadOS Safari before each release.
  • Verify the manifest's apple-touch-icon link tag is present + the icon resolves.
test('iOS install metadata present', async ({ page }) => {
  await page.goto('https://localhost:3000');
  await expect(page.locator('link[rel="apple-touch-icon"]')).toHaveCount(1);
  await expect(page.locator('meta[name="apple-mobile-web-app-capable"][content="yes"]')).toHaveCount(1);
});

Step 6 - Display-mode media query test

After install, display mode shifts. Detect:

test('display-mode standalone after install', async ({ page }) => {
  // Simulate installed mode
  await page.emulateMedia({ media: 'screen', forcedColors: 'none' });
  // Playwright doesn't natively emulate display-mode; use launch arg:
  // chromium.launchPersistentContext(dir, { args: ['--app=https://localhost:3000'] })

  const isStandalone = await page.evaluate(() =>
    matchMedia('(display-mode: standalone)').matches
  );
  expect(isStandalone).toBe(true);
});

Apps often hide the "Install" button when already installed - check via display-mode: standalone MQ.

Anti-patterns

Anti-patternWhy it failsFix
Test install flow without a registered SWbeforeinstallprompt never firesStep 2 prerequisite
Manifest in subdir without scopestart_url outside scope; install fails silentlySet explicit scope matching start_url parent
Skip 512x512 maskable iconAndroid WebAPK minting failsStep 1 enforces both 192 + 512
Trigger prompt() automatically on page loadBrowser blocks; users hate itAlways require user gesture (Step 3 stores deferred prompt)
Test only on ChromiumiOS / Firefox install behavior differsStep 5 covers iOS metadata; manual smoke on each browser

Limitations

  • iOS install is fully manual; cannot be programmatically triggered or asserted on real devices via Playwright.
  • Chromium installability heuristics evolve; the PWA installation guide (opens in new window) is the authoritative source for current criteria.
  • Some embedded WebViews (Android WebView, iOS WKWebView in apps) do not expose install prompts at all.

References

Install-suite coverage matrix

View source (opens in new window)

Install-suite coverage matrix

Full tests/install-coverage.yaml for the Add-to-Home-Screen suite. Every matrix cell maps to a spec test plus the source criterion it verifies. CI gates on every cell having a passing spec.

# tests/install-coverage.yaml
matrix:
  stage_1_gate:
    - cell: manifest_link
      spec: install-gate.spec.ts > "manifest link present"
      source: install-criteria
    - cell: manifest_name
      spec: install-gate.spec.ts > "manifest declares name or short_name"
      source: install-criteria
    - cell: manifest_icons_192_512
      spec: install-gate.spec.ts > "manifest icons include 192px and 512px"
      source: install-criteria
    - cell: manifest_start_url
      spec: install-gate.spec.ts > "manifest start_url present"
      source: install-criteria
    - cell: manifest_display
      spec: install-gate.spec.ts > "manifest display is installable value"
      source: install-criteria
    - cell: prefer_related_applications_false
      spec: install-gate.spec.ts > "manifest does not opt out via prefer_related_applications"
      source: install-criteria
    - cell: https
      spec: install-gate.spec.ts > "site served over HTTPS"
      source: install-criteria
    - cell: service_worker_registered
      spec: install-gate.spec.ts > "service worker is registered (install prerequisite)"
      source: install-criteria
  stage_2_handshake:
    - cell: beforeinstallprompt_userChoice
      spec: install-prompt.spec.ts > "beforeinstallprompt: deferred prompt + click -> userChoice resolves"
      source: customize-install
    - cell: prompt_second_call_rejects
      spec: install-prompt.spec.ts > "beforeinstallprompt: second prompt() call rejects"
      source: customize-install "You can only call prompt() on the deferred event once"
  stage_2_appinstalled:
    - cell: appinstalled_fires
      spec: install-prompt.spec.ts > "appinstalled fires after install acceptance"
      source: customize-install
  stage_3_ios:
    - cell: apple_touch_icon_present
      spec: install-ios.spec.ts > "iOS install metadata: apple-touch-icon present"
      source: learn-pwa
    - cell: apple_mobile_web_app_capable
      spec: install-ios.spec.ts > "iOS install metadata: apple-mobile-web-app-capable yes"
      source: learn-pwa
    - cell: apple_touch_icon_resolves
      spec: install-ios.spec.ts > "iOS install metadata: apple-touch-icon resolves"
      source: learn-pwa
  stage_4_runtime:
    - cell: display_mode_standalone
      spec: install-display-mode.spec.ts > "display-mode: standalone in launched-as-app context"
      source: references/install-flow-reference.md Stage 4
    - cell: install_button_hidden_when_standalone
      spec: install-display-mode.spec.ts > "display-mode: hides Install button when standalone"
      source: references/install-flow-reference.md Stage 4

Source references:

  • install-criteria: https://web.dev/articles/install-criteria
  • customize-install: https://web.dev/articles/customize-install
  • learn-pwa: https://web.dev/learn/pwa/installation

Related skills

offline-fallback-tests

Build-an-X workflow that emits the offline-fallback test suite. Walks the eight Jake Archibald offline recipes per [web.dev/articles/offline-cookbook][off-cookbook] (`Cache only`, `Network only`, `Cache, falling back to network`, `Cache and network race`, `Network falling back to cache`, `Cache then network`, `Generic fallback`, `Service Worker side templating`), maps each recipe to its assertion shape, layers the Workbox `offlineFallback()` recipe per [developer.chrome.com/docs/workbox/modules/workbox-recipes][wb-recipes], and pins the offline storage strategy (Cache Storage vs IndexedDB vs Storage Manager `persist()`/`estimate()`) per [web.dev/learn/pwa/offline-data][off-data]. Output: a Playwright spec file with one test per route's chosen recipe + a coverage matrix mapping recipes to URL patterns. Use when a route's SW caching behavior is being chosen or changed, or when a "doesn't work offline" report needs the failing route's recipe localized.

service-worker-lifecycle-tests

Build-an-X workflow that emits per-SW state-transition tests covering the six `ServiceWorkerState` values per [w3c-github-io/ServiceWorker][sw-spec] (`parsed → installing → installed → activating → activated → redundant`), the `install` / `activate` / `fetch` event handlers per [MDN Service Worker API][mdn-sw], `event.waitUntil()` lifetime extension, `ServiceWorkerGlobalScope.skipWaiting()` and `Clients.claim()` upgrade-path semantics, the `statechange` event on `ServiceWorker` objects, `ServiceWorkerRegistration.update()`, and `navigator.serviceWorker.controller` checks. Output: a Playwright spec file with one test per transition plus a clean upgrade-path test (v1 active → v2 installed/waiting → v2 activated, with claim()). The general Playwright SW harness, `service-worker-mock` unit tests, and cache-strategy assertions live in references/. Use when authoring the baseline lifecycle spec, when a deploy leaves users stuck on the old service worker, or when a site's caching / offline behavior is uncovered.

web-push-tests

Test the browser web-push subscription lifecycle - `pushManager.subscribe({ userVisibleOnly, applicationServerKey })` per [W3C Push API][w3c-push] returning a `PushSubscription` with `endpoint` + `keys.p256dh` + `keys.auth` + optional `expirationTime`; the `pushsubscriptionchange` service-worker event on refresh / revoke / expiry; the `push` event delivery with `PushMessageData`; VAPID auth per RFC 8292 (ES256 JWT, `aud` / `exp` ≤ 24h / `sub`); RFC 8030 push-service responses (201 Created, 410 Gone for expired endpoints, 413 Payload Too Large, 429); and `unsubscribe()` cleanup. Use when a PWA ships web-push and the subscription lifecycle needs release-gate coverage - scoped to the browser Push API, not to cross-channel delivery over native APNs / FCM.

workbox-tests

Test Workbox-built service workers - pin behavior of the named recipes (`pageCache`, `staticResourceCache`, `imageCache`, `googleFontsCache`, `offlineFallback`, `warmStrategyCache`) per [developer.chrome.com/docs/workbox/modules/workbox-recipes][wb-recipes]; validate `workbox-precaching` manifest injection (`__WB_MANIFEST` revisioning); assert `workbox-routing` route handler matches; assert `workbox-expiration` and `workbox-cacheable-response` plugin gates; and verify the `workbox-window` registration helper events (`installed`, `waiting`, `controlling`, `activated`). Cache-strategy design (which strategy per route type, TTL + version-bump invalidation, migrating a hand-rolled sw.js to Workbox) lives in references/cache-strategy-design.md. Use when a project ships (or is adopting) a Workbox service worker and its strategy choices or recipe behavior need pinning against refactors or a version upgrade.