pwa-install-flow-tests
Test the Progressive Web App install flow (Web App Manifest validation, `beforeinstallprompt` event handling, installability criteria, install prompt UX). Covers desktop install badge, Android WebAPK minting, iOS Add to Home Screen, and the `appinstalled` event. Use when a site ships a `link rel="manifest"` and users must be able to install it - especially after a manifest, icon, or `start_url` edit that could silently drop install eligibility.
Install with skills.sh (any agent)
npx skills add testland/qa --skill pwa-install-flow-testspwa-install-flow-tests
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-tests skill 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
Related skills
browser-extension-tests
Test Chromium browser extensions (MV3) with Playwright via `launchPersistentContext` + `--load-extension` / `--disable-extensions-except` flags. Cover service worker, popup pages, content scripts, message passing, and `chrome.runtime` API mocking. Service worker auto-suspends ~30s; Playwright keeps the Worker object alive across restarts. Use when a repo builds a Chromium extension (a `manifest.json` with `manifest_version: 3` and a built `dist/`) and its popup, content-script injection, background worker, or `chrome.storage` behavior needs automated coverage.
service-worker-tests
Test service workers with Playwright (`context.serviceWorkers()` + `waitForEvent('serviceworker')`) and unit tests via `service-worker-mock`. Covers the MV3 service-worker lifecycle (~30s suspend), cache strategies (cache-first, network-first, stale-while-revalidate), and `evaluate()` continuity across worker restart. Use when a site registers a service worker and its caching / offline behavior is uncovered, or users report stale content surviving a deploy; for the install / add-to-homescreen flow use pwa-install-flow-tests, and to design (not test) the caching policy use sw-cache-strategy-author.
sw-cache-strategy-author
Author service worker cache strategies (cache-first, network-first, stale-while-revalidate, cache-only, network-only) per Workbox conventions, plus generate the matching Playwright assertions to lock the strategy in. Avoids the common "cached forever" pitfall by enforcing TTL + version-bump invalidation. Use when a new route needs defined offline behavior, when a hand-rolled `sw.js` is being moved to Workbox strategies, or when users report seeing stale content after a deploy.
web-vitals-inp-deep
Deep INP (Interaction to Next Paint) testing: decomposes input delay, processing duration, and presentation delay via the web-vitals/attribution build, asserts per-interaction INP budgets in Playwright using PerformanceObserver plus the web-vitals visibilitychange flush, and identifies long tasks blocking the main thread. Use when a page feels unresponsive while LCP and CLS are green, or to gate key interactions (form submit, modal open, route change) under an INP budget in CI. Covers interactions only: for service-worker cache-strategy latency use service-worker-tests.