pwa-install-flow-reference
Pure reference for the PWA install flow as a test surface - the installability gate (manifest required fields per [install-criteria], registered service worker, HTTPS, ~30s user engagement), the `beforeinstallprompt` handshake (preventDefault → stash → prompt() on gesture → userChoice → appinstalled), and the `display-mode` post-install signal, with a how-to-use walkthrough and a worked Chromium install-flow assertion; per-platform paths (Android WebAPK, iOS Share menu, Firefox no-op) live in references/. Use when authoring or triaging install-flow assertions and you need the gate fields, the event contract, and the per-platform expectations in one place instead of re-reading three vendor docs.
Install with skills.sh (any agent)
npx skills add testland/qa --skill pwa-install-flow-referencepwa-install-flow-reference
Overview
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. This skill is the reference the per-stage builders (add-to-homescreen-flow-tests, web-push-tests) and the audit reader (lighthouse-pwa-audit) consult.
The body is tables + verbatim spec quotes, a how-to-use walkthrough, and one worked Chromium assertion. Builders consume it to emit tests without re-fetching the source pages. Per-platform divergences live in references/per-platform-install-paths.md.
When to use
How to use this reference
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.
| Cell | Requirement | Source |
|---|---|---|
| Manifest name | "short_name" or "name" present | install-criteria (opens in new window) |
| Manifest icons | "icons" array must include both 192px and 512px icons | install-criteria (opens in new window) |
| Manifest start | "start_url" present | install-criteria (opens in new window) |
| Manifest display | "display" must be fullscreen, standalone, minimal-ui, or window-controls-overlay | install-criteria (opens in new window) |
| Manifest related-apps gate | "prefer_related_applications" must not be present or be false | install-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 (see the per-platform reference).
Stage 2 - The beforeinstallprompt handshake
Per customize-install (opens in new window), the canonical four-call lifecycle:
| Call | Purpose | Source |
|---|---|---|
event.preventDefault() | "Prevent the mini-infobar from appearing on mobile" | customize-install (opens in new window) |
Stash event reference | Save the deferred prompt for the app's own "Install" button | customize-install (opens in new window) |
event.prompt() | Show the prompt; must be called from a user-gesture handler. "You can only call prompt() on the deferred event once" per customize-install (opens in new window) | customize-install (opens in new window) |
await event.userChoice | Resolves to { outcome: 'accepted' | 'dismissed' } per customize-install (opens in new window) | customize-install (opens in new window) |
appinstalled event | Fires "whenever installation succeeds, regardless of the trigger mechanism" per customize-install (opens in new window) - covers both custom-button installs and browser-driven installs | customize-install (opens in new window) |
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: Chromium desktop and Android Chrome fire beforeinstallprompt; iOS / iPadOS Safari and desktop Safari use a manual Share-menu flow with no event; Firefox desktop exposes no install UI. The full per-platform table, WebAPK / Share-menu specifics, the apple-touch-icon requirement, and their testing caveats live in references/per-platform-install-paths.md.
Stage 4 - Post-install runtime signal
After install, the running PWA can detect 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:
The signal can be polled (matchMedia('(display-mode: standalone)').matches) or observed (mql.addEventListener('change', ...)); both are fair-game in tests.
The full event timeline
For a single user who installs and launches the PWA, the event sequence is:
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").
Worked example - a Chromium install-flow assertion
A Chromium-desktop smoke that walks steps 5-12 of the timeline above - capture the deferred event, drive prompt() from a gesture, assert the userChoice outcome and appinstalled, then the Stage 4 standalone signal. It uses only the event names the stages above define:
test('installs and reports standalone', async ({ page }) => {
// Stage 2: stash the deferred prompt the moment beforeinstallprompt fires.
await page.addInitScript(() => {
window.__deferred = null;
window.__installed = false;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // suppress the mini-infobar
window.__deferred = e; // save for the app's Install button
});
window.addEventListener('appinstalled', () => { window.__installed = true; });
});
await page.goto('https://app.example.com');
// Stage 1: simulate engagement; the wait only resolves once the gate passes.
await page.mouse.click(200, 200);
await page.waitForFunction(() => window.__deferred !== null);
// Stage 2: drive prompt() from the gesture and assert the userChoice outcome.
const outcome = await page.evaluate(async () => {
await window.__deferred.prompt(); // callable once per event
return (await window.__deferred.userChoice).outcome;
});
expect(outcome).toBe('accepted'); // 'accepted' | 'dismissed'
// Stage 2: the browser fired appinstalled.
await page.waitForFunction(() => window.__installed === true);
// Stage 4: the running PWA now reports installed state.
const standalone = await page.evaluate(
() => matchMedia('(display-mode: standalone)').matches
);
expect(standalone).toBe(true);
});Common test-setup anti-patterns
| Anti-pattern | Why it fails | Pointer to better posture |
|---|---|---|
Calling prompt() without user gesture | Browser 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 install attempt |
| Asserting installability without 30s+ engagement | Stage 1 user-engagement cell fails silently per install-criteria (opens in new window) | Page-load Playwright tests must simulate engagement (scroll, click) before asserting beforeinstallprompt |
Asserting appinstalled then immediately closing the page | The event may fire post-close; race on visibility | Bind the listener at page load, not at click-time |
| Treating iOS the same as Chromium | Stage 2 doesn't apply on Safari; the install path is manual per learn-pwa (opens in new window) | Branch test paths on userAgent or test plan; assert metadata + display-mode only |
Manifest in subdirectory without scope | start_url outside scope invalidates the manifest gate | Set scope to the parent path of start_url |
Limitations
References
Per-platform PWA install paths
View source (opens in new window)Per-platform PWA install paths
Deep reference for pwa-install-flow-reference SKILL.md (Stage 3). Consult when a test must branch across Chromium desktop, Android Chrome, iOS / iPadOS Safari, desktop Safari, or Firefox, or when asserting WebAPK / Share-menu install behavior.
Per-platform install path
The install path itself diverges by platform. Tests must branch:
| Platform | Path | Trigger | Test posture |
|---|---|---|---|
| Chromium desktop (Chrome, Edge, Brave) | Install badge in URL bar; "Install" item in overflow menu | beforeinstallprompt fires when Stage 1 passes | Drive prompt() from a user-gesture click; assert userChoice.outcome and appinstalled |
| Android Chrome | WebAPK 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 prompt | Smoke 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 path | WebAPK 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 event | Test the metadata (apple-touch-icon, apple-mobile-web-app-capable meta) statically; assert installed runtime via display-mode MQ (Stage 4); the actual install step is manual smoke |
| Desktop Safari | App-driven install on macOS Sonoma+ via the "Add to Dock" Share menu | Manual user gesture only | Same posture as iOS - static metadata + post-install MQ |
| Firefox desktop | Install UI not exposed | n/a | No 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 that are otherwise invisible until a user files a bug.
Per-platform testing caveats
Related skills
add-to-homescreen-flow-tests
Build-an-X workflow that emits the Add-to-Home-Screen / install-flow test suite. Walks the four-stage install timeline (gate → `beforeinstallprompt` handshake → per-platform path → `display-mode` MQ), emits 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`, `apple-mobile-web-app-capable`) per [web.dev/learn/pwa/installation][learn-pwa], and the post-install `(display-mode: standalone)` MQ assertion. Output: a Playwright spec file with per-stage cells plus the iOS metadata spec, plus a coverage matrix mapping each install criterion to its assertion. Use when a PWA's manifest, icons, or install handler change, or when install conversion drops and it is unclear which install stage users fall out at.
lighthouse-pwa-audit
Run and interpret Lighthouse PWA audits - even after the PWA *category* was deprecated per [developer.chrome.com/docs/lighthouse/pwa][lh-pwa], the individual audits (`installable-manifest`, `service-worker`, `splash-screen`, `themed-omnibox`, `viewport`, `content-width`, `apple-touch-icon`, `maskable-icon`) still run and report under a custom Lighthouse config or via direct audit invocation. Covers CLI flags (`--only-categories`, `--output`, `--form-factor`, `--throttling-method`), programmatic Node.js invocation, Lighthouse CI assertions (`categories:{id}`, `audit-id` thresholds), and LHR JSON parsing. Use when a manifest or icon change needs a precise installable-manifest verdict, or when CI must gate PRs on PWA audit scores despite the category badge being gone.
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()). Use when authoring the baseline lifecycle spec, or when a deploy leaves users stuck on the old service worker - scoped to the state machine and the `skipWaiting` / `clients.claim` upgrade path, not to general service-worker assertion or cache-strategy patterns.
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`). Use when a project already ships a Workbox-built service worker and its recipe behavior needs pinning against refactors or a version upgrade.