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.
74
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
The PWA install flow is a four-stage test surface per
pwa-install-flow-reference:
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 to its assertion. Composes with the reference skill for the contract; consumes the contract by emitting verification cells.
Distinct from pwa-install-flow-tests (in the qa-modern-web plugin):
that skill authors install-flow tests as a generic wrapper.
This builder generates the per-PWA suite from the project's
actual manifest + SW + page handler - the artifact you check into
the repo, not the pattern reference.
Composes with:
pwa-install-flow-reference - the four-stage timeline this builder follows step-by-step.lighthouse-pwa-audit - the
installable-manifest and apple-touch-icon audits are the
Lighthouse counterpart of the Step 2 + Step 5 cells here.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.name, new icon, dropped display
field) - re-run the builder to catch criteria regressions.display-mode flips wrong).# 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 |
Per install-criteria, 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();
});
});beforeinstallprompt handshake testPer customize-install, 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.
Per customize-install: "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();
});appinstalled analytics testPer customize-install: 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.
Per learn-pwa, 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: iOS install "requires apple-touch-icon tag" -
omitting this means installed PWAs get a generic icon, a regression
invisible until users file a bug.
display-mode testPost-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 pwa-install-flow-reference Stage 4: apps hide the Install button when already installed
await expect(page.locator('[data-testid="install-pwa"]')).not.toBeVisible();
await ctx.close();
});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.
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:
prompt() called outside a user gesture.| 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; test never reaches userChoice | Always bind to a user-gesture click |
Pin a specific display value | Per install-criteria, 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) |
appinstalled test verifies the event fires; the actual Android
WebAPK creation is opaque per
pwa-install-flow-reference
Stage 3.--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; 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).window.__deferredPrompt and window.__triggerInstall to be
surfaced by the page bundle in test mode. Document the hooks
alongside the spec files.apple-touch-icon requirement) - learn-pwa.beforeinstallprompt
handshake, prompt() once-only, appinstalled firing
conditions) - customize-install.pwa-install-flow-reference,
lighthouse-pwa-audit,
service-worker-lifecycle-tests.pwa-install-flow-tests is the generic pattern
wrapper. This builder emits the
per-PWA suite tied to the project's actual manifest /
handlers - the checked-in artifact, not the pattern reference.offline-fallback-tests,
service-worker-lifecycle-tests.