Qaskills Playwright Mobile Web Testing
Mobile web testing skill using Playwright device emulation covering responsive testing, touch interactions, viewport management, network throttling, geolocation testing, and mobile-specific UI patterns.
install
source · Clone the upstream repo
git clone https://github.com/PramodDutta/qaskills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/PramodDutta/qaskills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/seed-skills/playwright-mobile-web" ~/.claude/skills/pramoddutta-qaskills-playwright-mobile-web-testing && rm -rf "$T"
manifest:
seed-skills/playwright-mobile-web/SKILL.mdsource content
Playwright Mobile Web Testing Skill
You are an expert QA automation engineer specializing in mobile web testing with Playwright. When the user asks you to write, review, or debug mobile web tests using Playwright device emulation, follow these detailed instructions.
Core Principles
- Device-first testing -- Always test with realistic device profiles. Use Playwright's built-in device descriptors for accurate viewport sizes, user agents, and device scale factors.
- Touch interaction fidelity -- Mobile users interact via touch, not mouse clicks. Simulate tap, swipe, pinch, and long-press gestures accurately.
- Responsive breakpoint coverage -- Test all critical breakpoints, not just one mobile size. Cover small phones (320px), standard phones (375px), large phones (428px), and tablets (768px).
- Network-aware testing -- Mobile users frequently experience slow or intermittent connectivity. Test under throttled network conditions.
- Orientation handling -- Test both portrait and landscape orientations. Verify layout adapts correctly when orientation changes.
- Performance-conscious -- Mobile devices have less processing power. Monitor and assert on performance metrics like LCP, FID, and CLS.
- Accessibility on mobile -- Touch targets must be at least 44x44 pixels. Verify that all interactive elements meet mobile accessibility standards.
Project Structure
Always organize mobile web testing projects with this structure:
tests/ mobile/ auth/ login-mobile.spec.ts signup-mobile.spec.ts navigation/ hamburger-menu.spec.ts bottom-nav.spec.ts forms/ mobile-form-input.spec.ts keyboard-interactions.spec.ts responsive/ breakpoints.spec.ts orientation.spec.ts performance/ mobile-performance.spec.ts pwa/ offline-basic.spec.ts fixtures/ mobile.fixture.ts network.fixture.ts pages/ mobile-nav.page.ts mobile-form.page.ts base-mobile.page.ts utils/ touch-helpers.ts viewport-helpers.ts network-helpers.ts playwright.config.ts
Device Emulation Configuration
Playwright Config with Mobile Projects
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests/mobile', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: [ ['html', { open: 'never' }], process.env.CI ? ['github'] : ['list'], ], use: { baseURL: process.env.BASE_URL || 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ // Small phone { name: 'iphone-se', use: { ...devices['iPhone SE'], }, }, // Standard phone { name: 'iphone-14', use: { ...devices['iPhone 14'], }, }, // Large phone { name: 'iphone-14-pro-max', use: { ...devices['iPhone 14 Pro Max'], }, }, // Android phone { name: 'pixel-7', use: { ...devices['Pixel 7'], }, }, // Tablet { name: 'ipad-pro', use: { ...devices['iPad Pro 11'], }, }, // Landscape mode { name: 'iphone-14-landscape', use: { ...devices['iPhone 14 landscape'], }, }, ], webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, });
Custom Device Profiles
import { test as base } from '@playwright/test'; const customDevices = { 'Galaxy Fold (folded)': { viewport: { width: 280, height: 653 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-F946B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', }, 'Galaxy Fold (unfolded)': { viewport: { width: 717, height: 512 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-F946B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', }, }; export const test = base.extend({ // Fixture to test with a foldable device foldablePage: async ({ browser }, use) => { const context = await browser.newContext({ ...customDevices['Galaxy Fold (folded)'], }); const page = await context.newPage(); await use(page); await context.close(); }, });
Touch and Gesture Simulation
Basic Touch Interactions
import { test, expect, Page } from '@playwright/test'; test.describe('Touch Interactions', () => { test('should handle tap on mobile elements', async ({ page }) => { await page.goto('/mobile-app'); // Simple tap await page.tap('[data-testid="menu-button"]'); await expect(page.getByRole('navigation')).toBeVisible(); }); test('should simulate swipe gesture', async ({ page }) => { await page.goto('/carousel'); const carousel = page.getByTestId('image-carousel'); const box = await carousel.boundingBox(); if (box) { // Swipe left await page.touchscreen.tap(box.x + box.width * 0.8, box.y + box.height / 2); await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(box.x + box.width * 0.2, box.y + box.height / 2, { steps: 10 }); await page.mouse.up(); await expect(page.getByTestId('slide-2')).toBeVisible(); } }); test('should handle long press for context menu', async ({ page }) => { await page.goto('/gallery'); const image = page.getByTestId('gallery-image-1'); const box = await image.boundingBox(); if (box) { const centerX = box.x + box.width / 2; const centerY = box.y + box.height / 2; // Long press simulation await page.touchscreen.tap(centerX, centerY); await page.mouse.down(); await page.waitForTimeout(800); // Hold for 800ms await page.mouse.up(); await expect(page.getByRole('menu')).toBeVisible(); } }); test('should handle pull-to-refresh', async ({ page }) => { await page.goto('/feed'); const feedContainer = page.getByTestId('feed-container'); const box = await feedContainer.boundingBox(); if (box) { // Pull down from top of the feed await page.mouse.move(box.x + box.width / 2, box.y + 10); await page.mouse.down(); await page.mouse.move(box.x + box.width / 2, box.y + 200, { steps: 20 }); await page.mouse.up(); await expect(page.getByText('Refreshing...')).toBeVisible(); await expect(page.getByText('Updated just now')).toBeVisible({ timeout: 5000 }); } }); });
Swipe Helper Utility
// utils/touch-helpers.ts import { Page } from '@playwright/test'; export async function swipe( page: Page, startX: number, startY: number, endX: number, endY: number, steps = 10, duration = 300 ): Promise<void> { await page.touchscreen.tap(startX, startY); await page.mouse.move(startX, startY); await page.mouse.down(); const stepDelay = duration / steps; for (let i = 1; i <= steps; i++) { const x = startX + ((endX - startX) * i) / steps; const y = startY + ((endY - startY) * i) / steps; await page.mouse.move(x, y); await page.waitForTimeout(stepDelay); } await page.mouse.up(); } export async function swipeLeft(page: Page, element: string, distance = 200): Promise<void> { const locator = page.locator(element); const box = await locator.boundingBox(); if (!box) throw new Error(`Element ${element} not found`); const centerY = box.y + box.height / 2; const startX = box.x + box.width * 0.8; await swipe(page, startX, centerY, startX - distance, centerY); } export async function swipeRight(page: Page, element: string, distance = 200): Promise<void> { const locator = page.locator(element); const box = await locator.boundingBox(); if (!box) throw new Error(`Element ${element} not found`); const centerY = box.y + box.height / 2; const startX = box.x + box.width * 0.2; await swipe(page, startX, centerY, startX + distance, centerY); } export async function swipeDown(page: Page, element: string, distance = 200): Promise<void> { const locator = page.locator(element); const box = await locator.boundingBox(); if (!box) throw new Error(`Element ${element} not found`); const centerX = box.x + box.width / 2; const startY = box.y + box.height * 0.2; await swipe(page, centerX, startY, centerX, startY + distance); }
Viewport and Responsive Testing
import { test, expect } from '@playwright/test'; test.describe('Responsive Breakpoint Testing', () => { const breakpoints = [ { name: 'small-phone', width: 320, height: 568 }, { name: 'standard-phone', width: 375, height: 812 }, { name: 'large-phone', width: 428, height: 926 }, { name: 'small-tablet', width: 768, height: 1024 }, { name: 'large-tablet', width: 1024, height: 1366 }, ]; for (const bp of breakpoints) { test(`should render correctly at ${bp.name} (${bp.width}x${bp.height})`, async ({ page }) => { await page.setViewportSize({ width: bp.width, height: bp.height }); await page.goto('/'); // Hamburger menu visible on mobile, hidden on tablet if (bp.width < 768) { await expect(page.getByTestId('hamburger-menu')).toBeVisible(); await expect(page.getByTestId('desktop-nav')).toBeHidden(); } else { await expect(page.getByTestId('hamburger-menu')).toBeHidden(); await expect(page.getByTestId('desktop-nav')).toBeVisible(); } // Visual regression at each breakpoint await expect(page).toHaveScreenshot(`homepage-${bp.name}.png`, { maxDiffPixelRatio: 0.05, }); }); } test('should handle orientation change', async ({ page }) => { // Portrait await page.setViewportSize({ width: 375, height: 812 }); await page.goto('/dashboard'); await expect(page.getByTestId('sidebar')).toBeHidden(); // Rotate to landscape await page.setViewportSize({ width: 812, height: 375 }); await expect(page.getByTestId('sidebar')).toBeVisible(); // Visual snapshot for landscape await expect(page).toHaveScreenshot('dashboard-landscape.png'); }); test('should handle dynamic viewport changes (soft keyboard)', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); await page.goto('/login'); const emailInput = page.getByLabel('Email'); await emailInput.focus(); // Simulate soft keyboard reducing viewport await page.setViewportSize({ width: 375, height: 400 }); // Verify input is still visible and not obscured await expect(emailInput).toBeVisible(); await expect(emailInput).toBeFocused(); // Submit button should be reachable by scrolling const submitButton = page.getByRole('button', { name: 'Sign in' }); await submitButton.scrollIntoViewIfNeeded(); await expect(submitButton).toBeVisible(); }); });
Network Throttling
import { test, expect, chromium } from '@playwright/test'; test.describe('Mobile Network Conditions', () => { test('should load content on slow 3G', async ({ browser }) => { const context = await browser.newContext({ ...devices['iPhone 14'], }); const page = await context.newPage(); // Throttle network via CDP (Chromium only) const cdpSession = await page.context().newCDPSession(page); await cdpSession.send('Network.emulateNetworkConditions', { offline: false, downloadThroughput: (500 * 1024) / 8, // 500 kbps uploadThroughput: (500 * 1024) / 8, latency: 400, // 400ms RTT }); await page.goto('/'); // Should show loading skeleton first await expect(page.getByTestId('loading-skeleton')).toBeVisible(); // Content should eventually load await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible({ timeout: 15000 }); await context.close(); }); test('should handle offline mode gracefully', async ({ browser }) => { const context = await browser.newContext({ ...devices['Pixel 7'], }); const page = await context.newPage(); // Load the page first await page.goto('/'); await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible(); // Go offline await page.context().setOffline(true); // Try navigating to another page await page.getByRole('link', { name: 'Products' }).click(); // Should show offline indicator or cached content const offlineIndicator = page.getByTestId('offline-banner'); const cachedContent = page.getByRole('heading', { name: 'Products' }); // Either offline banner or cached content should be visible await expect(offlineIndicator.or(cachedContent)).toBeVisible({ timeout: 5000 }); // Go back online await page.context().setOffline(false); await page.reload(); await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible(); await context.close(); }); test('should measure page load time under throttled conditions', async ({ browser }) => { const context = await browser.newContext({ ...devices['iPhone 14'], }); const page = await context.newPage(); const cdpSession = await page.context().newCDPSession(page); // Regular 4G conditions await cdpSession.send('Network.emulateNetworkConditions', { offline: false, downloadThroughput: (4 * 1024 * 1024) / 8, // 4 Mbps uploadThroughput: (3 * 1024 * 1024) / 8, // 3 Mbps latency: 20, }); const startTime = Date.now(); await page.goto('/', { waitUntil: 'domcontentloaded' }); const loadTime = Date.now() - startTime; // Page should load within 3 seconds on 4G expect(loadTime).toBeLessThan(3000); await context.close(); }); }); import { devices } from '@playwright/test';
Geolocation Testing
import { test, expect, devices } from '@playwright/test'; test.describe('Geolocation on Mobile', () => { test('should show nearby stores based on location', async ({ browser }) => { const context = await browser.newContext({ ...devices['iPhone 14'], geolocation: { latitude: 40.7128, longitude: -74.006 }, // New York City permissions: ['geolocation'], }); const page = await context.newPage(); await page.goto('/store-locator'); await page.getByRole('button', { name: 'Find nearby stores' }).click(); await expect(page.getByText('New York')).toBeVisible(); await expect(page.getByTestId('store-list')).not.toBeEmpty(); await context.close(); }); test('should update content when location changes', async ({ browser }) => { const context = await browser.newContext({ ...devices['Pixel 7'], geolocation: { latitude: 51.5074, longitude: -0.1278 }, // London permissions: ['geolocation'], }); const page = await context.newPage(); await page.goto('/weather'); await expect(page.getByText('London')).toBeVisible(); // Change location to Tokyo await context.setGeolocation({ latitude: 35.6762, longitude: 139.6503 }); await page.getByRole('button', { name: 'Refresh location' }).click(); await expect(page.getByText('Tokyo')).toBeVisible(); await context.close(); }); test('should handle geolocation permission denied', async ({ browser }) => { const context = await browser.newContext({ ...devices['iPhone 14'], permissions: [], // No geolocation permission }); const page = await context.newPage(); await page.goto('/store-locator'); await page.getByRole('button', { name: 'Find nearby stores' }).click(); // Should show fallback UI await expect(page.getByText('Enter your location manually')).toBeVisible(); await expect(page.getByLabel('ZIP code')).toBeVisible(); await context.close(); }); });
Mobile-Specific UI Patterns
import { test, expect } from '@playwright/test'; test.describe('Mobile Navigation Patterns', () => { test('should open and close hamburger menu', async ({ page }) => { await page.goto('/'); const menuButton = page.getByTestId('hamburger-menu'); const mobileNav = page.getByTestId('mobile-nav-drawer'); // Menu should be closed initially await expect(mobileNav).toBeHidden(); // Open menu await menuButton.click(); await expect(mobileNav).toBeVisible(); // Verify all nav items are present await expect(page.getByRole('link', { name: 'Home' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Products' })).toBeVisible(); await expect(page.getByRole('link', { name: 'About' })).toBeVisible(); // Close menu by tapping overlay await page.getByTestId('nav-overlay').click(); await expect(mobileNav).toBeHidden(); }); test('should show and interact with bottom sheet', async ({ page }) => { await page.goto('/products/1'); // Open bottom sheet await page.getByRole('button', { name: 'Add to cart' }).click(); const bottomSheet = page.getByTestId('bottom-sheet'); await expect(bottomSheet).toBeVisible(); // Select options in bottom sheet await page.getByRole('button', { name: 'Size: M' }).click(); await page.getByRole('button', { name: 'Confirm' }).click(); await expect(page.getByText('Added to cart')).toBeVisible(); }); test('should handle sticky header behavior on scroll', async ({ page }) => { await page.goto('/blog'); const header = page.getByTestId('sticky-header'); // Header visible at top await expect(header).toBeVisible(); // Scroll down -- header should hide await page.evaluate(() => window.scrollBy(0, 500)); await page.waitForTimeout(300); // Wait for scroll animation await expect(header).toHaveCSS('transform', /translateY\(-/); // Scroll up -- header should reappear await page.evaluate(() => window.scrollBy(0, -200)); await page.waitForTimeout(300); await expect(header).toBeVisible(); }); test('should handle infinite scroll loading', async ({ page }) => { await page.goto('/feed'); // Initial items const initialItems = await page.getByTestId('feed-item').count(); expect(initialItems).toBeGreaterThan(0); // Scroll to bottom to trigger infinite scroll await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); // Wait for new items to load await page.waitForResponse('**/api/feed?page=2'); const updatedItems = await page.getByTestId('feed-item').count(); expect(updatedItems).toBeGreaterThan(initialItems); }); });
Mobile Form Testing
import { test, expect } from '@playwright/test'; test.describe('Mobile Form Interactions', () => { test('should handle mobile date picker', async ({ page }) => { await page.goto('/booking'); const dateInput = page.getByLabel('Check-in date'); await dateInput.click(); // Interact with native mobile date picker await dateInput.fill('2025-06-15'); await expect(dateInput).toHaveValue('2025-06-15'); }); test('should support autofill on mobile forms', async ({ page }) => { await page.goto('/checkout'); // Fill form fields -- mobile browsers may suggest autofill await page.getByLabel('Full name').fill('Jane Doe'); await page.getByLabel('Email').fill('jane@example.com'); await page.getByLabel('Phone').fill('+1234567890'); // Verify autocomplete attributes are present for mobile autofill await expect(page.getByLabel('Full name')).toHaveAttribute('autocomplete', 'name'); await expect(page.getByLabel('Email')).toHaveAttribute('autocomplete', 'email'); await expect(page.getByLabel('Phone')).toHaveAttribute('autocomplete', 'tel'); }); test('should validate touch target sizes meet accessibility standards', async ({ page }) => { await page.goto('/'); // Check that all clickable elements meet 44x44 minimum touch target const clickableElements = page.locator('a, button, input, select, textarea, [role="button"]'); const count = await clickableElements.count(); for (let i = 0; i < count; i++) { const element = clickableElements.nth(i); if (await element.isVisible()) { const box = await element.boundingBox(); if (box) { expect(box.width, `Element ${i} width too small`).toBeGreaterThanOrEqual(44); expect(box.height, `Element ${i} height too small`).toBeGreaterThanOrEqual(44); } } } }); });
Mobile Performance Testing
import { test, expect, devices } from '@playwright/test'; test.describe('Mobile Performance Metrics', () => { test('should meet Core Web Vitals on mobile', async ({ browser }) => { const context = await browser.newContext({ ...devices['iPhone 14'], }); const page = await context.newPage(); // Collect performance metrics await page.goto('/', { waitUntil: 'networkidle' }); const performanceMetrics = await page.evaluate(() => { return new Promise<{ lcp: number; fid: number; cls: number; ttfb: number; }>((resolve) => { let lcp = 0; let cls = 0; new PerformanceObserver((list) => { const entries = list.getEntries(); lcp = entries[entries.length - 1].startTime; }).observe({ type: 'largest-contentful-paint', buffered: true }); new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (!(entry as any).hadRecentInput) { cls += (entry as any).value; } } }).observe({ type: 'layout-shift', buffered: true }); const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; setTimeout(() => { resolve({ lcp, fid: 0, // FID requires real user interaction cls, ttfb: navEntry.responseStart - navEntry.requestStart, }); }, 3000); }); }); // Assert Core Web Vitals thresholds expect(performanceMetrics.lcp).toBeLessThan(2500); // LCP < 2.5s expect(performanceMetrics.cls).toBeLessThan(0.1); // CLS < 0.1 expect(performanceMetrics.ttfb).toBeLessThan(800); // TTFB < 800ms await context.close(); }); test('should not load oversized images on mobile', async ({ browser }) => { const context = await browser.newContext({ ...devices['iPhone 14'], }); const page = await context.newPage(); const imageRequests: { url: string; size: number }[] = []; page.on('response', async (response) => { const contentType = response.headers()['content-type'] || ''; if (contentType.startsWith('image/')) { const body = await response.body().catch(() => Buffer.alloc(0)); imageRequests.push({ url: response.url(), size: body.length, }); } }); await page.goto('/', { waitUntil: 'networkidle' }); // No single image should exceed 200KB on mobile for (const img of imageRequests) { expect(img.size, `Image too large: ${img.url}`).toBeLessThan(200 * 1024); } await context.close(); }); });
Visual Regression Testing for Mobile
import { test, expect, devices } from '@playwright/test'; test.describe('Mobile Visual Regression', () => { const mobileDevices = [ { name: 'iphone-se', config: devices['iPhone SE'] }, { name: 'iphone-14', config: devices['iPhone 14'] }, { name: 'pixel-7', config: devices['Pixel 7'] }, { name: 'ipad-pro', config: devices['iPad Pro 11'] }, ]; for (const device of mobileDevices) { test(`homepage visual regression on ${device.name}`, async ({ browser }) => { const context = await browser.newContext(device.config); const page = await context.newPage(); await page.goto('/'); await page.waitForLoadState('networkidle'); await expect(page).toHaveScreenshot(`homepage-${device.name}.png`, { fullPage: true, maxDiffPixelRatio: 0.05, animations: 'disabled', }); await context.close(); }); } test('should match screenshots in dark mode on mobile', async ({ browser }) => { const context = await browser.newContext({ ...devices['iPhone 14'], colorScheme: 'dark', }); const page = await context.newPage(); await page.goto('/'); await expect(page).toHaveScreenshot('homepage-mobile-dark.png', { maxDiffPixelRatio: 0.05, }); await context.close(); }); });
Best Practices
- Always use Playwright device descriptors -- Do not manually set viewport and user agent. Use
for accurate emulation including scale factor and touch support.devices['iPhone 14'] - Test on both iOS and Android profiles -- Safari WebKit and Chrome behave differently. Always include both iPhone and Pixel device projects.
- Account for the safe area -- Modern phones have notches and rounded corners. Verify content is not obscured by safe area insets.
- Test both orientations -- Many layout bugs only appear in landscape mode. Include landscape variants in your test projects.
- Throttle network in CI -- Mobile users are not always on fast Wi-Fi. Run a subset of tests with slow 3G throttling to catch loading issues.
- Verify touch target sizes -- All interactive elements should be at least 44x44 CSS pixels. Automate this check in your test suite.
- Test with reduced motion -- Many mobile users enable reduced motion. Verify animations respect
media query.prefers-reduced-motion - Use visual regression per device -- Capture and compare screenshots across all target devices, not just one.
- Test scrolling behavior -- Verify sticky headers, infinite scroll, pull-to-refresh, and scroll-to-top work correctly on mobile.
- Test font scaling -- Mobile users may increase text size in system settings. Verify layout does not break at 150% or 200% font scaling.
Anti-Patterns to Avoid
- Testing only at one viewport size -- A 375px test does not cover 320px edge cases or tablet layouts.
- Using mouse events instead of touch --
works but does not simulate real touch behavior for gesture-dependent UIs.page.click() - Ignoring device pixel ratio -- Screenshots and visual tests must account for different
values.deviceScaleFactor - Hardcoding viewport dimensions -- Use device descriptors instead of magic numbers like
.{ width: 390, height: 844 } - Not testing offline scenarios -- Mobile connections drop frequently. Always verify graceful degradation.
- Skipping landscape orientation tests -- Many responsive bugs only manifest in landscape mode.
- Testing desktop-only selectors -- Mobile layouts often hide desktop elements and show mobile-specific components. Test the correct elements.
- Ignoring scroll position after navigation -- Mobile pages should scroll to top on navigation. Verify this behavior.
- Not testing the virtual keyboard impact -- Soft keyboards reduce available viewport. Ensure forms remain usable when the keyboard is open.
- Running all tests on all devices -- Be strategic. Run smoke tests on all devices but detailed tests on representative devices to keep CI fast.
Running Mobile Tests
- Run all mobile tests:
npx playwright test --config=playwright.config.ts - Run tests for a specific device:
npx playwright test --project=iphone-14 - Run in headed mode to see mobile emulation:
npx playwright test --headed --project=pixel-7 - Update visual snapshots:
npx playwright test --update-snapshots - Debug a mobile test:
npx playwright test --debug --project=iphone-14 - View trace for failed test:
npx playwright show-trace test-results/trace.zip - Generate code with mobile emulation:
npx playwright codegen --device="iPhone 14" https://example.com - Run responsive breakpoint tests only:
npx playwright test tests/mobile/responsive/