mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
chore: add playwright cursor skill
This commit is contained in:
parent
25aad38ca4
commit
d52225c18d
57 changed files with 25244 additions and 0 deletions
409
.cursor/skills/playwright-testing/advanced/mobile-testing.md
Normal file
409
.cursor/skills/playwright-testing/advanced/mobile-testing.md
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
# Mobile & Responsive Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Device Emulation](#device-emulation)
|
||||
2. [Touch Gestures](#touch-gestures)
|
||||
3. [Viewport Testing](#viewport-testing)
|
||||
4. [Mobile-Specific UI](#mobile-specific-ui)
|
||||
5. [Responsive Breakpoints](#responsive-breakpoints)
|
||||
|
||||
## Device Emulation
|
||||
|
||||
### Use Built-in Devices
|
||||
|
||||
```typescript
|
||||
import { test, devices } from "@playwright/test";
|
||||
|
||||
// Configure in playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: "Desktop Chrome", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "Mobile Safari", use: { ...devices["iPhone 14"] } },
|
||||
{ name: "Mobile Chrome", use: { ...devices["Pixel 7"] } },
|
||||
{ name: "Tablet", use: { ...devices["iPad Pro 11"] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Device Configuration
|
||||
|
||||
```typescript
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 3,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",
|
||||
});
|
||||
|
||||
test("custom mobile device", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Test runs with custom device settings
|
||||
});
|
||||
```
|
||||
|
||||
### Test Across Multiple Devices
|
||||
|
||||
```typescript
|
||||
const mobileDevices = ["iPhone 14", "Pixel 7", "Galaxy S21"];
|
||||
|
||||
for (const deviceName of mobileDevices) {
|
||||
test(`checkout on ${deviceName}`, async ({ browser }) => {
|
||||
const device = devices[deviceName];
|
||||
const context = await browser.newContext({ ...device });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/checkout");
|
||||
await expect(page.getByRole("button", { name: "Pay" })).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Touch Gestures
|
||||
|
||||
### Tap
|
||||
|
||||
```typescript
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("tap to interact", async ({ page }) => {
|
||||
await page.goto("/gallery");
|
||||
|
||||
// Tap is like click but for touch devices
|
||||
await page.getByRole("img", { name: "Photo 1" }).tap();
|
||||
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Swipe
|
||||
|
||||
```typescript
|
||||
test("swipe carousel", async ({ page }) => {
|
||||
await page.goto("/carousel");
|
||||
|
||||
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);
|
||||
|
||||
// Or use drag
|
||||
await carousel.dragTo(carousel, {
|
||||
sourcePosition: { x: box.width - 50, y: box.height / 2 },
|
||||
targetPosition: { x: 50, y: box.height / 2 },
|
||||
});
|
||||
}
|
||||
|
||||
await expect(page.getByText("Slide 2")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Swipe Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/touch.fixture.ts
|
||||
import { test as base, Page } from "@playwright/test";
|
||||
|
||||
type TouchFixtures = {
|
||||
swipe: (
|
||||
element: Locator,
|
||||
direction: "left" | "right" | "up" | "down",
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<TouchFixtures>({
|
||||
swipe: async ({ page }, 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();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("swipe to delete", async ({ page, swipe }) => {
|
||||
await page.goto("/inbox");
|
||||
|
||||
const message = page.getByTestId("message-1");
|
||||
await swipe(message, "left");
|
||||
|
||||
await expect(page.getByRole("button", { name: "Delete" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Long Press
|
||||
|
||||
```typescript
|
||||
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);
|
||||
|
||||
// Context menu should appear
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Pinch Zoom
|
||||
|
||||
```typescript
|
||||
test("pinch to zoom image", async ({ page }) => {
|
||||
await page.goto("/map");
|
||||
|
||||
// Pinch zoom requires two touch points
|
||||
// Playwright doesn't have native pinch support, so we simulate via evaluate
|
||||
await page.evaluate(() => {
|
||||
const element = document.querySelector("#map");
|
||||
if (element) {
|
||||
// Simulate wheel event as fallback for zoom
|
||||
element.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: -100, // Negative = zoom in
|
||||
ctrlKey: true, // Ctrl+wheel = pinch on many apps
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Or trigger the app's zoom function directly
|
||||
await page.evaluate(() => {
|
||||
(window as any).mapInstance?.setZoom(15);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Viewport Testing
|
||||
|
||||
### Test Different Sizes
|
||||
|
||||
```typescript
|
||||
const viewports = [
|
||||
{ name: "mobile", width: 375, height: 667 },
|
||||
{ name: "tablet", width: 768, height: 1024 },
|
||||
{ name: "desktop", width: 1920, height: 1080 },
|
||||
];
|
||||
|
||||
for (const { name, width, height } of viewports) {
|
||||
test(`navigation on ${name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto("/");
|
||||
|
||||
if (width < 768) {
|
||||
// Mobile: should have hamburger menu
|
||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||
} else {
|
||||
// Desktop: should have visible nav links
|
||||
await expect(page.getByRole("link", { name: "Products" })).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Viewport Changes
|
||||
|
||||
```typescript
|
||||
test("responsive layout change", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1200, height: 800 });
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Desktop: sidebar visible
|
||||
await expect(page.getByRole("complementary")).toBeVisible();
|
||||
|
||||
// Resize to mobile
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// Mobile: sidebar hidden, hamburger visible
|
||||
await expect(page.getByRole("complementary")).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Mobile-Specific UI
|
||||
|
||||
### Hamburger Menu
|
||||
|
||||
```typescript
|
||||
test("mobile navigation", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto("/");
|
||||
|
||||
// Open hamburger menu
|
||||
await page.getByRole("button", { name: "Menu" }).click();
|
||||
|
||||
// Navigation drawer should appear
|
||||
const nav = page.getByRole("navigation");
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
// Navigate via mobile menu
|
||||
await nav.getByRole("link", { name: "Products" }).click();
|
||||
|
||||
await expect(page).toHaveURL("/products");
|
||||
// Menu should close after navigation
|
||||
await expect(nav).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Bottom Sheet
|
||||
|
||||
```typescript
|
||||
test("bottom sheet interaction", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto("/product/123");
|
||||
|
||||
await page.getByRole("button", { name: "Add to Cart" }).click();
|
||||
|
||||
// Bottom sheet appears
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// Select options
|
||||
await sheet.getByRole("combobox", { name: "Size" }).selectOption("Large");
|
||||
await sheet.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
await expect(page.getByText("Added to cart")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Pull to Refresh
|
||||
|
||||
```typescript
|
||||
test("pull to refresh", async ({ page }) => {
|
||||
await page.goto("/feed");
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Wait for refresh
|
||||
await expect(page.getByTestId("loading")).toBeVisible();
|
||||
await expect(page.getByTestId("loading")).toBeHidden();
|
||||
|
||||
// Content should be updated (in a real app)
|
||||
});
|
||||
```
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
### Test All Breakpoints
|
||||
|
||||
```typescript
|
||||
const breakpoints = {
|
||||
xs: 320,
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
"2xl": 1536,
|
||||
};
|
||||
|
||||
test.describe("responsive header", () => {
|
||||
for (const [name, width] of Object.entries(breakpoints)) {
|
||||
test(`header at ${name} (${width}px)`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto("/");
|
||||
|
||||
if (width < 768) {
|
||||
await expect(page.getByTestId("mobile-menu-button")).toBeVisible();
|
||||
await expect(page.getByTestId("desktop-nav")).toBeHidden();
|
||||
} else {
|
||||
await expect(page.getByTestId("mobile-menu-button")).toBeHidden();
|
||||
await expect(page.getByTestId("desktop-nav")).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Visual Regression at Breakpoints
|
||||
|
||||
```typescript
|
||||
test.describe("visual regression", () => {
|
||||
const sizes = [
|
||||
{ width: 375, height: 667, name: "mobile" },
|
||||
{ width: 768, height: 1024, name: "tablet" },
|
||||
{ width: 1440, height: 900, name: "desktop" },
|
||||
];
|
||||
|
||||
for (const { width, height, name } of sizes) {
|
||||
test(`homepage at ${name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page).toHaveScreenshot(`homepage-${name}.png`);
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| --------------------------- | ------------------------- | -------------------------------- |
|
||||
| Only testing one viewport | Misses responsive bugs | Test multiple breakpoints |
|
||||
| Ignoring touch events | Features broken on mobile | Test tap, swipe, long press |
|
||||
| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()` |
|
||||
| Not testing orientation | Landscape bugs missed | Test both portrait and landscape |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing
|
||||
- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors
|
||||
- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications)
|
||||
- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements
|
||||
Loading…
Add table
Add a link
Reference in a new issue