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-testsadd-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:
When to use
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.txtCapture for the test:
| Fact | Used in |
|---|---|
Manifest fields (name, short_name, display, start_url, icons sizes) | Step 2 |
Whether the page binds beforeinstallprompt | Step 3 |
| The selector of the in-app "Install" button | Step 3 |
Whether appinstalled is bound (analytics) | Step 4 |
Whether apple-touch-icon + apple-mobile-web-app-capable meta are present | Step 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 hiddenCI 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:
| Stage | Cells 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:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| One test asserting "install works" | Per-cell regressions invisible | One spec per gate cell (Step 2) |
| Skip the 30s engagement wait | beforeinstallprompt never fires; test falsely fails on a gate cell | Step 3 explicit wait |
Test prompt() on page load | Browser blocks per customize-install (opens in new window); test never reaches userChoice | Always bind to a user-gesture click |
Pin a specific display value | Per install-criteria (opens in new window), four values are installable | toContain not toBe (Step 2) |
Mock beforeinstallprompt with dispatchEvent(new Event()) | Real BeforeInstallPromptEvent has .prompt() + .userChoice methods; synthetic event lacks them | Hook 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 404 | Always include Step 5 |
Assert display-mode: standalone from a normal Playwright page | A normal page is display-mode: browser; need --app= launch | Step 6 launches with --app= |
Assume appinstalled fires the same session | Real installs may fire post-close; test plan must bind early | Bind listener at page load (Step 4) |
Limitations
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.
| 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 (Stage 3).
Stage 2 - The beforeinstallprompt handshake
Per customize-install (opens in new window), the canonical lifecycle:
| Call | Purpose |
|---|---|
event.preventDefault() | "Prevent the mini-infobar from appearing on mobile" |
Stash event reference | Save 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.userChoice | Resolves to { outcome: 'accepted' | 'dismissed' } |
appinstalled event | Fires "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:
| 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 the display-mode MQ; 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 invisible until a user files a bug.
Per-platform caveats:
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:
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-pattern | Why it fails | 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 attempt |
| Asserting installability without 30s+ engagement | Stage 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 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; 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
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
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:
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-pattern | Why it fails | Fix |
|---|---|---|
| Test install flow without a registered SW | beforeinstallprompt never fires | Step 2 prerequisite |
Manifest in subdir without scope | start_url outside scope; install fails silently | Set explicit scope matching start_url parent |
| Skip 512x512 maskable icon | Android WebAPK minting fails | Step 1 enforces both 192 + 512 |
Trigger prompt() automatically on page load | Browser blocks; users hate it | Always require user gesture (Step 3 stores deferred prompt) |
| Test only on Chromium | iOS / Firefox install behavior differs | Step 5 covers iOS metadata; manual smoke on each browser |
Limitations
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 4Source references:
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.