From 0700f58ef113a647005e37ded3af597184aa5b23 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Fri, 16 Oct 2026 03:31:10 +0800 Subject: [PATCH 1/2] docs: fix broken internal reference links --- playwright-best-practices/advanced/network-advanced.md | 4 ++-- playwright-best-practices/browser-apis/service-workers.md | 4 ++-- playwright-best-practices/debugging/error-testing.md | 2 +- playwright-best-practices/debugging/flaky-tests.md | 2 +- playwright-best-practices/infrastructure-ci-cd/performance.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/playwright-best-practices/advanced/network-advanced.md b/playwright-best-practices/advanced/network-advanced.md index fa017fe..609e140 100644 --- a/playwright-best-practices/advanced/network-advanced.md +++ b/playwright-best-practices/advanced/network-advanced.md @@ -398,8 +398,8 @@ Use `context.setOffline(true/false)` to simulate network connectivity changes. > **For comprehensive offline testing patterns:** > -> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](error-testing.md#offline-testing) -> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](service-workers.md#offline-testing) +> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](../debugging/error-testing.md#offline-testing) +> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](../browser-apis/service-workers.md#offline-testing) ### Network Throttling Fixture diff --git a/playwright-best-practices/browser-apis/service-workers.md b/playwright-best-practices/browser-apis/service-workers.md index 7603de3..1255460 100644 --- a/playwright-best-practices/browser-apis/service-workers.md +++ b/playwright-best-practices/browser-apis/service-workers.md @@ -254,7 +254,7 @@ test("cache updates on new version", async ({ page }) => { ## 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). +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](../debugging/error-testing.md#offline-testing). ### Simulating Offline Mode @@ -498,7 +498,7 @@ test("sync event fires when online", async ({ context, page }) => { ## Related References -- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns +- **Network Failures**: See [error-testing.md](../debugging/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 diff --git a/playwright-best-practices/debugging/error-testing.md b/playwright-best-practices/debugging/error-testing.md index 43b17b6..fb48ae0 100644 --- a/playwright-best-practices/debugging/error-testing.md +++ b/playwright-best-practices/debugging/error-testing.md @@ -162,7 +162,7 @@ test("handles failure during request", async ({ page }) => { ## Offline Testing -This section covers **unexpected network failures** and error recovery. For **offline-first apps (PWAs)** with service workers, caching, and background sync, see [service-workers.md](service-workers.md#offline-testing). +This section covers **unexpected network failures** and error recovery. For **offline-first apps (PWAs)** with service workers, caching, and background sync, see [service-workers.md](../browser-apis/service-workers.md#offline-testing). ### Go Offline During Session diff --git a/playwright-best-practices/debugging/flaky-tests.md b/playwright-best-practices/debugging/flaky-tests.md index d081f21..0d640ec 100644 --- a/playwright-best-practices/debugging/flaky-tests.md +++ b/playwright-best-practices/debugging/flaky-tests.md @@ -199,7 +199,7 @@ await responsePromise; await expect(page.locator(".data-row")).toHaveCount(10); ``` -> **For comprehensive waiting strategies** (navigation, element state, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies). +> **For comprehensive waiting strategies** (navigation, element state, network, polling with `toPass()`), see [assertions-waiting.md](../core/assertions-waiting.md#waiting-strategies). **Problem: Complex async state** diff --git a/playwright-best-practices/infrastructure-ci-cd/performance.md b/playwright-best-practices/infrastructure-ci-cd/performance.md index bc03da8..e3a6293 100644 --- a/playwright-best-practices/infrastructure-ci-cd/performance.md +++ b/playwright-best-practices/infrastructure-ci-cd/performance.md @@ -128,7 +128,7 @@ jobs: Avoid logging in for every test. Use setup projects with storage state to authenticate once and reuse the session. -> **For authentication patterns** (storage state, multiple auth states, setup projects), see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). +> **For authentication patterns** (storage state, multiple auth states, setup projects), see [fixtures-hooks.md](../core/fixtures-hooks.md#authentication-patterns). ### Reuse Page State (serial only — trade-off with isolation) From 3475c9a4e1ef8b3764271ca7742d69b7e5c914bf Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Fri, 16 Oct 2026 03:57:20 +0800 Subject: [PATCH 2/2] docs: correct Playwright API examples and coverage workflow --- .../advanced/authentication.md | 2 +- .../advanced/mobile-testing.md | 135 +++-- .../browser-apis/browser-apis.md | 12 +- .../browser-apis/service-workers.md | 48 +- .../browser-apis/websockets.md | 66 +-- .../core/assertions-waiting.md | 2 +- .../core/configuration.md | 4 +- .../core/fixtures-hooks.md | 2 +- .../core/global-setup.md | 2 +- playwright-best-practices/core/locators.md | 2 +- playwright-best-practices/core/test-data.md | 4 +- .../frameworks/angular.md | 8 +- .../infrastructure-ci-cd/other-providers.md | 16 +- .../infrastructure-ci-cd/parallel-sharding.md | 4 +- .../infrastructure-ci-cd/reporting.md | 2 +- .../infrastructure-ci-cd/test-coverage.md | 465 ++++-------------- .../testing-patterns/browser-extensions.md | 43 +- .../testing-patterns/component-testing.md | 38 +- .../testing-patterns/drag-drop.md | 7 +- .../testing-patterns/file-upload-download.md | 11 +- .../testing-patterns/forms-validation.md | 4 +- .../testing-patterns/graphql-testing.md | 5 +- .../testing-patterns/visual-regression.md | 4 +- 23 files changed, 302 insertions(+), 584 deletions(-) diff --git a/playwright-best-practices/advanced/authentication.md b/playwright-best-practices/advanced/authentication.md index 02c2dd7..f38a3b8 100644 --- a/playwright-best-practices/advanced/authentication.md +++ b/playwright-best-practices/advanced/authentication.md @@ -58,7 +58,7 @@ async function generateAuthState() { await page.getByLabel("Username").fill("testuser@example.com"); await page.getByLabel("Password").fill("secretPass123"); await page.getByRole("button", { name: "Log in" }).click(); - await page.waitForURL("/home"); + await page.waitForURL("**/home"); await context.storageState({ path: ".auth/session.json" }); await browser.close(); diff --git a/playwright-best-practices/advanced/mobile-testing.md b/playwright-best-practices/advanced/mobile-testing.md index e928bde..f417f53 100644 --- a/playwright-best-practices/advanced/mobile-testing.md +++ b/playwright-best-practices/advanced/mobile-testing.md @@ -13,7 +13,7 @@ ### Use Built-in Devices ```typescript -import { test, devices } from "@playwright/test"; +import { test, devices, defineConfig } from "@playwright/test"; // Configure in playwright.config.ts export default defineConfig({ @@ -82,25 +82,44 @@ test("tap to interact", async ({ page }) => { ### Swipe -```typescript -test("swipe carousel", async ({ page }) => { - await page.goto("/carousel"); +`touchscreen.tap()` sends both touch start and touch end; a following mouse move does not continue a touch gesture. For apps that handle legacy touch events, dispatch the touch sequence explicitly. These synthetic events are untrusted and do not test native scrolling or apps that require trusted pointer input. - const carousel = page.getByTestId("carousel"); - const box = await carousel.boundingBox(); - - if (box) { - // Swipe left - await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2); - await page.mouse.move(box.x + 50, box.y + box.height / 2); +```typescript +// fixtures/touch.ts +import type { Locator } from "@playwright/test"; - // Or use drag - await carousel.dragTo(carousel, { - sourcePosition: { x: box.width - 50, y: box.height / 2 }, - targetPosition: { x: 50, y: box.height / 2 }, +export async function pan(element: Locator, deltaX: number, deltaY: number) { + const { x, y } = await element.evaluate((target) => { + const box = target.getBoundingClientRect(); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + }); + const touches = [{ identifier: 0, clientX: x, clientY: y }]; + await element.dispatchEvent("touchstart", { + touches, changedTouches: touches, targetTouches: touches, + }); + for (let i = 1; i <= 10; i++) { + const touches = [{ + identifier: 0, + clientX: x + deltaX * i / 10, + clientY: y + deltaY * i / 10, + }]; + await element.dispatchEvent("touchmove", { + touches, changedTouches: touches, targetTouches: touches, }); } + await element.dispatchEvent("touchend", { + touches: [], targetTouches: [], + changedTouches: [{ identifier: 0, clientX: x + deltaX, clientY: y + deltaY }], + }); +} +``` + +```typescript +import { pan } from "./fixtures/touch"; +test("swipe carousel", async ({ page }) => { + await page.goto("/carousel"); + await pan(page.getByTestId("carousel"), -100, 0); await expect(page.getByText("Slide 2")).toBeVisible(); }); ``` @@ -109,7 +128,8 @@ test("swipe carousel", async ({ page }) => { ```typescript // fixtures/touch.fixture.ts -import { test as base, Page } from "@playwright/test"; +import { test as base, type Locator } from "@playwright/test"; +import { pan } from "./touch"; type TouchFixtures = { swipe: ( @@ -119,44 +139,14 @@ type TouchFixtures = { }; export const test = base.extend({ - swipe: async ({ page }, use) => { + swipe: async ({}, use) => { await use(async (element, direction) => { - const box = await element.boundingBox(); - if (!box) throw new Error("Element not visible"); - - const centerX = box.x + box.width / 2; - const centerY = box.y + box.height / 2; - const distance = 100; - - const moves = { - left: { - startX: centerX + distance, - endX: centerX - distance, - y: centerY, - }, - right: { - startX: centerX - distance, - endX: centerX + distance, - y: centerY, - }, - up: { - startX: centerX, - endX: centerX, - startY: centerY + distance, - endY: centerY - distance, - }, - down: { - startX: centerX, - endX: centerX, - startY: centerY - distance, - endY: centerY + distance, - }, - }; - - const move = moves[direction]; - await page.touchscreen.tap(move.startX, move.startY ?? move.y); - await page.mouse.move(move.endX, move.endY ?? move.y, { steps: 10 }); - await page.mouse.up(); + const deltas = { + left: [-100, 0], right: [100, 0], + up: [0, -100], down: [0, 100], + } as const; + const [deltaX, deltaY] = deltas[direction]; + await pan(element, deltaX, deltaY); }); }, }); @@ -164,10 +154,7 @@ export const test = base.extend({ // Usage test("swipe to delete", async ({ page, swipe }) => { await page.goto("/inbox"); - - const message = page.getByTestId("message-1"); - await swipe(message, "left"); - + await swipe(page.getByTestId("message-1"), "left"); await expect(page.getByRole("button", { name: "Delete" })).toBeVisible(); }); ``` @@ -179,18 +166,20 @@ test("long press for context menu", async ({ page }) => { await page.goto("/files"); const file = page.getByText("document.pdf"); - const box = await file.boundingBox(); - - if (box) { - // Touch down - await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); - - // Hold for 500ms - await page.waitForTimeout(500); + const { x, y } = await file.evaluate((target) => { + const box = target.getBoundingClientRect(); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + }); + const touches = [{ identifier: 0, clientX: x, clientY: y }]; + await file.dispatchEvent("touchstart", { + touches, changedTouches: touches, targetTouches: touches, + }); - // Context menu should appear - await expect(page.getByRole("menu")).toBeVisible(); - } + // The app opens its menu while the touch remains down. + await expect(page.getByRole("menu")).toBeVisible(); + await file.dispatchEvent("touchend", { + touches: [], targetTouches: [], changedTouches: touches, + }); }); ``` @@ -323,13 +312,9 @@ test("pull to refresh", async ({ page }) => { const feed = page.getByTestId("feed"); const initialFirstItem = await feed.locator("> *").first().textContent(); - // Simulate pull down - const box = await feed.boundingBox(); - if (box) { - await page.touchscreen.tap(box.x + box.width / 2, box.y + 50); - await page.mouse.move(box.x + box.width / 2, box.y + 200, { steps: 20 }); - await page.mouse.up(); - } + // Use the pan helper above for an app-level touch-event refresh handler. + // This does not simulate the browser's native pull-to-refresh behavior. + await pan(feed, 0, 150); // Wait for refresh await expect(page.getByTestId("loading")).toBeVisible(); diff --git a/playwright-best-practices/browser-apis/browser-apis.md b/playwright-best-practices/browser-apis/browser-apis.md index cc4c269..bd717b1 100644 --- a/playwright-best-practices/browser-apis/browser-apis.md +++ b/playwright-best-practices/browser-apis/browser-apis.md @@ -156,16 +156,16 @@ test("video call with permissions", async ({ context }) => { // All permissions should be granted const permissions = await page.evaluate(async () => ({ - camera: await navigator.permissions.query({ + camera: (await navigator.permissions.query({ name: "camera" as PermissionName, - }), - microphone: await navigator.permissions.query({ + })).state, + microphone: (await navigator.permissions.query({ name: "microphone" as PermissionName, - }), + })).state, })); - expect(permissions.camera.state).toBe("granted"); - expect(permissions.microphone.state).toBe("granted"); + expect(permissions.camera).toBe("granted"); + expect(permissions.microphone).toBe("granted"); }); ``` diff --git a/playwright-best-practices/browser-apis/service-workers.md b/playwright-best-practices/browser-apis/service-workers.md index 1255460..5ca80be 100644 --- a/playwright-best-practices/browser-apis/service-workers.md +++ b/playwright-best-practices/browser-apis/service-workers.md @@ -225,19 +225,11 @@ test("cache updates on new version", async ({ page }) => { 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(); - }); - `, - }); - }); + // The local test server must serve the v2 worker at the same /sw.js URL. + // Implement this test-only endpoint in the fixture server; it switches the + // worker response to a version whose install handler creates app-cache-v2. + // Playwright routing cannot intercept the service worker's update script. + await page.request.post("/api/test/sw-version", { data: { version: "v2" } }); // Trigger update await page.evaluate(async () => { @@ -345,14 +337,20 @@ test("handles push subscription", async ({ page, context }) => { // Grant notification permission await context.grantPermissions(["notifications"]); + // Mock the subscription boundary; do not contact a real push service. + await page.addInitScript(() => { + PushManager.prototype.subscribe = async () => ({ + toJSON: () => ({ endpoint: "https://push.example.test/subscription" }), + }) as PushSubscription; + }); await page.goto("/pwa-app"); - // Subscribe to push + // Exercise the mock (not real VAPID key validation or push delivery) const subscription = await page.evaluate(async () => { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: "test-key", + // The mocked subscribe method does not validate a VAPID public key. }); return sub.toJSON(); }); @@ -368,17 +366,15 @@ 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; + // Registration may have happened during navigation. + const sw = context.serviceWorkers()[0] || + (await context.waitForEvent("serviceworker")); // 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" }), - ), + data: JSON.stringify({ title: "Test", body: "Push message" }), }); self.dispatchEvent(pushEvent); }); @@ -407,6 +403,7 @@ test("notification click opens page", async ({ context, page }) => { await page.evaluate(async () => { const reg = await navigator.serviceWorker.ready; await reg.showNotification("Test", { + tag: "test-notification", body: "Click me", data: { url: "/notification-target" }, }); @@ -414,11 +411,10 @@ test("notification click opens page", async ({ context, page }) => { // Simulate clicking notification (via SW) const sw = context.serviceWorkers()[0]; - await sw.evaluate(() => { + await sw.evaluate(async () => { + const [notification] = await self.registration.getNotifications({ tag: "test-notification" }); self.dispatchEvent( - new NotificationEvent("notificationclick", { - notification: { data: { url: "/notification-target" } } as any, - }), + new NotificationEvent("notificationclick", { notification }), ); }); @@ -482,7 +478,7 @@ test("sync event fires when online", async ({ context, page }) => { await context.setOffline(false); // Wait for sync to complete - await page.waitForFunction(() => window.syncCompleted, { timeout: 10000 }); + await page.waitForFunction(() => window.syncCompleted, undefined, { timeout: 10000 }); }); ``` diff --git a/playwright-best-practices/browser-apis/websockets.md b/playwright-best-practices/browser-apis/websockets.md index 075a997..0e7e747 100644 --- a/playwright-best-practices/browser-apis/websockets.md +++ b/playwright-best-practices/browser-apis/websockets.md @@ -110,29 +110,13 @@ test("displays incoming chat message", async ({ page }) => { ### Mock WebSocket with Route Handler ```typescript -test("mock websocket entirely", async ({ page, context }) => { - // Intercept the WebSocket upgrade - await context.route("**/ws/**", async (route) => { - // For WebSocket routes, we can't fulfill directly - // Instead, use page.evaluate to mock the client-side - }); - - // Alternative: Mock at application level - await page.addInitScript(() => { - const OriginalWebSocket = window.WebSocket; - (window as any).WebSocket = function (url: string) { - const ws = { - readyState: 1, - send: (data: string) => { - console.log("WS Send:", data); - }, - close: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - }; - setTimeout(() => ws.onopen?.(), 100); - return ws; - }; +test("mock websocket entirely", async ({ page }) => { + // Register before the app creates the socket. HTTP route() does not route + // WebSocket frames; routeWebSocket() is available since Playwright 1.48. + await page.routeWebSocket("**/ws/**", (ws) => { + ws.onMessage((message) => { + if (message === "ping") ws.send("pong"); + }); }); await page.goto("/chat"); @@ -143,7 +127,7 @@ test("mock websocket entirely", async ({ page, context }) => { ```typescript // fixtures/websocket.fixture.ts -import { test as base, Page } from "@playwright/test"; +import { test as base, type WebSocketRoute } from "@playwright/test"; type WsMessage = { type: string; [key: string]: any }; @@ -158,34 +142,22 @@ export const test = base.extend({ mockWebSocket: async ({ page }, use) => { const sentMessages: WsMessage[] = []; - // Capture sent messages - await page.addInitScript(() => { - (window as any).__wsSent = []; - const OriginalWebSocket = window.WebSocket; - window.WebSocket = function (url: string) { - const ws = new OriginalWebSocket(url); - const originalSend = ws.send.bind(ws); - ws.send = (data: string) => { - (window as any).__wsSent.push(JSON.parse(data)); - originalSend(data); - }; - (window as any).__ws = ws; - return ws; - } as any; + // This example app opens one JSON chat socket per page. + let resolveSocket!: (socket: WebSocketRoute) => void; + const connected = new Promise((resolve) => { + resolveSocket = resolve; + }); + await page.routeWebSocket("**/ws/**", (socket) => { + socket.onMessage((data) => sentMessages.push(JSON.parse(data.toString()))); + resolveSocket(socket); }); await use({ injectMessage: async (message) => { - await page.evaluate((msg) => { - const event = new MessageEvent("message", { - data: JSON.stringify(msg), - }); - (window as any).__ws?.dispatchEvent(event); - }, message); - }, - getSentMessages: async () => { - return page.evaluate(() => (window as any).__wsSent || []); + const socket = await connected; + socket.send(JSON.stringify(message)); }, + getSentMessages: async () => sentMessages, }); }, }); diff --git a/playwright-best-practices/core/assertions-waiting.md b/playwright-best-practices/core/assertions-waiting.md index bd03dd8..bac88cb 100644 --- a/playwright-best-practices/core/assertions-waiting.md +++ b/playwright-best-practices/core/assertions-waiting.md @@ -138,7 +138,7 @@ test("check form", async ({ page }) => { await expect.soft(page.getByRole("form")).toBeVisible(); // Exit early if form not visible (pointless to check fields) - if (expect.soft.hasFailures()) { + if (test.info().errors.length > 0) { return; } diff --git a/playwright-best-practices/core/configuration.md b/playwright-best-practices/core/configuration.md index 66b9d33..23899c2 100644 --- a/playwright-best-practices/core/configuration.md +++ b/playwright-best-practices/core/configuration.md @@ -15,7 +15,7 @@ ## CLI Quick Reference ```bash -npx playwright init # scaffold config + first test +npm init playwright@latest # scaffold config + first test npx playwright test --config=custom.config.ts # use alternate config npx playwright test --project=chromium # run single project npx playwright test --reporter=html # override reporter @@ -33,7 +33,7 @@ DEBUG=pw:api npx playwright test # verbose logging |---------|---------|---------|-------------| | Test takes too long overall | `timeout` | 30s | 30-60s (max 120s) | | Assertion retries too long/short | `expect.timeout` | 5s | 5-10s | -| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s | 10-30s | +| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 0 (unlimited, bounded by test timeout) | 10-30s | | `click()`, `fill()` time out | `actionTimeout` | 0 (unlimited) | 10-15s | | Dev server slow to start | `webServer.timeout` | 60s | 60-180s | diff --git a/playwright-best-practices/core/fixtures-hooks.md b/playwright-best-practices/core/fixtures-hooks.md index ff9dc93..7e6061d 100644 --- a/playwright-best-practices/core/fixtures-hooks.md +++ b/playwright-best-practices/core/fixtures-hooks.md @@ -111,7 +111,7 @@ export default defineConfig({ ### Automatic Fixtures ```typescript -export const test = base.extend<{}, { setupDb: void }>({ +export const test = base.extend<{ setupDb: void }>({ // Auto-fixture runs for every test without explicit usage setupDb: [ async ({}, use) => { diff --git a/playwright-best-practices/core/global-setup.md b/playwright-best-practices/core/global-setup.md index a033522..d1e60e8 100644 --- a/playwright-best-practices/core/global-setup.md +++ b/playwright-best-practices/core/global-setup.md @@ -398,7 +398,7 @@ Use **worker-scoped fixtures** instead of globalSetup when: | Scenario | Why Fixtures Are Better | | ------------------------------------ | ---------------------------------------------------- | | Each worker needs isolated resources | Fixtures can create per-worker databases, servers | -| Setup needs Playwright APIs | Fixtures have access to `page`, `request`, `browser` | +| Setup needs Playwright APIs | Worker fixtures can use `browser` and `playwright`; `page`, `context`, and `request` are test-scoped | | Setup depends on test configuration | Fixtures receive test context and options | | Resources need cleanup per worker | Worker fixtures auto-cleanup when worker exits | diff --git a/playwright-best-practices/core/locators.md b/playwright-best-practices/core/locators.md index f806635..ce70e5e 100644 --- a/playwright-best-practices/core/locators.md +++ b/playwright-best-practices/core/locators.md @@ -183,7 +183,7 @@ Playwright pierces shadow DOM by default: page.getByRole("button", { name: "Shadow Button" }); // Explicit shadow DOM traversal (if needed) -page.locator("my-component").locator("internal:shadow=button"); +page.locator("my-component").locator("button"); ``` ## Iframes diff --git a/playwright-best-practices/core/test-data.md b/playwright-best-practices/core/test-data.md index 1c07af8..4e5bb13 100644 --- a/playwright-best-practices/core/test-data.md +++ b/playwright-best-practices/core/test-data.md @@ -407,8 +407,6 @@ type SeedFixtures = { }; export const test = base.extend({ - cleanupUsers: [], - seedUser: async ({ request, cleanupUsers }, use) => { await use(async (overrides = {}) => { const userData = createUser(overrides); @@ -448,7 +446,7 @@ test("user profile page", async ({ page, seedUser }) => { ```typescript // fixtures/db.fixture.ts -export const test = base.extend<{}, { db: DbTransaction }>({ +export const test = base.extend<{ db: DbTransaction }>({ db: [ async ({}, use) => { const client = await pool.connect(); diff --git a/playwright-best-practices/frameworks/angular.md b/playwright-best-practices/frameworks/angular.md index ec09ecd..196efdf 100644 --- a/playwright-best-practices/frameworks/angular.md +++ b/playwright-best-practices/frameworks/angular.md @@ -49,7 +49,7 @@ export default defineConfig({ webServer: { command: process.env.CI - ? 'npx ng build && npx http-server dist/my-app/browser -p 4200 -s' + ? 'npx ng build && npx serve -s dist/my-app/browser -l 4200' : 'npx ng serve', url: 'http://localhost:4200', reuseExistingServer: !process.env.CI, @@ -493,11 +493,11 @@ test('no hydration errors', async ({ page }) => { | Scenario | Command | Notes | |---|---|---| | Local dev | `npx ng serve` | Fast rebuild, source maps | -| CI production | `npx ng build && npx http-server dist/app/browser -p 4200 -s` | Tests production bundle | +| CI production | `npx ng build && npx serve -s dist/app/browser -l 4200` | Tests production bundle with SPA fallback | | CI SSR | `npx ng build --ssr && node dist/app/server/server.mjs` | Tests server-side rendering | | Staging | No `webServer` | Point `baseURL` to staging URL | -The `-s` flag on `http-server` enables SPA fallback for Angular Router. +The `-s` flag on `serve` enables SPA fallback for Angular Router. In `http-server`, `-s` means silent logging, not SPA fallback. ## CDK Overlay Container @@ -520,7 +520,7 @@ const listbox = page.getByRole('listbox'); | `page.evaluate(() => window.ng)` | Not available in production builds | Test through the DOM | | `await page.waitForTimeout(500)` | Zone.js timing varies | Use auto-retrying assertions | | `browser.waitForAngular()` | Does not exist in Playwright | Remove entirely | -| `ng serve` in CI | Slower, includes debug code | Use `ng build && http-server` | +| `ng serve` in CI | Slower, includes debug code | Use `ng build` and serve the production bundle with SPA fallback | ## Related diff --git a/playwright-best-practices/infrastructure-ci-cd/other-providers.md b/playwright-best-practices/infrastructure-ci-cd/other-providers.md index ca80859..faf5c1d 100644 --- a/playwright-best-practices/infrastructure-ci-cd/other-providers.md +++ b/playwright-best-practices/infrastructure-ci-cd/other-providers.md @@ -413,11 +413,20 @@ stages: - task: DownloadPipelineArtifact@2 inputs: patterns: "blob-report-*/**" - path: all-blob-reports + path: downloaded-blob-reports displayName: "Download blob reports" + # Downloading multiple artifacts creates a subdirectory per artifact; + # merge-reports expects the ZIP files directly in its input directory. + - script: | + mkdir -p all-blob-reports + find downloaded-blob-reports -name '*.zip' -exec cp {} all-blob-reports/ \; + displayName: "Flatten blob reports" + - script: npx playwright merge-reports --reporter=html ./all-blob-reports displayName: "Merge reports" + env: + PLAYWRIGHT_HTML_OUTPUT_DIR: pw-report - task: PublishPipelineArtifact@1 inputs: @@ -428,7 +437,7 @@ stages: ## JUnit Reporter Config -All platforms benefit from JUnit output for native test result display: +All platforms benefit from JUnit output for native test result display. The examples above use `pw-report/` for HTML artifacts and `results/junit.xml` for test results, so configure those paths explicitly. Sharded examples also need the blob reporter: ```typescript // playwright.config.ts @@ -438,8 +447,9 @@ export default defineConfig({ reporter: process.env.CI ? [ ["dot"], - ["html", { open: "never" }], + ["html", { outputFolder: "pw-report", open: "never" }], ["junit", { outputFile: "results/junit.xml" }], + ["blob"], ] : [["html", { open: "on-failure" }]], }); diff --git a/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md b/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md index 898a425..95248d3 100644 --- a/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md +++ b/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md @@ -286,11 +286,11 @@ jobs: | Tests modify shared database | 1 or isolate | Useful | Sharding splits files; workers run them | | CI has limited resources | 1 or `'25%'` | More | Compensate with more machines | -| Aspect | Workers (in-process) | Shards (across machines) | +| Aspect | Workers (separate processes) | Shards (across machines) | | -------------- | ------------------------- | -------------------------- | | What it splits | Tests across CPU cores | Test files across CI jobs | | Controlled by | Config or `--workers` CLI | `--shard=X/Y` CLI flag | -| Shares memory | Yes | No | +| Shares memory | No | No | | Report merging | Not needed | Required (`merge-reports`) | | Cost | Free (same machine) | More CI minutes | diff --git a/playwright-best-practices/infrastructure-ci-cd/reporting.md b/playwright-best-practices/infrastructure-ci-cd/reporting.md index 55e1a93..ac90ad8 100644 --- a/playwright-best-practices/infrastructure-ci-cd/reporting.md +++ b/playwright-best-practices/infrastructure-ci-cd/reporting.md @@ -421,4 +421,4 @@ use: { }, ``` -The HTML report embeds screenshots from `test-results/`. Deleting that directory removes screenshots from the report. +The HTML reporter copies screenshot attachments into `playwright-report/data/`. Preserve the entire `playwright-report/` directory when uploading or moving the report; `test-results/` is not required to view those copied attachments. diff --git a/playwright-best-practices/infrastructure-ci-cd/test-coverage.md b/playwright-best-practices/infrastructure-ci-cd/test-coverage.md index 9bc73ae..b7135cd 100644 --- a/playwright-best-practices/infrastructure-ci-cd/test-coverage.md +++ b/playwright-best-practices/infrastructure-ci-cd/test-coverage.md @@ -13,155 +13,94 @@ ### Install Dependencies -```bash -# For V8 coverage (built into Playwright) -# No additional dependencies needed +Playwright's coverage API is Chromium-only. JavaScript coverage entries contain `functions` with V8 execution counts and `source`; CSS coverage entries contain `ranges` and `text`. They are different formats: do not combine them and read `.ranges` from JavaScript entries. -# For Istanbul-based coverage (more features) -npm install -D nyc @istanbuljs/nyc-config-typescript +```bash +npm install -D v8-to-istanbul istanbul-lib-coverage nyc tsx ``` ### Basic Configuration ```typescript // playwright.config.ts -import { defineConfig } from "@playwright/test"; +import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ - use: { - // Enable coverage collection - contextOptions: { - // V8 coverage is automatic with the API below - }, - }, + projects: [ + { name: "coverage", use: { ...devices["Desktop Chrome"] } }, + ], + use: { baseURL: "http://localhost:3000" }, }); ``` ### V8 Coverage Fixture +Collect from the same `page` that the test uses, not an unused page in a separate worker-scoped context. Each test writes a unique output file. Import this fixture instead of `@playwright/test` in tests that need automatic coverage. + ```typescript // fixtures/coverage.ts -import { test as base, expect } from "@playwright/test"; -import fs from "fs"; -import path from "path"; -import { randomUUID } from "crypto"; +import { test as base } from "@playwright/test"; +import fs from "node:fs/promises"; -export const test = base.extend<{}, { collectCoverage: void }>({ +export const test = base.extend<{ collectCoverage: void }>({ collectCoverage: [ - async ({ browser }, use) => { - // Start coverage for all pages - const context = await browser.newContext(); - const page = await context.newPage(); - - await page.coverage.startJSCoverage(); - await page.coverage.startCSSCoverage(); - + async ({ page }, use, testInfo) => { + await page.coverage.startJSCoverage({ resetOnNavigation: false }); + await page.coverage.startCSSCoverage({ resetOnNavigation: false }); await use(); - - // Collect coverage - const [jsCoverage, cssCoverage] = await Promise.all([ + const [js, css] = await Promise.all([ page.coverage.stopJSCoverage(), page.coverage.stopCSSCoverage(), ]); - - // Save coverage data - const coverageDir = "./coverage"; - if (!fs.existsSync(coverageDir)) { - fs.mkdirSync(coverageDir, { recursive: true }); - } - - fs.writeFileSync( - path.join(coverageDir, `coverage-${randomUUID()}.json`), - JSON.stringify([...jsCoverage, ...cssCoverage]) - ); - - await context.close(); + // outputPath creates the test output directory. + await fs.writeFile(testInfo.outputPath("js-coverage.json"), JSON.stringify(js)); + await fs.writeFile(testInfo.outputPath("css-coverage.json"), JSON.stringify(css)); }, - { scope: "worker", auto: true }, + { auto: true }, ], }); + +export { expect } from "@playwright/test"; ``` ## Collecting Coverage ### Per-Test Coverage +For manual collection, do not also enable the automatic fixture on the same page: + ```typescript -test("collect coverage for single test", async ({ page }) => { - // Start coverage collection - await page.coverage.startJSCoverage({ - resetOnNavigation: false, - }); +import { test, expect } from "@playwright/test"; - // Run test +test("collect JavaScript coverage", async ({ page }) => { + await page.coverage.startJSCoverage({ resetOnNavigation: false }); await page.goto("/app"); await page.getByRole("button", { name: "Submit" }).click(); await expect(page.getByText("Success")).toBeVisible(); - - // Stop and get coverage const coverage = await page.coverage.stopJSCoverage(); - - // Filter to only your source files const appCoverage = coverage.filter((entry) => entry.url.includes("/src/")); - console.log(`Covered ${appCoverage.length} source files`); }); ``` ### Coverage for Specific Files -```typescript -test("track specific module coverage", async ({ page }) => { - await page.coverage.startJSCoverage(); - - await page.goto("/checkout"); - await page.getByRole("button", { name: "Pay" }).click(); - - const coverage = await page.coverage.stopJSCoverage(); - - // Find coverage for checkout module - const checkoutCoverage = coverage.find((c) => c.url.includes("checkout.js")); - - if (checkoutCoverage) { - const totalBytes = checkoutCoverage.text?.length || 0; - const coveredBytes = checkoutCoverage.ranges.reduce( - (sum, range) => sum + (range.end - range.start), - 0 - ); - const percentage = (coveredBytes / totalBytes) * 100; - - console.log(`Checkout module: ${percentage.toFixed(1)}% covered`); - expect(percentage).toBeGreaterThan(80); - } -}); -``` +Select the intended module by its served URL. For JavaScript byte/line percentages, convert V8 function ranges to Istanbul rather than adding overlapping V8 ranges or treating them as CSS ranges. ### CSS Coverage +CSS ranges can be used directly for a stylesheet's used-byte estimate: + ```typescript test("collect CSS coverage", async ({ page }) => { await page.coverage.startCSSCoverage(); - await page.goto("/app"); - - // Interact to trigger different CSS states await page.getByRole("button").hover(); - await page.getByRole("dialog").waitFor(); - const cssCoverage = await page.coverage.stopCSSCoverage(); - - // Find unused CSS for (const entry of cssCoverage) { - const totalBytes = entry.text?.length || 0; - const usedBytes = entry.ranges.reduce( - (sum, range) => sum + (range.end - range.start), - 0 - ); - const unusedPercentage = ((totalBytes - usedBytes) / totalBytes) * 100; - - if (unusedPercentage > 50) { - console.warn(`${entry.url}: ${unusedPercentage.toFixed(1)}% unused CSS`); - } + const usedBytes = entry.ranges.reduce((sum, r) => sum + r.end - r.start, 0); + const percentage = entry.text.length === 0 ? 0 : usedBytes / entry.text.length * 100; + console.log(`${entry.url}: ${percentage.toFixed(1)}% CSS used`); } }); ``` @@ -170,326 +109,120 @@ test("collect CSS coverage", async ({ page }) => { ### Converting to Istanbul Format +This script consumes only JavaScript files from the fixture. Supply the captured source to `v8-to-istanbul`: browser script URLs are usually HTTP URLs, not local `file://` URLs. Select your app's served script paths; the `/src/` filter below must match your build. Source maps must be available for mapping bundled code back to original source files. + ```typescript // scripts/convert-coverage.ts -import { execSync } from "child_process"; -import fs from "fs"; -import path from "path"; +import fs from "node:fs/promises"; +import path from "node:path"; import v8ToIstanbul from "v8-to-istanbul"; - -async function convertCoverage() { - const coverageDir = "./coverage"; - const files = fs.readdirSync(coverageDir).filter((f) => f.endsWith(".json")); - - const istanbulCoverage: any = {}; - - for (const file of files) { - const coverageData = JSON.parse( - fs.readFileSync(path.join(coverageDir, file), "utf-8") - ); - - for (const entry of coverageData) { - if (!entry.url.startsWith("file://")) continue; - - const filePath = entry.url.replace("file://", ""); - const converter = v8ToIstanbul(filePath); - +import istanbulCoverage from "istanbul-lib-coverage"; +import type { Page } from "@playwright/test"; + +type JSCoverage = Awaited>; +const map = istanbulCoverage.createCoverageMap({}); + +// Optional arguments allow processing downloaded shard output directories. +const roots = process.argv.slice(2); +if (roots.length === 0) roots.push("test-results"); +for (const root of roots) { + const files = await fs.readdir(root, { recursive: true }); + for (const file of files.filter((f) => f.endsWith("js-coverage.json"))) { + const entries: JSCoverage = JSON.parse(await fs.readFile(path.join(root, file), "utf8")); + for (const entry of entries) { + if (!entry.url.includes("/src/") || entry.source === undefined) continue; + const converter = v8ToIstanbul(entry.url, 0, { source: entry.source }); await converter.load(); - converter.applyCoverage(entry.functions || []); - - const istanbul = converter.toIstanbul(); - Object.assign(istanbulCoverage, istanbul); + converter.applyCoverage(entry.functions); + map.merge(converter.toIstanbul()); } } - - fs.writeFileSync( - path.join(coverageDir, "coverage-final.json"), - JSON.stringify(istanbulCoverage) - ); } +await fs.mkdir(".nyc_output", { recursive: true }); +await fs.writeFile(".nyc_output/coverage.json", JSON.stringify(map.toJSON())); +``` + +Run the script as an ES module (for example, set `"type": "module"` in the test project's `package.json`): -convertCoverage(); +```bash +npx tsx scripts/convert-coverage.ts ``` ### Generating HTML Report ```bash -# Using nyc to generate report -npx nyc report --reporter=html --reporter=text --temp-dir=./coverage +npx nyc report --temp-dir=.nyc_output --report-dir=coverage --reporter=html --reporter=text --reporter=lcov --reporter=json-summary ``` -```typescript -// package.json scripts -{ - "scripts": { - "test": "playwright test", - "test:coverage": "playwright test && npm run coverage:report", - "coverage:report": "npx nyc report --reporter=html --reporter=lcov --temp-dir=./coverage" - } -} -``` +`coverage/coverage-summary.json` now contains actual Istanbul line/statement/function/branch summaries, not a byte percentage relabeled as line coverage. ### Custom Coverage Reporter -```typescript -// reporters/coverage-reporter.ts -import type { Reporter, FullResult } from "@playwright/test/reporter"; -import fs from "fs"; -import path from "path"; - -class CoverageReporter implements Reporter { - private coverageData: any[] = []; - - onEnd(result: FullResult) { - // Aggregate all coverage files - const coverageDir = "./coverage"; - const files = fs - .readdirSync(coverageDir) - .filter((f) => f.endsWith(".json")); - - for (const file of files) { - const data = JSON.parse( - fs.readFileSync(path.join(coverageDir, file), "utf-8") - ); - this.coverageData.push(...data); - } - - // Generate summary - const summary = this.generateSummary(); - console.log("\n📊 Coverage Summary:"); - console.log(` Files: ${summary.totalFiles}`); - console.log(` Lines: ${summary.lineCoverage.toFixed(1)}%`); - console.log(` Bytes: ${summary.byteCoverage.toFixed(1)}%`); - - if (summary.lineCoverage < 80) { - console.warn("⚠️ Coverage below 80% threshold!"); - } - } - - private generateSummary() { - let totalBytes = 0; - let coveredBytes = 0; - const files = new Set(); - - for (const entry of this.coverageData) { - if (entry.url.includes("/src/")) { - files.add(entry.url); - totalBytes += entry.text?.length || 0; - coveredBytes += entry.ranges.reduce( - (sum: number, r: any) => sum + (r.end - r.start), - 0 - ); - } - } - - return { - totalFiles: files.size, - byteCoverage: (coveredBytes / totalBytes) * 100, - lineCoverage: (coveredBytes / totalBytes) * 100, // Simplified - }; - } -} - -export default CoverageReporter; -``` +Run conversion/report generation after Playwright exits, so all test-scoped fixture teardowns have written their files. A test's `afterAll` hook is not a global aggregation point across workers. Custom reporters can consume the generated summary after conversion. ## Coverage Thresholds ### Enforcing Minimum Coverage -```typescript -// tests/coverage.spec.ts -import { test, expect } from "@playwright/test"; -import fs from "fs"; -import path from "path"; - -test.afterAll(async () => { - const coverageDir = "./coverage"; - const files = fs.readdirSync(coverageDir).filter((f) => f.endsWith(".json")); - - let totalBytes = 0; - let coveredBytes = 0; - - for (const file of files) { - const coverage = JSON.parse( - fs.readFileSync(path.join(coverageDir, file), "utf-8") - ); - - for (const entry of coverage) { - if (!entry.url.includes("/src/")) continue; - totalBytes += entry.text?.length || 0; - coveredBytes += entry.ranges.reduce( - (sum: number, r: any) => sum + (r.end - r.start), - 0 - ); - } - } - - const coveragePercent = (coveredBytes / totalBytes) * 100; - - // Enforce threshold - expect(coveragePercent).toBeGreaterThan(80); -}); +```bash +npx nyc check-coverage --temp-dir=.nyc_output --lines=80 --statements=80 --functions=80 --branches=70 ``` ### Per-Directory Thresholds -```typescript -// coverage-check.ts -interface CoverageThreshold { - pattern: RegExp; - minCoverage: number; -} +Use nyc's `--include` and `--per-file` options for a selected source directory after source-map conversion: -const thresholds: CoverageThreshold[] = [ - { pattern: /\/src\/core\//, minCoverage: 90 }, - { pattern: /\/src\/utils\//, minCoverage: 85 }, - { pattern: /\/src\/components\//, minCoverage: 70 }, - { pattern: /\/src\/pages\//, minCoverage: 60 }, -]; - -function checkThresholds(coverage: any[]): string[] { - const violations: string[] = []; - - for (const threshold of thresholds) { - const matchingFiles = coverage.filter((c) => threshold.pattern.test(c.url)); - - let total = 0; - let covered = 0; - - for (const file of matchingFiles) { - total += file.text?.length || 0; - covered += file.ranges.reduce( - (sum: number, r: any) => sum + (r.end - r.start), - 0 - ); - } - - const percent = total > 0 ? (covered / total) * 100 : 0; - - if (percent < threshold.minCoverage) { - violations.push( - `${threshold.pattern}: ${percent.toFixed(1)}% < ${ - threshold.minCoverage - }%` - ); - } - } - - return violations; -} +```bash +npx nyc check-coverage --temp-dir=.nyc_output --include='**/src/core/**' --per-file --lines=90 ``` +Ensure your app's expected source files appear in the report. Runtime coverage only includes loaded scripts; an empty or incomplete report is not proof that unvisited source files meet a threshold. + ## Advanced Patterns ### Merging Coverage Across Shards -```typescript -// scripts/merge-coverage.ts -import fs from "fs"; -import { glob } from "glob"; - -async function mergeCoverage() { - const files = await glob("shard-*/coverage/*.json"); - const merged = new Map(); - - for (const file of files) { - const data = JSON.parse(fs.readFileSync(file, "utf-8")); - for (const entry of data) { - if (merged.has(entry.url)) { - const existing = merged.get(entry.url); - existing.ranges.push(...entry.ranges); - } else { - merged.set(entry.url, { ...entry }); - } - } - } - - fs.writeFileSync( - "./coverage/merged.json", - JSON.stringify([...merged.values()]) - ); -} +Download each shard's `test-results/` into a separate directory, then pass all roots to the conversion script. `CoverageMap.merge()` combines execution counts for the same source file; concatenating raw ranges and adding lengths would double-count overlapping coverage. -mergeCoverage(); +```bash +npx tsx scripts/convert-coverage.ts shard-1/test-results shard-2/test-results +npx nyc report --temp-dir=.nyc_output --report-dir=coverage --reporter=html --reporter=lcov ``` ### Incremental Coverage -```typescript -// Check coverage only for changed files in CI -import { execSync } from "child_process"; -import fs from "fs"; - -const changedFiles = execSync("git diff --name-only HEAD~1") - .toString() - .split("\n") - .filter((f) => f.endsWith(".ts")); - -const coverage = JSON.parse(fs.readFileSync("./coverage/merged.json", "utf-8")); - -for (const file of changedFiles) { - const entry = coverage.find((c: any) => c.url.includes(file)); - if (entry) { - const percent = - (entry.ranges.reduce((s: number, r: any) => s + r.end - r.start, 0) / - (entry.text?.length || 1)) * - 100; - console.log(`${file}: ${percent.toFixed(1)}%`); - } -} -``` +Compare changed source paths with the source-mapped Istanbul report, not browser bundle URLs. Retain the full report as the baseline; an unloaded changed file must not silently count as covered. ## CI Integration ### GitHub Actions ```yaml -# .github/workflows/test.yml -name: Tests with Coverage - -on: [push, pull_request] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - run: npm ci - - run: npx playwright install --with-deps - - - name: Run tests with coverage - run: npm run test:coverage - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - files: ./coverage/lcov.info - fail_ci_if_error: true - - - name: Check coverage threshold - run: | - COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct') - if (( $(echo "$COVERAGE < 80" | bc -l) )); then - echo "Coverage $COVERAGE% is below 80% threshold" - exit 1 - fi +# Steps in an existing job, after checkout, npm ci and browser installation. +- name: Run tests with coverage fixture + run: npx playwright test --project=coverage +- name: Convert and report coverage + run: | + npx tsx scripts/convert-coverage.ts + npx nyc report --temp-dir=.nyc_output --report-dir=coverage --reporter=lcov --reporter=json-summary + npx nyc check-coverage --temp-dir=.nyc_output --lines=80 +- uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: coverage + path: coverage/ ``` ## Anti-Patterns to Avoid -| Anti-Pattern | Problem | Solution | -| ---------------------------- | -------------------------------------- | --------------------------- | -| Coverage for coverage's sake | Gaming metrics | Focus on critical paths | -| 100% coverage target | Diminishing returns, tests for getters | Set realistic thresholds | -| Ignoring coverage drops | Technical debt | Enforce thresholds in CI | -| No source map support | Wrong line numbers | Enable source maps in build | -| Coverage only in CI | Late feedback | Run locally too | +| Anti-Pattern | Problem | Solution | +| --- | --- | --- | +| Coverage for coverage's sake | Gaming metrics | Focus on critical paths | +| 100% coverage target | Diminishing returns | Set realistic thresholds | +| Ignoring coverage drops | Technical debt | Enforce thresholds in CI | +| No source map support | Wrong source locations | Enable source maps in build | +| Collecting on an unused page | Misses test activity | Use the test's page fixture | +| Mixing JS and CSS coverage schemas | Invalid calculations/conversion | Process each format separately | ## Related References diff --git a/playwright-best-practices/testing-patterns/browser-extensions.md b/playwright-best-practices/testing-patterns/browser-extensions.md index f3991fb..18a986f 100644 --- a/playwright-best-practices/testing-patterns/browser-extensions.md +++ b/playwright-best-practices/testing-patterns/browser-extensions.md @@ -29,7 +29,7 @@ import path from "path"; export default defineConfig({ testDir: "./tests", use: { - // Extensions require non-headless Chromium + // Use headed Chromium, or channel: "chromium" for headless extensions. headless: false, }, projects: [ @@ -115,8 +115,9 @@ test("load MV3 extension", async () => { ], }); - // Wait for service worker - const serviceWorker = await context.waitForEvent("serviceworker"); + // The worker may already exist when launchPersistentContext returns. + const serviceWorker = context.serviceWorkers()[0] || + (await context.waitForEvent("serviceworker")); expect(serviceWorker.url()).toContain("chrome-extension://"); await context.close(); @@ -125,6 +126,8 @@ test("load MV3 extension", async () => { ### Manifest V2 (Background Page) +Legacy only: current bundled Chromium no longer supports Manifest V2. The following example requires an older compatible browser; use Manifest V3 with current Playwright. + ```typescript test("load MV2 extension", async () => { const pathToExtension = path.join(__dirname, "../my-extension-v2"); @@ -137,8 +140,9 @@ test("load MV2 extension", async () => { ], }); - // Wait for background page - const backgroundPage = await context.waitForEvent("backgroundpage"); + // The background page may already exist when launch returns. + const backgroundPage = context.backgroundPages()[0] || + (await context.waitForEvent("backgroundpage")); expect(backgroundPage.url()).toContain("chrome-extension://"); await context.close(); @@ -160,11 +164,8 @@ test("load multiple extensions", async () => { ], }); - // Both service workers should be available - await context.waitForEvent("serviceworker"); - await context.waitForEvent("serviceworker"); - - expect(context.serviceWorkers().length).toBe(2); + // Count existing workers too; waiting for two new events can miss startup. + await expect.poll(() => context.serviceWorkers().length).toBe(2); await context.close(); }); @@ -276,7 +277,8 @@ test("background script logic", async ({ context }) => { ```typescript test("alarm triggers correctly", async ({ context }) => { - const serviceWorker = await context.waitForEvent("serviceworker"); + const serviceWorker = context.serviceWorkers()[0] || + (await context.waitForEvent("serviceworker")); // Create alarm await serviceWorker.evaluate(async () => { @@ -370,7 +372,8 @@ test("content script modifies page", async ({ context }) => { ```typescript test("chrome.storage operations", async ({ context }) => { - const serviceWorker = await context.waitForEvent("serviceworker"); + const serviceWorker = context.serviceWorkers()[0] || + (await context.waitForEvent("serviceworker")); // Set storage await serviceWorker.evaluate(async () => { @@ -401,7 +404,8 @@ test("chrome.storage operations", async ({ context }) => { ```typescript test("chrome.tabs operations", async ({ context }) => { - const serviceWorker = await context.waitForEvent("serviceworker"); + const serviceWorker = context.serviceWorkers()[0] || + (await context.waitForEvent("serviceworker")); // Create a tab const page = await context.newPage(); @@ -426,7 +430,8 @@ test("chrome.tabs operations", async ({ context }) => { ```typescript test("context menu actions", async ({ context, extensionId }) => { - const serviceWorker = await context.waitForEvent("serviceworker"); + const serviceWorker = context.serviceWorkers()[0] || + (await context.waitForEvent("serviceworker")); // Create context menu await serviceWorker.evaluate(async () => { @@ -448,10 +453,12 @@ test("context menu actions", async ({ context, extensionId }) => { window.getSelection()?.addRange(range); }); - // Trigger context menu action programmatically + // Chrome's public onClicked event has no dispatch() method. + // For a handler-level test, the test extension must expose the same handler + // registered with onClicked.addListener as globalThis.testHooks.onContextMenuClick. + // This tests handler logic, not the native context-menu UI. await serviceWorker.evaluate(async () => { - // Simulate the click handler - chrome.contextMenus.onClicked.dispatch( + await (globalThis as any).testHooks.onContextMenuClick( { menuItemId: "test-menu", selectionText: "selected text" }, { id: 1, url: "https://example.com" }, ); @@ -493,7 +500,7 @@ test("request permissions", async ({ context, extensionId }) => { | Anti-Pattern | Problem | Solution | | ------------------------------ | --------------------- | ---------------------------------------- | -| Testing in headless mode | Extensions don't load | Use `headless: false` | +| Using the default headless shell | Extensions don't load | Use headed mode or `channel: "chromium"` for headless extensions | | Not waiting for service worker | Race conditions | Wait for `serviceworker` event | | Hardcoding extension ID | ID changes on reload | Extract ID from service worker URL | | Testing packed extensions only | Slow iteration | Test unpacked during development | diff --git a/playwright-best-practices/testing-patterns/component-testing.md b/playwright-best-practices/testing-patterns/component-testing.md index 9cb36ae..f3b6ae2 100644 --- a/playwright-best-practices/testing-patterns/component-testing.md +++ b/playwright-best-practices/testing-patterns/component-testing.md @@ -230,7 +230,7 @@ test("click event fires", async ({ mount }) => { await component.click(); - expect(clicked).toBe(true); + await expect.poll(() => clicked).toBe(true); }); ``` @@ -250,7 +250,7 @@ test("onChange provides correct value", async ({ mount }) => { await component.getByRole("combobox").click(); await component.getByRole("option", { name: "b" }).click(); - expect(values).toEqual(["b"]); + await expect.poll(() => values).toEqual(["b"]); }); ``` @@ -272,7 +272,7 @@ test("form submission", async ({ mount }) => { await component.getByLabel("Password").fill("secret123"); await component.getByRole("button", { name: "Sign in" }).click(); - expect(submittedData).toEqual({ + await expect.poll(() => submittedData).toEqual({ email: "test@example.com", password: "secret123", }); @@ -338,14 +338,26 @@ test("renders named slots", async ({ mount }) => { ### Testing Render Props ```tsx -test("render prop pattern", async ({ mount }) => { - const component = await mount( +// DataFetcherStory.tsx — browser-side wrapper, next to the component. +export function DataFetcherStory() { + return ( {({ data, loading }) => loading ? Loading... : {data.name} } - , + ); +} +``` + +```tsx +// In the spec: +import { DataFetcherStory } from "./DataFetcherStory"; + +// Render props execute synchronously in the browser; a Node-side callback +// passed directly through mount cannot return JSX to the browser. +test("render prop pattern", async ({ mount }) => { + const component = await mount(); // Initially loading await expect(component.getByText("Loading...")).toBeVisible(); @@ -427,14 +439,12 @@ test("with mocked hook", async ({ mount }) => { ### React Testing ```tsx -// React with refs -test("exposes ref methods", async ({ mount }) => { - let inputRef: HTMLInputElement | null = null; - - const component = await mount( (inputRef = el)} />); - +// DOM nodes cannot be passed from the browser to a Node-side ref callback. +// Assert rendered input state through a locator instead. +test("input value", async ({ mount }) => { + const component = await mount(); await component.locator("input").fill("test"); - expect(inputRef?.value).toBe("test"); + await expect(component.locator("input")).toHaveValue("test"); }); // React with context @@ -466,7 +476,7 @@ test("v-model binding", async ({ mount }) => { }); await component.locator("input").fill("test"); - expect(modelValue).toBe("test"); + await expect.poll(() => modelValue).toBe("test"); }); ``` diff --git a/playwright-best-practices/testing-patterns/drag-drop.md b/playwright-best-practices/testing-patterns/drag-drop.md index 4d4d0ab..e7fe14c 100644 --- a/playwright-best-practices/testing-patterns/drag-drop.md +++ b/playwright-best-practices/testing-patterns/drag-drop.md @@ -295,9 +295,12 @@ test('simulates drag-over visual feedback', async ({ page }) => { const dropZone = page.locator('[data-testid="file-drop-zone"]'); - await dropZone.dispatchEvent('dragenter', { - dataTransfer: { types: ['Files'] }, + const dataTransfer = await page.evaluateHandle(() => { + const transfer = new DataTransfer(); + transfer.items.add(new File(['content'], 'report.pdf', { type: 'application/pdf' })); + return transfer; }); + await dropZone.dispatchEvent('dragenter', { dataTransfer }); await expect(dropZone).toHaveClass(/drag-active|drop-highlight/); await expect(dropZone).toContainText(/drop.*here|release.*upload/i); diff --git a/playwright-best-practices/testing-patterns/file-upload-download.md b/playwright-best-practices/testing-patterns/file-upload-download.md index 75a4348..43996e4 100644 --- a/playwright-best-practices/testing-patterns/file-upload-download.md +++ b/playwright-best-practices/testing-patterns/file-upload-download.md @@ -241,7 +241,7 @@ test('removes one file from selection', async ({ page }) => { ## Drag-and-Drop Zones -Drop zones always have an underlying `input[type="file"]`—target it directly instead of simulating OS-level drag events. +When a drop zone has an underlying `input[type="file"]`, target it directly for file selection. To exercise a drop-event handler itself, dispatch a `drop` event with a real `DataTransfer` handle (see [file-operations.md](file-operations.md#drag-and-drop)). ```typescript test('uploads via drop zone', async ({ page }) => { @@ -267,9 +267,12 @@ test('shows visual feedback on drag-over', async ({ page }) => { const dropZone = page.locator('[data-testid="drop-zone"]'); - await dropZone.dispatchEvent('dragenter', { - dataTransfer: { types: ['Files'], files: [] }, + const dataTransfer = await page.evaluateHandle(() => { + const transfer = new DataTransfer(); + transfer.items.add(new File(['content'], 'dropped.pdf', { type: 'application/pdf' })); + return transfer; }); + await dropZone.dispatchEvent('dragenter', { dataTransfer }); await expect(dropZone).toHaveClass(/active|highlight|drag-over/); await expect(dropZone).toContainText(/release|drop now/i); @@ -551,7 +554,7 @@ test('downloads file requiring authentication', async ({ page, request }) => { ## Tips -1. **Use `setInputFiles` for uploads**. Even drag-and-drop zones have an underlying `input[type="file"]`. Target it directly instead of simulating OS-level drag events. +1. **Use `setInputFiles` for file inputs**. For a drop zone with an underlying `input[type="file"]`, target the input to select files. This does not exercise the zone's drag/drop handlers; use a real `DataTransfer` handle when testing those handlers. 2. **Prefer in-memory buffers**. Creating files with `Buffer.from()` keeps tests self-contained. Use fixture files only when you need real content (e.g., a valid PDF your app parses). diff --git a/playwright-best-practices/testing-patterns/forms-validation.md b/playwright-best-practices/testing-patterns/forms-validation.md index 2cfdd8c..876c25e 100644 --- a/playwright-best-practices/testing-patterns/forms-validation.md +++ b/playwright-best-practices/testing-patterns/forms-validation.md @@ -511,7 +511,7 @@ test("confirmation dialog before resetting a dirty form", async ({ page }) => { ### `fill()` does nothing or clears but doesn't type -**Cause**: The input field uses a contenteditable div (rich text editors), not a real `` or `