e2e 0.21.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/.build.json +1 -1
  2. package/dist/agent/executor.d.ts +7 -0
  3. package/dist/agent/observation-feed.js +1 -0
  4. package/dist/agent/observation.d.ts +2 -0
  5. package/dist/agent/observation.js +1 -0
  6. package/dist/cli/bin.js +2 -0
  7. package/dist/cli/broken-pipe.d.ts +10 -0
  8. package/dist/cli/broken-pipe.js +17 -0
  9. package/dist/cli/index.js +4 -3
  10. package/dist/cli/init/sibling-versions.json +1 -1
  11. package/dist/cli/run-output.d.ts +1 -1
  12. package/dist/cli/run-output.js +2 -20
  13. package/dist/engine/index.d.ts +7 -0
  14. package/dist/engine/session.js +1 -0
  15. package/dist/engine/surface.d.ts +2 -0
  16. package/dist/expect/attempt.d.ts +2 -0
  17. package/dist/mcp/catalog.js +3 -1
  18. package/dist/run/execute.js +3 -3
  19. package/dist/run/open-session.js +48 -32
  20. package/dist/run/process-output.d.ts +4 -0
  21. package/dist/run/process-output.js +59 -0
  22. package/dist/run/public-ownership.d.ts +4 -0
  23. package/dist/run/public-ownership.js +38 -0
  24. package/dist/run/public-run.d.ts +15 -0
  25. package/dist/run/public-run.js +58 -0
  26. package/dist/run/runner.d.ts +9 -0
  27. package/dist/run/runner.js +1 -1
  28. package/dist/run/standalone-attempt.js +17 -7
  29. package/dist/run/units.d.ts +5 -0
  30. package/dist/run/units.js +9 -0
  31. package/dist/runner.d.ts +3 -10
  32. package/dist/runner.js +2 -10
  33. package/dist/telemetry/telemetry.d.ts +2 -2
  34. package/dist/telemetry/telemetry.js +2 -2
  35. package/docs/browser.mdx +7 -2
  36. package/docs/decision-models.mdx +36 -11
  37. package/docs/executors.mdx +3 -0
  38. package/docs/migrate/playwright.mdx +2 -1
  39. package/docs/reference/engine.mdx +6 -0
  40. package/docs/reference/errors.mdx +2 -1
  41. package/docs/reference/runner.mdx +61 -4
  42. package/docs/reference/web.mdx +7 -3
  43. package/docs/telemetry.mdx +5 -3
  44. package/package.json +2 -2
  45. package/skills/e2e/references/bug-bash.md +4 -1
  46. package/skills/e2e/references/running.md +7 -0
  47. package/skills/e2e/references/writing-tests.md +4 -2
package/dist/.build.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.21.0"}
1
+ {"version":"0.21.1"}
@@ -117,6 +117,13 @@ export interface ExecutorObservation {
117
117
  readonly text: string;
118
118
  readonly truncated: boolean;
119
119
  readonly viewport: ViewportSize;
120
+ /**
121
+ * Whether an on-screen keyboard is showing, as the engine measured it apart
122
+ * from the tree; `false` when it measured none. Absent only when the engine
123
+ * did not measure it; the tree's `keyboard` and `key` nodes, or a
124
+ * screenshot, are then the evidence.
125
+ */
126
+ readonly keyboardVisible?: boolean;
120
127
  /**
121
128
  * The current location as path and query, redacted, when the engine
122
129
  * reports one. Absent on engines without a location (a device screen).
@@ -132,6 +132,7 @@ export class ObservationFeed {
132
132
  ...metadata,
133
133
  text: observation.text,
134
134
  truncated: observation.truncated,
135
+ ...(observation.keyboardVisible === undefined ? {} : { keyboardVisible: observation.keyboardVisible }),
135
136
  ...(options.tree === true ? { tree: projectTree(observation.tree, this.runtime.app.base?.origin) } : {}),
136
137
  ...pixels,
137
138
  };
@@ -11,6 +11,8 @@ export type AgentObservation = AgentObservationMetadata & ({
11
11
  readonly parents: ReadonlyMap<string, string>;
12
12
  readonly tree: RedactedNode;
13
13
  readonly truncated: boolean;
14
+ /** The engine's measured keyboard state; `false` when it measured none, absent only when it did not measure. */
15
+ readonly keyboardVisible?: boolean | undefined;
14
16
  readonly pixels?: ExecutorPixels | undefined;
15
17
  readonly pixelsWithheld?: PixelsWithheld | undefined;
16
18
  } | {
@@ -94,6 +94,7 @@ export function prepareObservation(observation, options) {
94
94
  parents,
95
95
  tree,
96
96
  truncated,
97
+ ...(observation.keyboardVisible === undefined ? {} : { keyboardVisible: observation.keyboardVisible }),
97
98
  ...(pixels.cleared === undefined ? {} : { pixels: pixels.cleared }),
98
99
  ...(pixels.withheld === undefined ? {} : { pixelsWithheld: pixels.withheld }),
99
100
  };
package/dist/cli/bin.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { unsupportedRuntimeMessage } from '../internal/node-version.js';
3
+ import { ignoreBrokenPipes } from './broken-pipe.js';
3
4
  // Checked before the CLI module loads: that module and its dependencies are
4
5
  // what an old runtime would fail on, with a stack trace instead of a sentence.
5
6
  const unsupported = unsupportedRuntimeMessage();
@@ -7,5 +8,6 @@ if (unsupported !== undefined) {
7
8
  process.stderr.write(`${unsupported}\n`);
8
9
  process.exit(2);
9
10
  }
11
+ ignoreBrokenPipes([process.stdout, process.stderr]);
10
12
  const { main } = await import('./index.js');
11
13
  await main(process.argv);
@@ -0,0 +1,10 @@
1
+ import type { Writable } from 'node:stream';
2
+ /**
3
+ * A reader that stops early (`e2e --help | head -1`) closes the pipe, and the
4
+ * next write fails with EPIPE, an `error` event that kills the process with a
5
+ * stack trace when nothing listens. EPIPE is dropped instead: output nobody
6
+ * reads is discarded and the command finishes as it would have, teardown and
7
+ * exit code included. Any other error still throws, unless another listener
8
+ * (the MCP server's, on its protocol pipe) handles it.
9
+ */
10
+ export declare function ignoreBrokenPipes(streams: readonly Writable[]): void;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A reader that stops early (`e2e --help | head -1`) closes the pipe, and the
3
+ * next write fails with EPIPE, an `error` event that kills the process with a
4
+ * stack trace when nothing listens. EPIPE is dropped instead: output nobody
5
+ * reads is discarded and the command finishes as it would have, teardown and
6
+ * exit code included. Any other error still throws, unless another listener
7
+ * (the MCP server's, on its protocol pipe) handles it.
8
+ */
9
+ export function ignoreBrokenPipes(streams) {
10
+ for (const stream of streams) {
11
+ stream.on('error', (error) => {
12
+ if (error.code === 'EPIPE' || stream.listenerCount('error') > 1)
13
+ return;
14
+ throw error;
15
+ });
16
+ }
17
+ }
package/dist/cli/index.js CHANGED
@@ -5,6 +5,7 @@ import { Argument, Command, CommanderError, InvalidArgumentError, Option } from
5
5
  import picocolors from 'picocolors';
6
6
  import { detectPackageManager, execCommand, runScriptCommand } from '../internal/package-manager.js';
7
7
  import { packageVersion } from '../internal/package-version.js';
8
+ import { MAX_TIMER_MS } from '../internal/time.js';
8
9
  import { classifyError, exitCodeForCategory } from '../internal/errors.js';
9
10
  import { list } from '../runner.js';
10
11
  import { run } from '../run/runner.js';
@@ -33,7 +34,7 @@ import { skillTopics } from './skill.js';
33
34
  import { watchSession } from './watch.js';
34
35
  import { telemetry as telemetryCommand, TELEMETRY_ACTIONS } from './telemetry.js';
35
36
  import { wordmarkBanner } from './wordmark.js';
36
- /** An integer of at least one, for `--max-failures` and `--max-duration`. */
37
+ /** An integer of at least one, for `--max-failures` and `--repeat-each`. */
37
38
  function parsePositiveInt(value) {
38
39
  const parsed = Number(value);
39
40
  if (!Number.isSafeInteger(parsed) || parsed < 1)
@@ -75,7 +76,7 @@ function parseShard(value) {
75
76
  }
76
77
  return { index, total };
77
78
  }
78
- /** An integer inside a closed range, for the explore budgets. */
79
+ /** An integer inside a closed range, for the explore budgets and `--max-duration`. */
79
80
  function parseBoundedInt(bounds) {
80
81
  return (value) => {
81
82
  const parsed = Number(value);
@@ -462,7 +463,7 @@ function createProgram(version, telemetry) {
462
463
  .option('--workers <n>', 'parallel workers (default: from the config)', parseNonNegativeInt)
463
464
  .option('--retries <n>', 'retries per failing test (default: from the config)', parseNonNegativeInt)
464
465
  .option('--max-failures <n>', 'stop the run once this many tests have failed; the rest are skipped', parsePositiveInt)
465
- .option('--max-duration <ms>', 'stop the run once it has spent this many milliseconds running tests; the rest are skipped (default: maxDuration in the config)', parsePositiveInt)
466
+ .option('--max-duration <ms>', 'stop the run once it has spent this many milliseconds running tests; the rest are skipped (default: maxDuration in the config)', parseBoundedInt({ min: 1, max: MAX_TIMER_MS }))
466
467
  .option('--repeat-each <n>', 'run every selected test this many times, each run its own result (pair with --no-cache to exercise the model each time)', parsePositiveInt)
467
468
  .option('--no-cache', 'run with the replay cache off, whatever the config says')
468
469
  .option('--strict-cache', 'fail a step whose recording no longer replays (REPLAY_STALE) instead of handing it to the agent; never writes the cache')
@@ -1 +1 @@
1
- {"@e2e-dev/web":"0.15.0","@e2e-dev/mobile":"0.12.1"}
1
+ {"@e2e-dev/web":"0.15.1","@e2e-dev/mobile":"0.12.2"}
@@ -5,7 +5,7 @@
5
5
  * through a `RunnerOutput`, redacted, until the process exits; only the list
6
6
  * reporter writes to the terminal past it.
7
7
  */
8
- import { RunnerOutput } from '../run/process-output.ts';
8
+ import { type RunnerOutput } from '../run/process-output.ts';
9
9
  /**
10
10
  * Takes over `process.stdout` and `process.stderr` for the rest of the
11
11
  * process, once: a reporter the run stopped waiting for, or a timer the
@@ -5,14 +5,8 @@
5
5
  * through a `RunnerOutput`, redacted, until the process exits; only the list
6
6
  * reporter writes to the terminal past it.
7
7
  */
8
- import { RunnerOutput } from '../run/process-output.js';
8
+ import { claimProcessOutput } from '../run/process-output.js';
9
9
  import { processSecrets } from '../run/secrecy.js';
10
- /**
11
- * Encodings in which a string spells bytes rather than text: the write puts
12
- * those bytes on the terminal, so they are what is redacted. A string in any
13
- * other encoding is the text itself, redacted and written as UTF-8.
14
- */
15
- const BYTE_ENCODINGS = new Set(['hex', 'base64', 'base64url']);
16
10
  /** The process's claimed output, once claimed: the wrappers stay for the rest of the process. */
17
11
  let claimed;
18
12
  /**
@@ -26,19 +20,7 @@ let claimed;
26
20
  export function claimRunnerOutput() {
27
21
  if (claimed !== undefined)
28
22
  return claimed;
29
- const streams = { stdout: process.stdout, stderr: process.stderr };
30
- const terminal = {
31
- stdout: streams.stdout.write.bind(streams.stdout),
32
- stderr: streams.stderr.write.bind(streams.stderr),
33
- };
34
- const output = new RunnerOutput(terminal, processSecrets);
35
- for (const name of ['stdout', 'stderr']) {
36
- streams[name].write = ((chunk, encoding, callback) => {
37
- const done = typeof encoding === 'function' ? encoding : callback;
38
- const bytes = typeof chunk === 'string' && typeof encoding === 'string' && BYTE_ENCODINGS.has(encoding);
39
- return output.write(name, bytes ? Buffer.from(chunk, encoding) : chunk, done);
40
- });
41
- }
23
+ const output = claimProcessOutput(processSecrets);
42
24
  process.once('exit', () => output.end());
43
25
  claimed = output;
44
26
  return output;
@@ -563,6 +563,13 @@ export interface EngineSnapshot {
563
563
  * read whole.
564
564
  */
565
565
  readonly truncated?: boolean;
566
+ /**
567
+ * Whether an on-screen keyboard is showing, as the engine measured it apart
568
+ * from `root`: a platform can raise a keyboard the tree lists only in part,
569
+ * or not at all. Set `false` when it measured none, and omit it only when
570
+ * the engine did not measure it; consumers then fall back to the tree.
571
+ */
572
+ readonly keyboardVisible?: boolean;
566
573
  /**
567
574
  * Semantic capture timed out. Return only the stable root reference, with no other fields,
568
575
  * and fresh pixels whose masking was proven independently of the failed
@@ -355,6 +355,7 @@ export function createEngineSession(options) {
355
355
  kind: 'semantic',
356
356
  tree: stampRevision(snapshot.root, minted),
357
357
  truncated: snapshot.truncated === true,
358
+ ...(snapshot.keyboardVisible === undefined ? {} : { keyboardVisible: snapshot.keyboardVisible }),
358
359
  ...(snapshot.pixels === undefined ? {} : { pixels: snapshot.pixels }),
359
360
  };
360
361
  showScreen(observation);
@@ -32,6 +32,8 @@ export type Observation = ObservationMetadata & ({
32
32
  readonly kind: 'semantic';
33
33
  readonly tree: SemanticNode;
34
34
  readonly truncated: boolean;
35
+ /** The engine's measured keyboard state (`EngineSnapshot.keyboardVisible`); `false` when it measured none, absent only when it did not measure. */
36
+ readonly keyboardVisible?: boolean;
35
37
  readonly pixels?: ObservationPixels;
36
38
  } | {
37
39
  readonly kind: 'pixels';
@@ -4,6 +4,8 @@ import type { SoftFailures } from './soft.ts';
4
4
  import type { PollScope } from './poll-scope.ts';
5
5
  export interface PublishedAttempt {
6
6
  readonly attemptId: string;
7
+ /** Host preparation may overlap a hosted attempt, but not a suite attempt. */
8
+ readonly owner: 'suite' | 'host';
7
9
  /** Setup tests owe their sessions and may not skip themselves. */
8
10
  readonly testKind: 'test' | 'setup';
9
11
  /** The config default for a poll's `timeout`. */
@@ -125,7 +125,9 @@ const CONTENT_NAMED_ROLES = new Set(['status', 'alert', 'listitem']);
125
125
  /** The zero-match advice for a content-named role: match the text instead of retrying it as a name. */
126
126
  function contentNameNotFound(role, name, exact) {
127
127
  const quoted = JSON.stringify(name);
128
- return `A role query matches the accessible name only, and observe shows a ${role} by its text in its place. If ${quoted} is that text, use screen.getByRole(${JSON.stringify(role)}).filter({ hasText: ${quoted} }) or screen.getByText(${quoted}).${exact ? ' Otherwise loosen the match with exact: false.' : ''}`;
128
+ const article = /^[aeiou]/.test(role) ? 'an' : 'a';
129
+ const text = `screen.getByText(${quoted}${exact ? '' : ', { exact: false }'})`;
130
+ return `A role query matches the accessible name only, and observe shows ${article} ${role} by its text in its place. If ${quoted} is that text, use screen.getByRole(${JSON.stringify(role)}).filter({ hasText: ${quoted} }) or ${text}.${exact ? ' Otherwise loosen the match with exact: false.' : ''}`;
129
131
  }
130
132
  /** Renders a locate result: the count, the verdict a test would get, and the nodes, each through the attempt's redactor. */
131
133
  export function describeLocate(query, count, nodes, redaction) {
@@ -28,7 +28,7 @@ import { isFailedStatus } from './records.js';
28
28
  import { traceScreen } from './step-screens.js';
29
29
  import { runWithRetries } from './retry.js';
30
30
  import { runSerialUnit } from './serial.js';
31
- import { interruptedSkip, pairKey, pairResult, unstartedResult } from './units.js';
31
+ import { interruptedInPhase, interruptedSkip, pairKey, pairResult, unstartedResult } from './units.js';
32
32
  import { adoptSecrecy, carriedSecrecy, processSecrets, registerDerivedSecrets, redactForSession, resolveSecretValue, sessionSecrecy, staticSecretLedger } from './secrecy.js';
33
33
  import { isSecret } from '../secrets.js';
34
34
  import { SessionStaging, SessionStore, targetIdentity } from './sessions.js';
@@ -729,7 +729,7 @@ export class TargetExecutor {
729
729
  // `expect.poll` and `expect.soft` take no fixture, so the attempt they
730
730
  // run on is published here and cleared when `attemptEnd` fires in `finally`.
731
731
  const soft = new SoftFailures();
732
- publishAttempt({ attemptId, testKind: pair.test.kind, assertionTimeout: this.config.assertionTimeout, budget, soft }, attemptEnd.signal);
732
+ publishAttempt({ attemptId, owner: 'suite', testKind: pair.test.kind, assertionTimeout: this.config.assertionTimeout, budget, soft }, attemptEnd.signal);
733
733
  const beforeEachHooks = this.realms.hooksFor(realm, registered, 'beforeEach');
734
734
  const afterEachHooks = this.realms.hooksFor(realm, registered, 'afterEach');
735
735
  // The test's own chain decides the fixture set; hooks get the same
@@ -802,7 +802,7 @@ export class TargetExecutor {
802
802
  // down with the worker process; the attempt records the interrupt and
803
803
  // moves to cleanup.
804
804
  const work = this.options.isolated
805
- ? withAbort(runInPollScope(bodyPolls, mainWork), this.interruptSignal, () => new E2EError('interrupted', 'INTERRUPTED', `run interrupted in phase ${phase}`))
805
+ ? withAbort(runInPollScope(bodyPolls, mainWork), this.interruptSignal, () => new E2EError('interrupted', 'INTERRUPTED', interruptedInPhase(this.interruptSignal, phase)))
806
806
  : runInPollScope(bodyPolls, mainWork);
807
807
  const body = Promise.race([
808
808
  work,
@@ -6,44 +6,60 @@ import { allocateTargetPorts } from './app-ports.js';
6
6
  import { processSecrets, registerStaticSecrets } from './secrecy.js';
7
7
  import { selectTarget } from './select-target.js';
8
8
  import { openStandaloneHost } from './standalone.js';
9
+ import { reserveHostedRun } from './public-ownership.js';
9
10
  /** Opens the worker-scoped owner of preparation, app processes, and engine initialization. */
10
11
  export async function openHost(options = {}) {
11
12
  return redactOpening(() => openHostWorker(options));
12
13
  }
13
14
  /** Loads the selected target and opens its worker behind the redacted public boundary. */
14
15
  async function openHostWorker(options) {
15
- const cwd = options.cwd ?? process.cwd();
16
- const env = options.env ?? process.env;
17
- const loaded = await loadHostConfig(cwd, options.config, env);
18
- registerStaticSecrets(loaded.allSecrets);
19
- const { config, target } = await allocateTargetPorts(loaded, selectTarget(loaded, options.target, 'pass `target` to openHost'));
20
- const host = await openStandaloneHost({
21
- config, target, headed: options.headed ?? false, env,
22
- signal: options.signal ?? new AbortController().signal,
23
- ...(options.onNotice === undefined ? {} : { notice: options.onNotice }),
24
- });
25
- return {
26
- runId: host.runId, target: target.name, close: host.close,
27
- [Symbol.asyncDispose]: () => dispose(host.close, 'host'),
28
- openSession: (sessionOptions) => redactOpening(async () => {
29
- if (typeof sessionOptions.name !== 'string')
30
- throw new ConfigurationError('INVALID_CONFIG', 'a hosted session requires a string name');
31
- const attempt = await host.openAttempt({
32
- name: sessionOptions.name,
33
- signal: sessionOptions.signal ?? new AbortController().signal,
34
- timeoutMs: sessionOptions.timeout ?? config.timeout,
35
- agent: sessionOptions.agent,
36
- ...(sessionOptions.onStep === undefined ? {} : { onProgress: sessionOptions.onStep }),
37
- });
38
- return {
39
- runId: attempt.runId, attemptId: attempt.attemptId, target: target.name,
40
- fixtures: attempt.fixtures,
41
- artifactsDir: attempt.artifactsDir, tracePath: attempt.tracePath,
42
- steps: () => attempt.steps.all(), close: attempt.close,
43
- [Symbol.asyncDispose]: () => dispose(attempt.close, 'session'),
44
- };
45
- }),
46
- };
16
+ const release = reserveHostedRun();
17
+ try {
18
+ const cwd = options.cwd ?? process.cwd();
19
+ const env = options.env ?? process.env;
20
+ const loaded = await loadHostConfig(cwd, options.config, env);
21
+ registerStaticSecrets(loaded.allSecrets);
22
+ const { config, target } = await allocateTargetPorts(loaded, selectTarget(loaded, options.target, 'pass `target` to openHost'));
23
+ const host = await openStandaloneHost({
24
+ config, target, headed: options.headed ?? false, env,
25
+ signal: options.signal ?? new AbortController().signal,
26
+ ...(options.onNotice === undefined ? {} : { notice: options.onNotice }),
27
+ });
28
+ const close = async () => {
29
+ try {
30
+ return await host.close();
31
+ }
32
+ finally {
33
+ release();
34
+ }
35
+ };
36
+ return {
37
+ runId: host.runId, target: target.name, close,
38
+ [Symbol.asyncDispose]: () => dispose(close, 'host'),
39
+ openSession: (sessionOptions) => redactOpening(async () => {
40
+ if (typeof sessionOptions.name !== 'string')
41
+ throw new ConfigurationError('INVALID_CONFIG', 'a hosted session requires a string name');
42
+ const attempt = await host.openAttempt({
43
+ name: sessionOptions.name,
44
+ signal: sessionOptions.signal ?? new AbortController().signal,
45
+ timeoutMs: sessionOptions.timeout ?? config.timeout,
46
+ agent: sessionOptions.agent,
47
+ ...(sessionOptions.onStep === undefined ? {} : { onProgress: sessionOptions.onStep }),
48
+ });
49
+ return {
50
+ runId: attempt.runId, attemptId: attempt.attemptId, target: target.name,
51
+ fixtures: attempt.fixtures,
52
+ artifactsDir: attempt.artifactsDir, tracePath: attempt.tracePath,
53
+ steps: () => attempt.steps.all(), close: attempt.close,
54
+ [Symbol.asyncDispose]: () => dispose(attempt.close, 'session'),
55
+ };
56
+ }),
57
+ };
58
+ }
59
+ catch (cause) {
60
+ release();
61
+ throw cause;
62
+ }
47
63
  }
48
64
  /** Opening errors go straight to the external runner, never through a report writer. */
49
65
  async function redactOpening(open) {
@@ -11,6 +11,10 @@ import type { ListReporterOutput } from '../report/list.ts';
11
11
  export type OutputStream = 'stdout' | 'stderr';
12
12
  /** Writes text to one stream of the terminal; the callback fires once the stream took it. Answers false while the stream is backed up, like `Writable.write`. */
13
13
  export type TerminalWrite = (text: string, callback?: () => void) => boolean;
14
+ /** The CLI owns every process write, including timers that print after a run ends. */
15
+ export declare function claimProcessOutput(ledger: SecretLedger): RunnerOutput;
16
+ /** Embedded runs own their async output; caller output resumes once cleanup ends. */
17
+ export declare function withProcessOutput<T>(ledger: SecretLedger, run: (output: RunnerOutput) => Promise<T>): Promise<T>;
14
18
  /** Where runner output goes instead of the terminal while the list reporter shows the run. */
15
19
  export interface OutputView {
16
20
  /** Prints redacted text from `stream`. */
@@ -7,6 +7,65 @@
7
7
  * what is printed meanwhile is held, and withheld if the load fails.
8
8
  */
9
9
  import { StreamRedactor } from '../internal/redact.js';
10
+ import { AsyncLocalStorage, AsyncResource } from 'node:async_hooks';
11
+ import { realmSlot } from '../internal/realm-slot.js';
12
+ const BYTE_ENCODINGS = new Set(['hex', 'base64', 'base64url']);
13
+ const routingSlot = realmSlot('e2e.process-output.routing.v1');
14
+ /** One process dispatcher preserves redaction for asynchronous work that outlives a suite. */
15
+ function routing() {
16
+ const streams = { stdout: process.stdout, stderr: process.stderr };
17
+ let state = routingSlot.get(process);
18
+ if (state === undefined) {
19
+ state = {
20
+ scope: new AsyncLocalStorage(),
21
+ terminal: {
22
+ stdout: streams.stdout.write.bind(streams.stdout),
23
+ stderr: streams.stderr.write.bind(streams.stderr),
24
+ },
25
+ dispatchers: {},
26
+ process: undefined,
27
+ };
28
+ routingSlot.set(process, state);
29
+ }
30
+ for (const name of ['stdout', 'stderr']) {
31
+ if (streams[name].write === state.dispatchers[name])
32
+ continue;
33
+ const forward = streams[name].write.bind(streams[name]);
34
+ state.terminal[name] = (text, callback) => {
35
+ const done = callback === undefined ? undefined : AsyncResource.bind(callback);
36
+ return state.scope.run(null, () => forward(text, done));
37
+ };
38
+ const dispatch = ((chunk, encoding, callback) => {
39
+ const context = state.scope.getStore();
40
+ const output = context === null ? undefined : context ?? state.process;
41
+ if (output === undefined)
42
+ return forward(chunk, encoding, callback);
43
+ const done = typeof encoding === 'function' ? encoding : callback;
44
+ const bytes = typeof chunk === 'string' && typeof encoding === 'string' && BYTE_ENCODINGS.has(encoding);
45
+ return output.write(name, bytes ? Buffer.from(chunk, encoding) : chunk, done);
46
+ });
47
+ state.dispatchers[name] = dispatch;
48
+ streams[name].write = dispatch;
49
+ }
50
+ return state;
51
+ }
52
+ /** The CLI owns every process write, including timers that print after a run ends. */
53
+ export function claimProcessOutput(ledger) {
54
+ const state = routing();
55
+ state.process ??= new RunnerOutput(state.terminal, ledger);
56
+ return state.process;
57
+ }
58
+ /** Embedded runs own their async output; caller output resumes once cleanup ends. */
59
+ export async function withProcessOutput(ledger, run) {
60
+ const state = routing();
61
+ const output = new RunnerOutput(state.terminal, ledger);
62
+ try {
63
+ return await state.scope.run(output, () => run(output));
64
+ }
65
+ finally {
66
+ output.end();
67
+ }
68
+ }
10
69
  /** The warning that stands in for what a config printed before it failed to load. */
11
70
  function withheldWarning(bytes) {
12
71
  return `withheld ${bytes} bytes of output printed while the config failed to load: its secrets are unknown, so the output cannot be redacted`;
@@ -0,0 +1,4 @@
1
+ /** Reserves the process before a suite can change registries or claim output. */
2
+ export declare function reserveSuiteRun(): () => void;
3
+ /** Holds a host's process ownership from before config loading through close. */
4
+ export declare function reserveHostedRun(): () => void;
@@ -0,0 +1,38 @@
1
+ import { currentAttempt } from '../expect/attempt.js';
2
+ import { ConfigurationError } from '../internal/errors.js';
3
+ import { realmSlot } from '../internal/realm-slot.js';
4
+ const slot = realmSlot('e2e.public-runner.owners.v1');
5
+ /** Public runner copies in isolated realms share one set of process owners. */
6
+ function owners() {
7
+ let state = slot.get(globalThis);
8
+ if (state === undefined) {
9
+ state = { suite: undefined, hosts: new Set() };
10
+ slot.set(globalThis, state);
11
+ }
12
+ return state;
13
+ }
14
+ /** Reserves the process before a suite can change registries or claim output. */
15
+ export function reserveSuiteRun() {
16
+ const state = owners();
17
+ if (state.suite !== undefined)
18
+ throw new ConfigurationError('RUNNER_BUSY', 'a public run is already in progress; await it before starting another');
19
+ if (state.hosts.size > 0)
20
+ throw new ConfigurationError('RUNNER_BUSY', 'a hosted runner is still open; close it before starting a suite');
21
+ if (currentAttempt() !== undefined)
22
+ throw new ConfigurationError('RUNNER_BUSY', 'an attempt is still running; close it before starting a suite');
23
+ const owner = {};
24
+ state.suite = owner;
25
+ return () => { if (state.suite === owner)
26
+ state.suite = undefined; };
27
+ }
28
+ /** Holds a host's process ownership from before config loading through close. */
29
+ export function reserveHostedRun() {
30
+ const state = owners();
31
+ if (state.suite !== undefined)
32
+ throw new ConfigurationError('RUNNER_BUSY', 'a suite is running; await it before opening a host');
33
+ if (currentAttempt()?.owner === 'suite')
34
+ throw new ConfigurationError('RUNNER_BUSY', 'a suite attempt is still running; await it before opening a host');
35
+ const owner = {};
36
+ state.hosts.add(owner);
37
+ return () => { state.hosts.delete(owner); };
38
+ }
@@ -0,0 +1,15 @@
1
+ import type { Report } from '../types.ts';
2
+ import type { RunExitCode, RunStatus } from './events.ts';
3
+ import { type ListOptions, type RunOptions as InternalRunOptions } from './runner.ts';
4
+ /** Options for executing a suite through the public runner API. */
5
+ export type SuiteRunOptions = Pick<ListOptions, 'cwd' | 'config' | 'files'> & Pick<InternalRunOptions, 'interruptSignal' | 'onEvent'>;
6
+ /** The completed run's report and output paths, without live engine or configuration handles. */
7
+ export interface RunResult {
8
+ readonly exitCode: RunExitCode;
9
+ readonly status: RunStatus;
10
+ readonly report: Report;
11
+ readonly reportPath: string | undefined;
12
+ readonly aiTracePath: string | undefined;
13
+ }
14
+ /** Executes a suite without exiting the caller; await its cleanup before starting another run. */
15
+ export declare function runSuite(options?: SuiteRunOptions): Promise<RunResult>;
@@ -0,0 +1,58 @@
1
+ import { AsyncResource } from 'node:async_hooks';
2
+ import path from 'node:path';
3
+ import { packageVersion } from '../internal/package-version.js';
4
+ import { runsFromCheckout } from '../telemetry/checkout.js';
5
+ import { runCompletedEvent } from '../telemetry/events.js';
6
+ import { Telemetry } from '../telemetry/telemetry.js';
7
+ import { withProcessOutput } from './process-output.js';
8
+ import { reserveSuiteRun } from './public-ownership.js';
9
+ import { processSecrets } from './secrecy.js';
10
+ import { run as execute, toSelectionRunOptions } from './runner.js';
11
+ /** Executes a suite without exiting the caller; await its cleanup before starting another run. */
12
+ export async function runSuite(options = {}) {
13
+ const release = reserveSuiteRun();
14
+ try {
15
+ const telemetry = new Telemetry({
16
+ version: packageVersion(import.meta.url, '../../package.json', '0.0.0'),
17
+ checkout: runsFromCheckout(import.meta.url),
18
+ cwd: path.resolve(options.cwd ?? process.cwd()),
19
+ });
20
+ telemetry.notice();
21
+ const onEvent = options.onEvent === undefined ? undefined : AsyncResource.bind(options.onEvent);
22
+ const outcome = await withProcessOutput(processSecrets, async (output) => {
23
+ const interrupt = ownedInterrupt(options.interruptSignal);
24
+ try {
25
+ return await execute({
26
+ ...toSelectionRunOptions({ cwd: options.cwd, config: options.config, files: options.files }),
27
+ quiet: true,
28
+ processOutput: output,
29
+ interruptSignal: interrupt.signal,
30
+ onEvent,
31
+ });
32
+ }
33
+ finally {
34
+ interrupt.dispose();
35
+ }
36
+ });
37
+ telemetry.record(runCompletedEvent(outcome.report, { command: 'runner.run', flags: [], config: outcome.config }));
38
+ // The caller's result never waits on the network; the send has its own deadline.
39
+ void telemetry.flush();
40
+ const { exitCode, status, report, reportPath, aiTracePath } = outcome;
41
+ return { exitCode, status, report, reportPath, aiTracePath };
42
+ }
43
+ finally {
44
+ release();
45
+ }
46
+ }
47
+ /** Caller cancellation enters the suite's async context before invoking its listeners. */
48
+ function ownedInterrupt(signal) {
49
+ if (signal === undefined)
50
+ return { signal: undefined, dispose: () => undefined };
51
+ const controller = new AbortController();
52
+ const abort = AsyncResource.bind(() => controller.abort(signal.reason));
53
+ if (signal.aborted)
54
+ abort();
55
+ else
56
+ signal.addEventListener('abort', abort, { once: true });
57
+ return { signal: controller.signal, dispose: () => signal.removeEventListener('abort', abort) };
58
+ }
@@ -250,3 +250,12 @@ export interface RunOutcome {
250
250
  }
251
251
  /** Executes one complete run and returns the outcome without exiting. */
252
252
  export declare function run(options?: RunOptions): Promise<RunOutcome>;
253
+ /**
254
+ * What `loadRunConfig`, `selectionInputs`, and `validateRunOptions` read.
255
+ * `run` passes `RunOptions`; `list` translates the public bag into these
256
+ * (`config` string to `configPath`, object to `rawConfig`, `targets` to `targetIds`).
257
+ */
258
+ type SelectionRunOptions = Pick<RunOptions, 'configPath' | 'rawConfig' | 'files' | 'tags' | 'tagMode' | 'excludeTags' | 'grep' | 'grepInvert' | 'lastFailed' | 'freshGraph' | 'shard' | 'targetIds' | 'passWithNoTests' | 'output' | 'maxFailures' | 'maxDuration' | 'repeatEach'>;
259
+ /** The public `list` bag as the options config loading and selection already take. */
260
+ export declare function toSelectionRunOptions(options: ListOptions): SelectionRunOptions;
261
+ export {};
@@ -731,7 +731,7 @@ function workerEnvFor(plans, env) {
731
731
  };
732
732
  }
733
733
  /** The public `list` bag as the options config loading and selection already take. */
734
- function toSelectionRunOptions(options) {
734
+ export function toSelectionRunOptions(options) {
735
735
  const { config, targets, ...rest } = options;
736
736
  return {
737
737
  ...rest,
@@ -36,7 +36,7 @@ export function reserveAttempt(worker, options) {
36
36
  return { attemptId, options, end, signal, budget, soft, polls, release: () => undefined };
37
37
  const { config } = worker;
38
38
  const publication = new AbortController();
39
- publishAttempt({ attemptId, testKind: 'test', assertionTimeout: config.assertionTimeout, budget, soft, polls }, publication.signal);
39
+ publishAttempt({ attemptId, owner: 'host', testKind: 'test', assertionTimeout: config.assertionTimeout, budget, soft, polls }, publication.signal);
40
40
  // The live attempt's secrets resolve against its own config while other hosts hold theirs.
41
41
  const releaseRegistry = holdSecretRegistry(config, { live: true });
42
42
  return { attemptId, options, end, signal, budget, soft, polls, release: () => { publication.abort(); releaseRegistry(); } };
@@ -102,16 +102,23 @@ export async function launchAttempt(worker, reservation, onClosed) {
102
102
  await closeOpened({ status: 'failed', cleanup: 'complete' });
103
103
  throw cause;
104
104
  }
105
+ const { deadline } = budget;
106
+ let timedOut = false;
107
+ /** Fails the attempt as timed out, once, and cuts whatever is still in flight. */
108
+ const expire = () => {
109
+ if (timedOut)
110
+ return;
111
+ timedOut = true;
112
+ const timeout = new TestTimeoutError(`session timed out after ${options.timeoutMs} ms`);
113
+ recordFailure(timeout, 'body');
114
+ end.abort(timeout);
115
+ };
105
116
  // An engine that ignores its operation's timeout, or a model call, must not
106
117
  // outlive the deadline: the attempt is cut the way a run's body is. One
107
118
  // tick after it, so an operation that honors the deadline reports its own
108
119
  // failure first; whatever is still in flight then fails as timed out.
109
- const remaining = budget.deadline.remaining() + 1;
110
- const expiry = remaining > MAX_TIMER_MS ? undefined : setTimeout(() => {
111
- const timedOut = new TestTimeoutError(`session timed out after ${options.timeoutMs} ms`);
112
- recordFailure(timedOut, 'body');
113
- end.abort(timedOut);
114
- }, remaining);
120
+ const remaining = deadline.remaining() + 1;
121
+ const expiry = remaining > MAX_TIMER_MS ? undefined : setTimeout(expire, remaining);
115
122
  expiry?.unref();
116
123
  const startedAt = timestamp();
117
124
  const startedMs = Date.now();
@@ -120,6 +127,9 @@ export async function launchAttempt(worker, reservation, onClosed) {
120
127
  const close = () => {
121
128
  ending ??= (async () => {
122
129
  clearTimeout(expiry);
130
+ // The deadline decides the outcome, not whether the timer ran before close.
131
+ if (deadline.expired())
132
+ expire();
123
133
  const abandonedPoll = polls.close();
124
134
  const abandonedStep = steps.abandonRunning();
125
135
  end.abort();
@@ -66,6 +66,11 @@ export declare function timeLimitSkip(limit: TimeLimit): SkipInfo;
66
66
  * so), else the plain interrupt.
67
67
  */
68
68
  export declare function interruptedSkip(signal: AbortSignal, fallback?: SkipInfo): SkipInfo;
69
+ /**
70
+ * The message of an attempt an interrupt cut short in `phase`, naming the
71
+ * limit that stopped the run when the interrupt carried one.
72
+ */
73
+ export declare function interruptedInPhase(signal: AbortSignal, phase: string): string;
69
74
  /**
70
75
  * Builds a result for one pair, narrowing the collected test to its identity
71
76
  * so no test function or realm state can reach a record or the wire.
package/dist/run/units.js CHANGED
@@ -106,6 +106,15 @@ export function interruptedSkip(signal, fallback = INTERRUPTED_BEFORE_START) {
106
106
  const reason = signal.reason;
107
107
  return isSkipInfo(reason) ? reason : fallback;
108
108
  }
109
+ /**
110
+ * The message of an attempt an interrupt cut short in `phase`, naming the
111
+ * limit that stopped the run when the interrupt carried one.
112
+ */
113
+ export function interruptedInPhase(signal, phase) {
114
+ const reason = signal.reason;
115
+ const message = `run interrupted in phase ${phase}`;
116
+ return isSkipInfo(reason) ? `${message}: ${reason.reason}` : message;
117
+ }
109
118
  function isSkipInfo(value) {
110
119
  return typeof value === 'object' && value !== null && typeof value.cause === 'string' && typeof value.reason === 'string';
111
120
  }
package/dist/runner.d.ts CHANGED
@@ -1,14 +1,7 @@
1
- /**
2
- * In-process runner APIs. `list` collects and selects as `e2e list` does and
3
- * stops before any app, engine, or worker starts. `openHost` opens a reusable
4
- * worker whose per-test sessions share the runner's attempt lifecycle.
5
- * Importing `e2e` does not load this module.
6
- *
7
- * `ConfigurationError` is also on `e2e/engine`. A caller of `list` or
8
- * `openHost` matches `error.code` from here, without importing the engine
9
- * contract.
10
- */
1
+ /** Runner APIs live on e2e/runner so importing e2e does not load them; ConfigurationError is shared with e2e/engine. */
11
2
  export { ConfigurationError, isE2EError, type SerializedError } from './internal/errors.ts';
12
3
  export { list, type ListOptions, type ListResult, type ListedPair } from './run/runner.ts';
4
+ export { runSuite as run, type SuiteRunOptions as RunOptions, type RunResult } from './run/public-run.ts';
5
+ export type { RunEventSink } from './run/events.ts';
13
6
  export { openHost, type E2EHost, type E2ESession, type OpenHostOptions, type OpenSessionOptions } from './run/open-session.ts';
14
7
  export type { StepProgress, StepRecord } from './run/steps.ts';
package/dist/runner.js CHANGED
@@ -1,13 +1,5 @@
1
- /**
2
- * In-process runner APIs. `list` collects and selects as `e2e list` does and
3
- * stops before any app, engine, or worker starts. `openHost` opens a reusable
4
- * worker whose per-test sessions share the runner's attempt lifecycle.
5
- * Importing `e2e` does not load this module.
6
- *
7
- * `ConfigurationError` is also on `e2e/engine`. A caller of `list` or
8
- * `openHost` matches `error.code` from here, without importing the engine
9
- * contract.
10
- */
1
+ /** Runner APIs live on e2e/runner so importing e2e does not load them; ConfigurationError is shared with e2e/engine. */
11
2
  export { ConfigurationError, isE2EError } from './internal/errors.js';
12
3
  export { list } from './run/runner.js';
4
+ export { runSuite as run } from './run/public-run.js';
13
5
  export { openHost } from './run/open-session.js';
@@ -6,8 +6,8 @@
6
6
  * command never waits on telemetry for more than the flush budget and never
7
7
  * fails because of it. A long-lived command (`e2e mcp`) sends what it has
8
8
  * queued as it goes with `sendQueued`, so a client that kills it loses only
9
- * what was still in progress. Telemetry is a CLI concern only: the runner
10
- * never constructs this class.
9
+ * what was still in progress. Besides the CLI, only the public `run()` on
10
+ * `e2e/runner` constructs this class, one per call.
11
11
  *
12
12
  * Off means off at every step. `E2E_TELEMETRY_DISABLED`, `DO_NOT_TRACK`, a
13
13
  * source checkout of the repository, an `e2e telemetry disable`, or a
@@ -6,8 +6,8 @@
6
6
  * command never waits on telemetry for more than the flush budget and never
7
7
  * fails because of it. A long-lived command (`e2e mcp`) sends what it has
8
8
  * queued as it goes with `sendQueued`, so a client that kills it loses only
9
- * what was still in progress. Telemetry is a CLI concern only: the runner
10
- * never constructs this class.
9
+ * what was still in progress. Besides the CLI, only the public `run()` on
10
+ * `e2e/runner` constructs this class, one per call.
11
11
  *
12
12
  * Off means off at every step. `E2E_TELEMETRY_DISABLED`, `DO_NOT_TRACK`, a
13
13
  * source checkout of the repository, an `e2e telemetry disable`, or a
package/docs/browser.mdx CHANGED
@@ -78,13 +78,18 @@ A function handler gets the dialog and must call `accept(text?)` or
78
78
  ## Download a file
79
79
 
80
80
  ```ts
81
+ import { readFile } from 'node:fs/promises';
82
+
81
83
  const file = await browser.waitForDownload(() =>
82
84
  screen.getByRole('link', 'Export CSV').tap(),
83
85
  );
86
+ const csv = await readFile(file.absolutePath, 'utf8');
87
+ expect(csv).toContain('Invoice');
84
88
  ```
85
89
 
86
- e2e saves the file as a `download` artifact. `file.path` is relative to the
87
- attempt's artifact directory, not to the project.
90
+ e2e saves the file as a `download` artifact. Read it through
91
+ `file.absolutePath`, which works with hosted browsers too. `file.path` is
92
+ relative to the attempt's artifact directory, not to the project.
88
93
 
89
94
  ## Reach into an iframe
90
95
 
@@ -109,10 +109,15 @@ newest screen: `tap`, `type`, `typeSecret`, `submit`, `select`, `check`,
109
109
  `upload`, `hover`, `secondary_tap` (a right-click), `double_tap`,
110
110
  `long_press`, `drag`, `scroll_to`, `scroll_up`, `scroll_down`, and `back`.
111
111
  Hover, right-click, drag, and scroll-to also reach labeled nodes that are
112
- not controls, such as a card that opens a menu on hover. While an on-screen
112
+ not controls, such as a card that opens a menu on hover, and a drag can drop
113
+ onto any of them. `tap` also reaches text fields, to open a date picker or a
114
+ suggestion list. While an on-screen
113
115
  keyboard shows on an engine that can dismiss it, such as the mobile engine,
114
- the executor also offers `dismiss_keyboard`.
116
+ the executor also offers `dismiss_keyboard`, even when the keyboard is
117
+ missing from the tree.
115
118
 
119
+ Every operation that acts on an element asks for its target, even when only
120
+ one element fits it.
116
121
  Every target question has a `none` option, so a provider with no fitting
117
122
  target answers `none` instead of refusing. `none` blocks the step.
118
123
 
@@ -130,7 +135,7 @@ secret, from its declared handle.
130
135
 
131
136
  The text model also answers `waitFor` and `extract`. Instead of
132
137
  `textModel`, you can set `model` on the agents entry. With neither, the
133
- executor never offers `type`, `upload`, or `tap_at`.
138
+ executor never offers `type`, `upload`, `tap_at`, or `type_at`.
134
139
 
135
140
  ## Finishing a step
136
141
 
@@ -167,17 +172,37 @@ doesn't show. The operation and target questions read the tree only.
167
172
  `agent.assert(..., { vision: true })` and `vision: 'only'` work too. After a
168
173
  secret fill in the attempt, the executor gets no pixels.
169
174
 
170
- The model must accept images and answer `score` questions, as
171
- `openai.decisionModel('gpt-6-luna')` does. A text-only model such as Jev
172
- refuses the screenshot. Vercel AI Gateway rejects it too, so keep `vision`
173
- off there.
175
+ The model must accept images and answer `score` questions. TypeSafe Jev
176
+ does with `@ai-sdk/typesafe-ai` 3.0.19 or later and a TypeSafe API key in
177
+ `TYPESAFE_AI_API_KEY`:
178
+
179
+ ```ts title="e2e.config.ts"
180
+ import { typeSafeAi } from '@ai-sdk/typesafe-ai';
181
+ import { openrouter } from '@openrouter/ai-sdk-provider';
182
+
183
+ const executor = decisionExecutor({
184
+ model: typeSafeAi.decisionModel('jev-latest'),
185
+ textModel: openrouter('inception/mercury-2.5'),
186
+ vision: true,
187
+ });
188
+ ```
189
+
190
+ `openai.decisionModel('gpt-6-luna')` takes images too. Vercel AI Gateway
191
+ rejects the screenshot for every decision model, so keep `vision` off
192
+ there.
174
193
 
175
194
  With a text model, vision also offers `tap_at` for a control the tree
176
195
  doesn't list. The text model names the control, such as "the 3 key on the
177
- keypad", and the decision model finds it on the screenshot, then on a
178
- zoomed crop. A point tap costs three decide calls and one text call, or one
196
+ keypad", and the decision model finds it on the screenshot, then on two
197
+ zoomed crops. A point tap costs four decide calls and one text call, or one
179
198
  of each to repeat a tap on the same page.
180
199
 
200
+ When the engine can type into whatever has focus, vision also offers
201
+ `type_at` for a drawn field. The text model names the field and the value
202
+ from the goal, the decision model finds the field on the screenshot, and
203
+ the executor taps it and types the value at the caret, after any text
204
+ already there. It costs two decide calls and one text call.
205
+
181
206
  ## Provider options
182
207
 
183
208
  The executor's `providerOptions` go with every decide call, completion
@@ -221,8 +246,8 @@ executor takes that choice with probability 1 and confidence 0, so a
221
246
 
222
247
  - No `navigate`. In-app navigation is a `tap` on a link. URLs belong in
223
248
  test code (`browser.goto`, `app.open`).
224
- - No multi-select `listbox`, no `scrollUntil`, and no typing at a point
225
- (`type_at`).
249
+ - No multi-select `listbox` and no `scrollUntil`. `type_at` never clears a
250
+ drawn field first, so it suits empty fields.
226
251
  - The text model that names a drawn control doesn't see pixels. It can't
227
252
  follow an instruction that exists only as drawn text, such as a wizard
228
253
  that paints "tap the circle".
@@ -144,6 +144,9 @@ without acting. Engines with a keyboard add `typeText`, `pressKey`, and
144
144
  path outside the project root or a hidden one with `POLICY_DENIED`, and a
145
145
  missing file with `INVALID_ARGUMENT`.
146
146
 
147
+ The observation's `keyboardVisible` says whether an on-screen keyboard is
148
+ showing, and is absent when the engine can't tell.
149
+
147
150
  When semantic capture fails, the observation sets `treeUnavailable: true`,
148
151
  has no `tree`, and includes a screenshot if policy allows. Earlier node ids
149
152
  are invalid, and a missing tree doesn't prove a control is absent.
@@ -187,7 +187,8 @@ uses it declares `requires: ['browser']` and other targets skip it. Calls on
187
187
  | `innerHTML()`, `dispatchEvent()`, `selectText()`, `blur()`, `highlight()` | not yet; `browser.evaluate` | missing |
188
188
  | `ariaSnapshot()` | not yet | missing |
189
189
  | `page.keyboard.press`, `keyboard.type` | `browser.keyboard.press`, `browser.keyboard.type`; plain text only, never a `Secret` | web only |
190
- | `keyboard.down`, `keyboard.up`, `keyboard.insertText` | not yet | missing |
190
+ | `keyboard.down`, `keyboard.up` | `browser.keyboard.down`, `browser.keyboard.up`; keys and modifiers stay held until released | web only |
191
+ | `keyboard.insertText` | not yet | missing |
191
192
  | `page.mouse.click(x, y)` | `screen.tapAt({ x, y })`, viewport coordinates | renamed |
192
193
  | `mouse.move`, `down`, `up` as a drag | `screen.swipe({ from, to })` | renamed |
193
194
  | `page.dragAndDrop(source, target, { sourcePosition, targetPosition })` | `source.dragTo(target)`, which takes no positions; for `sourcePosition` or `targetPosition`, `screen.swipe({ from, to })` with each `boundingBox()` origin plus its offset | renamed |
@@ -629,6 +629,12 @@ export interface EngineSnapshot {
629
629
  * read whole.
630
630
  */
631
631
  readonly truncated?: boolean;
632
+ /**
633
+ * Whether an on-screen keyboard is showing, as the engine measured it apart
634
+ * from `root`. Set `false` when it measured none, and omit it only when the
635
+ * engine did not measure it.
636
+ */
637
+ readonly keyboardVisible?: boolean;
632
638
  /** Semantic capture failed; only fresh, independently masked pixels are evidence. */
633
639
  readonly treeUnavailable?: true;
634
640
  /** Masked pixels, when requested and producible; omitted otherwise. */
@@ -104,6 +104,7 @@ These appear in terminal output and in the report as plain string codes.
104
104
  | `UNSUPPORTED_ARTIFACT` | A [`video` mode](/reference/config#recording) set on a target, or on a test that will run there, records on some attempt, and the target's engine cannot record video. The message names the target and, for a test's mode, the test. A mode from the config root or `--video` never raises it: it applies to the targets that can record, with a notice naming the rest | configuration | 2 |
105
105
  | `UNSUPPORTED_CAPABILITY` | A fixture, action, or tier the target's engine does not declare: a contributed fixture it does not contribute, `screen` without `locate`, state restore without `state`, screenshots without `artifacts`, `app.clearState()` on a persistent browser context (the message names the mode), or `agent.assert` `vision` with an executor that does not declare `vision: true` | configuration | 2 |
106
106
  | `NO_TESTS` | Zero runnable ordinary test-target pairs. The message names the cause: no file matched the `tests` globs (with look-alike files), a positional matched nothing (naming the `!` entry that excludes a file it names), a file registered no tests, or filters excluded everything. Suppress with `--pass-with-no-tests`. | configuration | 2 |
107
+ | `RUNNER_BUSY` | A suite overlapped another suite, an opening or open host, or a live attempt; or a host opened during a suite. Await the suite and close existing hosts before retrying. | configuration | 2 |
107
108
  | `NO_LAST_RUN` | `--last-failed` found no `report.json` where the run writes it, or a file there that is not a `report-1` document. Run once without the flag first. | configuration | 2 |
108
109
  | `ONLY_IN_CI` | A focused test reached a CI run | configuration | 2 |
109
110
  | `COLLECTION_ERROR` | Registration outside collection, an invalid title, a non-function body, an async `describe` body, nested serial groups, an option forbidden inside a serial group, a setup test inside `describe`, an invalid session name, a malformed test option such as `tags` that is not a list of distinct names, a test or `describe` option key the call does not take (the message names the nearest one), a test file that fails to import (in a run with no positionals, or a file the positionals selected), a session that a selected test consumes or `e2e explore --session` names but no collected setup test produces (the message lists the declared sessions), a consumed session on a target whose engine has no `state` capability to save it, or a positional path outside the project root | configuration | 2 |
@@ -132,7 +133,7 @@ These appear in terminal output and in the report as plain string codes.
132
133
  | `SESSION_KEY_INVALID` | A worker received a session key that is not 32 bytes. Packaging or IPC fault. | infrastructure | 3 |
133
134
  | `REPORT_WRITE_FAILED` | `report.json` or `ai-trace.json` could not be written. Recorded as a run error; the in-memory outcome is still complete. A `junit.xml` that could not be written is the `junit` reporter's stderr line instead | infrastructure | 3 |
134
135
  | `EVALUATE_FAILED` | `browser.evaluate` threw inside the page (`@e2e-dev/web`) | test | 1 |
135
- | `INTERRUPTED` | A user or CI signal aborted the run | interrupted | 130 |
136
+ | `INTERRUPTED` | A user or CI signal aborted the run. A test cut short by `--max-failures` or the time limit also ends with this code, its message naming the limit; that run exits 1, not 130 | interrupted | 130 |
136
137
 
137
138
  Errors raised by argument validation in test code use `INVALID_ARGUMENT` and
138
139
  `INVALID_LOCATOR`, both test failures with exit code 1. An unclassified thrown
@@ -1,12 +1,69 @@
1
1
  ---
2
2
  title: "e2e/runner"
3
- description: "List the tests a selection would run, or host e2e sessions in another test runner."
3
+ description: "Run a test suite, list its selection, or host e2e sessions in another test runner."
4
4
  icon: "file-lines"
5
- "og:image": "https://e2e.tester.army/docs/images/og/reference/runner.png?v=a4c80234f3"
6
- "twitter:image": "https://e2e.tester.army/docs/images/og/reference/runner.png?v=a4c80234f3"
5
+ "og:image": "https://e2e.tester.army/docs/images/og/reference/runner.png?v=98c298d3ed"
6
+ "twitter:image": "https://e2e.tester.army/docs/images/og/reference/runner.png?v=98c298d3ed"
7
7
  ---
8
8
 
9
- `e2e/runner` runs e2e in process without `e2e run`. `list` returns a selection. `openHost` prepares a target for your test runner, and `host.openSession` opens a fresh attempt for each test. Importing `e2e` from a test file does not load the runner.
9
+ `e2e/runner` runs a suite from code without invoking the CLI. `run` executes tests and returns their report; `list` returns a selection. `openHost` prepares a target for your test runner, and `host.openSession` opens a fresh attempt for each test. Importing `e2e` from a test file does not load the runner.
10
+
11
+ ## run
12
+
13
+ `run(options?: RunOptions)` executes a complete suite and returns `Promise<RunResult>`. It finishes cleanup before resolving and never exits the caller's process.
14
+
15
+ ```ts
16
+ import type { E2EConfig } from 'e2e';
17
+ import { run } from 'e2e/runner';
18
+ import { web } from '@e2e-dev/web';
19
+
20
+ const config: E2EConfig = {
21
+ targets: [{ name: 'web', engine: web(), app: { url: 'http://localhost:3000' } }],
22
+ };
23
+ const controller = new AbortController();
24
+ const result = await run({
25
+ config,
26
+ files: ['tests/signup.e2e.ts'],
27
+ interruptSignal: controller.signal,
28
+ onEvent(event) {
29
+ if (event.type === 'test-started') console.log(event.title);
30
+ },
31
+ });
32
+
33
+ console.log(result.status, result.report.run.summary);
34
+ ```
35
+
36
+ ### Execution options
37
+
38
+ `run` accepts these options. Put execution settings, such as retries and workers, in the config.
39
+
40
+ | Option | Meaning |
41
+ | --- | --- |
42
+ | `cwd` | Project directory. Defaults to the current working directory. |
43
+ | `config` | Config file path or an `E2EConfig` value. Omit it to discover `e2e.config.ts`. |
44
+ | `files` | File selection: paths, globs, bare names, or `file:line`. Omit it to run the configured suite. |
45
+ | `interruptSignal` | Abort to interrupt tests and execute teardown. |
46
+ | `onEvent` | Receives typed progress events. Must not block; a throwing or rejecting callback is disabled without failing the run. |
47
+
48
+ Runtime config runs with one worker in the caller's process. A file config supports the configured worker processes. Terminal progress is disabled; use `onEvent` for progress.
49
+
50
+ Helper modules your test files import stay cached between `run` calls in the same process, so edits to them need a new process.
51
+
52
+ ### Completed result
53
+
54
+ | Field | Meaning |
55
+ | --- | --- |
56
+ | `exitCode` | The CLI-equivalent exit code: `0`, `1`, `2`, `3`, `4`, or `130`. |
57
+ | `status` | `passed`, `failed`, `blocked`, `error`, or `interrupted`. |
58
+ | `report` | The complete report. Read `report.run.results`, `report.run.errors`, and `report.run.summary` for outcomes. |
59
+ | `reportPath` | Absolute path to `report.json`, or `undefined` if it could not be written. |
60
+ | `aiTracePath` | AI trace path, or `undefined` when no AI trace was written. |
61
+
62
+ Test failures, config failures, and interruption resolve with their status and errors in the report. Overlapping suites reject with `RUNNER_BUSY`. Close every host before running a suite, and await the suite before opening a host.
63
+
64
+ Each run that returns a report sends one anonymous [`e2e_run_completed`](/telemetry) event, with the same opt-outs as the CLI. The first call can print the telemetry notice on stderr.
65
+
66
+ Suite output uses UTF-8 text redaction and does not preserve binary data. Your application's logs and progress callback output stay under your control.
10
67
 
11
68
  ## list
12
69
 
@@ -341,6 +341,10 @@ export interface Browser extends Expectable<BrowserExpectation> {
341
341
  readonly keyboard: {
342
342
  /** Sends one key. */
343
343
  press(key: string): Promise<void>;
344
+ /** Holds a key or modifier until up, with repeat on another down. */
345
+ down(key: string): Promise<void>;
346
+ /** Releases a held key. */
347
+ up(key: string): Promise<void>;
344
348
  /** Types plain text. */
345
349
  type(text: string): Promise<void>;
346
350
  };
@@ -632,9 +636,9 @@ page it opens. Each defaults to the browser's own.
632
636
  every open tab and every tab opened after them, through `app.restart()` and
633
637
  `app.clearState()`. It works before the first navigation, so the next
634
638
  `app.open` loads with them. A feature it leaves out keeps its value. A tab
635
- the app opens itself paints with the configured values and gets a test's
636
- change as it appears, on a persistent context too. A
637
- feature or value outside `WebMedia` is `INVALID_CONFIG` in `web()` and
639
+ the app opens itself gets a test's change as it appears, on a persistent
640
+ context too, so a script it runs at load may still read the configured
641
+ values. A feature or value outside `WebMedia` is `INVALID_CONFIG` in `web()` and
638
642
  `INVALID_ARGUMENT` in `emulateMedia`.
639
643
 
640
644
  ```ts
@@ -67,7 +67,9 @@ sends nothing.
67
67
  Each command sends one `e2e_cli_session` event. `run` and `explore` also
68
68
  send `e2e_run_completed`, `init` sends `e2e_init_completed`, and `mcp` sends
69
69
  one `e2e_mcp_session` per MCP session. `--help` and `--version` send
70
- nothing, also on a command.
70
+ nothing, also on a command. `run()` from `e2e/runner` sends one
71
+ `e2e_run_completed` per run that returns a report, and no session event. Its
72
+ first call can print the notice below on stderr.
71
73
 
72
74
  The events go in one request when the command ends, with a two-second
73
75
  deadline. `e2e mcp` sends each MCP session event in its own request. Events
@@ -112,11 +114,11 @@ hold only the properties below.
112
114
  | `skill`, `mcp`, `install` | Whether the agent skill, the MCP registration, and the dependency install were chosen |
113
115
 
114
116
  </Accordion>
115
- <Accordion title="e2e_run_completed, once per e2e run or e2e explore">
117
+ <Accordion title="e2e_run_completed, once per e2e run, e2e explore, or completed run()">
116
118
 
117
119
  | Property | Value |
118
120
  | --- | --- |
119
- | `command` | `run` or `explore` |
121
+ | `command` | `run`, `explore`, or `runner.run` for `run()` from `e2e/runner` |
120
122
  | `status`, `exit_code`, `duration_ms` | The run's outcome |
121
123
  | `flags` | The names of the flags given to the run command, never their values |
122
124
  | `tests_discovered`, `tests_selected`, `tests_executed`, `tests_passed`, `tests_failed`, `tests_interrupted`, `tests_flaky`, `tests_skipped` | The summary counts |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "e2e",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "SDK, runner, and CLI for agentic end-to-end testing",
5
5
  "keywords": [
6
6
  "e2e",
@@ -132,7 +132,7 @@
132
132
  "@ai-sdk/openai": "4.0.90",
133
133
  "@ai-sdk/openai-compatible": "3.0.53",
134
134
  "@ai-sdk/xai": "5.0.4",
135
- "@e2e-dev/web": "0.15.0",
135
+ "@e2e-dev/web": "0.15.1",
136
136
  "@modelcontextprotocol/client": "2.2.0",
137
137
  "@openrouter/ai-sdk-provider": "3.1.0",
138
138
  "@types/node": "26.6.2",
@@ -80,7 +80,10 @@ const persona = { model: gateway('openai/gpt-6-luna-fast'), maxSteps: 40, maxMod
80
80
  export default {
81
81
  ...shared,
82
82
  // The project's tests, so a repro can use its setup tests' sessions, plus the repro tests from step 6.
83
- tests: [shared.tests ?? 'tests/**/*.e2e.ts', 'tests/bugbash/**/*.e2e.ts'].flat(),
83
+ tests: [
84
+ ...[shared.tests ?? 'tests/**/*.e2e.ts'].flat().filter((glob) => !glob.startsWith('!tests/bugbash/')),
85
+ 'tests/bugbash/**/*.e2e.ts',
86
+ ],
84
87
  // The app already runs: no command.
85
88
  targets: [{ name: 'web', engine: web(), app: { url: 'http://127.0.0.1:3000' } }],
86
89
  retries: 0,
@@ -79,6 +79,13 @@ target names.
79
79
  npx e2e list tests/signup.e2e.ts --tag smoke --reporter json
80
80
  ```
81
81
 
82
+ `import { run } from 'e2e/runner'` executes the suite and returns its report
83
+ without exiting the caller. Pass `cwd`, `config`, and `files` to select the
84
+ project and tests; `onEvent` receives progress and `interruptSignal` cancels
85
+ with teardown. A runtime config uses one in-process worker. Await each run
86
+ before starting the next and close every host first; overlaps reject with
87
+ `RUNNER_BUSY`.
88
+
82
89
  With a `package.json` script `"test:e2e": "e2e run"`, pnpm forwards `--`
83
90
  literally: `pnpm test:e2e -- --headed` reaches e2e as `run -- --headed` and
84
91
  exits 2. Write `pnpm test:e2e --headed` or `pnpm exec e2e run --headed`.
@@ -426,8 +426,10 @@ the app opens itself (`target="_blank"`, `window.open`) is not followed:
426
426
  - `waitForDownload(() => trigger, { timeout? })`: returns
427
427
  `{ path, absolutePath, suggestedFilename }`; `absolutePath` names the saved
428
428
  runner-side file for reading its contents, including with a hosted browser.
429
- - `keyboard.press(key)`, `keyboard.type(text)`, `mouse.*`: unfocused input;
430
- prefer `locator.press` and `locator.fill`.
429
+ - `keyboard.press(key)`, `keyboard.type(text)`, `keyboard.down(key)`,
430
+ `keyboard.up(key)`, `mouse.*`: unfocused input. Only `down(key)` holds a
431
+ key or modifier until `up(key)`. `press(key)` presses and releases within
432
+ the call. Prefer `locator.press` and `locator.fill`.
431
433
 
432
434
  A `route`, `unroute`, or `waitForResponse` pattern is a glob string or
433
435
  `RegExp` matched against the full URL (`*` stays within one path segment,