Testing touch gestures with Playwright

Recently I needed to find a way to implement an automated test that ensured that the pinch-to-zoom gesture (i.e. placing two fingers on the screen and moving them apart to zoom in) worked on a page in mobile browsers. “But,” I hear you object, “why would you want to test pinch-to-zoom? It’s a native browser feature. It doesn’t make sense to test it.” Yes, it is true that the zoom gesture is a browser feature. And it is also true that we don’t need to test it’s implementation. But it is also the case that our own page can contain code that disables the native behavior. In our particular case we were including a third party library that contained some CSS that disabled some gestures in order for that library to handle them itself. This piece of CSS sneaked in as part of an update and kept unnoticed for quite some time. When we realized that zooming in on the page did not work anymore we decided to look into creating an automated test to prevent this issue to come up again in the future.

Playwright and the Chrome DevTools Protocol

To simulate a pinch-to-zoom gesture we need to dispatch two touchstart events (one for the thumb and one for the index finger) followed by touchmove events that represent moving the fingers apart and finally two touchend events for lifting the fingers up and thus ending the gesture. Unfortunately Playwright does not support these touch events directly (as of version 1.43.1). While the Mouse class offers methods like down(), move() and up() for clicking and dragging with a mouse, the Touchscreen class only implements a tap() method for simple one-finger taps on the screen.

We found a workaround for this limitation though. Playwright provides the CDPSession class. It enables Playwright tests to issue raw Chrome DevTools Protocol (CDP) commands. Among other things these commands include a variety of input events. It’s important to point out that the Chrome DevTools Protocol is browser dependent and can not be used for testing Firefox. This was acceptable in our use case because we wanted to detect if some code (CSS or otherwise) broke the native zoom behavior. Of course we would not detect an issue with pinch-to-zoom that only affects non-Chrome browsers. While this is certainly not ideal it’s better than nothing and it was able to detect the actual issue at hand.

CDP provides a single command for triggering pinch-to-zoom gestures: Input.synthesizePinchGesture. It accepts coordinates for the center of the gesture as well as a scale factor which determines whether the page should be zoomed in (i.e. moving the fingers apart) or zoomed out (i.e. moving the fingers towards each other). Unfortunately it is marked as experimental and we had issues with it in our CI/CD pipeline. Instead we simulated the pinch gesture using three Input.dispatchTouchEvent commands: the first puts two fingers on the screen (touchStart), the second moves them apart (touchMove), and the third lifts them from the screen (touchEnd).

// The point where the thumb and index finger start the gesture
const pinchStart = { x: 200, y: 200 };

// The point where the thumb ends the gesture
const thumbPinchEnd = { x: 100, y: 100 };

// The point where the index finger ends the gesture
const indexFingerPinchEnd = { x: 300, y: 300 };

const cdpSession = await page.context().newCDPSession(page);
await cdpSession.send("Input.dispatchTouchEvent", {
  type: "touchStart",
  touchPoints: [pinchStart, pinchStart],
});
await cdpSession.send("Input.dispatchTouchEvent", {
  type: "touchMove",
  touchPoints: [thumbPinchEnd, indexFingerPinchEnd],
});
await cdpSession.send("Input.dispatchTouchEvent", {
  type: "touchEnd",
  touchPoints: [],
});

Determining if the page is zoomed in

What has been sort of complicated in the past and required a fair bit of knowledge about browser geometry and things like the difference between the visual and the layout viewport has become rather straightforward with the introduction of the VisualViewport API. It provides a scale property that tells you the scale factor right away. 1 if the page is not zoomed at all, a number between 0 and 1 if it is zoomed out and a number greater than 1 if it is zoomed in.

We can query this value from the browser by using Playwright’s evaluate method:

const scaleFactor = await page.evaluate(() => {
  return visualViewport?.scale;
});

We use the optional chaining operator ? because the visualViewport object can be null (in case the document is not fully active) and otherwise a type aware IDE will complain. Because Playwright waits for the page to be active when we use await page.goto(…) we don’t need to worry about that. If you use TypeScript you can alternatively use the non-null assertion operator ! instead.

The final test:

import { test, expect } from "@playwright/test";

const pinchToZoomGesture = async (page) => {
  const pinchStart = { x: 200, y: 200 };
  const thumbPinchEnd = { x: 100, y: 100 };
  const indexFingerPinchEnd = { x: 300, y: 300 };

  const cdpSession = await page.context().newCDPSession(page);
  await cdpSession.send("Input.dispatchTouchEvent", {
    type: "touchStart",
    touchPoints: [pinchStart, pinchStart],
  });
  await cdpSession.send("Input.dispatchTouchEvent", {
    type: "touchMove",
    touchPoints: [thumbPinchEnd, indexFingerPinchEnd],
  });
  await cdpSession.send("Input.dispatchTouchEvent", {
    type: "touchEnd",
    touchPoints: [],
  });
};

test("page supports pinch-to-zoom gesture", async ({ page, browserName }) => {
  // We need to skip this test on non-Chromium browsers because the Chrome
  // DevTools Protocol (CDP) that we use to trigger touch events is only
  // available in Chromium browsers.
  test.skip(browserName !== "chromium");

  // Replace `playwright.dev` with the actual page under test.
  await page.goto("https://playwright.dev/");
  await pinchToZoomGesture(page);

  const scaleFactor = await page.evaluate(() => {
    return visualViewport?.scale;
  });

  expect(scaleFactor).toBeGreaterThan(1);
});