WebXR API Testing with Playwright and Mock Devices

WebXR API Testing with Playwright and Mock Devices

WebXR applications are notoriously hard to test because they require browser APIs that only activate with real hardware. This guide shows how to use Playwright's WebXR emulation to test immersive sessions, controller input, hit testing, and XRSession lifecycle — all in CI without a headset.

WebXR brings immersive experiences to the browser, but it introduces a testing gap that trips up most teams. The navigator.xr.requestSession() API needs real hardware to return a valid session, and that means your test suite either skips XR paths entirely or relies on manual QA with a physical headset. Neither is acceptable at scale.

Playwright addresses this with built-in WebXR device emulation, available since Chromium 120. Combined with TypeScript and the right session configuration, you can drive full immersive-vr and immersive-ar sessions headlessly in CI.

Setting Up Playwright for WebXR

Start with a fresh Playwright project or add it to an existing one:

npm init playwright@latest
# or add to existing project
npm install --save-dev @playwright/test
npx playwright install chromium

WebXR emulation requires Chromium — Firefox and Safari/WebKit do not yet expose the emulation API. Your playwright.config.ts should restrict the project accordingly:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: 'http://localhost:3000',
  },
  projects: [
    {
      name: 'chromium-webxr',
      use: {
        ...devices['Desktop Chrome'],
        // Required for WebXR emulation
        launchOptions: {
          args: [
            '--enable-features=WebXR',
            '--enable-blink-features=WebXRIncubations',
          ],
        },
      },
    },
  ],
});

Creating a Fake XR Device

Playwright exposes page.emulateXRDevice() (Chromium DevTools Protocol XR.initialize) to inject a virtual XR device into the browser. You configure the device descriptor with the features your app uses:

import { test, expect, Page } from '@playwright/test';

async function setupXRDevice(page: Page) {
  const session = await page.context().newCDPSession(page);

  await session.send('XR.initialize', {
    deviceAttributes: {
      name: 'Test XR Device',
      supportedModes: ['inline', 'immersive-vr', 'immersive-ar'],
      views: [
        {
          eye: 'left',
          projectionMatrix: [
            1.0, 0, 0, 0,
            0, 1.0, 0, 0,
            0, 0, -1.0, -1.0,
            0, 0, -0.2, 0,
          ],
          viewOffset: {
            position: [-0.032, 0, 0],
            orientation: [0, 0, 0, 1],
          },
          resolution: { width: 1832, height: 1920 },
        },
        {
          eye: 'right',
          projectionMatrix: [
            1.0, 0, 0, 0,
            0, 1.0, 0, 0,
            0, 0, -1.0, -1.0,
            0, 0, -0.2, 0,
          ],
          viewOffset: {
            position: [0.032, 0, 0],
            orientation: [0, 0, 0, 1],
          },
          resolution: { width: 1832, height: 1920 },
        },
      ],
      features: ['viewer', 'local', 'local-floor', 'bounded-floor', 'hit-test', 'anchors'],
      inputSources: [],
      environmentBlendMode: 'opaque',
    },
  });

  return session;
}

Testing XRSession Lifecycle

The most fundamental test is whether your app can request, enter, and exit an XR session without throwing. Many WebXR bugs occur at session boundaries:

test('app enters and exits immersive-vr session', async ({ page }) => {
  const cdpSession = await setupXRDevice(page);
  await page.goto('/');

  // Verify XR support is detected
  const isSupported = await page.evaluate(async () => {
    return await navigator.xr?.isSessionSupported('immersive-vr');
  });
  expect(isSupported).toBe(true);

  // Click the "Enter VR" button (or invoke directly)
  await page.click('[data-testid="enter-vr"]');

  // Verify the app is in XR mode
  await expect(page.locator('[data-testid="vr-ui-overlay"]')).toBeVisible();
  await expect(page.locator('[data-testid="enter-vr"]')).toBeHidden();

  // Simulate the user pressing the headset's back/menu button to exit
  await cdpSession.send('XR.sessionEnded', {});

  // Verify the app returns to flat mode cleanly
  await expect(page.locator('[data-testid="enter-vr"]')).toBeVisible({ timeout: 3000 });
});

Simulating Controller Input

Input sources are added to the fake device via the CDP XR.addInputSource command. You can configure controllers with button, axis, and pose data:

test('right trigger selects object', async ({ page }) => {
  const cdpSession = await setupXRDevice(page);
  await page.goto('/');
  await page.click('[data-testid="enter-vr"]');

  // Add a right-hand controller
  await cdpSession.send('XR.addInputSource', {
    inputSource: {
      handedness: 'right',
      targetRayMode: 'tracked-pointer',
      profiles: ['oculus-touch-v3', 'generic-trigger-squeeze-thumbstick'],
      buttons: [
        { pressed: false, touched: false, value: 0 }, // trigger
        { pressed: false, touched: false, value: 0 }, // squeeze
        { pressed: false, touched: false, value: 0 }, // thumbstick
        { pressed: false, touched: false, value: 0 }, // X/A button
        { pressed: false, touched: false, value: 0 }, // Y/B button
      ],
      axes: [0, 0, 0, 0], // thumbstick x/y, touchpad x/y
      gripPose: {
        position: [0.3, 1.2, -0.5],
        orientation: [0, 0, 0, 1],
      },
      targetRayPose: {
        position: [0.3, 1.2, -0.5],
        orientation: [0, -0.1, 0, 0.99],
      },
    },
  });

  // Simulate trigger press (button index 0, value 1.0)
  await cdpSession.send('XR.updateInputSource', {
    buttons: [
      { pressed: true, touched: true, value: 1.0 },
    ],
  });

  // Verify selection event was handled
  await expect(page.locator('[data-testid="selected-object-info"]'))
    .toContainText('Object Selected');
});

Testing Immersive AR and Hit Testing

AR sessions add hit testing against a simulated environment mesh. Configure hit test results via CDP:

test('ar hit test places object on surface', async ({ page }) => {
  const cdpSession = await setupXRDevice(page);

  // Override device for AR
  await cdpSession.send('XR.initialize', {
    deviceAttributes: {
      supportedModes: ['inline', 'immersive-ar'],
      environmentBlendMode: 'alpha-blend',
      features: ['viewer', 'local', 'hit-test'],
    },
  });

  await page.goto('/ar-placement');
  await page.click('[data-testid="enter-ar"]');

  // Simulate a hit test result at a specific world position
  await cdpSession.send('XR.setHitTestResults', {
    results: [
      {
        hitMatrix: [
          1, 0, 0, 0,
          0, 1, 0, 0,
          0, 0, 1, 0,
          0.5, 0, -1.0, 1,  // position: x=0.5, y=0, z=-1
        ],
      },
    ],
  });

  // The app should show a placement indicator at the hit position
  await expect(page.locator('[data-testid="placement-indicator"]'))
    .toHaveAttribute('data-world-pos', '0.50,0.00,-1.00');

  // Simulate tap to place
  await page.tap('[data-testid="ar-canvas"]');

  await expect(page.locator('[data-testid="placed-object"]')).toBeVisible();
});

Testing WebGL Context and Rendering

WebXR requires a WebGL2 context. Test that your app falls back gracefully when it's unavailable:

test('shows fallback when WebGL2 unavailable', async ({ page }) => {
  // Disable WebGL before loading the page
  await page.addInitScript(() => {
    HTMLCanvasElement.prototype.getContext = function (type: string) {
      if (type === 'webgl2' || type === 'webgl') return null;
      return HTMLCanvasElement.prototype.getContext.call(this, type);
    };
  });

  await page.goto('/');

  await expect(page.locator('[data-testid="webgl-unsupported-message"]'))
    .toBeVisible();
  await expect(page.locator('[data-testid="enter-vr"]')).toBeDisabled();
});

XRSession State Machine Testing

WebXR sessions go through pending → running → ended states. Test the transitions explicitly:

test('session state transitions fire in correct order', async ({ page }) => {
  const cdpSession = await setupXRDevice(page);
  await page.goto('/');

  const events: string[] = await page.evaluate(() => {
    return new Promise<string[]>((resolve) => {
      const log: string[] = [];

      navigator.xr!.requestSession('immersive-vr').then((session) => {
        log.push('session:start');
        session.addEventListener('end', () => {
          log.push('session:end');
          resolve(log);
        });

        // Request one animation frame then end the session
        session.requestAnimationFrame(() => {
          log.push('session:frame');
          session.end();
        });
      });
    });
  });

  expect(events).toEqual(['session:start', 'session:frame', 'session:end']);
});

Running WebXR Tests in CI

GitHub Actions with Chromium works well. The key is ensuring Chromium is installed and that the correct flags are passed:

# .github/workflows/webxr-tests.yml
name: WebXR Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Start dev server
        run: npm run dev &
        env:
          PORT: 3000

      - name: Wait for server
        run: npx wait-on http://localhost:3000

      - name: Run WebXR tests
        run: npx playwright test --project=chromium-webxr

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Puppeteer Alternatives

If you are on Puppeteer rather than Playwright, the underlying CDP commands are identical — XR.initialize, XR.addInputSource, XR.setHitTestResults. The difference is that Playwright wraps them more ergonomically. With Puppeteer:

const browser = await puppeteer.launch({
  args: ['--enable-features=WebXR'],
});
const page = await browser.newPage();
const cdpSession = await page.createCDPSession();
await cdpSession.send('XR.initialize', { deviceAttributes: { /* ... */ } });

The test logic is the same; only the session API differs.

What to Test and What to Skip

Test: session lifecycle, input event handling, state management, fallback behavior, spatial audio init, WebGL context creation, and your app's XR entry/exit flows.

Skip in unit tests: actual rendering quality, frame timing accuracy, and anything requiring GPU rasterization. Those belong in device testing or visual regression with a real headset.

HelpMeTest can layer continuous monitoring on top of your Playwright suite, running your WebXR smoke tests on a schedule and alerting you when a browser update or CDN change breaks your immersive experience entry flow.

Read more