CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/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.

74

Quality

93%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files
name:
pwa-install-flow-tests
description:
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.
metadata:
{"keywords":"pwa, web-app-manifest, beforeinstallprompt, installability, service-worker"}

pwa-install-flow-tests

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.

When to use

  • Pre-release: validate the manifest meets installability criteria on Chrome/Edge/Android.
  • Regression: ensure a manifest change didn't break install eligibility.
  • UX testing: the in-app install prompt fires when expected and records outcome for analytics.

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: 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: iOS/iPadOS requires manual install via Share menu → "Add to Home Screen". Cannot be triggered programmatically. Test by:

  • Manual smoke test on real iOS Safari + iPadOS Safari before each release.
  • Verify the manifest's 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);
});

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-patternWhy it failsFix
Test install flow without a registered SWbeforeinstallprompt never firesStep 2 prerequisite
Manifest in subdir without scopestart_url outside scope; install fails silentlySet explicit scope matching start_url parent
Skip 512x512 maskable iconAndroid WebAPK minting failsStep 1 enforces both 192 + 512
Trigger prompt() automatically on page loadBrowser blocks; users hate itAlways require user gesture (Step 3 stores deferred prompt)
Test only on ChromiumiOS / Firefox install behavior differsStep 5 covers iOS metadata; manual smoke on each browser

Limitations

  • iOS install is fully manual; cannot be programmatically triggered or asserted on real devices via Playwright.
  • Chromium installability heuristics evolve; the PWA installation guide is the authoritative source for current criteria.
  • Some embedded WebViews (Android WebView, iOS WKWebView in apps) do not expose install prompts at all.

References

Workspace
testland
Visibility
Public
Created
Last updated
Publish Source
GitHub
Badge
testland/pwa-install-flow-tests badge