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 withUNSUPPORTED_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) declaresobserve
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
Astate 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
ThrowEngineError 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
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 withdefineTool 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 testpackages/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.
