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
90%
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
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 |