Skip to main content
@e2edev/e2e/engine is the authoring import for an engine. defineEngine validates the body synchronously at config load, freezes it, computes its capability set, and returns the branded handle a target accepts.
Validation fails with INVALID_CONFIG, never a silent demotion: a missing or empty version, a spiVersion other than 1, an unknown top-level key on an object literal (a class instance’s own fields are its state and are not checked), perform, locate, swipe, or tapAt without observe, a state, artifacts, or app manifest that is not an object, carries an unknown key, or has a non-function member (state needs both capture and restore; artifacts needs screenshot, and startTrace/stopTrace come together, as do startVideo/stopVideo), or a fixture name that is not a lower-camel identifier or shadows a universal fixture (agent, app, screen, platform, session). Every function on the handle is bound to the spec it came from, so a class instance is as valid a body as an object literal.

Engine

version is required and non-empty. It is recorded as provenance in the report and is part of the trace cache identity: an engine that resolves nodes differently must never replay another version’s traces. workers bounds how many workers the engine serves per target at once: one per surface it can drive concurrently, such as the size of a device pool. The scheduler never starts more for that target, whatever config.workers allows; an engine without such a bound omits it. The agent offers the model only the verbs the engine declares: tap, type, press, select, and type_secret need perform; scroll needs swipe (a scroll aimed at a node is perform with { kind: 'swipe' }); navigate needs app.navigate; tap_at needs perform or tapAt. A tap_at whose point lands on nothing listed reaches tapAt; without it, the tool reports that this engine taps listed nodes only. An undeclared verb is absent from the toolset rather than a runtime surprise. engine.capabilities is the computed ReadonlySet of capability names and is what selection, the report, and UNSUPPORTED_CAPABILITY messages read.

Lifecycle

app.baseUrl is absent when the project configured no app URL. A surface that needs one fails the call that needs it; it never navigates to a placeholder.

Observation

Return the surface as it is right now. The harness mints the revision, stamps it onto every ref, redacts registered secret values, bounds the byte size, and checks that maskedRegionCount covers every secure node when pixels are present. A pixels-only snapshot (nodes: []) is valid: that is how a screenshot-and-coordinates surface is observable. When location requires a snapshot, return url from that same capture. The harness reuses it for the executor’s path and trace-cache anchors. url() remains a fresh probe for replay polling after mutations; engines without snapshot location metadata still use that hook.

Actions

perform(ref, action, context) performs exactly one action on a ref this engine minted, from the newest observation or from locate (both live in one id space), with the platform’s actionability checks. The agent’s grammar verbs and the screen tier’s actions both bottom out here, so a surface implements each action once. Throw a retryable NODE_STALE when the ref no longer binds and ACTION_MAY_HAVE_COMMITTED when input may have reached the app; the harness never blindly repeats an uncertain mutation. An action kind the surface cannot express fails with UNSUPPORTED_CAPABILITY. tapAt(point, context) is the one action addressed by a point rather than a node: a ViewportPoint in the CSS pixels of SemanticNode.rect, already clamped to the viewport by the harness, which the agent’s tap_at reads off the observation’s pixels (divided by ObservationPixels.scale) when the tree lists nothing at the target. Dispatch the pointer at the point as given; there is no element to wait on. The grammar is closed. Anything beyond it is an agent-side defineTool.

App

The app manifest is the app under test: what the engine declares about it, and the hooks behind the universal app fixture.
The declaration is the engine’s to make and the runner’s to enforce: a browser engine passes its url and command options through, a device engine declares the bundle id it pins as identity. The runner resolves it once per target at config load (invalid values fail naming the target) and owns everything built on it: navigation and origin policy (allowedOrigins defaults to the URL’s origin), cache and session identity (identity defaults to the URL’s origin and path; a surface without a URL declares one or keys entries on the target alone), the report’s target record, and the app process (command, polled at readyUrl, default url; targets declaring the same command share one process). Every field is documented under the app under test; the resolved URL and origins come back to the engine in EngineInitInfo.app. navigate receives a URL the harness already resolved against the base URL and the origin policy, and is absent on a surface without addressable locations. Node actions never live here; they are perform.

Errors

Retryability is closed to RETRYABLE_ENGINE_ERROR_CODES, the two repeatable-read codes; a retryable claim on any other code is coerced to a non-retryable ENGINE_FAILURE. A contributed fixture method fails a step with the runner’s own taxonomy, exported from the same entry so an engine never carries a copy:

Shared semantics

What the spec requires every engine and fixture to reproduce exactly is exported rather than re-implemented per engine: Beyond these and the four utilities described under Contributed fixtures (obj, raceAbort, withTimeout, withinCleanupBudget) there is no shared entry: anything else an engine needs is its own to write.

State

A snapshot is a credential. The harness encrypts it at rest with a per-run AES-256-GCM key, binds it to run, target, engine, and app identity, deletes it at run end, and never logs, reports, digests, or sends it to a model. An engine must not persist, cache, log, or echo a snapshot, and restore replaces the surface’s persisted state rather than merging into it.

Artifacts

Every returned path is relative to the artifactsDir the engine received in startAttempt. screenshot masks secure fields at the source. startTrace and stopTrace are declared together or not at all, and so are startVideo and stopVideo. The harness starts the video, then the trace, right after startAttempt (and after a session restore), under the launch budget, and stops them before endAttempt, under the cleanup budget. The video comes first because a surface that records both through one screencast sizes it for whichever client came first, and the recording is the one a person watches: the browser engine opens the attempt’s page when the video starts, so the trace that follows records its frames at the recording’s size. stopVideo returns every segment written, in order, each with the instant it started recording, so a consumer can place the report’s step timestamps on it. A surface whose recording is bound to a page returns one segment per page the attempt showed (a restart or a state reset opens a new one), and an attempt that never showed anything returns none. stopTrace returns the trace’s path, or every archive written in order when the trace had to be cut: a browser trace is bound to one context, so replacing the context closes the running trace as a segment and a new one records on from there (in the Playwright engine a state reset always replaces the context; a restart does so only while a recording is armed). The harness registers each returned archive once it is safe to keep: when a secret was filled in the attempt, every text entry of each archive is rewritten through the secret redactor first, and if that fails the archives are deleted and the attempt records TRACE_WITHHELD instead (see authentication). Recordings are the one artifact the harness cannot vouch for: it records them with redaction: 'incomplete', since a screencast masks nothing.

Contributed fixtures

Use context.fixture(name, surface, operations) to declare which methods are recorded. Each operation specifies kind: 'resource' | 'assertion', an optional safe label(...args), an optional timeout (milliseconds, a function of the arguments, or false when the method owns its deadline), and optional verifies. The default timeout is actionTimeout; assertions verify by default. Passing verification steps confirm earlier staged action traces. Nested operation maps describe namespaces. Undeclared synchronous accessors retain their identity. Recording decorates the supplied fixture object in place, preserving mutable fields, private-field receivers, and namespace accessors. Create a fresh fixture surface in each factory invocation; declared methods and namespace properties must allow replacement. The harness opens the step before calling a declared method, so synchronous attachments, failures, and asynchronous work have the same owner. Events and artifacts remain attached to that operation across nested and overlapping calls. Work arriving after its step has closed cannot attach to a later step. A factory must return the surface it declared through context.fixture; a plain, undeclared surface is rejected with INVALID_CONFIG the first time a test reaches for the fixture, because its methods would run engine code outside any recorded step. expectable attaches an expectation surface so expect(fixture) returns it; use context.fixture('expect', matchers, operations) in its factory to declare matcher recording. Type the fixture with test.extend<{ name: Type }>(). app.resolveUrl throws APP_URL_REQUIRED when no app URL is configured and POLICY_DENIED for a disallowed origin or scheme. The engine entrypoint also exports obj(value), which drops undefined-valued keys and types them as absent optional properties, so a manifest or fixture result built from optional inputs satisfies exact optional property types without a conditional spread per key. It also exports raceAbort(work, signal, label), withTimeout(promise, timeoutMs, onTimeout), and withinCleanupBudget(promise, { signal, timeoutMs }). Pass a thunk to raceAbort to check cancellation before dispatch. Cleanup waiting absorbs failures and releases its listeners at cancellation or timeout; abandoning a wait does not stop an underlying command.

Vocabulary

OperationContext, TextPattern, SemanticQuery, LocatorExpression, NodeRef, SemanticNode, ObservationPixels, LocatorAction, ENGINE_SPI_VERSION, ENGINE_ERROR_CODES, RETRYABLE_ENGINE_ERROR_CODES, OBSERVED_NAME_LIMIT, OBSERVED_TEXT_LIMIT, and the shared semantics above are exported from @e2edev/e2e/engine and are the platform-neutral words every engine speaks. The selector expression kind carries a platform-native selector string (CSS or XPath on a document platform); frame scopes a query into a nested document. A SemanticQuery with visible: true must match only nodes whose states.hidden is false, judged by the same predicate the engine reports on SemanticNode, so getByText('x', { visible: true }) and toBeVisible() never disagree about a node. The harness drops hidden nodes from a top-level query as a backstop; only the engine can apply the predicate under a scope, filter, or index, so evaluate it wherever the query sits in the expression.