e2e/engine is the contract an engine implements to drive a user interface, such as a browser or simulator. An engine implements shared types for nodes, locators, actions, and errors. defineEngine validates and freezes the implementation, derives its capabilities, and returns a handle for targets in the config. Use test.extend fixtures for resources outside the UI, such as APIs or databases.

This sketch assumes a connect adapter for the platform:

src/index.ts
import { defineEngine, resolveExpression, type EngineHandle } from 'e2e/engine';
import { connect } from './client.ts';

export function kiosk(options: { host: string }): EngineHandle {
  const client = connect(options.host);
  return defineEngine({
    name: 'kiosk',
    version: '1.0.0',
    spiVersion: 1,
    platform: 'kiosk',
    async observe() {
      const screen = await client.snapshot();
      return {
        root: { ref: { id: 'root', revision: '' }, role: 'window', children: screen.nodes },
        viewport: screen.viewport,
      };
    },
    async locate(expression) {
      const screen = await client.snapshot();
      return resolveExpression(expression, screen.nodes);
    },
    actions: ['tap'],
    async perform(ref) {
      await client.tap(ref.id);
    },
  });
}
e2e.config.ts
import type { E2EConfig } from 'e2e';
import { kiosk } from './src/index.ts';

export default {
  targets: [{ name: 'kiosk', engine: kiosk({ host: 'kiosk.local' }) }],
} satisfies E2EConfig;

Reference

defineEngine

Validates an engine spec, freezes it, computes its capabilities, and returns a branded handle. isEngineHandle tells such a handle from any other value.

import { defineEngine, isEngineHandle, type EngineHandle } from 'e2e/engine';

export function defineEngine(spec: Engine): EngineHandle;
export function isEngineHandle(value: unknown): value is EngineHandle;

/** A validated engine: branded, frozen, capabilities computed. */
export interface EngineHandle extends Engine {
  readonly capabilities: ReadonlySet<EngineCapability>;
}

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.

defineEngine throws INVALID_CONFIG when the spec breaks one of these rules:

MemberRejected when
the specIt is not an object.
nameMissing or empty.
versionMissing or empty.
spiVersionAnything but 1.
platformEmpty.
workersNot a positive safe integer.
observe, locate, perform, performAt, prepare, finish, init, startAttempt, settleAttempt, endAttempt, dispose, validateAppNot a function.
any other top-level keyOn an object literal, app included: declare the app on the target and check it in validateApp. A class instance’s own fields are not checked.
perform, locate, performAt, keyboardDeclared without observe.
perform, actionsOne without the other. An actions list that is empty, repeats a kind, or names one outside LOCATOR_ACTION_KINDS.
performAt, pointerActionsOne without the other. A pointerActions list that is empty, repeats a kind, or names one outside POINTER_ACTION_KINDS.
tapModifiersNot a boolean, or true without perform.
keyboard, state, artifacts, sessionAn unknown key or a non-function member. keyboard without type and press; state without both capture and restore; artifacts without screenshot; startVideo without stopVideo, or the reverse.
secretsNot an array of secrets.get() handles.
fixturesNot an object, a fixture that is not a factory function, or a fixture name that is not a lower-camel identifier or shadows agent, app, screen, platform, or session.

artifacts.startTrace and artifacts.stopTrace were removed from the contract. An engine published before their removal that declares them still passes validation; the runner never calls them.

Engine

The body of one target. Every member but name, version, and spiVersion is optional; the members an engine implements decide its capabilities.

MemberType
namestring
versionstring
spiVersion1
platformstring
workersnumber
observemethod
perform, actionsmethod, kinds
locatemethod
performAt, pointerActionsmethod, kinds
tapModifiersboolean
keyboardEngineKeyboard
fixturesfactories
stateEngineStateCapability
artifactsEngineArtifacts
validateAppmethod
secretsSecret[]
sessionEngineSession
prepare, finish, init, startAttempt, settleAttempt, endAttempt, disposehooks
/** The body of one target: typed, model-free, capability-graded. */
export interface Engine {
  /** Engine name, for diagnostics. */
  readonly name: string;
  /**
   * Required implementation version, recorded in reports and sessions.
   * A saved session never restores across engine versions; a cached trace
   * replays across them and re-finds its nodes.
   */
  readonly version: string;
  /**
   * Contract version literal. Additive optional members never bump it;
   * changed required semantics do.
   */
  readonly spiVersion: EngineSpiVersion;
  /**
   * Platform label this engine drives (`web`, `ios`, `android`, or a label of
   * the engine's own). Tests filter on it through `platforms` and tool packs
   * are scoped by it; the harness attaches no meaning to any value. A target
   * inherits it; a target that names a platform of its own must agree with it.
   */
  readonly platform?: string;
  /**
   * Maximum workers for this target, such as the number of devices in a
   * pool. The scheduler applies this cap even when `config.workers` is
   * higher. Omit when the engine has no concurrency limit.
   */
  readonly workers?: number;
  /** capability: observation. */
  observe?(context: OperationContext, options?: EngineObserveOptions): Promise<EngineSnapshot>;
  /**
   * Performs one action on a ref from the newest observation or `locate`,
   * with the platform's actionability checks. Both agent tools and locator
   * actions call this method. A `swipe` on the observation root swipes the
   * viewport.
   *
   * For safe trace replay, throw a retryable
   * `NODE_STALE`-coded error when a ref no longer binds, and an
   * `ACTION_MAY_HAVE_COMMITTED`-coded error when input may have reached the
   * app. The runner never repeats an action that may have committed.
   */
  perform?(ref: NodeRef, action: LocatorAction, context: OperationContext): Promise<void>;
  /**
   * The action kinds `perform` honors, required with it. The harness offers
   * the agent and the `screen` tier exactly these: a kind left out is never
   * presented as a tool that can only decline, and `screen`/`Locator` methods
   * for it fail with `UNSUPPORTED_CAPABILITY` before reaching the engine.
   * `perform` still throws `UNSUPPORTED_CAPABILITY` for a declared kind one
   * particular node cannot take (a `check` on a toggle whose state is unknown).
   */
  readonly actions?: readonly LocatorActionKind[];
  /**
   * capability: location - requires observation. Deterministic locator
   * resolution for the `screen`/`expect` tier: resolve one expression to the
   * nodes it currently matches. The harness owns polling, strictness, and
   * staleness; an engine resolves once, immediately.
   */
  locate?(
    expression: LocatorExpression,
    context: OperationContext,
  ): Promise<readonly SemanticNode[]>;
  /**
   * capability: pointer - requires observation. Performs one pointer action
   * at a viewport point, in the CSS pixels of `SemanticNode.rect`, with no
   * node behind it: the agent's `tap_at` and `hover_at` verbs land here with
   * the point the model named in a screenshot (a shape on a canvas, a pin on
   * a map, a control in a system sheet or one the tree lists), and so do a test's
   * `screen.tapAt`, `screen.swipe({ from, to })`, and `tap({ position })`.
   * Dispatch the pointer at the point as given; the harness has clamped an
   * agent's point to the viewport and validated a test's. Throw
   * `UNSUPPORTED_CAPABILITY` for a declared kind the surface cannot deliver
   * at this particular point.
   */
  performAt?(point: ViewportPoint, action: PointerAction, context: OperationContext): Promise<void>;
  /**
   * The pointer action kinds `performAt` honors, required with it. The
   * harness routes a point-addressed action to the engine only for a kind
   * listed here; an undeclared kind fails with `UNSUPPORTED_CAPABILITY`
   * before reaching the engine.
   */
  readonly pointerActions?: readonly PointerActionKind[];
  /**
   * The surface honors `modifiers` on the `tap`, `doubleTap`, and
   * `secondaryTap` actions `perform` receives. Without it the harness fails
   * an action that carries modifiers with `UNSUPPORTED_CAPABILITY` before it
   * reaches the engine.
   */
  readonly tapModifiers?: boolean;
  /** capability: keyboard - requires observation. Input to whatever holds focus; see `EngineKeyboard`. */
  readonly keyboard?: EngineKeyboard;
  /**
   * Named deterministic surfaces this engine contributes to TestFixtures
   * (a device fixture, a document fixture - anything). Keys become fixture and
   * capability names; `requires: ['<name>']` gates at selection.
   */
  readonly fixtures?: Readonly<Record<string, EngineFixtureFactory>>;
  /** capability: state - opaque snapshot capture/restore for session reuse. */
  readonly state?: EngineStateCapability;
  /** capability: artifacts - screenshots under the attempt directory, and videos there or at a provider. */
  readonly artifacts?: EngineArtifacts;
  /**
   * Checks the app a target declares against what this engine can drive, at
   * config load, synchronously: throw `INVALID_CONFIG` naming
   * `info.targetName` for a field the platform needs and the target left out
   * (a URL, a bundle id) or one it cannot drive. The harness has already
   * checked every field's shape.
   */
  validateApp?(app: EngineAppDeclaration, info: EngineAppCheckInfo): void;
  /**
   * The `secrets.get()` handles the engine's options hold, such as a
   * basic-auth password. The config load checks each names a configured
   * secret (`INVALID_CONFIG` otherwise), and only these resolve through
   * `EngineAttemptContext.resolveSecret`. The handles carry names only, so
   * no value reaches the config digest.
   */
  readonly secrets?: readonly Secret[];
  /** Steering hooks: open, back, restart, reset. */
  readonly session?: EngineSession;
  /**
   * Runs once per target in the runner, after collection and before workers,
   * app commands, and the run's clock start. A run with nothing to run (the
   * test filters left nothing, or `--pass-with-no-tests` on an empty
   * selection) prepares no target, and so calls no `finish`. Download
   * dependencies or boot devices here; report progress through `info.log`.
   * No launch timeout applies. A failure ends the run as an infrastructure
   * error.
   * The result can lower the target's worker cap, for example after
   * discovering how many devices are available.
   */
  prepare?(info: EnginePrepareInfo): Promise<void | EnginePrepareResult>;
  /**
   * Once per run and target in the runner process, after every worker of the
   * target is gone, on every exit path, whenever `prepare` was called.
   * Release what `prepare` acquired for the run (a leased cloud device).
   * Runs within the cleanup budget, never cancelled by an interrupt; a
   * failure is a cleanup-phase run error.
   */
  finish?(info: EngineFinishInfo): Promise<void>;
  /**
   * Once per worker, before the first step; boot devices here, not in a step
   * budget. The same handle can be booted again after `dispose`: a config-held
   * handle outlives an in-process worker, so init MUST work on a disposed
   * engine as it does on a fresh one.
   */
  init?(info: EngineInitInfo): Promise<void>;
  /** Before each attempt: set up per-test isolation. */
  startAttempt?(context: EngineAttemptContext): Promise<void>;
  /**
   * After the body has settled, and again after teardown, before the verdict,
   * within the cleanup budget: report a failure that landed on a path no step
   * awaited (a callback the surface ran for the test: a request interceptor,
   * a native dialog handler). Wait for one still running, then throw its
   * error; the attempt fails with it the way a step would, an error already
   * classified keeping its code. MUST report each failure once.
   */
  settleAttempt?(context: EngineCleanupContext): Promise<void>;
  /**
   * After each attempt, within the cleanup budget: tear that isolation down.
   * MUST be idempotent, and safe to call after a failed `startAttempt`.
   */
  endAttempt?(context: EngineCleanupContext): Promise<void>;
  /**
   * Worker shutdown, within the cleanup budget; failure is a run error. Runs
   * whether or not `init` ran, so it MUST tolerate a cold engine. A live
   * `reuseSignal` permits preserving run-owned resources for a replacement,
   * until that permission is revoked or this hook settles.
   */
  dispose?(context: EngineDisposeContext): Promise<void>;
}

OperationContext

/** What every engine call carries: budget, ids, and who is acting. */
export interface OperationContext {
  /**
   * Aborts when the attempt, or the hook running the call, ends. It does not
   * abort at `timeoutMs`: bound each call by that budget yourself.
   */
  readonly signal: AbortSignal;
  /** Remaining operation budget when the call starts. */
  readonly timeoutMs: number;
  /** The run id. */
  readonly runId: string;
  /** The attempt id. */
  readonly attemptId: string;
  /**
   * Who asked: a test's own deterministic step, or the agent acting on a
   * screen it observed. A deterministic step verifies its outcome with
   * `expect`, so an engine may act as soon as the target holds still; an
   * agent reads the screen right after acting, so an engine may wait for the
   * transition to end first.
   */
  readonly origin: 'test' | 'agent';
}

Capabilities

The members an engine implements set its capabilities. A screen, Locator, or app call that needs a missing one fails with UNSUPPORTED_CAPABILITY. Test APIs lists what needs which member. Three gaps end differently:

  • A test that requires a fixture the engine lacks is skipped (capability-unavailable).
  • A selected test that uses a test.setup session, on an engine without state, is a collection error before the setup runs.
  • A video mode on an engine without video recording prints a notice and records no video.
MemberCapabilityRequiresUnlocks
observeobservation-The judgment tier, prompt snapshots, trace replay, the viewport swipe’s root.
perform + actionsactionsobserveNode actions of the declared kinds, with platform actionability checks.
locatelocationobservescreen, Locator, expect(locator).
tapModifiers: true-performmodifiers on tap, doubleTap, and secondaryTap.
performAt + pointerActionspointerobserveOne pointer action at a viewport point, with no node behind it.
keyboard.{type,press,dismiss?}keyboardobserveInput to whatever holds focus.
fixtures.<name><name>-A contributed fixture.
statestate-test.setup sessions and the session: option.
artifactsartifacts-Screenshots and videos.
validateApp--A config-load check of the target’s app.
session--The app steering methods and the agent’s navigate and back verbs.

Agent verbs

The agent offers the model only the verbs the engine’s declarations unlock. An undeclared verb is absent from the toolset.

VerbNeeds
tapthe tap kind in actions
double_tapdoubleTap
long_presslongPress
right_clicksecondaryTap
hoverhover
typefill; without a target, keyboard
type_secretfill
presspress; without a target, keyboard
selectselectOption
checkboth check and uncheck, since it sets a state rather than flipping one
scroll, scroll_to({ text })swipe
scroll_to({ target })scrollIntoView
dragdragTo
uploadsetInputFiles
navigatesession.open
backsession.back
tap_at, hover_atthe tap or hover kind, in actions or in pointerActions
type_atfill or keyboard
press_atpress or keyboard
select_atselectOption
dismiss_keyboardkeyboard.dismiss
  • clear and focus unlock no verb.
  • tap_at and hover_at go through performAt (the tap and hover pointer kinds). Without that kind they go through perform when the point lands on a node the tree lists.
  • type_at, press_at, and select_at go through perform when the point lands on a node the tree lists. Elsewhere type_at and press_at fall back to keyboard.

Test APIs

The test-facing APIs each engine member backs. A screen or Locator method for an undeclared kind, or a call with modifiers and no tapModifiers, fails with UNSUPPORTED_CAPABILITY before it reaches the engine.

APINeeds
screen, Locator, expect(locator)locate
screen and Locator actionsperform, each only for a kind listed in actions
screen.swipe()perform with swipe on the observation root
modifiers on a tap (row.click({ modifiers: ['Shift'] }))tapModifiers: true; the keys are held for the click
screen.tapAt, tap({ position })performAt with the tap pointer kind
screen.swipe({ from, to })performAt with the swipeTo pointer kind
locator.pressSequentiallythe focus action and keyboard; the harness composes focus then keyboard.type
test.setup sessions, the session: optionstate
app.screenshot(), assertion evidence, the failure screenshotartifacts
every video mode but off, from the config, a target, a test, or --videoartifacts
app.open()session.open; on a target with no app URL, session.restart
app.back()session.back
app.restart()session.restart
app.clearState()session.reset
requires: ['<name>']fixtures.<name>

engine.capabilities

The computed ReadonlySet of capability names. Selection, the report, and UNSUPPORTED_CAPABILITY messages read it.

const engine = kiosk({ host: 'kiosk.local' });
engine.capabilities.has('location'); // true: the engine implements locate

Selection also grades a target by the kind of app it declares, not by its engine. A target with app.url has browser, and one with app.bundleId or app.appPath has native-app, so a test can requires: ['native-app'].

/**
 * Capability names: the closed harness capabilities plus one name per
 * contributed fixture. `actions` is `perform`; `location` is `locate`; both
 * require observation, because their refs live in the observation's id space.
 * `pointer` is `performAt`, an action addressed by a viewport point rather
 * than a node, and requires observation because the point is read off its
 * pixels.
 */
export type EngineCapability =
  | 'observation'
  | 'actions'
  | 'location'
  | 'pointer'
  | 'keyboard'
  | 'state'
  | 'artifacts'
  | (string & {});

Keyboard

/**
 * capability: keyboard - requires observation. Input to whatever holds focus,
 * with no node behind it: the agent's `type` and `press` without a target, and
 * the fallback of its point-addressed `type_at` and `press_at` when the point
 * lands on nothing the tree lists (a field drawn on a canvas, an input a
 * platform flattens out of its accessibility tree). Focus is the app's: the
 * harness tapped first, or the model said the field already has it.
 */
export interface EngineKeyboard {
  /**
   * Types `text` into the focused field as a person typing would, through
   * the platform's keyboard path: a browser dispatches the key events of each
   * character, a device sends the text through its keyboard. Where the
   * platform has key events the app must receive them; setting the value
   * without keystrokes is not a compliant implementation, since
   * `locator.pressSequentially` and the agent's `type` rely on the app
   * reacting as it does to a user. With `replace`, clears the field first
   * (select-all and delete on a keyboard surface; the platform's clear on a
   * device). Throw `NOT_ACTIONABLE` when nothing that accepts text has focus,
   * so the keystrokes are never silently discarded into the document body.
   */
  type(text: string, options: { readonly replace: boolean }, context: OperationContext): Promise<void>;
  /**
   * Sends one key of the contract grammar (`Enter`, `Escape`, `Tab`,
   * `Shift+Tab`, a character) to whatever holds focus. The harness has
   * already validated the key's shape; refuse with `UNSUPPORTED_CAPABILITY`
   * a key the surface cannot deliver.
   */
  press(key: string, context: OperationContext): Promise<void>;
  /** Hides an on-screen keyboard. Platforms without one leave it undefined. */
  dismiss?(context: OperationContext): Promise<void>;
}

Lifecycle

The hooks the runner calls around workers and attempts. Every hook is optional. The Engine declaration’s JSDoc above states each hook’s contract.

init and startAttempt run within launchTimeout. finish, settleAttempt, endAttempt, and dispose run within cleanupTimeout. prepare has no timeout. Both budgets come from the config.

HookRuns
prepare(info)Once per target, before any worker
finish(info)After the target’s last worker is gone
init(info)Once per worker, before its first step
startAttempt(context)Before each attempt
settleAttempt(context)After the body and after teardown
endAttempt(context)After each attempt
dispose(context)Worker shutdown

prepare

Runs once per target in the runner process, after collection and before workers, app commands, and the run’s clock start.

async prepare(info) {
  if (info.slots === 0) return; // nothing runs on this target
  info.log('booting devices');
  const devices = await pool.boot(info.slots, { signal: info.signal });
  return { workers: devices.length, env: { KIOSK_DEVICES: devices.join(',') } };
},

A failure ends the run before any test:

ThrownBecomes
A runner error class, such as a ConfigurationError for an option the engine cannot honourThe same code.
An EngineError with code CANCELLEDA cancellation.
Any other EngineError, or a plain ErrorInfrastructure ENGINE_FAILURE.
/**
 * What `prepare` learned that the run must honour. `workers` replaces the
 * engine's declared cap for this target and run: the surfaces the engine
 * actually provisioned, `1` to `info.slots`. Declared `workers` stays the
 * static answer for engines that know it up front.
 */
export interface EnginePrepareResult {
  /** Lowers the target's worker cap for this run, `1` to `info.slots`. */
  readonly workers?: number;
  /**
   * Variables every worker of this target is spawned with, on top of the
   * run's environment: how a runner-side `prepare` hands what it provisioned
   * (a device pool it discovered) to each worker's `init`. The run's own
   * variables win on a clash.
   */
  readonly env?: Readonly<Record<string, string>>;
}

/**
 * Facts handed to `prepare`, once per run and target in the runner process,
 * before any worker exists.
 */
export interface EnginePrepareInfo {
  /** The run id. */
  readonly runId: string;
  /** The target being prepared. */
  readonly targetName: string;
  /** Directory relative paths in the config resolve against; see `EngineInitInfo.projectRoot`. */
  readonly projectRoot: string;
  /** The app the target declares, as `init` receives it: what a device installs or warms up here. */
  readonly app: EngineAppInfo;
  /**
   * The worker slots the run will start for this target, `0` to `slots - 1`:
   * the run's worker cap, the engine's declared `workers`, and the work units
   * selected for the target, whichever is smallest; `0` when nothing runs on
   * it while another target has work. A run with nothing to run prepares no
   * target. Provision for exactly these slots (boot that many devices of a
   * pool); `init` then finds them ready.
   */
  readonly slots: number;
  /**
   * The run's environment: what every worker is started with. A host may hand
   * the run an environment other than the runner process's own, so anything
   * provisioned here that a worker later looks up by environment (a browser
   * cache location) must be resolved and spawned against this, not
   * `process.env`.
   */
  readonly env: Readonly<Record<string, string | undefined>>;
  /**
   * Aborts on interrupt only. Provisioning has no budget: a first-run
   * download is as long as the network makes it, and cutting it short would
   * fail every test behind it.
   */
  readonly signal: AbortSignal;
  /** Whether the run asked for a visible surface (`--headed`), as `EngineInitInfo` declares it. */
  readonly headed: boolean;
  /**
   * Reports one line of progress. The runner streams it as a `notice` run
   * event, so it reaches the reporter and every host sink instead of being
   * written to a worker's stderr underneath the live status block.
   */
  readonly log: (line: string) => void;
}

finish

Runs once per run and target in the runner process, after every worker of the target is gone. Release what prepare acquired for the run.

async finish(info) {
  await pool.release({ signal: info.signal });
},

The finish hooks of all prepared targets share one cleanupTimeout.

/** Handed to `finish`, once per run and target after the last worker is gone, with the cleanup budget of one hook. */
export interface EngineFinishInfo extends EngineCleanupContext {
  /** The run id. */
  readonly runId: string;
  /** The target being finished. */
  readonly targetName: string;
  /** The run's environment, the same `prepare` saw. */
  readonly env: Readonly<Record<string, string | undefined>>;
  /** Reports one line of progress, streamed as a `notice` run event like `prepare`'s. */
  readonly log: (line: string) => void;
}

init

Runs once per worker, before the first step. Boot devices here, not in a step budget.

async init(info) {
  const device = info.env.KIOSK_DEVICES?.split(',')[info.workerSlot];
  info.log(`slot ${info.workerSlot}: ${device}`);
},

init never learns the app URL; URLs arrive through session.open only. A failure is an infrastructure error.

/** Run identity and harness-resolved facts handed to `init`, once per worker before the first step. */
export interface EngineInitInfo {
  /** The run id. */
  readonly runId: string;
  /** The target this worker serves. */
  readonly targetName: string;
  /**
   * Directory relative paths in the config resolve against: the config file's
   * directory, or the run's `cwd` for a programmatic config. An engine option
   * naming a file (a build to install) resolves here, never against
   * `process.cwd()`, which an in-process run does not change.
   */
  readonly projectRoot: string;
  /** The app the target declares: the site policy of its URL, and what a device launches. */
  readonly app: EngineAppInfo;
  /**
   * This worker's environment: the run's, plus what this target's `prepare`
   * returned in its result's `env`. Read what `prepare` provisioned from
   * here, never from `process.env`: an in-process worker shares its process
   * with the runner and every other target.
   */
  readonly env: Readonly<Record<string, string | undefined>>;
  /** Whether the run asked for a visible surface (`--headed`). */
  readonly headed: boolean;
  /**
   * This worker's 0-based slot among the target's workers: the lowest slot
   * free when the worker was spawned, so a replacement worker takes over the
   * slot of the one that exited. An engine with several devices hands each
   * slot its own; `config.workers` bounds the slots a target can reach.
   */
  readonly workerSlot: number;
  /** Aborts on interrupt and when init exceeds the launch timeout; init must stop promptly. */
  readonly signal: AbortSignal;
  /**
   * Reports one line of progress. The runner streams it as a `notice` run
   * event, prefixed with the target and worker slot, so it reaches the
   * reporter and every host sink instead of being written to a worker's
   * stderr underneath the live status block. This is where a fact that only
   * exists once the worker is up belongs: the URL a hosted browser or device
   * can be watched at, which simulator a slot got, a version it detected.
   * Meant for `init` itself; a line logged later is still delivered while
   * the worker lives and dropped once it has exited. The line is shown as is
   * in reporters and host sinks, so keep tokens and other secrets out of it
   * (a viewer URL without its credential).
   */
  readonly log: (line: string) => void;
}

startAttempt

Runs before each attempt, to set up the per-test isolation that endAttempt tears down.

async startAttempt(context) {
  const password = await context.resolveSecret(options.password);
  await client.openContext({ password, signal: context.signal });
  context.environment({ device: 'Kiosk 2', os: 'KioskOS 4.1' });
},

On failure or timeout, context.signal aborts and endAttempt runs.

A value from resolveSecret is protected as text only. Reports, logs, and observations redact it and the attempt’s text downloads are rewritten, but unlike a fill it withholds no screenshot or model pixels.

/**
 * Per-attempt isolation context. An engine that must give each test a fresh
 * surface (a fresh isolated context per test, a reset device) sets it up in
 * `startAttempt` and tears it down in `endAttempt`.
 */
export interface EngineAttemptContext {
  /** The attempt id. */
  readonly attemptId: string;
  /** Absolute directory every artifact of this attempt is written under. */
  readonly artifactsDir: string;
  /**
   * The plaintext of a secret the engine declared in `secrets`, for an
   * option the engine hands the app itself (basic-auth credentials). A
   * provider runs again on every call. The value, and every value
   * `options.derived` computes from it, join the attempt's redaction before
   * this resolves, so reports, logs, and every observation redact them, and
   * the attempt's text downloads are rewritten. The protection is
   * text only: unlike a fill, it withholds no screenshot or model pixels.
   * A secret the engine did not declare is `SECRET_UNAVAILABLE`.
   */
  readonly resolveSecret: (secret: Secret, options?: ResolveSecretOptions) => Promise<string>;
  /**
   * Aborts with the attempt, and the moment `startAttempt` fails or exceeds
   * the launch timeout: a hook still running then must stop, because the
   * harness ends the attempt's isolation right behind it and may retry.
   */
  readonly signal: AbortSignal;
  /**
   * Reports something the app did on its own while the attempt ran: a
   * console line, an uncaught exception, a request that failed. The harness
   * redacts the text, keeps it with the step that was running (from an event
   * callback outside any step, the step that started last; before the first
   * step, with none), and shows it on the trace page. Call it from the moment `startAttempt` begins until
   * `endAttempt`; entries past a per-attempt cap are dropped.
   */
  readonly appLog: (entry: AppLogEntry) => void;
  /**
   * Reports where the app went on its own, as one line (`navigated to
   * /login`, `the app opened a new tab at /help`): a navigation the engine
   * did not start, a tab, a frame. Shown under the step that was running.
   */
  readonly navigation: (line: string) => void;
  /**
   * Hands the harness a screen for the trace: the capture a `locate`
   * matched against, or one read right after an action. A read after an
   * action is on the action's path, so bound it and skip it where it is
   * expensive; it never fails the action. The trace page shows how each
   * step's screen changed. Refs in it are not valid for `perform`. Absent
   * when the attempt keeps no trace.
   */
  readonly screen?: (snapshot: EngineSnapshot) => void;
  /**
   * What the attempt runs on, such as `{ browser: 'chromium 141.0' }` or
   * `{ device: 'iPhone 16', os: 'iOS 26.0' }`, for the trace page. Later
   * calls add to and overwrite earlier facts.
   */
  readonly environment: (facts: Readonly<Record<string, string>>) => void;
}

/** One line of what the app did during an attempt, as `EngineAttemptContext.appLog` takes it. */
export interface AppLogEntry {
  /**
   * Where it came from: the app's `console`, an `error` nothing caught, the
   * `network`, or the `system` the app runs on (a crash, an OS log).
   */
  readonly source: 'console' | 'error' | 'network' | 'system';
  readonly level: 'error' | 'warning' | 'info';
  /** One line, such as `GET /api/todos 500` or `TypeError: x is undefined`; the harness clips it. */
  readonly text: string;
}

/** How `EngineAttemptContext.resolveSecret` treats what the engine makes of the value. */
export interface ResolveSecretOptions {
  /**
   * The forms of the value the app sees instead of the value itself, for the
   * same redaction under the secret's name: the base64 `user:password` an
   * `Authorization: Basic` header carries, which a page echoing its request
   * headers would otherwise show.
   */
  readonly derived?: (plaintext: string) => readonly string[];
}

settleAttempt

Runs after the body has settled, and again after teardown, before the verdict. It reports a failure on a path no step awaited, such as a request interceptor or a native dialog handler.

async settleAttempt() {
  const running = [...dialogHandlers];
  dialogHandlers.clear();
  await Promise.all(running); // rethrows a handler's failure
},

endAttempt

Runs after each attempt and tears down what startAttempt set up. A failure marks cleanup failed; the test keeps its status.

async endAttempt(context) {
  await client.closeContext({ signal: context.signal });
},

dispose

Runs one time per worker, including when init never ran.

Missing or aborted context.reuseSignal requires final cleanup. A live signal permits keeping run-owned resources for a replacement worker. An engine that keeps resources must observe revocation until its hook settles. Final shutdown, a forced interrupt, or parent disconnect revokes that permission. The cleanup budget still uses context.signal and context.timeoutMs.

Cleanup is best effort within that budget. The runner does not guarantee cleanup after a worker exits or after disposal settles with reuse permission.

async dispose(context) {
  await client.shutdown({ signal: context.signal });
},
/**
 * Budget of one cleanup hook (`settleAttempt`, `endAttempt`, `dispose`). `signal` aborts when
 * the budget is exhausted, so a hook that cannot finish in time stops instead
 * of running on into the next attempt's setup.
 */
export interface EngineCleanupContext {
  /** Aborts when the cleanup budget is spent. */
  readonly signal: AbortSignal;
  /** Remaining cleanup budget when the call starts. */
  readonly timeoutMs: number;
}

/** Cleanup of one worker, with optional permission to leave run-owned resources for a replacement. */
export interface EngineDisposeContext extends EngineCleanupContext {
  /**
   * A live signal permits reusable resources owned by `prepare` and `finish`
   * to survive this worker. Missing or aborted permission requires final
   * cleanup. Observe revocation until disposal settles. This signal does
   * not cancel cleanup, which remains bounded by `signal` and `timeoutMs`.
   */
  readonly reuseSignal?: AbortSignal;
}

Observation

observe returns one fresh semantic snapshot of the surface under test.

FieldType
locationstring
rootSemanticNode
viewportViewportSize
truncatedboolean
keyboardVisibleboolean
treeUnavailabletrue
pixelsObservationPixels
maskedRegionCountnumber

Return the surface as it is right now. The runner mints the revision and stamps it onto every ref. When pixels are present, it checks that maskedRegionCount covers every secure node. A valid empty tree may have a childless root and include pixels.

/**
 * One fresh semantic snapshot of the surface under test. The harness owns
 * everything downstream: revision minting, secret redaction, and the
 * observation byte budget apply to every engine equally.
 */
export interface EngineSnapshot {
  /**
   * Where the surface is, as an opaque address the platform understands: a
   * URL on a document platform, the foreground screen or activity on a
   * device, the front window on a desktop. The harness shows it to the model
   * and to the report, anchors trace replay on it, and applies the origin
   * policy to it only when it parses as a URL. Omit when the platform has no
   * notion of a current location.
   */
  readonly location?: string;
  /**
   * The observation tree under one root the engine minted. The root's id MUST
   * be stable for the surface across observations: `perform(root, swipe)` is
   * the viewport swipe, and the harness addresses it from an earlier
   * observation, possibly one taken before a `restart` or `reset`, so the
   * root swipe MUST NOT depend on a live handle from that observation. A
   * platform with several top-level elements (a device's windows) wraps them
   * in a root of its own.
   */
  readonly root: SemanticNode;
  /** The viewport `SemanticNode.rect` and `ViewportPoint` are measured in, in CSS pixels. */
  readonly viewport: ViewportSize;
  /**
   * True when `root` leaves out nodes that are on the surface: the engine
   * stopped reading at a node cap or a depth limit, or could not enter a
   * child document. The harness tells the model the listing is incomplete
   * and never calls such a screen unchanged. Omit or set false for a tree
   * read whole.
   */
  readonly truncated?: boolean;
  /**
   * Whether an on-screen keyboard is showing, as the engine measured it apart
   * from `root`. Set `false` when it measured none, and omit it only when the
   * engine did not measure it.
   */
  readonly keyboardVisible?: boolean;
  /** Semantic capture failed; only fresh, independently masked pixels are evidence. */
  readonly treeUnavailable?: true;
  /** Masked pixels, when requested and producible; omitted otherwise. */
  readonly pixels?: ObservationPixels;
  /** Regions masked in `pixels`; the harness checks it covers every secure node. */
  readonly maskedRegionCount?: number;
}

export interface EngineObserveOptions {
  /** Requests masked viewport pixels for the same revision as the tree. */
  readonly pixels?: boolean;
  /** Permits independently masked pixels to replace a failed semantic capture. */
  readonly pixelFallback?: boolean;
  /** The pixels are compared against a stored screenshot: hold system chrome (a status bar's clock and battery) in a fixed state where the platform can. */
  readonly comparable?: boolean;
}

Truncated trees

Set truncated when a node limit or an inaccessible subtree leaves content out. The truncated JSDoc above says how the runner treats it.

Pixel fallback

When semantic capture times out, an engine may return masked pixels in place of the tree, but only when pixelFallback is true and it can prove screenshot masking without that capture.

return {
  treeUnavailable: true,
  root: { ref: rootRef },
  viewport,
  pixels,
};
  • Return treeUnavailable: true, fresh pixels, and a root containing only its stable ref. That ref supports viewport actions; it supplies no semantic node evidence. Retire earlier node refs.
  • The flag tells missing semantic evidence from a valid empty tree. Never infer absence from it.
  • Without permission, pixels, or independent masking proof, propagate the capture failure. Cancellation, policy denial, and hard engine failures propagate too.
  • The runner permits fallback for act and for judgments with vision: true or 'only', before any secret fill. It includes the screenshot even if the initial observation requested only a tree.
  • A step that loses semantic evidence cannot record or finish by replaying a trace.
  • Engines whose masks depend on their semantic capture cannot provide this fallback.

EngineSnapshot is an extendable interface, so TypeScript can’t enforce these rules. The runner checks the shape at the engine boundary, at runtime:

ThrowsWhen
UNSUPPORTED_CAPABILITYA fallback without permitted pixels.
INVALID_STATEA fallback whose root carries semantic fields.

Actions

perform carries out one LocatorAction on a node; performAt carries out one PointerAction at a viewport point. An engine declares the kinds it honors in actions and pointerActions.

KindFieldsactionspointerActions
tapmodifiers? on a nodeyesyes
doubleTapmodifiers? on a nodeyesyes
secondaryTapmodifiers? on a nodeyesyes
longPressdurationMs?yesyes
fillvalue, sensitiveyes-
clear-yes-
presskeyyes-
check-yes-
uncheck-yes-
focus-yes-
hover-yesyes
scrollIntoView-yes-
selectOptionvalueyes-
setInputFilespathsyes-
dragTotarget: a NodeRef or a ViewportPointyesyes
swipedirection, momentum?yesyes
swipeTotarget, durationMs?-yes

The grammar is closed. Anything beyond it is an agent-side defineTool.

Locator actions

async perform(ref, action, context) {
  switch (action.kind) {
    case 'tap':
      return client.tap(ref.id, { signal: context.signal });
    case 'swipe':
      return client.scroll(ref.id === rootId ? undefined : ref.id, action.direction);
    default:
      throw new EngineError('UNSUPPORTED_CAPABILITY', `kiosk cannot ${action.kind}`, { retryable: false });
  }
},
/**
 * One deterministic action `perform` carries out on a node. The `swipe`
 * kind on the observation's root node is the viewport swipe: the agent's
 * `scroll` verb and `screen.swipe()` both arrive as `perform(root, swipe)`,
 * so a surface implements scrolling once.
 *
 * `press` takes one key in the `Key` grammar below.
 */
export type LocatorAction =
  | {
      readonly kind: 'tap' | 'doubleTap' | 'secondaryTap';
      /** Keys held for the click; only an engine that declares `tapModifiers` receives it. */
      readonly modifiers?: readonly KeyModifier[];
    }
  | {
      readonly kind:
        | 'check'
        | 'uncheck'
        | 'clear'
        | 'focus'
        | 'hover'
        | 'scrollIntoView';
    }
  | { readonly kind: 'longPress'; readonly durationMs?: number }
  | { readonly kind: 'fill'; readonly value: string; readonly sensitive: boolean }
  | { readonly kind: 'press'; readonly key: Key }
  | { readonly kind: 'selectOption'; readonly value: SelectOption }
  | { readonly kind: 'setInputFiles'; readonly paths: readonly string[] }
  | { readonly kind: 'dragTo'; readonly target: NodeRef }
  | {
      readonly kind: 'swipe';
      readonly direction: ScrollDirection;
      readonly momentum?: Momentum;
    };

/** Every action kind; what an engine declares in `actions`. */
export type LocatorActionKind = LocatorAction['kind'];

/** The closed list of action kinds, in contract order; the type is derived from it. */
export const LOCATOR_ACTION_KINDS = [
  'tap',
  'doubleTap',
  'secondaryTap',
  'longPress',
  'fill',
  'clear',
  'press',
  'check',
  'uncheck',
  'focus',
  'hover',
  'scrollIntoView',
  'selectOption',
  'setInputFiles',
  'dragTo',
  'swipe',
] as const satisfies readonly LocatorActionKind[];

Pointer actions

Pointer actions use viewport coordinates when no semantic node is available.

  • dragTo ends at another point.
  • swipe starts a scroll gesture at the given point.
  • swipeTo is the one kind with no node form: the finger’s path from the point to target, a touch swipe on a device and a pointer drag on a document platform. It differs from dragTo, where a platform holds before it drags.
export type PointerAction =
  | { readonly kind: 'tap' | 'doubleTap' | 'secondaryTap' | 'hover' }
  | { readonly kind: 'longPress'; readonly durationMs?: number }
  | { readonly kind: 'dragTo'; readonly target: ViewportPoint }
  | {
      readonly kind: 'swipe';
      readonly direction: ScrollDirection;
      readonly momentum?: Momentum;
    }
  | {
      readonly kind: 'swipeTo';
      readonly target: ViewportPoint;
      readonly durationMs?: number;
    };

/** Every pointer action kind; what an engine declares in `pointerActions`. */
export type PointerActionKind = PointerAction['kind'];

/** The closed list of pointer action kinds, in contract order; the type is derived from it. */
export const POINTER_ACTION_KINDS = [
  'tap',
  'doubleTap',
  'secondaryTap',
  'longPress',
  'hover',
  'dragTo',
  'swipe',
  'swipeTo',
] as const satisfies readonly PointerActionKind[];

Keys

The key grammar of press, shared by every engine, and its parser.

parseKey('Control+a'); // { modifiers: ['Control'], key: { kind: 'char', char: 'a' } }
parseKey('Shift+Tab'); // { modifiers: ['Shift'], key: { kind: 'named', name: 'Tab' } }
parseKey('Ctrl+a'); // undefined: Ctrl is not a modifier of the grammar
/**
 * The key grammar of `press`, shared by every engine so a test's key names are
 * portable: zero or more modifiers and one key, joined by `+`.
 *
 * - Modifiers: `Shift`, `Control`, `Alt`, `Meta`, `ControlOrMeta` (Control on
 *   Windows and Linux, Meta on macOS).
 * - Named keys: those in `KEY_NAMES` (`Enter`, `Escape`, `Tab`, `Backspace`,
 *   `Delete`, `ArrowUp`..., `Home`, `End`, `PageUp`, `PageDown`, `Insert`,
 *   `Space`, `F1`..`F12`).
 * - Any single printable character (`a`, `A`, `1`, `$`), typed as itself.
 *
 * `Control+a`, `Shift+Tab`, `Enter`, `$`. A platform without a key maps it
 * or throws `UNSUPPORTED_CAPABILITY`; it never reinterprets the spelling.
 * `parseKey` is the one parser, exported so no engine carries its own.
 */
export type Key = string;

export const KEY_MODIFIERS = ['Shift', 'Control', 'Alt', 'Meta', 'ControlOrMeta'] as const;

export const KEY_NAMES = [
  'Enter',
  'Escape',
  'Tab',
  'Backspace',
  'Delete',
  'Insert',
  'Space',
  'ArrowUp',
  'ArrowDown',
  'ArrowLeft',
  'ArrowRight',
  'Home',
  'End',
  'PageUp',
  'PageDown',
  'F1',
  'F2',
  'F3',
  'F4',
  'F5',
  'F6',
  'F7',
  'F8',
  'F9',
  'F10',
  'F11',
  'F12',
] as const;

/** One parsed `press` key: its modifiers and either a named key or a single character. */
export interface ParsedKey {
  /** Modifiers, in the order written. */
  readonly modifiers: readonly KeyModifier[];
  /** The key itself. */
  readonly key: { readonly kind: 'named'; readonly name: KeyName } | { readonly kind: 'char'; readonly char: string };
}

export function parseKey(key: string): ParsedKey | undefined;

App

The app under test is declared on the target (targets: [{ engine, app }], see the app under test); an engine only drives it. The harness checks the shape of every field. Then it hands the declaration to validateApp at config load.

Target fieldReaches the engine as
urlvalidateApp only. The harness opens it through session.open.
the site of urlinfo.app.site in prepare and init
bundleId, appPath, launchArguments, permissionsvalidateApp, and info.app in prepare and init, as the target declared them
command, readyUrlNever. The runner starts the app.

CommandConfig is exported from e2e, not e2e/engine.

validateApp

Checks the declared app at config load, synchronously.

validateApp(app, { targetName }) {
  if (app.url === undefined) {
    throw new ConfigurationError('INVALID_CONFIG', `target "${targetName}" needs app.url`);
  }
},
  • It runs for every target the engine serves, with {} for a target that declares no app.
  • @e2e-dev/web requires url; @e2e-dev/mobile requires bundleId or appPath and refuses url.
ThrowsWhen
INVALID_CONFIGA field the platform needs is missing, or the engine cannot drive one. Name info.targetName in the message.
/**
 * The app a target declares, as its engine reads it at config load: what it
 * is and where it is served. `EngineAppInfo` (below) is what `prepare` and
 * `init` receive; the declaration adds the URL, which a surface never gets.
 */
export type EngineAppDeclaration = EngineAppInfo & {
  /** Base URL the target opens, as declared; see `TargetApp.url`. */
  readonly url?: string;
};

/** Handed to `Engine.validateApp` beside the declaration. */
export interface EngineAppCheckInfo {
  /** The target declaring the app, for the message of a refusal. */
  readonly targetName: string;
}

/** A permission's state when the app launches: held, refused, or not asked for yet, so the OS asks again. */
export type AppPermissionState = 'grant' | 'deny' | 'reset';

EngineAppInfo

The resolved app, as EnginePrepareInfo.app and EngineInitInfo.app: the site of the URL and the device fields as the target declared them. Apply the site to a URL with sameSite.

if (sameSite(request.url, info.app.site ?? '')) request.headers.set('x-api-key', key);
/**
 * The app the harness resolved from the target's declaration, handed to
 * `prepare` and `init`: the site policy for the decisions only a surface can
 * make (which nested documents enter an observation, which requests carry
 * injected credentials), and what a device launches.
 */
export interface EngineAppInfo {
  /**
   * The app URL's registrable domain, as read by `siteOf`. Use `sameSite`
   * to scope secret fills, injected headers, and observed child frames.
   * Absent when the app has no URL. The runner passes navigation URLs
   * separately through `session.open`.
   */
  readonly site?: string;
  /** The installed app to launch, as the target declared it. */
  readonly bundleId?: string;
  /** The build to install, as the target declared it; relative to `projectRoot`. */
  readonly appPath?: string;
  /** Arguments every fresh launch passes the app. */
  readonly launchArguments?: readonly string[];
  /** Permissions the app holds on every fresh launch, by name. */
  readonly permissions?: Readonly<Record<string, AppPermissionState>>;
}

Session

Steering hooks behind the universal app fixture and the agent’s navigate and back verbs.

HookBehind
open(url)app.open(), the agent’s navigate verb, the reopen after restart and reset
back()app.back(), the agent’s back verb
restart()app.restart()
reset()app.clearState()
session: {
  async back(context) { await client.back({ signal: context.signal }); },
  async restart(context) { await client.relaunch(bundleId, { signal: context.signal }); },
},
/**
 * Steering hooks behind the universal `app` fixture and the agent's
 * `navigate` verb. The app itself is the target's `app`, handed over as data;
 * node actions never live here, they are `perform`.
 *
 * `restart` and `reset` open nothing on an addressable surface: they end at
 * a fresh surface with no location shown, and the harness reopens the app
 * through `open` when the target has an address. A surface therefore never
 * needs to know the app's URL, and an unreachable app after a restart is
 * reported the same way as on first open. A surface without `open` (a
 * device with a pinned app) relaunches the app inside `restart` and `reset`
 * itself, since nothing else will.
 */
export interface EngineSession {
  /**
   * Opens one URL the harness already resolved against the base URL and the
   * origin policy. Absent on a surface without addressable locations.
   */
  open?(url: string, context: OperationContext): Promise<void>;
  /** Navigates back once in the surface's history. */
  back?(context: OperationContext): Promise<void>;
  /** Recreates the execution context, keeping persisted state. */
  restart?(context: OperationContext): Promise<void>;
  /** Clears persisted client state and recreates the execution context. */
  reset?(context: OperationContext): Promise<void>;
}

Errors

An engine throws EngineError with one code of a closed set. The runner maps each code to the error a test sees.

CodeRunner mappingRetryable
NODE_STALEre-resolve, then LOCATOR_NOT_FOUNDyes
FRAME_NOT_FOUNDre-resolve, then LOCATOR_NOT_FOUNDyes
FRAME_AMBIGUOUSLOCATOR_AMBIGUOUSno
NOT_ACTIONABLE, ACTION_MAY_HAVE_COMMITTED, OPERATION_TIMEOUTACTION_FAILEDno
CANCELLEDinfrastructure CANCELLEDno
UNSUPPORTED_CAPABILITYconfiguration UNSUPPORTED_CAPABILITYno
INVALID_STATEAPP_NOT_OPENno
ENGINE_FAILURE, any non-EngineErrorinfrastructure ENGINE_FAILUREno

EngineError

throw new EngineError('NODE_STALE', `node ${ref.id} is gone`, { retryable: true });
export class EngineError extends Error {
  constructor(code: EngineErrorCode, message: string, options: { retryable: boolean; cause?: unknown });
  /** One of `ENGINE_ERROR_CODES`. */
  readonly code: EngineErrorCode;
  /** Whether the runner may re-resolve and retry; only `RETRYABLE_ENGINE_ERROR_CODES` can be. */
  readonly retryable: boolean;
}

/** The closed engine error code set; the type is derived from it, so the two cannot drift. */
export const ENGINE_ERROR_CODES = [
  'NODE_STALE',
  'FRAME_NOT_FOUND',
  'FRAME_AMBIGUOUS',
  'NOT_ACTIONABLE',
  'ACTION_MAY_HAVE_COMMITTED',
  'OPERATION_TIMEOUT',
  'CANCELLED',
  'UNSUPPORTED_CAPABILITY',
  'INVALID_STATE',
  'ENGINE_FAILURE',
] as const;

/** One of `ENGINE_ERROR_CODES`. */
export type EngineErrorCode = (typeof ENGINE_ERROR_CODES)[number];

/**
 * The only codes that may be retryable: both describe a repeatable read. Any
 * other retryable claim, from this module's class or a foreign copy of it, is
 * coerced to a non-retryable `ENGINE_FAILURE`.
 */
export const RETRYABLE_ENGINE_ERROR_CODES: ReadonlySet<EngineErrorCode> = new Set([
  'NODE_STALE',
  'FRAME_NOT_FOUND',
]);

Timeouts at a deadline

An OPERATION_TIMEOUT on a locate or read that started with under 100 ms of a waiting call’s deadline left, after one that completed, ends the wait instead:

Waiting callFails with
an action, waitForLOCATOR_NOT_FOUND, with the timeout as its cause
an assertionASSERTION_FAILED, with the timeout as its cause

A negated assertion passes there if its negation had held long enough by the time the read was cut off. pollCondition polls by these rules.

Runner error classes

An EngineError a contributed fixture method throws is mapped by the table above, so a fixture over a session that ended reports APP_NOT_OPEN the way an observation would. A method can also fail a step with the runner’s own error classes, exported from the same entry.

throw new TestError('ASSERTION_FAILED', 'expected the receipt to print');

E2EError itself is not exported; construct one of the three.

/** The runner's error base: `category` sets the exit code, `code` names the cause. */
declare class E2EError extends Error {
  readonly category: 'test' | 'configuration' | 'infrastructure' | 'internal' | 'interrupted';
  readonly code: string;
  readonly retryable: boolean;
}

/** A test failure: ASSERTION_FAILED, INVALID_LOCATOR, ACTION_FAILED, ... */
export class TestError extends E2EError {
  constructor(code: string, message: string, options?: { retryable?: boolean; cause?: unknown });
}
/** POLICY_DENIED, APP_URL_REQUIRED, UNSUPPORTED_CAPABILITY, ... */
export class ConfigurationError extends E2EError {
  constructor(code: string, message: string, options?: { cause?: unknown });
}
/** The surface itself failed: BROWSER_INSTALL_FAILED, ... */
export class InfrastructureError extends E2EError {
  constructor(code: string, message: string, options?: { cause?: unknown });
}

Shared semantics

Helpers that keep behavior consistent across engines. Every engine reproduces these semantics; import them instead of carrying a copy.

ExportReturns
matchesText, toTextPattern, describePatternboolean, TextPattern, string
resolveExpressionSemanticNode[]
urlMatchesboolean
siteOf, sameSitestring, boolean
pollCondition, DeadlinePromise<void>, Deadline
validateJsonValuevoid
rejectUnknownKeysvoid
rejectUnknownOptionsvoid
objWithoutUndefined<T>
raceAbort, withTimeout, withinCleanupBudgetPromise<T>, Promise<T>, Promise<void>
isSecretboolean
isProviderRecording, stopProviderRecordingboolean, Promise<VideoSegment>

Text patterns

TextPattern matching for locate and text matchers.

export function toTextPattern(match: TextMatch, options?: { exact?: boolean }): TextPattern;
export function matchesText(actual: string, pattern: TextPattern): boolean;
export function describePattern(pattern: TextPattern): string;
const pattern = toTextPattern(/total/i);
matchesText('Order total', pattern); // true
describePattern(pattern); // '/total/i'
ThrowsWhen
INVALID_ARGUMENTtoTextPattern gets a match that is neither a string nor a RegExp.

resolveExpression

The reference locate. Resolves one LocatorExpression over a semantic tree, in document order.

export function resolveExpression(
  expression: LocatorExpression,
  nodes: readonly SemanticNode[],
  options?: ResolveExpressionOptions,
): SemanticNode[];
async locate(expression) {
  const screen = await client.snapshot();
  return resolveExpression(expression, screen.nodes);
},
  • text and label answer with the innermost match when a container echoes a descendant’s text.
  • An engine whose tree is the whole truth (a device) calls it; a browser engine reproduces it.
  • A selector expression needs the selector option.
  • A frame expression is FRAME_NOT_FOUND.
  • A union expression matches either branch in document order, without duplicate nodes.

urlMatches

toHaveURL-style comparison of a current URL against an expected one.

export function urlMatches(
  current: string,
  expected: string | RegExp,
  baseHref: string,
  ignoreCase?: boolean,
): boolean;
urlMatches('https://shop.test/cart', '/cart', 'https://shop.test/'); // true

ignoreCase works as on the locator matchers.

ThrowsWhen
INVALID_ARGUMENTexpected is neither a string nor a RegExp, as in toTextPattern.

Site helpers

siteOf reads a hostname’s registrable domain, the form EngineAppInfo.site takes. sameSite tells whether a URL is on a site.

export function siteOf(hostname: string): string;
export function sameSite(url: string | URL, site: string): boolean;

pollCondition

Assertion polling and the negation grace window.

export function pollCondition(options: PollConditionOptions): Promise<void>;
const deadline = new Deadline(context.timeouts.assertion);
await pollCondition({
  deadline,
  signal: context.signal,
  negated: false,
  evaluate: async () => (await read(context.operation(deadline.remaining()))) === 'Paid',
  onTimeout: (cause) => new TestError('ASSERTION_FAILED', 'status never became Paid', { cause }),
});
  • Bound each read evaluate makes by deadline (context.operation(deadline.remaining())). A read on the action timeout can outlast the assertion’s own.
  • An OPERATION_TIMEOUT from an evaluate ends the poll under the rules in Timeouts at a deadline. onTimeout(cause) builds the failure with it.

validateJsonValue

Checks that a value is plain JSON.

export function validateJsonValue(value: unknown, label: string, rules?: JsonValueRules): void;
validateJsonValue(result, 'kiosk.evaluate result', { maxDepth: 32 });
  • A top-level undefined passes.
  • rules.onSecret replaces the POLICY_DENIED a Secret raises with an error of its own; rules.maxDepth caps the nesting.
ThrowsWhen
INVALID_ARGUMENTA non-finite number, a cycle, an object that is not a plain object or array, or a function, symbol, bigint, or undefined inside. The message names label.
POLICY_DENIEDA Secret, unless rules.onSecret throws its own.

rejectUnknownKeys

Throws for the first own key of value outside keys: the check the runner runs on its own config objects, for an engine factory’s options.

export function rejectUnknownKeys(
  label: string,
  value: object,
  keys: readonly string[],
  code?: 'INVALID_CONFIG' | 'INVALID_ARGUMENT', // default: 'INVALID_CONFIG'
): void;
rejectUnknownKeys('web()', options, ['browser', 'viewport']);
// web() has unknown key "viewprt"; did you mean "viewport"?
  • The message names the nearest known key when one is a plausible typo, and the known keys otherwise.
  • Pass 'INVALID_ARGUMENT' as code for an object a test passes to a fixture method.

rejectUnknownOptions

Refuses an option key a fixture method or matcher does not take, so a JavaScript caller’s unsupported option fails instead of running on the default.

export function rejectUnknownOptions(
  api: string,
  options: object | undefined,
  known: readonly string[],
  code?: 'INVALID_ARGUMENT' | 'INVALID_LOCATOR', // default: 'INVALID_ARGUMENT'
): void;
rejectUnknownOptions('kiosk.print', options, ['copies']);
  • Pass INVALID_LOCATOR as code for a locator option.
  • options must be a plain object or undefined; a class instance or an array is refused.
  • Non-enumerable own keys count as keys.

obj

Drops undefined-valued keys, so a result built from optional inputs satisfies exact optional property types.

obj({ a: 1, b: undefined, c: 3 }); // { a: 1, c: 3 }

Timing helpers

export function raceAbort<T>(work: Promise<T> | (() => Promise<T>), signal: AbortSignal, label: string): Promise<T>;
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T>;
export function withinCleanupBudget(
  promise: Promise<unknown>,
  budget: { readonly signal: AbortSignal; readonly timeoutMs: number },
): Promise<void>;
await raceAbort(() => client.tap(ref.id), context.signal, 'kiosk tap');
  • raceAbort abandons the wait when signal aborts. Pass a thunk to check cancellation before dispatch.
  • withTimeout races a promise against a timeout and throws what onTimeout builds.
  • withinCleanupBudget never rejects. It resolves when the promise settles, failures included, when signal aborts, or when timeoutMs passes.

isSecret

Tells a secrets.get() handle from a string, for an option that takes either.

const password = isSecret(options.password)
  ? await context.resolveSecret(options.password)
  : options.password;

State

Platform-neutral state capture and restore, behind test.setup sessions and the session: option.

MemberDoes
capture(context)Captures the surface’s persisted state as an EngineState.
restore(state, context)Replaces the surface’s whole persisted state with state.
state: {
  async capture(context) {
    return { format: 'kiosk-storage', version: 1, data: await client.exportStorage(context) };
  },
  async restore(state, context) {
    await client.replaceStorage(state.data, context);
  },
},

Snapshots are credentials: cookies, tokens, and storage that authenticate the app under test. An engine must not persist, cache, or log a snapshot on its own, must not echo snapshot contents in an error message, and must treat restore as a replacement, never a merge, so a restored session cannot leak into the next attempt.

/**
 * An opaque, restorable snapshot of an engine's state. `data` is JSON the
 * harness never inspects - a document platform's storage state, a device's app state, a
 * desktop's window state all satisfy it identically. `format`/`version` let a
 * engine reject a snapshot it can no longer read.
 */
export interface EngineState {
  /** Engine-defined format name. */
  readonly format: string;
  /** Format version, for rejecting a snapshot the engine can no longer read. */
  readonly version: number;
  /** Opaque JSON the runner never inspects. */
  readonly data: unknown;
  /** Earliest moment the state is known to be invalid, if the engine knows one. */
  readonly expiresAt?: string;
}

/**
 * Platform-neutral state capture and restore. A setup test captures a
 * snapshot; an ordinary test restores it at launch.
 *
 * Snapshots are credentials: cookies, tokens, and storage that authenticate
 * the app under test. The duties split at the seam. The harness owns the
 * snapshot from the moment `capture` returns: it encrypts it at rest with a
 * per-run AES-256-GCM key that never touches disk, stores it under the run's
 * private session directory, binds it to the run, target, engine, and app
 * identity, and deletes it when the run ends; it never logs it, reports it,
 * digests it, or sends it to a model. An engine MUST NOT persist, cache, or
 * log a snapshot on its own, MUST NOT echo snapshot contents in an error
 * message, and MUST treat `restore` as a replacement of the surface's whole
 * persisted state, never a merge, so a restored session cannot leak into the
 * next attempt.
 */
export interface EngineStateCapability {
  /** Captures the surface's persisted state. */
  capture(context: OperationContext): Promise<EngineState>;
  /** Replaces the surface's persisted state with `state`. */
  restore(state: EngineState, context: OperationContext): Promise<void>;
}

Artifacts

Evidence capture: screenshots under the attempt directory, and videos there or at a provider.

MemberReturns
screenshot(label, context)Promise<string>
startVideo(context)Promise<void>
stopVideo(context)Promise<readonly VideoSegment[]>

screenshot is required and returns the path of the file it wrote.

artifacts: {
  async screenshot(_label, context) {
    // The label comes from test code, so it never becomes part of the path.
    shots += 1;
    const path = `screenshots/${String(shots).padStart(3, '0')}.png`;
    await client.capture(join(artifactsDir, path), context);
    return path;
  },
},
/**
 * Evidence capture. Paths are relative to the attempt artifact directory the
 * engine received in `startAttempt`.
 */
export interface EngineArtifacts {
  /** Captures a redacted screenshot; secure fields are masked at the source. */
  screenshot(label: string | undefined, context: OperationContext): Promise<string>;
  /**
   * Starts recording the surface for the attempt. Declared together with
   * `stopVideo`. A surface that has nothing to show yet (no page open) may
   * defer the actual capture to the moment it does; the segments returned by
   * `stopVideo` say when each one began.
   */
  startVideo?(context: OperationContext): Promise<void>;
  /**
   * Stops recording and returns every segment written, in order. One segment
   * is the common case; a surface whose recording is bound to a page returns
   * one per page the attempt showed (a restart or a state reset opens a new
   * one). An attempt that never showed anything returns none.
   */
  stopVideo?(context: OperationContext): Promise<readonly VideoSegment[]>;
}

/**
 * One recorded video of an attempt: a file the engine wrote into the attempt
 * artifact directory, or a recording a hosted service keeps and links to.
 */
export type VideoSegment = VideoFile | VideoLink;

/** A video file in the attempt artifact directory. */
export interface VideoFile {
  /** Path relative to the attempt artifact directory. */
  readonly path: string;
  /** When the segment started recording, as an ISO timestamp; its first frame is at or just after it. */
  readonly startedAt: string;
}

/** A recording a hosted service keeps, by URL: the report links to it and the runner never downloads it. */
export interface VideoLink {
  /** `http(s)` URL of the recording. */
  readonly url: string;
  /** Media type of what the URL serves: `video/mp4`, or `text/html` for a player page. */
  readonly mediaType: string;
  /** When the recording started, as an ISO timestamp; its first frame is at or just after it. */
  readonly startedAt: string;
}

Videos

The runner calls startVideo only for an attempt whose video mode records.

  • startVideo runs after startAttempt and any session restore, within the launch budget.
  • stopVideo runs before endAttempt, within the cleanup budget.
  • When the video mode is set explicitly, a startVideo that fails fails the launch and a stopVideo that fails fails cleanup. With the default mode, a failure of either drops the video quietly.
  • The runner decides which recordings are kept and deletes a file an attempt does not keep.

Provider recordings

An engine that leases its browser or device from a hosted service can let the service record the attempt in place of its own capture. That is how BrowserProvider.record and DeviceProvider.record work. e2e/engine exports the shared shape and stopProviderRecording, so every engine turns a provider’s recording into a VideoSegment the same way.

async stopVideo(context) {
  if (recording === undefined) return [];
  return [await stopProviderRecording(recording, { artifactsDir, provider: 'browser provider "kernel"', leaseId, signal: context.signal })];
},
  • isProviderRecording(value) tells whether what a provider’s record resolved to is a recording: a parseable startedAt and a stop. Check it before keeping one, so a malformed recording fails the start, named, instead of the stop.
  • stopProviderRecording creates the attempt’s video directory and calls stop with it. A file must be a plain name that exists in that directory; a link must be an http(s) URL with a media type.
  • It returns { path: 'video/<file>', startedAt } or { url, mediaType, startedAt } for stopVideo to hand back.
  • The target argument’s type is exported as ProviderRecordingTarget.
ThrowsWhen
ENGINE_FAILUREstop throws or returns anything else. The message names the provider and the lease.
/** Handed to a provider's `record`, once per attempt that records video. */
export interface ProviderRecordContext {
  readonly runId: string;
  readonly targetName: string;
  /** The attempt the recording covers. */
  readonly attemptId: string;
  /** The run's environment, the same the provider's `acquire` saw. */
  readonly env: Readonly<Record<string, string | undefined>>;
  /** Aborts when the attempt is cancelled or its launch budget is spent. */
  readonly signal: AbortSignal;
}

/** A recording a provider's service makes of the browser or device it leased, as `record` started it. */
export interface ProviderRecording {
  /** When the recording started, as an ISO timestamp; its first frame is at or just after it. */
  readonly startedAt: string;
  stop(context: ProviderRecordingStopContext): Promise<ProviderRecordingResult>;
}

/** Handed to `ProviderRecording.stop`, once per recording. */
export interface ProviderRecordingStopContext {
  /** Absolute directory, already created, a recording written as a file goes into. */
  readonly dir: string;
  /** Aborts when the cleanup budget is spent. */
  readonly signal: AbortSignal;
}

/**
 * What a stopped recording became: a file the provider wrote into the
 * directory it was given, by name, or a recording its service keeps, by
 * `http(s)` URL and media type.
 */
export type ProviderRecordingResult =
  | { readonly file: string }
  | { readonly url: string; readonly mediaType: string };

export function isProviderRecording(value: unknown): value is ProviderRecording;

export function stopProviderRecording(
  recording: ProviderRecording,
  target: {
    /** Absolute attempt artifact directory; a file recording goes into its `video` directory. */
    readonly artifactsDir: string;
    /** The provider as error messages name it: `browser provider "kernel"`. */
    readonly provider: string;
    /** The lease the recording covers, named in error messages. */
    readonly leaseId: string;
    readonly signal: AbortSignal;
  },
): Promise<VideoSegment>;

Redaction

  • Videos are unmasked and carry redaction: 'incomplete'.
  • A download an engine attaches is the app’s bytes. When the session knows a secret value, the runner rewrites a text one (.txt, .csv, .json, .html) through the ledger and labels it complete.
  • Every other download keeps its bytes and carries redaction: 'incomplete'.

Contributed fixtures

An engine contributes named surfaces to the test fixtures through fixtures. Each key becomes a fixture and a capability name.

src/index.ts
fixtures: {
  printer: (context) =>
    context.fixture(
      'printer',
      {
        async print(text: string): Promise<void> {
          await client.print(text, context.operation());
        },
      },
      { print: { kind: 'resource', label: (text) => text } },
    ),
},
tests/receipt.e2e.ts
import { test as base } from 'e2e';

const test = base.extend<{ printer: { print(text: string): Promise<void> } }>();

test('prints a receipt', { requires: ['printer'] }, async ({ printer }) => {
  await printer.print('Order 42');
});

The factory runs once per attempt with an EngineFixtureContext:

MemberUse
targetNameThe target the fixture serves.
fixture(name, surface, operations)Declares which methods are recorded steps.
appThe resolved app, plus resolveUrl(url).
timeoutsThe test, action, and assertion budgets.
signalThe running phase’s signal. Read it per call.
operation(timeoutMs?)A per-call OperationContext.
attachArtifact(kind, relativePath)Registers a file the current step produced.
attachViewport(viewport)Records the viewport the current step established.
locator(expression)Mints a public locator from a raw expression.
screen(scope)Mints a screen scope.
expectable(target, factory)Makes expect(fixture) return matchers.
ThrowsWhen
INVALID_CONFIGThe factory returns a plain surface, not one declared through context.fixture, the first time a test reaches for it.
APP_URL_REQUIREDapp.resolveUrl on a target that declares no URL.
POLICY_DENIEDapp.resolveUrl with any scheme but http: and https: (about:blank aside).
/**
 * A contributed fixture: any record of async methods, sync accessors, and
 * nested namespaces, declared through context.fixture so its metadata controls
 * labels, deadlines and verification. The factory must return the declared
 * surface; a plain one is rejected with INVALID_CONFIG.
 */
export type EngineFixtureFactory = (context: EngineFixtureContext) => object;

/** Context handed to a contributed fixture factory, once per attempt. */
export interface EngineFixtureContext {
  /** The target the fixture serves. */
  readonly targetName: string;
  /** Records the declared operations before invoking them; undeclared accessors retain their identity. */
  fixture<T extends object>(name: string, surface: T, operations: FixtureOperations<T>): T;
  readonly app: EngineAppInfo & {
    /**
     * Resolves a navigation target against the base URL. Throws
     * `APP_URL_REQUIRED` when the target declares no URL and `POLICY_DENIED`
     * for any scheme but `http:` and `https:` (`about:blank` aside), so a
     * fixture never re-implements the rule the harness owns.
     */
    resolveUrl(url: string): string;
  };
  /** Harness budgets, for fixtures that poll or navigate. */
  readonly timeouts: {
    readonly test: number;
    readonly action: number;
    readonly assertion: number;
  };
  /**
   * The running phase's signal: the attempt's through `beforeEach` and the
   * body, then a fresh one per `afterEach` hook so teardown can still drive
   * the app after a body timeout. Read it per call; a fixture factory that
   * captures it once keeps a signal that is dead by teardown.
   */
  readonly signal: AbortSignal;
  /** Per-call operation budget: the action timeout (or an explicit one) plus the attempt signal. */
  operation(timeoutMs?: number): OperationContext;
  /** Registers a file the current step produced under the attempt artifact directory. */
  attachArtifact(
    kind: 'screenshot' | 'video' | 'download' | 'log',
    relativePath: string,
  ): void;
  /** Records the viewport the current step established. */
  attachViewport(viewport: { width: number; height: number; scale: number }): void;
  /** Mints a public locator from a raw expression (a platform selector, say). */
  locator(expression: LocatorExpression): Locator;
  /** Mints a screen scope whose every query is wrapped by `scope` (a nested document, say). */
  screen(scope: (expression: LocatorExpression) => LocatorExpression): Screen;
  /**
   * Attaches an expectation surface to a fixture object so `expect(fixture)`
   * returns it. The harness records every matcher call as an assertion step
   * named `expect.<matcher>`; the factory only decides what the matchers mean.
   */
  expectable<T extends object, E extends object>(target: T, factory: () => E): T & Expectable<E>;
}

Declaring operations

context.fixture(name, surface, operations) declares which methods are recorded steps. Each operation takes:

FieldValuesDefault
kind'resource' or 'assertion'required
label(...args) => stringnone
timeoutmilliseconds, a function of the arguments, or false when the method owns its deadlineactionTimeout
verifiesbooleantrue for assertions
  • Nested maps describe namespaces.
  • Recording decorates the object in place, so create a fresh surface in each factory invocation.
  • The runner opens the step before calling a declared method, so attachments and failures from synchronous and asynchronous work land on the same step. Work arriving after its step has closed cannot attach to a later one.
  • Type the fixture with test.extend<{ name: Type }>().
/** Explicit recording policy for one async fixture method. Arguments enter reports only through label. */
export interface FixtureOperation<Args extends unknown[] = unknown[]> {
  readonly kind: 'resource' | 'assertion';
  readonly label?: (...args: Args) => string;
  /** Action timeout by default; false when the method owns its assertion/navigation deadline. */
  readonly timeout?: number | false | ((...args: Args) => number | undefined);
  /** Assertions verify by default; waits may opt in as well. */
  readonly verifies?: boolean;
}

/** Only declared async methods are recorded. Sync accessors pass through unchanged. */
export type FixtureOperations<T extends object> = {
  readonly [Key in keyof T]?: T[Key] extends (...args: infer Args) => Promise<unknown>
    ? FixtureOperation<Args>
    : T[Key] extends object ? FixtureOperations<T[Key]> : never;
};

Expectations

expectable attaches an expectation surface so expect(fixture) returns it. Declare matcher recording with context.fixture('expect', matchers, operations).

const printer = context.expectable(surface, () =>
  context.fixture('expect', matchers, { toHavePrinted: { kind: 'assertion' } }),
);
return context.fixture('printer', printer, { print: { kind: 'resource' } });

Types

Exported names

EngineCleanupContext, EngineDisposeContext, OperationContext, TextPattern, SemanticQuery, LocatorExpression, NodeRef, SemanticNode, ViewportPoint, ViewportSize, ObservationPixels, LocatorAction, LocatorActionKind, PointerAction, PointerActionKind, ScrollDirection, Momentum, SelectOption, Key, KeyModifier, KeyName, ParsedKey, ENGINE_SPI_VERSION, LOCATOR_ACTION_KINDS, POINTER_ACTION_KINDS, KEY_MODIFIERS, KEY_NAMES, parseKey, ENGINE_ERROR_CODES, RETRYABLE_ENGINE_ERROR_CODES, Secret, isSecret, TextMatch, JsonValue, JsonValueRules, ResolveExpressionOptions, PollConditionOptions, WithoutUndefined, FixtureOperation, OBSERVED_NAME_LIMIT, and OBSERVED_TEXT_LIMIT are exported from e2e/engine.

SemanticNode

/** One node of an observation tree. */
export interface SemanticNode {
  /** Node reference, valid against the observation it came from. */
  readonly ref: NodeRef;
  /** Semantic role. */
  readonly role?: string;
  /** Accessible name, cut at `OBSERVED_NAME_LIMIT`. */
  readonly name?: string;
  /** Visible text, cut at `OBSERVED_TEXT_LIMIT`. */
  readonly text?: string;
  /** Current input value; never reported for a secure field. */
  readonly value?: string;
  /**
   * Text selected inside the node, for the focused field or editing host
   * whose selection the platform reports; absent when the selection is
   * collapsed, and never reported for a secure field. Cut at `OBSERVED_TEXT_LIMIT`.
   */
  readonly selection?: string;
  /**
   * The node's test id, when the platform gives it one: the value of the
   * project's test-id attribute on a document platform, an accessibility
   * identifier on iOS, a resource id on Android, an automation id on a
   * desktop. The `testId` query resolves against this field, so the harness
   * never knows where a platform keeps it. An engine may also match the id
   * without its platform's namespacing.
   */
  readonly testId?: string;
  /** What a field takes, deciding which secrets may fill it. */
  readonly inputPurpose?: 'username' | 'password' | 'one-time-code' | 'generic-secret' | 'none';
  /**
   * Boolean states the platform reports. `pressed` is a toggle button's
   * pressed state; `secure` marks a field whose value is never observed.
   */
  readonly states?: Readonly<
    Partial<
      Record<
        'checked' | 'disabled' | 'selected' | 'expanded' | 'pressed' | 'focused' | 'hidden' | 'secure',
        boolean
      >
    >
  >;
  /** Heading level of a heading node (`1` through `6` on a document platform); absent elsewhere. */
  readonly level?: number;
  /** Platform attributes, what `getAttribute` reads. */
  readonly attributes?: Readonly<Record<string, string>>;
  /**
   * The node's box in the top-level viewport's CSS pixels (`ViewportPoint`
   * space), for every node, including those inside nested documents: an
   * engine that measures a child document against its own viewport shifts
   * the boxes by the boundary element's before reporting them.
   */
  readonly rect?: {
    readonly x: number;
    readonly y: number;
    readonly width: number;
    readonly height: number;
  };
  /**
   * Platform selector that addresses this node within its own document, when
   * the platform has one. It is structural, not semantic: it survives the
   * content changes that rename a node, and a runner may store it to re-find
   * the node cheaply on a later run. It is never part of the node's identity,
   * so whatever it resolves to is still checked before it is used.
   *
   * It SHOULD be anchored on an attribute naming the node or one of its
   * ancestors, and SHOULD be absent rather than positional all the way to the
   * document root: such a path is shifted by anything inserted above the node,
   * so it does not survive to the later run it exists for.
   */
  readonly selector?: string;
  /**
   * Enclosing nested-document chain as selectors of each boundary node,
   * outermost first. Absent for nodes in the main document.
   */
  readonly framePath?: readonly string[];
  /** Child nodes. */
  readonly children?: readonly SemanticNode[];
}

Observed field limits

ConstantValueCuts
OBSERVED_NAME_LIMIT256name
OBSERVED_TEXT_LIMIT512text, and any value or selection the engine cuts
  • An engine that cuts an observed field cuts it at exactly its limit. A field exactly at its limit may have been cut; a shorter one, or a longer one the engine left whole, is complete.
  • Keep the start and cut the end: the runner redacts the leading part of a secret that a field exactly at its limit ends with, which a whole-value match cannot see.
  • Single-node reads are unbounded and always carry the full value.

Queries

The selector expression kind carries a platform-native selector string; frame scopes a query into a nested document.

/** The `screen` query kinds. */
export type QueryKind = 'role' | 'label' | 'placeholder' | 'text' | 'displayValue' | 'testId';

A SemanticQuery with visible: true must match only nodes whose states.hidden is false. Use the same predicate the engine reports on SemanticNode, so getByText('x', { visible: true }) and toBeVisible() never disagree. Apply it wherever the query sits in the expression, under a scope, filter, or index too.

Writing an engine walks through building one. @e2e-dev/web (reference) and @e2e-dev/mobile (reference) implement this contract with the same public defineEngine.