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.

OwnerConcerns
runnerCollection, targets, retries, hooks, sessions. Query and assertion polling, strict single-match checks, step recording, timeouts. Agent planning, prompts, budgets, ledger. Tool authorization, secret resolution, defense-in-depth redaction
engineOne immediate query, one node read. One node’s actionability and input dispatch. Process, page, and device mechanics. Source masking of secure observations

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.

src/index.ts
import { ConfigurationError, defineEngine, resolveExpression, type EngineHandle } from 'e2e/engine';

export function hyperdrive(options: { device: string }): EngineHandle {
  const client = createClient(options);
  let bundleId = '';
  return defineEngine({
    name: 'hyperdrive',
    version: '1.0.0',
    spiVersion: 1,
    platform: 'ios',
    validateApp(app, { targetName }) {
      if (app.bundleId === undefined) {
        throw new ConfigurationError('INVALID_CONFIG', `target "${targetName}" needs app.bundleId`);
      }
    },
    async init(info) {
      bundleId = info.app.bundleId!;
      await client.boot();
    },
    async dispose(context) { await client.shutdown({ signal: context.signal }); },
    async observe() {
      const screen = await client.snapshot();
      return {
        location: screen.title,
        // One root with a stable id: `perform(root, swipe)` is the viewport swipe.
        root: { ref: { id: 'root', revision: '' }, role: 'window', children: screen.nodes },
        viewport: screen.viewport,
      };
    },
    async locate(expression) {
      const screen = await client.snapshot();
      return resolveExpression(expression, screen.nodes);
    },
    actions: ['tap', 'fill', 'swipe'],
    async perform(ref, action) {
      switch (action.kind) {
        case 'tap': return client.tap(ref.id);
        case 'fill': return client.type(ref.id, action.value);
        case 'swipe': return client.scroll(ref.id === 'root' ? undefined : ref.id, action.direction);
      }
    },
    session: {
      async back() { await client.back(); },
      async restart() { await client.relaunch(bundleId); },
    },
  });
}
e2e.config.ts
targets: [{ name: 'phone', engine: hyperdrive({ device: 'iPhone 16' }), app: { bundleId: 'com.example.app' } }],
  • version is required. Reports name it, and a saved session restores only under the version that saved it. The replay cache doesn’t key on it.
  • locate answers screen queries. The exported resolveExpression applies the standard query rules to a semantic tree.
  • The app under test is the target’s app, not an engine option. validateApp checks it at config load. prepare and init receive it as info.app.

Capabilities

The optional methods you implement set the engine’s capabilities. Calls to a missing one fail with UNSUPPORTED_CAPABILITY. Start with these:

MemberUnlocks
observe(context, options?)Agent observations and the replay cache
perform(ref, action, context) + actionsNode actions of the declared kinds, including viewport scrolling through swipe
locate(expression, context)screen, Locator, and expect(locator)
validateApp(app, info)A config-load check of the target’s app. Throw INVALID_CONFIG naming info.targetName for a field the platform needs or can’t drive. Reference
session.{open?,back?,restart?,reset?}app.open(), app.back(), app.restart(), app.clearState(), and the agent’s navigate and back verbs

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

HookWhenUse it to
prepareOne time per target, in the runner process, on a run with tests to runInstall runtimes. The env it returns reaches every worker
initOne time per worker, before its first attemptBoot devices. It must also work on an engine that was disposed
startAttemptBefore each attemptCreate the test’s UI
settleAttemptAfter the body, and again after teardownThrow the error of a test callback no step awaited, such as a dialog handler
endAttemptAfter each attempt, also after a failed startClean up the test’s UI. Make it idempotent
disposeAt worker shutdown, even if init never ranRelease the worker’s resources
finishOne time per target, after the last worker is gone, whenever prepare ranRelease what prepare acquired

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 scroll and screen.swipe() call perform(root, { kind: 'swipe' }). A swipe on another node scrolls inside that node.
  • When perform receives { id, revision }, look up the id. Throw NODE_STALE when the node no longer exists.
  • When a match leaves the UI while locate reads it, throw a retryable NODE_STALE. Never return a node read from what is gone: a removed element has no size, so toBeHidden would 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 truncated when 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. The testId query 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_ACTIONABLE when no editable control has focus.
  • Leave out dismiss on platforms without an on-screen keyboard.
  • locator.pressSequentially uses the same hook after a focus action.

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:

  1. Accept string | Secret. Tell them apart with isSecret from e2e/engine.
  2. List each handle in the engine’s secrets. The config load rejects a name that no secret has.
  3. Read the value in startAttempt with context.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_STALE and FRAME_NOT_FOUND can be retryable.
  • Use ACTION_MAY_HAVE_COMMITTED when an action may already have taken effect, so the runner doesn’t repeat it.
  • INVALID_STATE becomes APP_NOT_OPEN, and UNSUPPORTED_CAPABILITY is 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.

fixtures: {
  device: (context) => context.fixture('device', {
    async setNetwork(state: 'online' | 'offline') { await client.network(state); },
    async home() { await client.home(); },
  }, {
    setNetwork: { kind: 'resource', label: (state) => state },
    home: { kind: 'resource' },
  }),
},
tests/fixtures.ts
import { test as base } from 'e2e';
import type { Device } from 'e2e-engine-hyperdrive';

export const test = base.extend<{ device: Device }>();

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.

Next