Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion playwright-best-practices/advanced/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
135 changes: 60 additions & 75 deletions playwright-best-practices/advanced/mobile-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
}];
Comment on lines +101 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Touch points omit page and screen coordinates

Legacy handlers that calculate movement from pageX/pageY or screenX/screenY see zero deltas, causing swipe or pull-to-refresh assertions to fail; include these fields in pan and the file-menu touchstart, since omitted TouchInit members default to 0.

Severity web_search

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`playwright-best-practices/advanced/mobile-testing.md` around lines 101-105, update the
`pan` helper’s `touchmove` points to include `pageX`/`pageY` and `screenX`/`screenY`
values consistent with the generated client coordinates. Apply the same complete
`TouchInit` coordinate fields to the `touchstart` example around lines 173-176 so legacy
handlers can calculate movement correctly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default-value observation is correct, but the need for those coordinates depends on the application handler. Playwright's v1.64.1 pan example explicitly supplies only clientX/clientY because its consumer uses only those fields; it says to supply page/screen coordinates when the app needs them. TouchInit defaults these coordinate members independently to zero, rather than deriving one coordinate space from another.

For a page-coordinate consumer, page coordinates must account for the document scroll offset; screen coordinates are screen-relative, not generally copies of client coordinates. That would require an app-specific adaptation, not indiscriminately assigning the same values to every field.

This collection does not include implementations of the illustrative /carousel or /files handlers, and this review identifies no concrete consumer that requires page/screen coordinates. The existing isolated regression verifies touch event delivery and a held-touch timer, not page/screen coordinate compatibility or a real carousel/file-menu implementation. Omitted page/screen fields do not affect a client-coordinate-only handler or that timer, but I am not claiming compatibility with handlers that read them.

Leaving these examples unchanged rather than adding unneeded fields without a demonstrated failing consumer. A specific handler requiring another coordinate space would be actionable evidence for a bounded adaptation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification—you're right that additional coordinate fields must be supplied only when a concrete consumer requires the appropriate coordinate space. I'll save this guidance to memory once the PR is merged.

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();
});
```
Expand All @@ -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: (
Expand All @@ -119,55 +139,22 @@ type TouchFixtures = {
};

export const test = base.extend<TouchFixtures>({
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);
});
},
});

// 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();
});
```
Expand All @@ -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,
});
});
```

Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions playwright-best-practices/advanced/network-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 6 additions & 6 deletions playwright-best-practices/browser-apis/browser-apis.md
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
```

Expand Down
52 changes: 24 additions & 28 deletions playwright-best-practices/browser-apis/service-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -254,7 +246,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

Expand Down Expand Up @@ -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();
});
Expand All @@ -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);
});
Expand Down Expand Up @@ -407,18 +403,18 @@ 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" },
});
});

// 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 }),
);
});

Expand Down Expand Up @@ -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 });
});
```

Expand All @@ -498,7 +494,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
Loading