> ## Documentation Index
> Fetch the complete documentation index at: https://docs.e2e.army/llms.txt
> Use this file to discover all available pages before exploring further.

# Writing an engine

> How the runner talks to a surface, and what it takes to add a new one.

Everything above the engine is portable. `test`, `expect`, `screen`, `app`,
and the agent never touch a browser or a device: they speak one narrow
contract from `@e2edev/e2e/engine`, and an engine is the package that maps it
onto a real surface.

`@e2edev/playwright` and `@e2edev/agent-device` are two such packages, built
with the same public `defineEngine` as anything you write. An engine for a
desktop shell or a TV integrates against the same contract, and neither the
runner nor a test can tell them apart. The agent-device package is the
smaller of the two and a good model for a device-shaped engine.

## The ownership boundary

Most engine bugs are an engine doing the runner's job.

| Concern                                           | Owner  |
| ------------------------------------------------- | ------ |
| Collection, targets, retries, hooks, sessions     | runner |
| Query polling and strict single-match cardinality | runner |
| Assertion polling, step recording, timeouts       | runner |
| One immediate query, one node read                | engine |
| One node's actionability and input dispatch       | engine |
| Agent planning, prompts, budgets, ledger          | runner |
| Tool authorization and secret resolution          | runner |
| Process, page, and device mechanics               | engine |
| Source masking of secure observations             | engine |
| Defense-in-depth redaction                        | runner |

Two rules follow from it and get broken most often. An engine **must not**
add hidden query retries: `locate` answers with what is on screen right now
and returns an empty array without complaint. An engine **must not** enforce
single-match strictness: deciding that two matches is an error is the
runner's call.

## The shape of an engine

An engine is a factory returning a validated handle. `defineEngine` checks
the manifest at config load, freezes it, computes its capability set, and
stamps the brand that makes the handle acceptable in a target.

```ts title="src/index.ts" theme={"theme":"catppuccin-mocha"}
import { EngineError, defineEngine, type EngineHandle } from '@e2edev/e2e/engine';

export function hyperdrive(options: { device: string }): EngineHandle {
  const client = createClient(options);
  return defineEngine({
    name: 'hyperdrive',
    version: '1.0.0',
    spiVersion: 1,
    platform: 'ios',
    async init(info) { await client.boot(); },
    async dispose(context) { await client.shutdown({ signal: context.signal }); },
    async observe() { return { nodes: await client.snapshot() }; },
    async perform(ref, action) {
      switch (action.kind) {
        case 'tap': return client.tap(ref.id);
        case 'fill': return client.type(ref.id, action.value);
        default:
          throw new EngineError('UNSUPPORTED_CAPABILITY', `hyperdrive cannot ${action.kind}`, { retryable: false });
      }
    },
    async swipe(direction) { await client.scroll(direction); },
  });
}
```

```ts title="e2e.config.ts" theme={"theme":"catppuccin-mocha"}
targets: [{ name: 'phone', engine: hyperdrive({ device: 'iPhone 16' }) }],
```

`version` is required. It is recorded as provenance and keys the trace
cache, so an engine that resolves nodes differently never replays another
version's traces.

<Note>
  Depend on `@e2edev/e2e/engine` only, and take `@e2edev/e2e` as a **peer**
  dependency: the engine and the runner must agree on one copy of the error
  classes. Everything an engine has to reproduce exactly is exported there:
  the error taxonomy, text-pattern matching, URL matching, assertion polling,
  the JSON-value rules, and the utilities `obj`, `raceAbort`, `withTimeout`,
  and `withinCleanupBudget`. There is no shared internal subpath.
</Note>

## Capabilities

Every member is optional. What you declare grades what a target can do; what
you omit fails at the first honest moment with `UNSUPPORTED_CAPABILITY`,
never silently.

| Member                                                                           | Capability                   | Unlocks                                                                                                                                                                                                   |
| -------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `observe(context, options?)`                                                     | `observation`                | `agent.act`, `waitFor`, `extract`, `assert`; prompt snapshots; trace replay                                                                                                                               |
| `perform(ref, action, context)`                                                  | `actions` (needs `observe`)  | Node-targeted actions: `tap`, `type`, `press`, `select`, node `scroll`, and the `Locator` action set; `type_secret` when the step declares credentials                                                    |
| `locate(expression, context)`                                                    | `location` (needs `observe`) | `screen`, `Locator`, `expect(locator)`                                                                                                                                                                    |
| `swipe(direction, momentum, context)`                                            | (needs `observe`)            | the agent's viewport `scroll` verb, `screen.swipe`, `scrollUntilVisible`                                                                                                                                  |
| `tapAt(point, context)`                                                          | `pointer` (needs `observe`)  | the agent's `tap_at` verb when the point lands on nothing the tree lists                                                                                                                                  |
| `state.{capture,restore}`                                                        | `state`                      | `test.setup` and the `session` option                                                                                                                                                                     |
| `artifacts.{screenshot,startTrace?,stopTrace?,startVideo?,stopVideo?}`           | `artifacts`                  | Screenshots while pixels are allowed (`app.screenshot()` raises `POLICY_DENIED` after a secret fill); traces when the trace hooks exist; video and `--video` when both `startVideo` and `stopVideo` exist |
| `app.{url?,allowedOrigins?,environment?,identity?,command?,readyUrl?,services?}` |                              | declares the app under test: navigation policy, cache and session identity, the app process ([reference](/reference/config#the-app-under-test))                                                           |
| `app.{navigate?,back?,restart?,clearState?}`                                     |                              | `app.open()` and the agent's `navigate` verb, `app.back()`, `app.restart()`, `app.clearState()`                                                                                                           |
| `url(context)`                                                                   |                              | trace start anchors, the secret-fill origin check                                                                                                                                                         |
| `fixtures.<name>`                                                                | `<name>`                     | a contributed fixture, typed through `test.extend`                                                                                                                                                        |

The agent derives its toolset from this manifest and the step's declared
credentials: a verb whose member is absent is never offered to the model.
Every node action, whether the agent or a `Locator` asked for it, arrives
as one `LocatorAction` on `perform`. Implement each action once and throw
`UNSUPPORTED_CAPABILITY` for a kind the surface cannot express.

There is no separate secure-fill capability flag. Before dispatching a
secret fill, the runner checks the declared credential, allowed origin,
current observation, and compatible editable field. The engine receives a
`fill` action with `sensitive: true` and must handle it securely or throw
`UNSUPPORTED_CAPABILITY`; declaring `perform` does not hide the tool when
that particular action is unsupported.

### Lifecycle

`prepare` runs once per run and target in the runner process, after
collection and before the run's clock starts: download runtimes there and
narrate through `info.log`. `init` runs once per worker before the first
attempt: boot devices there. A handle can outlive a worker, so `init` may run
again after `dispose` on the same handle. `startAttempt` and `endAttempt`
bracket one test attempt: give each test a fresh surface here. When
`startAttempt` fails or exceeds `launchTimeout`, its signal aborts and
`endAttempt` runs right behind it, so keep `endAttempt` idempotent. `dispose`
runs at worker shutdown whether or not `init` ran.

### Nodes and refs

`observe` and `locate` return `SemanticNode` trees. Ids are yours and must be
unique for the surface's lifetime. The harness stamps revisions onto them and
enforces the staleness rule, so you never implement it. `perform` receives
`{ id, revision }`; look the id up and throw `NODE_STALE` when it no longer
binds.

### Vision-only surfaces

A surface that offers screenshots and coordinates but no semantic tree (a
desktop shell, a canvas app, a computer-use agent) declares `observe`
returning a pixels-only snapshot (`nodes: []` plus `pixels`) and `tapAt` for
the taps: a screen that lists nothing opens with its screenshot attached, the
model names a point in it with `tap_at`, and the harness hands the point to
`tapAt` when the tree lists nothing there, so a surface with no refs to hand
out still gets tapped through the grammar. Bare points replay as recorded on a
viewport of the same size, and the recorded end state decides whether the
replay passes on its own; nothing else in the harness changes.

### State is a credential

A `state` snapshot carries whatever authenticates the app: cookies, tokens,
storage. The harness encrypts it at rest with a per-run key, binds it to the
run, target, engine, and app identity, deletes it when the run ends, and
never logs or reports it. Your side: never persist, cache, or log a
snapshot; never echo its contents in an error; make `restore` replace the
surface's whole persisted state rather than merge into it.

### Errors

Throw `EngineError` across the seam. Retryability is closed to `NODE_STALE`
and `FRAME_NOT_FOUND`; asking for a retryable `ENGINE_FAILURE` is coerced to
non-retryable, so an engine can never talk the runner into repeating an
action that may have committed. `ACTION_MAY_HAVE_COMMITTED` says exactly
that; `INVALID_STATE` maps to `APP_NOT_OPEN`; `UNSUPPORTED_CAPABILITY` is a
configuration error; anything else is infrastructure.

## Contributed fixtures

A platform-shaped surface, `web` for a browser or `device` for a simulator,
is a fixture the engine contributes. The harness records the methods declared
through `context.fixture` as steps named `<fixture>.<method>`, so artifacts
and failures are attributed to the call that produced them.

```ts theme={"theme":"catppuccin-mocha"}
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' },
  }),
},
```

```ts title="tests/fixtures.ts" theme={"theme":"catppuccin-mocha"}
import { test as base } from '@e2edev/e2e';
import type { Device } from 'e2e-engine-hyperdrive';

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

The factory's `context` carries what a fixture legitimately needs from the
harness: the app's base URL and origin policy, the configured timeouts,
`operation()` budgets, `attachArtifact` and `attachViewport` for the current
step, `locator(expression)` and `screen(scope)` to mint core locators from a
platform selector or a nested document, and `expectable(target, factory)` to
attach an expectation surface so `expect(fixture)` works. This is how
`@e2edev/playwright` builds `web` and `expect(web).toHaveURL()` without
touching a core file.

## Scoping agent tools to a platform

Agent tools are declared with `defineTool` on the agent, not on the engine.
The `platforms` annotation scopes a tool pack, so a swipe tool never reaches
a web target:

```ts theme={"theme":"catppuccin-mocha"}
const swipe = defineTool(swipeTool(client), {
  mutates: true,
  platforms: ['ios', 'android'],
});
```

## Verify it

Run the runner against your engine with a real test file. The contract test
`packages/e2e/tests/integration/engine-contract.test.ts` shows the
guarantees the runner holds, lifecycle order, operation contexts, error
mapping, capability gating, and is the reference for what your engine can
rely on.

<CardGroup cols={2}>
  <Card title="Engine contract" href="/reference/engine">
    Every type and hook of `@e2edev/e2e/engine`.
  </Card>

  <Card title="agent-device reference" href="/reference/agent-device">
    A complete device engine to read alongside.
  </Card>
</CardGroup>
