UI Testing: Gameface E2E
Standard browser testing frameworks like Cypress and Playwright do not run inside Gameface. They speak to a browser engine and have no access to Gameface’s custom engine object, data binding models, or the Gameface Player process. The official gameface-e2e framework solves this: it uses the DevTools protocol to communicate directly with the Gameface Player, and exposes a gf object that replicates the full Gameface interaction surface in test code.
Why Standard Frameworks Do Not Work
Section titled “Why Standard Frameworks Do Not Work”Cypress and Playwright launch a Chromium or WebKit browser instance and drive it via CDP (Chrome DevTools Protocol). A Gameface UI runs inside the game engine’s process, not inside a browser. There is no Chromium instance to attach to, no standard window.engine that Playwright can read, and no way to mock the data binding models that your UI components depend on.
Gameface E2E bridges this gap by spawning the Gameface Player executable directly, connecting to it over the DevTools protocol that Gameface exposes, and wrapping that connection in a purpose-built JavaScript API. Your test code runs in Node.js. The gf object it imports sends commands to the live Player process and reads results back, which means every test runs against the actual Gameface renderer with the real CSS engine, layout system, and data binding layer.
This matters for three specific reasons:
- Data binding is testable. You can create mock models, push state changes into the UI, and assert that elements reflect the correct values.
- Engine events are simulatable.
gf.triggerEngineEventandgf.onEngineEventlet you drive and observe the same event bus your production code uses. - Gamepad input works. The
GamefaceGamepadAPI sends real gamepad events that flow through Gameface’s input system, so navigation tests for controller UIs are accurate.
Installation
Section titled “Installation”Add gameface-e2e to your project as a dev dependency:
npm install gameface-e2e --save-devNode.js 20 or higher is required. The framework itself runs in Node, not inside Gameface.
You also need a Gameface Player executable (Player.exe). This ships with the Gameface SDK package at ${GamefacePackage}/Player/Player.exe. The test runner spawns this executable, loads your UI into it, and communicates with it over WebSocket.
Configuration
Section titled “Configuration”A gameface-e2e-config.js file at your project root lets you avoid repeating the Player path and test file globs on every run. The following configuration sets up a typical project structure:
module.exports = { // Path to the Gameface Player executable gamefacePath: 'C:/Gameface/Player/Player.exe',
// Glob pattern matching your spec files tests: 'tests/**/*.spec.js',};With this file in place, running tests is a single command with no flags:
npx gameface-e2eIf the config file has a different name or location, pass it explicitly:
npx gameface-e2e --config=./tests/my-config.jsBoth gamefacePath and tests can also be passed directly as CLI flags when you want to override the config for a specific run:
npx gameface-e2e --gamefacePath=./Player/Player.exe --tests=tests/inventory.spec.jsWriting Tests
Section titled “Writing Tests”Tests follow the Mocha describe/it pattern. The gf object is available globally inside spec files. All gf methods are async, so every test body must be async and every gf call awaited.
The simplest possible test checks that an element contains the expected text:
describe('HUD', () => { it('should display the player health value', async () => { const healthLabel = await gf.get('.hud-health-value'); const text = await healthLabel.text(); assert.equal(text, '100'); });});gf.get() takes a CSS selector and returns a DOMElement object. element.text() returns the element’s visible text content as a string.
Interacting with Elements
Section titled “Interacting with Elements”The DOMElement API covers the full interaction surface: clicks, keyboard input, hover, scroll, drag, and more. This test clicks a menu button and asserts that a panel becomes visible:
describe('Main Menu', () => { it('should open the settings panel when Settings is clicked', async () => { const settingsBtn = await gf.get('.menu-btn--settings'); await settingsBtn.click();
const settingsPanel = await gf.get('.settings-panel'); await settingsPanel.waitForVisibility(true); });
it('should close the settings panel when Back is clicked', async () => { const backBtn = await gf.get('.settings-panel .btn-back'); await backBtn.click();
const settingsPanel = await gf.get('.settings-panel'); await settingsPanel.waitForVisibility(false); });});waitForVisibility(true) polls until the element is visible, accommodating CSS transitions and animations. Pass false to wait for it to become hidden.
Querying Multiple Elements
Section titled “Querying Multiple Elements”gf.getAll() returns a DOMElements collection. Use .first(), .last(), and .nth(index) to access individual items:
describe('Inventory', () => { it('should display five item slots', async () => { const slots = await gf.getAll('.inventory-slot'); const count = await slots.length; assert.equal(count, 5); });
it('should show the first slot as occupied', async () => { const firstSlot = await (await gf.getAll('.inventory-slot')).first(); const hasOccupied = await firstSlot.classes(); assert.include(hasOccupied, 'inventory-slot--occupied'); });});Testing Data-Bound UIs
Section titled “Testing Data-Bound UIs”The data binding layer is what separates Gameface UIs from static HTML pages, and it is the area where Gameface E2E provides the most value over any generic testing tool. The gf object exposes the same model API your production UI code uses.
Creating a Mock Model
Section titled “Creating a Mock Model”gf.createModel registers a data model in the running Gameface instance, exactly as the game engine would. This lets tests drive the UI with controlled data without needing a real game session:
describe('Player Stats Panel', () => { before(async () => { // Register the model the stats panel binds to await gf.createModel('PlayerStats', { health: 80, maxHealth: 100, ammo: 24, maxAmmo: 30, shield: 50, }); });
it('should render the health bar at 80%', async () => { const healthBar = await gf.get('.health-bar-fill'); // Wait for the bound style to reflect the model value await healthBar.waitForStyles({ width: '80%' }); });
it('should show ammo as 24/30', async () => { const ammoLabel = await gf.get('.ammo-counter'); await ammoLabel.waitForText('24 / 30'); });});Updating a Model and Asserting State Change
Section titled “Updating a Model and Asserting State Change”gf.updateModel pushes new values into an existing model and triggers the binding update cycle. This is the correct way to test reactive UI behavior:
describe('Player Stats Panel', () => { it('should update the health bar when health drops', async () => { await gf.updateModel('PlayerStats', { health: 25 });
const healthBar = await gf.get('.health-bar-fill'); await healthBar.waitForStyles({ width: '25%' });
// Low health should activate the danger class const hasLowHealthClass = await healthBar.classes(); assert.include(hasLowHealthClass, 'health-bar-fill--critical'); });
it('should show an empty ammo indicator when ammo reaches zero', async () => { await gf.updateModel('PlayerStats', { ammo: 0 });
const ammoCounter = await gf.get('.ammo-counter'); await ammoCounter.waitForText('0 / 30');
const emptyIndicator = await gf.get('.ammo-empty-indicator'); await emptyIndicator.waitForVisibility(true); });});Triggering and Listening for Engine Events
Section titled “Triggering and Listening for Engine Events”gf.triggerEngineEvent fires an event on the Gameface event bus as if it came from the game engine. gf.onEngineEvent registers a callback to capture events emitted by the UI code back to the engine. Together they allow full round-trip testing of event-driven components:
describe('Notification System', () => { it('should show a notification when ObjectiveCompleted fires', async () => { await gf.triggerEngineEvent('ObjectiveCompleted', { title: 'Objective Complete', description: 'Reached the extraction point.', });
const notification = await gf.get('.notification-toast'); await notification.waitForVisibility(true); await notification.waitForText('Objective Complete'); });
it('should emit DismissNotification when the close button is clicked', async () => { let capturedEvent = null; await gf.onEngineEvent('DismissNotification', (data) => { capturedEvent = data; });
const closeBtn = await gf.get('.notification-toast .btn-close'); await closeBtn.click();
assert.isNotNull(capturedEvent); });});Testing Gamepad Navigation
Section titled “Testing Gamepad Navigation”The GamefaceGamepad API creates a virtual gamepad and sends input events through Gameface’s input system. This is accurate test coverage for any UI that uses tabindex and the spatial navigation system.
Use gf.connectGamepad() to get a gamepad instance, then gamepad.press() and gamepad.hold() to send button input:
describe('Menu Gamepad Navigation', () => { let gamepad;
before(async () => { gamepad = await gf.connectGamepad(); });
after(async () => { await gamepad.disconnect(); });
it('should move focus to the next button when D-Pad Down is pressed', async () => { // Focus the first button const firstBtn = await gf.get('.menu-btn:first-child'); await firstBtn.focus();
// Press D-Pad Down await gamepad.press(gf.GAMEPAD_BUTTONS.DPAD_DOWN);
// The second button should now be focused const secondBtn = await gf.get('.menu-btn:nth-child(2)'); const isFocused = await secondBtn.isFocused(); assert.isTrue(isFocused); });
it('should confirm selection when A button is pressed', async () => { const confirmBtn = await gf.get('.menu-btn--confirm'); await confirmBtn.focus(); await gamepad.press(gf.GAMEPAD_BUTTONS.A);
const confirmationPanel = await gf.get('.confirmation-panel'); await confirmationPanel.waitForVisibility(true); });});The gf.GAMEPAD_BUTTONS constant provides named button identifiers matching the standard gamepad layout. Use gf.KEYS for keyboard key constants in the same way.
Waiting for Asynchronous State
Section titled “Waiting for Asynchronous State”Game UI state changes are rarely synchronous. An animation may need to finish before a panel is interactive, a model update may take one frame to reflect in the DOM, or a screen transition may play before the next view is accessible. The waitFor* methods on DOMElement handle this correctly:
| Method | What it waits for |
|---|---|
element.waitForVisibility(bool) | Element becomes visible or hidden |
element.waitForText(string) | Element’s text content matches the expected value |
element.waitForValue(string) | Input element’s value matches |
element.waitForStyles(object) | Element’s computed styles match all supplied key-value pairs |
element.waitForClasses(array) | Element has all the specified CSS classes |
element.waitForAttributes(object) | Element has the specified attributes at the expected values |
element.waitForSize(object) | Element’s bounding dimensions match |
element.waitForPositionOnScreen(object) | Element’s screen position matches |
element.waitForVisibilityInScrollableArea(bool) | Element is visible within a scroll container |
All of these poll internally until the condition is met or a timeout elapses. Using them instead of arbitrary setTimeout delays keeps tests reliable across different machines and build configurations.
Debugging Failed Tests
Section titled “Debugging Failed Tests”When a test fails with a generic error, enabling debug logs exposes what the framework is doing internally, including the command used to spawn the Player executable:
# bash / macOS / LinuxDEBUG=oclif:gameface-e2e* npx gameface-e2e# Windows PowerShell$env:DEBUG = "oclif:gameface-e2e*"; npx gameface-e2e# Windows Command Promptcmd /C "set DEBUG=oclif:gameface-e2e* && npx gameface-e2e"The most common failure sources are an incorrect gamefacePath (the Player process fails to start), a selector that does not match any element (typo or the view has not navigated to the expected page), and a missing await on a gf call causing the test to move on before the command completes.
© 2026 Coherent Labs. All rights reserved.