@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.
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
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
Theapp manifest is the app under test: what the engine declares about it,
and the hooks behind the universal app fixture.
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
restore replaces the
surface’s persisted state rather than merging into it.
Artifacts
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
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.