IKivan-kozenko -aqa
Try yourself as a QA tester
All topics·Advanced·21 / 24

Touch events (legacy)

When an app handles legacy Touch Events — not Pointer Events — for swipe, pinch, and tap gestures, I dispatch them manually with locator.dispatchEvent('touchstart'/'touchmove'/'touchend'). The key gotcha: dispatchEvent doesn't set Event.isTrusted. If the app gates behavior on that property to detect automation, you'll need to disable that check during tests.

When to use manual touch dispatch

Modern apps typically use Pointer Events, which Playwright handles automatically with locator.tap() when hasTouch: true is set in device emulation. Manual dispatch is only needed for older apps that specifically listen for touchstart, touchmove, and touchend events on DOM elements.

Each touch event needs a list of Touch points with at minimum identifier, clientX, and clientY. Three lists are required: touches (all current touches), changedTouches (touches that changed this event), and targetTouches (touches on the target element).

Pan gesture (swipe)

A pan is one touch point moving across the element. The pattern: touchstart at the center, multiple touchmove events stepping toward the target offset, then touchend. I compute the element center from getBoundingClientRect() and move by deltaX/deltaY across steps increments:

js
test.use({ ...devices['Pixel 7'] });

async function pan(locator: Locator, deltaX?: number, deltaY?: number, steps?: number) {
  const { centerX, centerY } = await locator.evaluate((target: HTMLElement) => {
    const bounds = target.getBoundingClientRect();
    const centerX = bounds.left + bounds.width / 2;
    const centerY = bounds.top + bounds.height / 2;
    return { centerX, centerY };
  });

  // Providing only clientX and clientY as the app only cares about those.
  const touches = [{
    identifier: 0,
    clientX: centerX,
    clientY: centerY,
  }];
  await locator.dispatchEvent('touchstart',
      { touches, changedTouches: touches, targetTouches: touches });

  steps = steps ?? 5;
  deltaX = deltaX ?? 0;
  deltaY = deltaY ?? 0;
  for (let i = 1; i <= steps; i++) {
    const touches = [{
      identifier: 0,
      clientX: centerX + deltaX * i / steps,
      clientY: centerY + deltaY * i / steps,
    }];
    await locator.dispatchEvent('touchmove',
        { touches, changedTouches: touches, targetTouches: touches });
  }

  await locator.dispatchEvent('touchend');
}

test('pan gesture to move the map', async ({ page }) => {
  await page.goto('/dashboard');
  const map = page.locator('[data-test-id="met"]');
  for (let i = 0; i < 5; i++)
    await pan(map, 200, 100);
  await expect(map).toHaveScreenshot();
});

Pinch gesture (zoom)

A pinch uses two touch points. For pinch-in (zoom out), the points start far apart and move toward each other. For pinch-out (zoom in), they start close and move apart. Each step updates both touch points simultaneously in the touchmove event:

js
test.use({ ...devices['Pixel 7'] });

async function pinch(locator: Locator,
  arg: { deltaX?: number, deltaY?: number, steps?: number, direction?: 'in' | 'out' }) {
  const { centerX, centerY } = await locator.evaluate((target: HTMLElement) => {
    const bounds = target.getBoundingClientRect();
    const centerX = bounds.left + bounds.width / 2;
    const centerY = bounds.top + bounds.height / 2;
    return { centerX, centerY };
  });

  const deltaX = arg.deltaX ?? 50;
  const steps = arg.steps ?? 5;
  const stepDeltaX = deltaX / (steps + 1);

  // Two touch points equally distant from the center of the element.
  const touches = [
    {
      identifier: 0,
      clientX: centerX - (arg.direction === 'in' ? deltaX : stepDeltaX),
      clientY: centerY,
    },
    {
      identifier: 1,
      clientX: centerX + (arg.direction === 'in' ? deltaX : stepDeltaX),
      clientY: centerY,
    },
  ];
  await locator.dispatchEvent('touchstart',
      { touches, changedTouches: touches, targetTouches: touches });

  for (let i = 1; i <= steps; i++) {
    const offset = (arg.direction === 'in' ? (deltaX - i * stepDeltaX) : (stepDeltaX * (i + 1)));
    const touches = [
      {
        identifier: 0,
        clientX: centerX - offset,
        clientY: centerY,
      },
      {
        identifier: 0,
        clientX: centerX + offset,
        clientY: centerY,
      },
    ];
    await locator.dispatchEvent('touchmove',
        { touches, changedTouches: touches, targetTouches: touches });
  }

  await locator.dispatchEvent('touchend', { touches: [], changedTouches: [], targetTouches: [] });
}

test('pinch in gesture to zoom out the map', async ({ page }) => {
  await page.goto('/dashboard');
  const map = page.locator('[data-test-id="met"]');
  for (let i = 0; i < 5; i++)
    await pinch(map, { deltaX: 40, direction: 'in' });
  await expect(map).toHaveScreenshot();
});