SurfSense/.cursor/skills/playwright-testing/service-workers-and-pwa.md
2026-05-04 13:54:13 +05:30

19 KiB
Executable file

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, core/assertions-and-waiting.md

Quick Reference

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

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

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

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

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

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

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

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

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

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

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

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

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