mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
505 lines
14 KiB
Markdown
505 lines
14 KiB
Markdown
|
|
# Service Worker Testing
|
||
|
|
|
||
|
|
## Table of Contents
|
||
|
|
|
||
|
|
1. [Service Worker Basics](#service-worker-basics)
|
||
|
|
2. [Registration & Lifecycle](#registration--lifecycle)
|
||
|
|
3. [Cache Testing](#cache-testing)
|
||
|
|
4. [Offline Testing](#offline-testing)
|
||
|
|
5. [Push Notifications](#push-notifications)
|
||
|
|
6. [Background Sync](#background-sync)
|
||
|
|
|
||
|
|
## Service Worker Basics
|
||
|
|
|
||
|
|
### Waiting for Service Worker Registration
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("service worker registers", async ({ page }) => {
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Wait for SW to register
|
||
|
|
const swRegistered = await page.evaluate(async () => {
|
||
|
|
if (!("serviceWorker" in navigator)) return false;
|
||
|
|
|
||
|
|
const registration = await navigator.serviceWorker.ready;
|
||
|
|
return !!registration.active;
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(swRegistered).toBe(true);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Getting Service Worker State
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("check SW state", async ({ page }) => {
|
||
|
|
await page.goto("/");
|
||
|
|
|
||
|
|
const swState = await page.evaluate(async () => {
|
||
|
|
const registration = await navigator.serviceWorker.getRegistration();
|
||
|
|
if (!registration) return null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
installing: !!registration.installing,
|
||
|
|
waiting: !!registration.waiting,
|
||
|
|
active: !!registration.active,
|
||
|
|
scope: registration.scope,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(swState?.active).toBe(true);
|
||
|
|
expect(swState?.scope).toContain(page.url());
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Service Worker Context
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("access service worker", async ({ context, page }) => {
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Get all service workers in context
|
||
|
|
const workers = context.serviceWorkers();
|
||
|
|
|
||
|
|
// Wait for service worker if not yet available
|
||
|
|
if (workers.length === 0) {
|
||
|
|
await context.waitForEvent("serviceworker");
|
||
|
|
}
|
||
|
|
|
||
|
|
const sw = context.serviceWorkers()[0];
|
||
|
|
expect(sw.url()).toContain("sw.js");
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Registration & Lifecycle
|
||
|
|
|
||
|
|
### Testing SW Update Flow
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("service worker updates", async ({ page }) => {
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Check for update
|
||
|
|
const hasUpdate = await page.evaluate(async () => {
|
||
|
|
const registration = await navigator.serviceWorker.ready;
|
||
|
|
await registration.update();
|
||
|
|
|
||
|
|
return new Promise<boolean>((resolve) => {
|
||
|
|
if (registration.waiting) {
|
||
|
|
resolve(true);
|
||
|
|
} else {
|
||
|
|
registration.addEventListener("updatefound", () => {
|
||
|
|
resolve(true);
|
||
|
|
});
|
||
|
|
// Timeout if no update
|
||
|
|
setTimeout(() => resolve(false), 5000);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// If update found, test skip waiting flow
|
||
|
|
if (hasUpdate) {
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
const registration = await navigator.serviceWorker.ready;
|
||
|
|
registration.waiting?.postMessage({ type: "SKIP_WAITING" });
|
||
|
|
});
|
||
|
|
|
||
|
|
// Wait for controller change
|
||
|
|
await page.evaluate(() => {
|
||
|
|
return new Promise<void>((resolve) => {
|
||
|
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||
|
|
resolve();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing SW Installation
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("verify SW install event", async ({ context, page }) => {
|
||
|
|
// Listen for service worker before navigating
|
||
|
|
const swPromise = context.waitForEvent("serviceworker");
|
||
|
|
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
const sw = await swPromise;
|
||
|
|
|
||
|
|
// Evaluate in SW context
|
||
|
|
const swVersion = await sw.evaluate(() => {
|
||
|
|
// Access SW globals
|
||
|
|
return (self as any).SW_VERSION || "unknown";
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(swVersion).toBe("1.0.0");
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Unregistering Service Workers
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test.beforeEach(async ({ page }) => {
|
||
|
|
await page.goto("/");
|
||
|
|
|
||
|
|
// Unregister all service workers for clean state
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||
|
|
await Promise.all(registrations.map((r) => r.unregister()));
|
||
|
|
});
|
||
|
|
|
||
|
|
// Clear caches
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
const cacheNames = await caches.keys();
|
||
|
|
await Promise.all(cacheNames.map((name) => caches.delete(name)));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Cache Testing
|
||
|
|
|
||
|
|
### Verifying Cached Resources
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("assets are cached", async ({ page }) => {
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Wait for SW to cache assets
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
await navigator.serviceWorker.ready;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Check cache contents
|
||
|
|
const cachedUrls = await page.evaluate(async () => {
|
||
|
|
const cache = await caches.open("app-cache-v1");
|
||
|
|
const requests = await cache.keys();
|
||
|
|
return requests.map((r) => r.url);
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(cachedUrls).toContain(expect.stringContaining("/styles.css"));
|
||
|
|
expect(cachedUrls).toContain(expect.stringContaining("/app.js"));
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing Cache Strategies
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("cache-first strategy", async ({ page }) => {
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Wait for initial cache
|
||
|
|
await page.waitForFunction(async () => {
|
||
|
|
const cache = await caches.open("app-cache-v1");
|
||
|
|
const keys = await cache.keys();
|
||
|
|
return keys.length > 0;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Block network for cached resources
|
||
|
|
await page.route("**/styles.css", (route) => route.abort());
|
||
|
|
|
||
|
|
// Reload - should work from cache
|
||
|
|
await page.reload();
|
||
|
|
|
||
|
|
// Verify page still styled (CSS loaded from cache)
|
||
|
|
const hasStyles = await page.evaluate(() => {
|
||
|
|
const body = document.body;
|
||
|
|
const styles = window.getComputedStyle(body);
|
||
|
|
return styles.fontFamily !== ""; // Has custom font from CSS
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(hasStyles).toBe(true);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing Cache Updates
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("cache updates on new version", async ({ page }) => {
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Get initial cache
|
||
|
|
const initialCacheKeys = await page.evaluate(async () => {
|
||
|
|
const cache = await caches.open("app-cache-v1");
|
||
|
|
const keys = await cache.keys();
|
||
|
|
return keys.map((r) => r.url);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Simulate app update by mocking SW response
|
||
|
|
await page.route("**/sw.js", (route) => {
|
||
|
|
route.fulfill({
|
||
|
|
contentType: "application/javascript",
|
||
|
|
body: `
|
||
|
|
const VERSION = 'v2';
|
||
|
|
self.addEventListener('install', (e) => {
|
||
|
|
e.waitUntil(caches.open('app-cache-v2'));
|
||
|
|
self.skipWaiting();
|
||
|
|
});
|
||
|
|
`,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Trigger update
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
const reg = await navigator.serviceWorker.ready;
|
||
|
|
await reg.update();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Verify new cache exists
|
||
|
|
await page.waitForFunction(async () => {
|
||
|
|
return await caches.has("app-cache-v2");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Offline Testing
|
||
|
|
|
||
|
|
This section covers **offline-first apps (PWAs)** that are designed to work offline using service workers, caching, and background sync. For testing **unexpected network failures** (error recovery, graceful degradation), see [error-testing.md](error-testing.md#offline-testing).
|
||
|
|
|
||
|
|
### Simulating Offline Mode
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("app works offline", async ({ page, context }) => {
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Ensure SW is active and content cached
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
await navigator.serviceWorker.ready;
|
||
|
|
});
|
||
|
|
await page.waitForTimeout(1000); // Allow caching to complete
|
||
|
|
|
||
|
|
// Go offline
|
||
|
|
await context.setOffline(true);
|
||
|
|
|
||
|
|
// Navigate to cached page
|
||
|
|
await page.reload();
|
||
|
|
|
||
|
|
// Verify content loads
|
||
|
|
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||
|
|
|
||
|
|
// Verify offline indicator
|
||
|
|
await expect(page.locator(".offline-badge")).toBeVisible();
|
||
|
|
|
||
|
|
// Go back online
|
||
|
|
await context.setOffline(false);
|
||
|
|
await expect(page.locator(".offline-badge")).not.toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing Offline Fallback
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("shows offline page for uncached routes", async ({ page, context }) => {
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
await page.evaluate(() => navigator.serviceWorker.ready);
|
||
|
|
|
||
|
|
// Go offline
|
||
|
|
await context.setOffline(true);
|
||
|
|
|
||
|
|
// Navigate to uncached page
|
||
|
|
await page.goto("/uncached-page");
|
||
|
|
|
||
|
|
// Should show offline fallback
|
||
|
|
await expect(page.getByText("You are offline")).toBeVisible();
|
||
|
|
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing Offline Form Submission
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("queues form submission offline", async ({ page, context }) => {
|
||
|
|
await page.goto("/pwa-app/form");
|
||
|
|
|
||
|
|
// Go offline
|
||
|
|
await context.setOffline(true);
|
||
|
|
|
||
|
|
// Submit form
|
||
|
|
await page.getByLabel("Message").fill("Offline message");
|
||
|
|
await page.getByRole("button", { name: "Send" }).click();
|
||
|
|
|
||
|
|
// Should show queued status
|
||
|
|
await expect(page.getByText("Queued for sync")).toBeVisible();
|
||
|
|
|
||
|
|
// Go online
|
||
|
|
await context.setOffline(false);
|
||
|
|
|
||
|
|
// Trigger sync (or wait for automatic)
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
const reg = await navigator.serviceWorker.ready;
|
||
|
|
// Manually trigger sync for testing
|
||
|
|
await (reg as any).sync?.register("form-sync");
|
||
|
|
});
|
||
|
|
|
||
|
|
// Verify submission completed
|
||
|
|
await expect(page.getByText("Message sent")).toBeVisible({ timeout: 10000 });
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Push Notifications
|
||
|
|
|
||
|
|
### Mocking Push Subscription
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("handles push subscription", async ({ page, context }) => {
|
||
|
|
// Grant notification permission
|
||
|
|
await context.grantPermissions(["notifications"]);
|
||
|
|
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Subscribe to push
|
||
|
|
const subscription = await page.evaluate(async () => {
|
||
|
|
const reg = await navigator.serviceWorker.ready;
|
||
|
|
const sub = await reg.pushManager.subscribe({
|
||
|
|
userVisibleOnly: true,
|
||
|
|
applicationServerKey: "test-key",
|
||
|
|
});
|
||
|
|
return sub.toJSON();
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(subscription.endpoint).toBeDefined();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing Push Message Handling
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("handles push notification", async ({ context, page }) => {
|
||
|
|
await context.grantPermissions(["notifications"]);
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Wait for SW
|
||
|
|
const swPromise = context.waitForEvent("serviceworker");
|
||
|
|
const sw = await swPromise;
|
||
|
|
|
||
|
|
// Simulate push message to service worker
|
||
|
|
await sw.evaluate(async () => {
|
||
|
|
// Dispatch push event
|
||
|
|
const pushEvent = new PushEvent("push", {
|
||
|
|
data: new PushMessageData(
|
||
|
|
JSON.stringify({ title: "Test", body: "Push message" }),
|
||
|
|
),
|
||
|
|
});
|
||
|
|
self.dispatchEvent(pushEvent);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Note: Actual notification display testing is limited in Playwright
|
||
|
|
// Focus on verifying the SW handles the push correctly
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing Notification Click
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("notification click opens page", async ({ context, page }) => {
|
||
|
|
await context.grantPermissions(["notifications"]);
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Store notification URL target
|
||
|
|
let notificationUrl = "";
|
||
|
|
|
||
|
|
// Listen for new pages (notification click opens new page)
|
||
|
|
context.on("page", (newPage) => {
|
||
|
|
notificationUrl = newPage.url();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Trigger notification via SW
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
const reg = await navigator.serviceWorker.ready;
|
||
|
|
await reg.showNotification("Test", {
|
||
|
|
body: "Click me",
|
||
|
|
data: { url: "/notification-target" },
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Simulate clicking notification (via SW)
|
||
|
|
const sw = context.serviceWorkers()[0];
|
||
|
|
await sw.evaluate(() => {
|
||
|
|
self.dispatchEvent(
|
||
|
|
new NotificationEvent("notificationclick", {
|
||
|
|
notification: { data: { url: "/notification-target" } } as any,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Verify navigation occurred
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
// Check if new page opened or current page navigated
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Background Sync
|
||
|
|
|
||
|
|
### Testing Background Sync Registration
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("registers background sync", async ({ page }) => {
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Register sync
|
||
|
|
const syncRegistered = await page.evaluate(async () => {
|
||
|
|
const reg = await navigator.serviceWorker.ready;
|
||
|
|
if (!("sync" in reg)) return false;
|
||
|
|
|
||
|
|
await (reg as any).sync.register("my-sync");
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(syncRegistered).toBe(true);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing Sync Event
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
test("sync event fires when online", async ({ context, page }) => {
|
||
|
|
await page.goto("/pwa-app");
|
||
|
|
|
||
|
|
// Queue data while offline
|
||
|
|
await context.setOffline(true);
|
||
|
|
|
||
|
|
await page.evaluate(async () => {
|
||
|
|
// Store data in IndexedDB for sync
|
||
|
|
const db = await openDB();
|
||
|
|
await db.put("sync-queue", { id: 1, data: "test" });
|
||
|
|
|
||
|
|
// Register sync
|
||
|
|
const reg = await navigator.serviceWorker.ready;
|
||
|
|
await (reg as any).sync.register("data-sync");
|
||
|
|
});
|
||
|
|
|
||
|
|
// Track sync completion
|
||
|
|
await page.evaluate(() => {
|
||
|
|
window.syncCompleted = false;
|
||
|
|
navigator.serviceWorker.addEventListener("message", (e) => {
|
||
|
|
if (e.data.type === "SYNC_COMPLETE") {
|
||
|
|
window.syncCompleted = true;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Go online
|
||
|
|
await context.setOffline(false);
|
||
|
|
|
||
|
|
// Wait for sync to complete
|
||
|
|
await page.waitForFunction(() => window.syncCompleted, { timeout: 10000 });
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Anti-Patterns to Avoid
|
||
|
|
|
||
|
|
| Anti-Pattern | Problem | Solution |
|
||
|
|
| ------------------------------ | ----------------------- | -------------------------------------------- |
|
||
|
|
| Not clearing SW between tests | Tests affect each other | Unregister SW in beforeEach |
|
||
|
|
| Not waiting for SW ready | Race conditions | Always await `navigator.serviceWorker.ready` |
|
||
|
|
| Testing in isolation only | Misses real SW behavior | Test with actual caching |
|
||
|
|
| Hardcoded timeouts for caching | Flaky tests | Wait for cache to populate |
|
||
|
|
| Ignoring SW update cycle | Missing update bugs | Test install, activate, update flows |
|
||
|
|
|
||
|
|
## Related References
|
||
|
|
|
||
|
|
- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns
|
||
|
|
- **Browser APIs**: See [browser-apis.md](browser-apis.md) for permissions
|
||
|
|
- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for network interception
|
||
|
|
- **Browser Extensions**: See [browser-extensions.md](../testing-patterns/browser-extensions.md) for extension service worker patterns
|