Source profileQuality 93/100

PramodDutta/qaskills/seed-skills/playwright-mobile-web/SKILL.md

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.

Source repository stars
203
Declared platforms
4
Static risk flags
1
Last source update
2026-08-23
Source checked
2026-08-25

Decision brief

What it does: where it fits

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.

Best for

    Not for

    • Testing only at one viewport size -- A 375px test does not cover 320px edge cases or tablet layouts.
    • Using mouse events instead of touch -- page.click() works but does not simulate real touch behavior for gesture-dependent UIs.

    Compatibility matrix

    Platform support, with evidence labels

    PlatformStatusEvidenceWhat to check
    CodexDeclaredSource recordInstall path and trigger
    Claude CodeDeclaredSource recordInstall path and trigger
    CursorDeclaredSource recordInstall path and trigger
    Gemini CLIDeclaredSource recordInstall path and trigger
    Open the compatibility checker

    Installation

    Inspect first. Install second.

    The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.

    Source-detected install commandSource
    npx skills add https://github.com/PramodDutta/qaskills --skill "seed-skills/playwright-mobile-web"
    Safe inspection promptEditorial

    Inspect the Agent Skill "Playwright Mobile Web Testing" from https://github.com/PramodDutta/qaskills/blob/287bbfb352a6384e95db04996d4d92cbe40669f0/seed-skills/playwright-mobile-web/SKILL.md at commit 287bbfb352a6384e95db04996d4d92cbe40669f0. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.

    Workflow

    What the source asks the agent to do

    1. 01

      Core Principles

      1. 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. 2. Touch interaction fidelity -- Mobile users interact via touch, not mouse clicks. Simulate ta…

      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).
    2. 02

      Project Structure

      Always organize mobile web testing projects with this structure:

      Always organize mobile web testing projects with this structure:
    3. 03

      Device Emulation Configuration

      Review the “Device Emulation Configuration” section in the pinned source before continuing.

      Review and apply the “Device Emulation Configuration” source section.
    4. 04

      Playwright Config with Mobile Projects

      Review the “Playwright Config with Mobile Projects” section in the pinned source before continuing.

      Review and apply the “Playwright Config with Mobile Projects” source section.
    5. 05

      Custom Device Profiles

      Review the “Custom Device Profiles” section in the pinned source before continuing.

      Review and apply the “Custom Device Profiles” source section.

    Permission review

    Static risk signals and limitations

    Network access

    medium · line 69

    The documentation includes network, browsing, or remote request actions.

    baseURL: process.env.BASE_URL || 'http://localhost:3000',

    Network access

    medium · line 120

    The documentation includes network, browsing, or remote request actions.

    url: 'http://localhost:3000',

    Evidence record

    Why each signal appears

    EvidenceSourceComputedTestedEditorial
    SignalValueEvidence typeMeaning
    Quality score93/100ComputedDocumentation, specificity, maintenance, and trust rules
    Repository stars203SourceRepository attention, not individual Skill quality
    Compatibility4 platformsSourceDeclared in the catalog source record
    Usage guideautomated source guideEditorialGenerated or reviewed according to the visible evidence level

    Pinned source

    Provenance and original SKILL.md

    Repository
    PramodDutta/qaskills
    Skill path
    seed-skills/playwright-mobile-web/SKILL.md
    Commit
    287bbfb352a6384e95db04996d4d92cbe40669f0
    License
    MIT
    Collected
    2026-08-25
    Default branch
    main
    View the original SKILL.md

    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

    1. 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.
    2. Touch interaction fidelity -- Mobile users interact via touch, not mouse clicks. Simulate tap, swipe, pinch, and long-press gestures accurately.
    3. 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).
    4. Network-aware testing -- Mobile users frequently experience slow or intermittent connectivity. Test under throttled network conditions.
    5. Orientation handling -- Test both portrait and landscape orientations. Verify layout adapts correctly when orientation changes.
    6. Performance-conscious -- Mobile devices have less processing power. Monitor and assert on performance metrics like LCP, FID, and CLS.
    7. 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('[email protected]');
        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

    1. Always use Playwright device descriptors -- Do not manually set viewport and user agent. Use devices['iPhone 14'] for accurate emulation including scale factor and touch support.
    2. Test on both iOS and Android profiles -- Safari WebKit and Chrome behave differently. Always include both iPhone and Pixel device projects.
    3. Account for the safe area -- Modern phones have notches and rounded corners. Verify content is not obscured by safe area insets.
    4. Test both orientations -- Many layout bugs only appear in landscape mode. Include landscape variants in your test projects.
    5. 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.
    6. Verify touch target sizes -- All interactive elements should be at least 44x44 CSS pixels. Automate this check in your test suite.
    7. Test with reduced motion -- Many mobile users enable reduced motion. Verify animations respect prefers-reduced-motion media query.
    8. Use visual regression per device -- Capture and compare screenshots across all target devices, not just one.
    9. Test scrolling behavior -- Verify sticky headers, infinite scroll, pull-to-refresh, and scroll-to-top work correctly on mobile.
    10. 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

    1. Testing only at one viewport size -- A 375px test does not cover 320px edge cases or tablet layouts.
    2. Using mouse events instead of touch -- page.click() works but does not simulate real touch behavior for gesture-dependent UIs.
    3. Ignoring device pixel ratio -- Screenshots and visual tests must account for different deviceScaleFactor values.
    4. Hardcoding viewport dimensions -- Use device descriptors instead of magic numbers like { width: 390, height: 844 }.
    5. Not testing offline scenarios -- Mobile connections drop frequently. Always verify graceful degradation.
    6. Skipping landscape orientation tests -- Many responsive bugs only manifest in landscape mode.
    7. Testing desktop-only selectors -- Mobile layouts often hide desktop elements and show mobile-specific components. Test the correct elements.
    8. Ignoring scroll position after navigation -- Mobile pages should scroll to top on navigation. Verify this behavior.
    9. Not testing the virtual keyboard impact -- Soft keyboards reduce available viewport. Ensure forms remain usable when the keyboard is open.
    10. 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/

    Frequently asked questions

    What to verify before installation and use

    What does the Playwright Mobile Web Testing source document cover?

    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.

    How do I install Playwright Mobile Web Testing?

    The source record exposes this install command: npx skills add https://github.com/PramodDutta/qaskills --skill "seed-skills/playwright-mobile-web". Inspect the command and pinned source before running it.

    Which Agent platforms does the source record declare?

    The pinned source record declares support for: codex, claude code, cursor, gemini cli.

    Which permission-related actions were detected?

    Static rules flagged network in the source; the page lists the matching lines and excerpts.

    Alternatives

    Compare before choosing