mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
feat: add e2e test cursor skill
This commit is contained in:
parent
2e1b9b5582
commit
4ac994792b
45 changed files with 39848 additions and 0 deletions
524
.cursor/skills/playwright-testing/service-workers-and-pwa.md
Executable file
524
.cursor/skills/playwright-testing/service-workers-and-pwa.md
Executable file
|
|
@ -0,0 +1,524 @@
|
|||
# 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue