Skip to content
SiteEmail

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.

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.triggerEngineEvent and gf.onEngineEvent let you drive and observe the same event bus your production code uses.
  • Gamepad input works. The GamefaceGamepad API sends real gamepad events that flow through Gameface’s input system, so navigation tests for controller UIs are accurate.

Add gameface-e2e to your project as a dev dependency:

Terminal window
npm install gameface-e2e --save-dev

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


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:

gameface-e2e-config.js
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:

Terminal window
npx gameface-e2e

If the config file has a different name or location, pass it explicitly:

Terminal window
npx gameface-e2e --config=./tests/my-config.js

Both gamefacePath and tests can also be passed directly as CLI flags when you want to override the config for a specific run:

Terminal window
npx gameface-e2e --gamefacePath=./Player/Player.exe --tests=tests/inventory.spec.js

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:

tests/hud.spec.js
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.

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:

tests/main-menu.spec.js
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.

gf.getAll() returns a DOMElements collection. Use .first(), .last(), and .nth(index) to access individual items:

tests/inventory.spec.js
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');
});
});

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.

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:

tests/player-stats.spec.js
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:

tests/player-stats.spec.js
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:

tests/notifications.spec.js
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);
});
});

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:

tests/menu-navigation.spec.js
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.


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:

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


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:

Terminal window
# bash / macOS / Linux
DEBUG=oclif:gameface-e2e* npx gameface-e2e
Terminal window
# Windows PowerShell
$env:DEBUG = "oclif:gameface-e2e*"; npx gameface-e2e
Terminal window
# Windows Command Prompt
cmd /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.