Skip to main content
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. 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.
src/index.ts
e2e.config.ts
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.
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.

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. 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.
tests/fixtures.ts
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:

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.

Engine contract

Every type and hook of @e2edev/e2e/engine.

agent-device reference

A complete device engine to read alongside.