# 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
