CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/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.

72

Quality

90%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

install-flow-reference.mdreferences/

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

Per install-criteria, 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: "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, 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-pwabeforeinstallprompt 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-pwaSame 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-pwaManual 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: 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-installAlways 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-installRe-bind a fresh beforeinstallprompt listener for the next attempt
Asserting installability without 30s+ engagementStage 1 user-engagement cell fails silently per install-criteriaSimulate 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-pwaBranch 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; 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

  • web.dev - Install criteria (Stage 1 gate cells, engagement requirement) - install-criteria.
  • web.dev - Learn PWA: Installation (per-platform paths, WebAPK, iOS Share-menu flow, apple-touch-icon requirement) - learn-pwa.
  • web.dev - Customize the install experience (Stage 2 handshake) - customize-install.

SKILL.md

tile.json