Skip to content
Back to skills

Add To Homescreen Flow Tests

ASecurity

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 defe...

  • 10 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 12, 2026
testinggobash

Works with

  • cli

Security analysis

A100/100

Pro scans all 5 files and shows the line behind each finding

Scanned October 12, 2026

npx -y skills add testland/qa --skill add-to-homescreen-flow-tests --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Add To Homescreen Flow Tests?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Add To Homescreen Flow Tests
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/testland-add-to-homescreen-flow-tests/badge)](https://www.skillsdirectory.com/skills/testland-add-to-homescreen-flow-tests)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: add-to-homescreen-flow-tests
description: "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."
metadata:
  keywords: "add-to-homescreen, beforeinstallprompt, appinstalled, display-mode, apple-touch-icon"
  version: "1.2.7"
---

# add-to-homescreen-flow-tests

## Overview

The PWA install flow is a four-stage test surface per
[references/install-flow-reference.md](references/install-flow-reference.md):
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.
The contract itself - gate cells, event lifecycle, per-platform
paths, and the full event timeline - lives in
[references/install-flow-reference.md](references/install-flow-reference.md);
the workflow consumes it by emitting verification cells.

[install-criteria]: https://web.dev/articles/install-criteria
[learn-pwa]: https://web.dev/learn/pwa/installation
[customize-install]: https://web.dev/articles/customize-install

This builder generates the *per-PWA* suite from the project's
actual manifest + SW + page handler - the artifact you check into
the repo. Standalone generic recipes (single-test manifest
validation, prompt capture, iOS metadata, `display-mode` MQ) are in
[references/install-flow-tests.md](references/install-flow-tests.md).

Composes with:

- [references/install-flow-reference.md](references/install-flow-reference.md) - the four-stage timeline this builder follows step-by-step.
- `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.

## When to use

- New PWA, pre-launch - generate the install suite alongside the
  manifest.
- Manifest changed (renamed `name`, new icon, dropped `display`
  field) - re-run the builder to catch criteria regressions.
- Install conversion is dropping in analytics - author the
  per-stage cells to localize where users fall out (gate fails →
  prompt never fires; prompt fires → user dismisses; install
  succeeds but `display-mode` flips wrong).
- Adding iOS support to a Chromium-first PWA - author the manual-
  metadata branch.

## Workflow

### Step 1 - Read the manifest + page handler

```bash
# Inventory
cat public/manifest.webmanifest > tests/install-snapshot/manifest.json
grep -E "beforeinstallprompt|appinstalled" src/ -rn > tests/install-snapshot/handlers.txt
```

Capture 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 |

### Step 2 - Emit the gate-cell tests

Per [install-criteria], every gate cell is independently
assertable. Emit one test per cell:

```ts
// 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();
  });
});
```

### Step 3 - Emit the `beforeinstallprompt` handshake test

Per [customize-install], the canonical lifecycle is
`preventDefault` → stash → `prompt()` on user gesture →
`userChoice`. Emit the test:

```ts
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:

```ts
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();
});
```

### Step 4 - Emit the `appinstalled` analytics test

Per [customize-install]: `appinstalled` "fires whenever
installation succeeds, regardless of the trigger mechanism." Test
the listener binds and fires:

```ts
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.

### Step 5 - Emit the iOS-metadata branch

Per [learn-pwa], iOS Safari does not implement
`beforeinstallprompt`. The test surface is metadata + manual
smoke:

```ts
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.

### Step 6 - Emit the post-install `display-mode` test

Post-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:

```ts
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 references/install-flow-reference.md Stage 4: apps hide the Install button when already installed
  await expect(page.locator('[data-testid="install-pwa"]')).not.toBeVisible();
  await ctx.close();
});
```

### Step 7 - Emit the coverage matrix

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](references/install-suite.md); its
per-stage shape:

```yaml
# 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 hidden
```

CI gates on every matrix cell having a passing spec.

## Worked example: a 14-cell install suite

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:

1. Manifest field drift breaking the gate.
2. `prompt()` called outside a user gesture.
3. iOS icon omitted, install renders generic.
4. In-app Install button visible after install (looks broken).

## Anti-patterns

| 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) |

## Limitations

- **The 31-second engagement wait is real.** Per [install-criteria]
  "Users must click/tap the page at least once and spend minimum 30
  seconds viewing it." The test cell from Step 3 sleeps the wait
  literally - slow tests by ~30s. Run only on release branches if
  PR-CI time pressure is high.
- **Headless WebAPK minting can't be asserted.** Step 4's
  `appinstalled` test verifies the event fires; the actual Android
  WebAPK creation is opaque per
  [references/install-flow-reference.md](references/install-flow-reference.md)
  Stage 3.
- **iOS install is fully manual.** Step 5's tests cover the
  metadata; the actual Share → Add to Home Screen flow needs a
  real device. Document the manual smoke step in the PR template.
- **`--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).
- **Test fixture needs hooks.** Steps 3, 4 require
  `window.__deferredPrompt` and `window.__triggerInstall` to be
  surfaced by the page bundle in test mode. Document the hooks
  alongside the spec files.

## References

- web.dev - Install criteria (every gate cell, including HTTPS, SW,
  manifest fields, and the 30s engagement requirement) - 
  [install-criteria].
- web.dev - Learn PWA: Installation (per-platform flow, iOS Share
  menu path, `apple-touch-icon` requirement) - [learn-pwa].
- web.dev - Customize the install experience (`beforeinstallprompt`
  handshake, `prompt()` once-only, `appinstalled` firing
  conditions) - [customize-install].
- The install-flow contract (gate cells, handshake, per-platform paths,
  event timeline) -
  [references/install-flow-reference.md](references/install-flow-reference.md).
- Composes:
  `service-worker-lifecycle-tests`.
- Generic standalone install-flow test recipes -
  [references/install-flow-tests.md](references/install-flow-tests.md).
- Sibling builders:
  `offline-fallback-tests`,
  `service-worker-lifecycle-tests`.

Files in this skill

  • .tessl-plugin/plugin.json1.1 KB
  • SKILL.md17.2 KB
  • references/install-flow-reference.md9.1 KB
  • references/install-flow-tests.md6.6 KB
  • references/install-suite.md3 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.