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 chromiumWebXR 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.