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.
68
85%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
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, 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.
outcome for analytics.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: manifest fields drive desktop install badge + Android WebAPK minting + iOS home-screen icon.
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.
beforeinstallprompttest('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.
appinstalled event analyticstest('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.
Per the PWA installation guide: iOS/iPadOS requires manual install via Share menu → "Add to Home Screen". Cannot be triggered programmatically. Test by:
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);
});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-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 |