mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
525 lines
19 KiB
Markdown
525 lines
19 KiB
Markdown
|
|
# 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
|