An engine connects screen, app, and agent calls to a UI platform through
the e2e/engine contract. Write one to test a desktop app, a TV, or another
platform. @e2e-dev/web and @e2e-dev/mobile use the same public
defineEngine API and are complete examples.
Engines drive user interfaces only. For an inbox, a database, or a test API,
write a fixture with test.extend.
The ownership boundary
The runner manages tests and policy. The engine observes and controls the UI.
locate returns the current matches at once, or an empty array. Don’t
retry queries or reject multiple matches.
The shape of an engine
Return a handle from defineEngine. It validates the manifest and computes
the engine’s capabilities when the config loads.
Import types and helpers from e2e/engine only; there is no internal
subpath. Declare e2e as a peer dependency, so the engine and the runner
share one set of error classes.
This skeleton assumes a createClient adapter for your platform. Its nodes
must follow the semantic tree rules.
versionis required. Reports name it, and a saved session restores only under the version that saved it. The replay cache doesn’t key on it.locateanswersscreenqueries. The exportedresolveExpressionapplies the standard query rules to a semantic tree.- The app under test is the target’s
app, not an engine option.validateAppchecks it at config load.prepareandinitreceive it asinfo.app.
Capabilities
The optional methods you implement set the engine’s capabilities. Calls to
a missing one fail with UNSUPPORTED_CAPABILITY. Start with these:
Pointer actions, the keyboard, saved state, artifacts, and contributed
fixtures are optional capabilities too. actions, location, pointer,
and keyboard require observe. The agent gets only the tools your
declared kinds unlock; the
engine reference lists every member and
which kind unlocks which tool.
Agent and locator actions both reach perform as a LocatorAction. When
one node can’t do a declared action, throw UNSUPPORTED_CAPABILITY.
session.open receives a URL that the runner already resolved. After
restart or reset, the runner opens the configured address again through
open. If your engine has no open, relaunch the app in those hooks.
Lifecycle
Use info.env for the processes you start and info.log to report
progress. The engine reference has each
hook’s arguments and budgets.
Nodes and refs
observe returns a SemanticNode tree, and locate returns matching
nodes. Give each node an id that stays unique for the UI’s lifetime. The
runner assigns revisions and checks staleness.
- Keep the root id stable across observations. The agent’s
scrollandscreen.swipe()callperform(root, { kind: 'swipe' }). A swipe on another node scrolls inside that node. - When
performreceives{ id, revision }, look up the id. ThrowNODE_STALEwhen the node no longer exists. - When a match leaves the UI while
locatereads it, throw a retryableNODE_STALE. Never return a node read from what is gone: a removed element has no size, sotoBeHiddenwould pass while its replacement is on screen. The runner resolves again within the caller’s deadline. - Limit the snapshot size if the platform can return an unbounded tree, and
set
truncatedwhen the limit cuts off nodes. - Use the platform’s accessible names for nodes.
- Put test identifiers in
testId: a document attribute, an iOS accessibility identifier, an Android resource id, or a desktop automation id. ThetestIdquery matches only that field.
An observation’s optional location, such as a URL or a screen title,
goes to the model and the report, and anchors replay cache entries.
Keyboard
Implement keyboard to type into a control after the agent focuses it.
keyboard.type must go through the platform’s keyboard path, so the app
gets the key events a person’s typing makes: keydown, keypress, input,
and keyup per character in a browser, the device keyboard on a device.
Setting the value without keystrokes doesn’t comply.
- Throw
NOT_ACTIONABLEwhen no editable control has focus. - Leave out
dismisson platforms without an on-screen keyboard. locator.pressSequentiallyuses the same hook after afocusaction.
For press, read keys such as Enter or ControlOrMeta+a with the
exported parseKey. The runner rejects invalid spellings with
INVALID_ARGUMENT. Map valid keys to your platform, and throw
UNSUPPORTED_CAPABILITY for keys it can’t send. See the
key grammar.
Vision-only surfaces
For a UI without a semantic tree, return a root with no children, plus
pixels, from observe. Implement performAt and declare its kinds in
pointerActions.
The agent acts through tools such as tap_at, and the runner passes the
point to performAt when no listed node is at it. Coordinate actions replay
only at the recorded viewport size.
Handle secrets
A secret fill reaches the engine as a fill action with
sensitive: true, with no separate capability flag. For an agent’s
type_secret, the runner has already checked the credential, the
observation, and the field. A test’s fill(secret) skips these
checks. Handle the fill
securely, or throw
UNSUPPORTED_CAPABILITY. After it, the runner sends the model
no more screenshots.
An engine option can take a secret too, such as a password the engine sends to the app:
- Accept
string | Secret. Tell them apart withisSecretfrome2e/engine. - List each handle in the engine’s
secrets. The config load rejects a name that no secret has. - Read the value in
startAttemptwithcontext.resolveSecret(secret).
The runner redacts the value from the moment it resolves. When the app sees
the value in another form,
such as an encoded header, return that form from
resolveSecret(secret, { derived }) to redact it under the same name. This
protects text only: a session whose engine resolved a secret keeps its
screenshots.
State is a credential
A state snapshot can hold cookies and tokens. The runner encrypts it per
run, binds it to the run, target, engine, and app, and deletes it after the
run. Don’t persist, cache, or log snapshots, or put them in errors. restore
must replace the app’s whole persisted state, not merge into it.
Errors
Throw EngineError with a code from the
engine reference.
- Only
NODE_STALEandFRAME_NOT_FOUNDcan be retryable. - Use
ACTION_MAY_HAVE_COMMITTEDwhen an action may already have taken effect, so the runner doesn’t repeat it. INVALID_STATEbecomesAPP_NOT_OPEN, andUNSUPPORTED_CAPABILITYis a configuration error.- Unknown errors become infrastructure failures.
Contributed fixtures
An engine can contribute fixtures such as browser or device. Wrap their
methods with context.fixture to record each call as a <fixture>.<method>
step, with its own artifacts and failures.
The fixture context also gives URL policy checks, timeouts, budgets,
artifacts, and locator and assertion factories, such as
expectable(target, factory) for expect(fixture). See the
engine reference.
Ship a tool pack
For platform operations the action set lacks, ship
project tools built with defineTool, with platforms set so each
shows only on compatible targets, as @e2e-dev/mobile/tools does.
Verify it
Run a test file against your engine. Check the lifecycle order,
observations, actions, errors, and unsupported capabilities.
engine-contract.test.ts
has examples of these checks.