# Service Workers and PWA Testing

> **When to use**: When your application is a Progressive Web App (PWA) or uses service workers for offline support, caching, push notifications, or background sync.
> **Prerequisites**: [core/configuration.md](configuration.md), [core/assertions-and-waiting.md](assertions-and-waiting.md)

## Quick Reference

```typescript
// Service workers require a persistent context — use launchPersistentContext
const context = await chromium.launchPersistentContext(userDataDir, {
  baseURL: 'https://localhost:3000',
});

// Enable/disable offline mode
await context.setOffline(true);   // Go offline
await context.setOffline(false);  // Come back online

// Wait for service worker to register
const sw = await context.waitForEvent('serviceworker');
console.log('SW URL:', sw.url());
```

## Patterns

### Service Worker Registration

**Use when**: You need to verify that your service worker registers successfully, activates, and controls the page.
**Avoid when**: Your app does not use service workers.

Service workers require a persistent browser context because they are tied to a profile. The default `page` fixture uses a temporary context that does not persist service worker registrations across navigations reliably.

**TypeScript**
```typescript
import { test as base, expect, chromium, BrowserContext } from '@playwright/test';
import path from 'path';
import fs from 'fs';

// Create a fixture with a persistent context for SW testing
const test = base.extend<{ swContext: BrowserContext }>({
  swContext: async ({}, use) => {
    const userDataDir = path.join(__dirname, '.tmp-profile');
    const context = await chromium.launchPersistentContext(userDataDir, {
      baseURL: 'https://localhost:3000',
    });
    await use(context);
    await context.close();
    fs.rmSync(userDataDir, { recursive: true, force: true });
  },
});

test('service worker registers and activates', async ({ swContext }) => {
  const page = await swContext.newPage();

  // Listen for the service worker event
  const swPromise = swContext.waitForEvent('serviceworker');

  await page.goto('/');

  const sw = await swPromise;
  expect(sw.url()).toContain('service-worker.js');

  // Verify the SW is controlling the page
  const isControlled = await page.evaluate(() => {
    return navigator.serviceWorker.controller !== null;
  });
  expect(isControlled).toBe(true);
});
```

**JavaScript**
```javascript
const { test: base, expect, chromium } = require('@playwright/test');
const path = require('path');
const fs = require('fs');

const test = base.extend({
  swContext: async ({}, use) => {
    const userDataDir = path.join(__dirname, '.tmp-profile');
    const context = await chromium.launchPersistentContext(userDataDir, {
      baseURL: 'https://localhost:3000',
    });
    await use(context);
    await context.close();
    fs.rmSync(userDataDir, { recursive: true, force: true });
  },
});

test('service worker registers and activates', async ({ swContext }) => {
  const page = await swContext.newPage();
  const swPromise = swContext.waitForEvent('serviceworker');

  await page.goto('/');

  const sw = await swPromise;
  expect(sw.url()).toContain('service-worker.js');

  const isControlled = await page.evaluate(() => {
    return navigator.serviceWorker.controller !== null;
  });
  expect(isControlled).toBe(true);
});
```

### Offline Mode Testing

**Use when**: Your PWA should work offline -- serving cached pages, showing offline indicators, queuing data for sync.
**Avoid when**: Your app has no offline support.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('app serves cached page when offline', async ({ swContext }) => {
  const page = await swContext.newPage();

  // First visit — caches the page and assets
  await page.goto('/');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Wait for the service worker to finish caching
  await page.evaluate(async () => {
    const registration = await navigator.serviceWorker.ready;
    // Wait for the SW to be in the 'activated' state
    return registration.active?.state === 'activated';
  });

  // Go offline
  await swContext.setOffline(true);

  // Reload — should serve from cache
  await page.reload();
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Verify offline indicator appears
  await expect(page.getByText('You are offline')).toBeVisible();

  // Come back online
  await swContext.setOffline(false);
  await expect(page.getByText('You are offline')).toBeHidden();
});

test('form submission queues when offline', async ({ swContext }) => {
  const page = await swContext.newPage();
  await page.goto('/feedback');

  // Ensure SW is active
  await page.evaluate(() => navigator.serviceWorker.ready);

  // Go offline
  await swContext.setOffline(true);

  // Submit a form while offline
  await page.getByLabel('Feedback').fill('Great product!');
  await page.getByRole('button', { name: 'Submit' }).click();

  // App should show a "queued" message, not an error
  await expect(page.getByText('Saved offline. Will sync when connected.')).toBeVisible();

  // Come back online — the queued data should sync
  await swContext.setOffline(false);
  await expect(page.getByText('Feedback submitted successfully')).toBeVisible({ timeout: 10000 });
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('app serves cached page when offline', async ({ swContext }) => {
  const page = await swContext.newPage();

  await page.goto('/');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  await page.evaluate(async () => {
    const registration = await navigator.serviceWorker.ready;
    return registration.active?.state === 'activated';
  });

  await swContext.setOffline(true);
  await page.reload();
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await expect(page.getByText('You are offline')).toBeVisible();

  await swContext.setOffline(false);
  await expect(page.getByText('You are offline')).toBeHidden();
});
```

### Cache Verification

**Use when**: You need to confirm that specific resources are cached by the service worker.
**Avoid when**: You trust the framework's caching strategy and only need to verify the offline UX (test offline mode instead).

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('critical assets are cached by the service worker', async ({ swContext }) => {
  const page = await swContext.newPage();
  await page.goto('/');

  // Wait for SW to be ready
  await page.evaluate(() => navigator.serviceWorker.ready);

  // Check the cache contents
  const cachedUrls = await page.evaluate(async () => {
    const cacheNames = await caches.keys();
    const allUrls: string[] = [];
    for (const name of cacheNames) {
      const cache = await caches.open(name);
      const keys = await cache.keys();
      allUrls.push(...keys.map((req) => new URL(req.url).pathname));
    }
    return allUrls;
  });

  // Verify critical resources are cached
  expect(cachedUrls).toContain('/');
  expect(cachedUrls).toContain('/offline.html');
  expect(cachedUrls.some((url) => url.endsWith('.js'))).toBe(true);
  expect(cachedUrls.some((url) => url.endsWith('.css'))).toBe(true);
});

test('cache is invalidated after service worker update', async ({ swContext }) => {
  const page = await swContext.newPage();
  await page.goto('/');

  // Get initial cache version
  const initialCaches = await page.evaluate(() => caches.keys());

  // Simulate a new SW version by clearing and reloading
  await page.evaluate(async () => {
    const registration = await navigator.serviceWorker.getRegistration();
    await registration?.update();
  });

  // After update, old caches should be cleaned up
  await page.reload();
  await page.evaluate(() => navigator.serviceWorker.ready);

  const updatedCaches = await page.evaluate(() => caches.keys());

  // The app should manage cache names with versioning
  expect(updatedCaches.length).toBeGreaterThanOrEqual(1);
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('critical assets are cached by the service worker', async ({ swContext }) => {
  const page = await swContext.newPage();
  await page.goto('/');
  await page.evaluate(() => navigator.serviceWorker.ready);

  const cachedUrls = await page.evaluate(async () => {
    const cacheNames = await caches.keys();
    const allUrls = [];
    for (const name of cacheNames) {
      const cache = await caches.open(name);
      const keys = await cache.keys();
      allUrls.push(...keys.map((req) => new URL(req.url).pathname));
    }
    return allUrls;
  });

  expect(cachedUrls).toContain('/');
  expect(cachedUrls).toContain('/offline.html');
});
```

### PWA Install Prompt

**Use when**: You need to test the "Add to Home Screen" / PWA install flow.
**Avoid when**: Your app is not a PWA or does not handle the `beforeinstallprompt` event.

The `beforeinstallprompt` event cannot be synthetically triggered by Playwright. Instead, verify that your app captures and handles the event correctly.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('app captures install prompt and shows install button', async ({ swContext }) => {
  const page = await swContext.newPage();

  // Mock the beforeinstallprompt event
  await page.addInitScript(() => {
    let deferredPrompt: Event | null = null;

    // Simulate the browser firing beforeinstallprompt
    window.addEventListener('load', () => {
      const event = new Event('beforeinstallprompt');
      (event as any).preventDefault = () => {};
      (event as any).prompt = () => Promise.resolve();
      (event as any).userChoice = Promise.resolve({ outcome: 'accepted' });

      window.dispatchEvent(event);
    });
  });

  await page.goto('/');

  // Your app should show an install button when it captures the prompt
  await expect(page.getByRole('button', { name: 'Install app' })).toBeVisible();
});

test('web app manifest is valid', async ({ swContext }) => {
  const page = await swContext.newPage();
  await page.goto('/');

  // Verify manifest link exists
  const manifestUrl = await page.evaluate(() => {
    const link = document.querySelector('link[rel="manifest"]') as HTMLLinkElement;
    return link?.href;
  });
  expect(manifestUrl).toBeTruthy();

  // Fetch and validate the manifest
  const response = await page.request.get(manifestUrl!);
  expect(response.ok()).toBe(true);

  const manifest = await response.json();
  expect(manifest.name).toBeTruthy();
  expect(manifest.icons).toHaveLength(expect.any(Number));
  expect(manifest.start_url).toBeTruthy();
  expect(manifest.display).toBe('standalone');
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('app captures install prompt and shows install button', async ({ swContext }) => {
  const page = await swContext.newPage();

  await page.addInitScript(() => {
    window.addEventListener('load', () => {
      const event = new Event('beforeinstallprompt');
      event.preventDefault = () => {};
      event.prompt = () => Promise.resolve();
      event.userChoice = Promise.resolve({ outcome: 'accepted' });
      window.dispatchEvent(event);
    });
  });

  await page.goto('/');
  await expect(page.getByRole('button', { name: 'Install app' })).toBeVisible();
});
```

### Push Notification Testing

**Use when**: Your PWA uses the Push API to receive push notifications from a server.
**Avoid when**: Notifications are handled entirely client-side without the Push API.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('push notification triggers UI update', async ({ swContext }) => {
  const page = await swContext.newPage();
  await page.goto('/');

  // Wait for SW to be ready
  const swURL = await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.ready;
    return reg.active?.scriptURL;
  });
  expect(swURL).toBeTruthy();

  // Get the service worker and evaluate inside it
  const sw = swContext.serviceWorkers()[0];

  // Simulate a push event inside the service worker
  await sw.evaluate(async () => {
    const event = new PushEvent('push', {
      data: new TextEncoder().encode(JSON.stringify({
        title: 'New Message',
        body: 'You have a new message from Alice',
        url: '/messages',
      })),
    });
    // @ts-ignore — dispatchEvent works in SW context
    self.dispatchEvent(event);
  });

  // Verify the app reacts (e.g., shows a badge or notification banner)
  await expect(page.getByTestId('notification-badge')).toHaveText('1');
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('push notification triggers UI update', async ({ swContext }) => {
  const page = await swContext.newPage();
  await page.goto('/');

  const swURL = await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.ready;
    return reg.active?.scriptURL;
  });
  expect(swURL).toBeTruthy();

  const sw = swContext.serviceWorkers()[0];

  await sw.evaluate(async () => {
    const event = new PushEvent('push', {
      data: new TextEncoder().encode(JSON.stringify({
        title: 'New Message',
        body: 'You have a new message from Alice',
        url: '/messages',
      })),
    });
    self.dispatchEvent(event);
  });

  await expect(page.getByTestId('notification-badge')).toHaveText('1');
});
```

### Background Sync Testing

**Use when**: Your app uses the Background Sync API to defer network requests until the user has connectivity.
**Avoid when**: Your app does not use background sync.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('background sync retries failed requests when online', async ({ swContext }) => {
  const page = await swContext.newPage();
  await page.goto('/tasks');

  // Ensure SW is active
  await page.evaluate(() => navigator.serviceWorker.ready);

  // Go offline
  await swContext.setOffline(true);

  // Create a task (will fail to sync)
  await page.getByLabel('New task').fill('Buy groceries');
  await page.getByRole('button', { name: 'Add' }).click();

  // Task shows as "pending sync"
  await expect(page.getByText('Buy groceries')).toBeVisible();
  await expect(page.getByTestId('sync-status')).toHaveText('Pending');

  // Come back online — background sync should fire
  await swContext.setOffline(false);

  // Wait for sync to complete
  await expect(page.getByTestId('sync-status')).toHaveText('Synced', { timeout: 15000 });
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('background sync retries failed requests when online', async ({ swContext }) => {
  const page = await swContext.newPage();
  await page.goto('/tasks');
  await page.evaluate(() => navigator.serviceWorker.ready);

  await swContext.setOffline(true);

  await page.getByLabel('New task').fill('Buy groceries');
  await page.getByRole('button', { name: 'Add' }).click();

  await expect(page.getByText('Buy groceries')).toBeVisible();
  await expect(page.getByTestId('sync-status')).toHaveText('Pending');

  await swContext.setOffline(false);
  await expect(page.getByTestId('sync-status')).toHaveText('Synced', { timeout: 15000 });
});
```

## Decision Guide

| Scenario | Approach | Why |
|---|---|---|
| Verify SW registers | `context.waitForEvent('serviceworker')` | Confirms the SW file was fetched and registered |
| Test offline page serving | `context.setOffline(true)` + reload | Simulates network loss at the browser level |
| Verify specific assets are cached | `page.evaluate(() => caches.keys())` | Directly queries the Cache API |
| Test install prompt | `addInitScript` to mock `beforeinstallprompt` | Browser does not fire this event in automation |
| Validate web manifest | `page.request.get(manifestUrl)` | Fetch and parse the JSON manifest |
| Test push notifications | `sw.evaluate()` to dispatch PushEvent | Simulates a push message inside the SW |
| Test background sync | Go offline, perform action, come back online | Verifies the sync queue processes correctly |
| Test SW update flow | `registration.update()` via `page.evaluate` | Triggers a manual SW update check |

## Anti-Patterns

| Don't Do This | Problem | Do This Instead |
|---|---|---|
| Test service workers with the default `page` fixture | Default context is temporary; SWs may not persist | Use `launchPersistentContext` for reliable SW testing |
| Skip `navigator.serviceWorker.ready` before going offline | SW may not have finished caching | Always `await page.evaluate(() => navigator.serviceWorker.ready)` first |
| Use `page.route()` to simulate offline | Only intercepts Playwright-visible requests; SW fetch events are not intercepted | Use `context.setOffline(true)` for true network-level offline |
| Test the SW JavaScript file directly with unit tests only | Misses integration with the page and caching behavior | Combine unit tests with Playwright end-to-end SW tests |
| Assume caches are clean at test start | Previous test runs may leave stale caches | Clear caches in fixture setup or use a fresh `userDataDir` |
| `waitForTimeout` after `setOffline(false)` | Background sync timing is unpredictable | Use `expect(...).toHaveText('Synced', { timeout: 15000 })` |

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `context.waitForEvent('serviceworker')` never resolves | SW already registered before listener attached | Register listener before `page.goto()`, or check `context.serviceWorkers()` |
| SW does not activate | Old SW is still controlling the page | Use `skipWaiting()` in the SW or clear the `userDataDir` |
| Offline test still loads fresh content | SW cache was not populated before going offline | Wait for `navigator.serviceWorker.ready` and verify cache contents first |
| `context.setOffline(true)` has no effect | Using a non-persistent context or the wrong context | Ensure offline is set on the same context that owns the page |
| Push event does nothing | SW is not the active controller | Ensure `sw.evaluate()` targets the correct service worker from `context.serviceWorkers()` |
| Tests interfere with each other | Shared `userDataDir` between tests | Use a unique temp directory per test, cleaned in fixture teardown |
| Service worker tests only work in Chromium | Firefox and WebKit have limited SW support in Playwright | Run SW tests in Chromium only via `test.skip` or project config |

## Related

- [core/browser-apis.md](browser-apis.md) -- IndexedDB and localStorage used alongside service workers
- [core/configuration.md](configuration.md) -- project-level config for PWA testing
- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- wrapping persistent context in fixtures
- [core/websockets-and-realtime.md](websockets-and-realtime.md) -- real-time features that interact with offline/online state
