Test with simulated phones
Use an installed Chrome and the SDK's Node driver. It sends real touch input to the browser controller. It uses no Puppeteer, Playwright or browser downloads.
Run the complete local example from the SDK checkout:
node --test examples/testing/private-choice.test.mjs
The game (in the SDK download: examples/testing/game.mjs) uses ControllerHost. The test (in the SDK download: examples/testing/private-choice.test.mjs) opens two phones, taps their canvas keys, checks private receipts and feedback, rotates a phone, resumes a seat and checks held input. It closes Chrome and the host even if an assertion fails.
Page API v1
Add ?test before the join link's # fragment. The controller exposes one read-only object, window.controllerPlusTest. Normal pages expose no test object. The page removes the invitation from its address after joining; the object stays available until navigation.
| Member | Result |
|---|---|
version |
1. Check this before using the API. |
ready({ timeoutMs = 10000 } = {}) |
A promise for { playerId, room, viewKind } after seating and receiving a view. Rejects with the error shown on the page. A timeout also rejects. |
controls() |
All current touch controls. Each has id, kind, label, rect and center. Disabled controls also have disabled: true. Entry and options screens return no game controls. |
snapshot() |
{ version, connected, playerId, room, viewKind, mode, rotation, view, state, error, output }. state is the last public game state. view is this phone's private view. |
feedback() |
Up to 1,000 received feedback frames, oldest first. Each entry has sequence, receivedAt (Unix milliseconds), id, frame, result and output. |
rect is [left, top, width, height]. center is [x, y]. Both use viewport CSS pixels, including element offsets and portrait rotation. Do not multiply by device pixel ratio. Sticks include drag.x and drag.y: viewport displacement vectors for a unit axis movement. Wheels include an arc for drag steering.
Gamepad IDs match the input API: face.south, dpad.up, stick.left, stick.right, trigger.right, and so on. options opens the menu. Wheel mode adds wheel, calibrate and tilt; pedals use trigger.left and trigger.right. Flight mode uses stick.left, throttle, calibrate and the face buttons.
Custom surface keys use their declared key IDs. Give keys distinct IDs. Parts without IDs use their layout paths. Keyboard keys use char:a, char:b, enter:, delete: and similar IDs. Duplicate key IDs are qualified with the part path. IDs based on paths stay stable while the layout stays the same. The old window.__drawnForTest.targets() hook remains available for surface captures. Its coordinates now include rotation too.
The view is the view actually rendered. A host's choices or waiting view may become a surface through SDK compatibility selection. For control IDs you own, send an explicit surface. See custom controllers and private views.
Each feedback entry records the original frame and the result the page sent, such as { id, status: 'played' }. result stays null if no result was sent. Stop frames and frames without IDs do not get a receipt. Use distinct request IDs when checking receipts. Logs reset on navigation. Returned observations are copies.
output reports headless, audioAPI, audioState, soundEnabled, vibrationAPI and vibrationEnabled. physicalSound and physicalVibration are false in headless Chrome. In other browsers they are null, meaning unverified. A played receipt can mean a Web Audio buffer started; it does not prove audible sound or motor movement. See feedback.
Node driver
The driver requires Node 22 or newer on macOS or Linux. It uses Chrome's DevTools pipe and starts one headless Chrome. Each phone gets a separate browser context, local storage and session storage. Seats and resume tickets cannot mix between phones.
Import it from @controller-plus/sdk/testing. Inside an SDK checkout, ../testing/index.mjs from examples/ works too. The driver and its TypeScript declarations live in testing/ (in the SDK download: testing/).
import { withControllerTest, waitFor } from '@controller-plus/sdk/testing';
withControllerTest(options, async driver => { ... }) closes the driver in finally. Use it around the whole test. launchControllerTest(options) is also available when a test framework owns teardown; register driver.close() immediately.
Driver options are chromePath, tmpRoot, timeoutMs and noSandbox. Chrome discovery checks the explicit path, CHROME_PATH, CONTROLLER_PLUS_CHROME, then standard installed paths. findChrome() returns null if none exists. No download occurs. tmpRoot defaults to CONTROLLER_PLUS_TEST_TMP or an OS temporary directory.
| Call | Behavior |
|---|---|
driver.openPhone(joinURL, options?) |
Opens a new isolated phone and adds ?test. Call ready() before interacting. |
driver.openPhones(joinURL, count, options?) |
Opens 1–16 isolated phones in the same Chrome. |
driver.openPage(url, { width, height }?) |
Opens your game's own page (the lobby or the game screen) in the same Chrome at a desktop size (1280 x 720 by default), for a test that watches the game and the phones together. The page has evaluate(expression), waitFor(expression), screenshot(path), errors() and close(). |
phone.ready() |
Waits for seating and layout. Returns the page's seat record. |
phone.controls(), snapshot(), feedback() |
Reads the page API. |
phone.tap(id), hold(id, ms) |
Touches the control's real viewport position and releases it. |
phone.press(id), release(id?) |
Holds or releases a touch. Omit the release ID to lift all fingers. Multiple controls can be held together. |
phone.stick(x, y, id?) |
Drags a stick with real touches and keeps holding. Axes range from −1 to 1. Defaults to stick.left, or wheel in wheel mode. Wheel steering uses x with y = 0. |
phone.rotate('portrait' | 'landscape') |
Lifts held fingers, swaps phone dimensions and waits for layout. |
phone.reload() |
Reloads the page, as a phone's browser does to a background tab. The controller takes the same seat again (it keeps the invitation and resume ticket in the tab). Returns the resumed seat. |
phone.screenshot(path) |
Writes a PNG to the supplied path. The caller owns this capture. |
phone.errors() |
Returns uncaught page errors observed by DevTools. |
phone.close(), driver.close() |
Closes one context or kills Chrome and removes its temporary profile. Safe to call again. |
Phone options are width, height (default 390 × 844), deviceScaleFactor (default 1), orientation, mode (gamepad, wheel, flight) and turn (90 or -90). Mode and turn initialize the browser's existing player preferences before navigation. Portrait phones rotate the controller clockwise by default. Use turn: -90 to test the opposite portrait rotation.
The driver reads test observations and sends DevTools touch events. It never calls game event handlers or edits game state. Wait for a game observation after input; dispatch completion does not mean the host has received the packet. waitFor(check, { timeoutMs, intervalMs, message }) polls until the check returns a truthy value.
Complete local test
Save this as examples/local-testing.mjs in the SDK checkout, then run node examples/local-testing.mjs. The server binds only to loopback. The port is 49300.
import http from 'node:http';
import assert from 'node:assert/strict';
import { ControllerHost, serveControllerAssets } from '../adapters/node-host.mjs';
import { surface, column, lcd, keys } from '../src/surface.mjs';
import { withControllerTest, waitFor } from '@controller-plus/sdk/testing';
const answers = new Map(), inputs = new Map();
const server = http.createServer(async (req, res) => {
if (await serveControllerAssets(req, res)) return;
res.writeHead(404); res.end();
});
const host = new ControllerHost({ server, origin: 'http://127.0.0.1:49300',
game: { id: 'local-test', name: 'Choose a route', controls: {} } });
const choiceView = answer => surface({ id: answer ? 'receipt' : 'choice', root: column([
lcd({ size: 100, title: answer ? 'Choice received' : 'Choose a route',
text: answer ? `You chose ${answer}` : 'Forest or river?' }),
keys(['forest', 'river'].map(id => ({ id, label: id, event: 'choice',
payload: { target: id }, disabled: !!answer })), { shape: 'tile', columns: 2 }),
]) });
host.setDefaultView(choiceView());
host.on('input', ({ playerId, input }) => inputs.set(playerId, input));
host.on('event', ({ playerId, event, payload }) => {
if (event !== 'choice' || answers.has(playerId) || !host.players.get(playerId)?.socket) return;
if (!['forest', 'river'].includes(payload.target)) return;
answers.set(playerId, payload.target);
host.setView(playerId, choiceView(payload.target));
host.publish({ answered: answers.size });
host.playFeedback(playerId, 'ui.confirm', { id: `choice-${playerId}` });
});
try {
await new Promise((resolve, reject) => {
server.once('error', reject); server.listen(49300, '127.0.0.1', resolve);
});
await withControllerTest({}, async driver => {
const [first, second] = await driver.openPhones(host.credentials().joinURL, 2);
const [a, b] = await Promise.all([first.ready(), second.ready()]);
assert.notEqual(a.playerId, b.playerId);
await first.tap('forest');
await waitFor(() => answers.get(a.playerId) === 'forest');
await waitFor(async () => (await first.feedback()).some(f => f.result?.id === `choice-${a.playerId}`));
assert.equal((await second.feedback()).length, 0);
await second.rotate('landscape');
await second.tap('river');
await waitFor(async () => (await second.snapshot()).state?.answered === 2);
assert.equal((await first.reload()).playerId, a.playerId);
await waitFor(async () => JSON.stringify((await first.snapshot()).view).includes('You chose forest'));
host.setView(a.playerId, { kind: 'gamepad', completeControls: true });
await waitFor(async () => (await first.controls()).some(c => c.id === 'face.south'));
await first.press('face.south');
try { await waitFor(() => inputs.get(a.playerId)?.buttons[0] === 1); }
finally { await first.release('face.south'); }
await waitFor(() => inputs.get(a.playerId)?.buttons[0] === 0);
});
} finally {
await host.close();
server.closeAllConnections();
if (server.listening) await new Promise(resolve => server.close(resolve));
}
Only the answer count is public. Each receipt stays in that player's view. The host retains that view for resume. For a larger game, connect the received input to the simulation as in the quickstart.
Cleanup and CI
withControllerTest removes the temporary profile when a test returns or throws. Exit handlers also kill Chrome and remove its profile on normal process exit, uncaught exceptions, SIGINT and SIGTERM. The driver uses a POSIX process group so Chrome's subprocesses stop too. An OS kill that gives Node no cleanup time, such as SIGKILL or a machine crash, cannot run these handlers. Use an ephemeral CI workspace for that case. Captures outside the temporary profile need their own teardown.
Run one browser test worker at a time. Reuse one driver for all phones. A second driver in the same Node process is rejected until the first closes. Keep tmpRoot on a disk with space for Chrome. The driver opens no debugging TCP port. The local example uses the first free port in 49300–49399.
Install Chrome in the CI image, or set CHROME_PATH to its installed executable. Then run:
export CONTROLLER_PLUS_TEST_TMP="$RUNNER_TEMP/controller-plus-tests"
node --test tests/testing*.test.mjs
node --test examples/testing/private-choice.test.mjs
npm test
The browser tests skip cleanly when Chrome is absent. A configured Chrome that fails to start is a failure. Use noSandbox: true only when a Linux container requires it. Prefer an image where Chrome can keep its sandbox.
Headless tests prove touch routing, control geometry, isolation, private delivery, public state, feedback receipts and seat resume. They do not prove vibration, audible sound, camera QR scanning, sensor permissions, real tilt, fullscreen behavior or a phone's safe area. Check those on physical devices. Sound remains muted in the driver. The tests do not grant permissions or fake hardware output.