e2e 0.17.0 → 0.18.0

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 (166) hide show
  1. package/README.md +10 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/ai-sdk.js +5 -4
  4. package/dist/agent/error.js +3 -3
  5. package/dist/agent/invocation.js +3 -4
  6. package/dist/agent/model/tool-choice.d.ts +9 -0
  7. package/dist/agent/model/tool-choice.js +14 -0
  8. package/dist/agent/observation-feed.js +3 -4
  9. package/dist/agent/phases.d.ts +7 -1
  10. package/dist/agent/phases.js +12 -3
  11. package/dist/agent/prompts.js +3 -0
  12. package/dist/agent/replay.js +18 -15
  13. package/dist/agent/step-cache.d.ts +2 -1
  14. package/dist/agent/step-cache.js +9 -5
  15. package/dist/agent/tool-loop.js +92 -5
  16. package/dist/cache/trace.js +6 -2
  17. package/dist/cli/bin.js +2 -2
  18. package/dist/cli/index.js +35 -24
  19. package/dist/cli/init/mcp-config.d.ts +2 -1
  20. package/dist/cli/init/mcp-config.js +8 -3
  21. package/dist/cli/init/sibling-versions.json +1 -1
  22. package/dist/cli/mcp.d.ts +3 -2
  23. package/dist/cli/mcp.js +52 -16
  24. package/dist/cli/run-output.d.ts +17 -0
  25. package/dist/cli/run-output.js +45 -0
  26. package/dist/collect/collect.js +8 -4
  27. package/dist/collect/registry.js +27 -16
  28. package/dist/config/compiled-files.d.ts +36 -0
  29. package/dist/config/compiled-files.js +50 -0
  30. package/dist/config/diagnose.js +21 -19
  31. package/dist/config/esm-hooks.d.ts +39 -44
  32. package/dist/config/esm-hooks.js +238 -63
  33. package/dist/config/load.d.ts +1 -7
  34. package/dist/config/load.js +23 -66
  35. package/dist/config/register.d.ts +8 -0
  36. package/dist/config/register.js +9 -0
  37. package/dist/config/specifiers.d.ts +19 -0
  38. package/dist/config/specifiers.js +33 -0
  39. package/dist/config/tsconfig.d.ts +56 -0
  40. package/dist/config/tsconfig.js +115 -0
  41. package/dist/config/typescript.d.ts +19 -0
  42. package/dist/config/typescript.js +253 -0
  43. package/dist/engine/contract.d.ts +1 -0
  44. package/dist/engine/index.d.ts +2 -0
  45. package/dist/engine/session.js +2 -2
  46. package/dist/expect/async.js +2 -1
  47. package/dist/expect/poll.js +2 -2
  48. package/dist/internal/errors.d.ts +10 -3
  49. package/dist/internal/errors.js +23 -8
  50. package/dist/internal/node-version.d.ts +16 -7
  51. package/dist/internal/node-version.js +50 -15
  52. package/dist/internal/package-manager.js +1 -1
  53. package/dist/internal/realm-slot.d.ts +5 -4
  54. package/dist/internal/redact.d.ts +2 -0
  55. package/dist/internal/redact.js +4 -0
  56. package/dist/internal/time.d.ts +21 -5
  57. package/dist/internal/time.js +35 -3
  58. package/dist/locator/engine.js +47 -11
  59. package/dist/locator/screen.js +31 -5
  60. package/dist/mcp/output.d.ts +59 -0
  61. package/dist/mcp/output.js +129 -0
  62. package/dist/mcp/server.d.ts +4 -0
  63. package/dist/mcp/server.js +9 -12
  64. package/dist/mcp/session.d.ts +8 -1
  65. package/dist/mcp/session.js +55 -14
  66. package/dist/mcp/tools.js +7 -5
  67. package/dist/report/list.d.ts +11 -0
  68. package/dist/report/list.js +23 -6
  69. package/dist/run/artifacts.d.ts +4 -4
  70. package/dist/run/artifacts.js +3 -2
  71. package/dist/run/execute.d.ts +25 -5
  72. package/dist/run/execute.js +44 -20
  73. package/dist/run/failure-evidence.js +6 -2
  74. package/dist/run/in-process.js +51 -10
  75. package/dist/run/process-output.d.ts +73 -0
  76. package/dist/run/process-output.js +130 -0
  77. package/dist/run/provision.d.ts +2 -0
  78. package/dist/run/provision.js +1 -0
  79. package/dist/run/records.d.ts +4 -4
  80. package/dist/run/retry.d.ts +7 -0
  81. package/dist/run/retry.js +27 -11
  82. package/dist/run/runner.d.ts +8 -0
  83. package/dist/run/runner.js +32 -9
  84. package/dist/run/scheduler.js +255 -19
  85. package/dist/run/secrecy.d.ts +22 -19
  86. package/dist/run/secrecy.js +22 -18
  87. package/dist/run/serial.d.ts +73 -2
  88. package/dist/run/serial.js +239 -58
  89. package/dist/run/standalone.js +1 -1
  90. package/dist/run/trace-redaction.d.ts +5 -6
  91. package/dist/run/trace-redaction.js +5 -6
  92. package/dist/run/worker/entry.js +35 -14
  93. package/dist/run/worker/handle.js +10 -5
  94. package/dist/run/worker/protocol.d.ts +83 -3
  95. package/dist/run/worker/session.js +28 -0
  96. package/dist/telemetry/environment.d.ts +2 -1
  97. package/dist/telemetry/environment.js +12 -2
  98. package/dist/telemetry/project.d.ts +5 -6
  99. package/dist/telemetry/project.js +6 -11
  100. package/dist/telemetry/store.d.ts +5 -5
  101. package/dist/telemetry/store.js +5 -7
  102. package/dist/telemetry/telemetry.d.ts +2 -3
  103. package/dist/telemetry/telemetry.js +2 -4
  104. package/dist/types.d.ts +6 -3
  105. package/docs/agent-steps.mdx +1 -0
  106. package/docs/agents.mdx +1 -0
  107. package/docs/api-testing.mdx +2 -1
  108. package/docs/authentication.mdx +3 -2
  109. package/docs/browser.mdx +7 -6
  110. package/docs/bug-bash.mdx +2 -1
  111. package/docs/cache.mdx +2 -1
  112. package/docs/ci.mdx +3 -2
  113. package/docs/coding-agents.mdx +2 -1
  114. package/docs/core-concepts.mdx +208 -0
  115. package/docs/debugging.mdx +19 -9
  116. package/docs/decision-models.mdx +142 -0
  117. package/docs/example-projects.mdx +32 -0
  118. package/docs/executors.mdx +3 -0
  119. package/docs/explore.mdx +1 -0
  120. package/docs/github.mdx +2 -1
  121. package/docs/index.mdx +9 -5
  122. package/docs/integrations/eas.mdx +1 -0
  123. package/docs/integrations/index.mdx +1 -0
  124. package/docs/integrations/kernel.mdx +2 -1
  125. package/docs/migrate/cypress.mdx +26 -3
  126. package/docs/migrate/detox.mdx +24 -1
  127. package/docs/migrate/maestro.mdx +28 -4
  128. package/docs/migrate/playwright.mdx +27 -4
  129. package/docs/migrate/selenium.mdx +27 -3
  130. package/docs/mobile-ci.mdx +6 -5
  131. package/docs/mobile.mdx +79 -8
  132. package/docs/models.mdx +7 -2
  133. package/docs/quickstart.mdx +26 -15
  134. package/docs/reference/agent.mdx +13 -3
  135. package/docs/reference/app.mdx +1 -0
  136. package/docs/reference/cli.mdx +11 -5
  137. package/docs/reference/config.mdx +42 -6
  138. package/docs/reference/engine.mdx +22 -10
  139. package/docs/reference/environment.mdx +2 -1
  140. package/docs/reference/errors.mdx +11 -8
  141. package/docs/reference/expect.mdx +2 -1
  142. package/docs/reference/mcp.mdx +21 -8
  143. package/docs/reference/mobile.mdx +1 -0
  144. package/docs/reference/reporters.mdx +1 -0
  145. package/docs/reference/screen.mdx +19 -9
  146. package/docs/reference/test.mdx +21 -1
  147. package/docs/reference/web.mdx +8 -2
  148. package/docs/security.mdx +33 -27
  149. package/docs/subscriptions.mdx +1 -0
  150. package/docs/telemetry.mdx +8 -5
  151. package/docs/tools.mdx +1 -0
  152. package/docs/{starting-your-app.mdx → web.mdx} +157 -11
  153. package/docs/writing-an-engine.mdx +2 -1
  154. package/package.json +11 -7
  155. package/skills/e2e/SKILL.md +3 -1
  156. package/skills/e2e/references/agent.md +11 -0
  157. package/skills/e2e/references/debugging.md +1 -1
  158. package/skills/e2e/references/mcp.md +8 -3
  159. package/skills/e2e/references/running.md +2 -1
  160. package/skills/e2e/references/setup.md +9 -1
  161. package/skills/e2e/references/writing-tests.md +1 -1
  162. package/docs/assertions.mdx +0 -94
  163. package/docs/goals.mdx +0 -176
  164. package/docs/locators.mdx +0 -64
  165. package/docs/physical-devices.mdx +0 -76
  166. package/docs/viewports.mdx +0 -146
package/README.md CHANGED
@@ -7,6 +7,10 @@
7
7
  <a href="https://tester.army/discord"><img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-5865F2.svg?style=for-the-badge&logo=discord&logoColor=white&labelColor=000000" /></a>
8
8
  </p>
9
9
 
10
+ <p align="center">
11
+ <a href="https://www.star-history.com/tester-army/e2e"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=tester-army/e2e&type=trending&theme=dark" /><img alt="GitHub Trending Repository of the Day" src="https://api.star-history.com/badge?repo=tester-army/e2e&type=trending" /></picture></a>
12
+ </p>
13
+
10
14
  # e2e
11
15
 
12
16
  [e2e](https://tester.army/e2e?utm_source=e2e&utm_medium=github&utm_campaign=readme_intro) is an end-to-end testing framework for web and mobile apps. Describe a goal in natural language and an agent drives the app to reach it. Check the result with locators and assertions in the same test.
@@ -40,6 +44,11 @@ npx e2e init
40
44
  config and an example test. The
41
45
  [quickstart](https://e2e.tester.army/docs/quickstart) covers the rest.
42
46
 
47
+ To see a finished setup in your stack, open
48
+ [`examples/`](https://github.com/tester-army/e2e/tree/main/examples):
49
+ Vite, Next.js, Expo, and SwiftUI, each a standalone project with a passing
50
+ suite.
51
+
43
52
  ## Packages
44
53
 
45
54
  | Package | What it does |
@@ -50,6 +59,7 @@ config and an example test. The
50
59
  | [`@e2e-dev/github`](https://www.npmjs.com/package/@e2e-dev/github) | Reporter that posts results as a pull request comment. |
51
60
  | [`@e2e-dev/kernel`](https://www.npmjs.com/package/@e2e-dev/kernel) | Kernel hosted browsers for the web engine. |
52
61
  | [`@e2e-dev/eas`](https://www.npmjs.com/package/@e2e-dev/eas) | EAS Simulators hosted iOS simulators and Android emulators for the mobile engine. |
62
+ | [`@e2e-dev/decision`](https://e2e.tester.army/docs/decision-models) | Decision-model executors for bounded semantic actions and assertions. |
53
63
 
54
64
  ## Documentation
55
65
 
package/dist/.build.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.17.0"}
1
+ {"version":"0.18.0"}
@@ -23,10 +23,11 @@ export function asSdkLanguageModel(instance) {
23
23
  }
24
24
  /**
25
25
  * The cache lives on `globalThis`, not in module state: config and test
26
- * modules load in an isolated tsx realm, so an executor constructed in config
27
- * code and the runner's own model plumbing would otherwise hold two separate
28
- * caches — and two separate `ai` module instances whose error classes fail
29
- * each other's instanceof checks. One slot, one SDK instance, every realm.
26
+ * modules may import a copy of e2e other than the runner's, so an executor
27
+ * constructed in config code and the runner's own model plumbing would
28
+ * otherwise hold two separate caches, and two separate `ai` module instances
29
+ * whose error classes fail each other's instanceof checks. One slot, one SDK
30
+ * instance, every copy.
30
31
  */
31
32
  const cacheSlot = realmSlot('e2e.ai-sdk.v1');
32
33
  function cache() {
@@ -1,5 +1,5 @@
1
1
  /** Runner-owned agent error classification. */
2
- import { classifyError, E2EError } from '../internal/errors.js';
2
+ import { classifyError, E2EError, isE2EError } from '../internal/errors.js';
3
3
  /**
4
4
  * The single source of truth for the closed agent code set: exit/result class
5
5
  * for every code, the blocked category for codes a `blocked` verdict may
@@ -55,7 +55,7 @@ const MODEL_UNREACHABLE_CODES = new Set(Object.entries(AGENT_CODE_TABLE)
55
55
  * model was shown may be what went wrong.
56
56
  */
57
57
  export function isModelUnreachable(error) {
58
- if (!(error instanceof E2EError) && !isAgentError(error))
58
+ if (!isE2EError(error))
59
59
  return false;
60
60
  return MODEL_UNREACHABLE_CODES.has(error.code);
61
61
  }
@@ -95,7 +95,7 @@ const AGENT_CODES = new Set(Object.keys(CATEGORY_BY_CODE));
95
95
  export function toAgentError(cause) {
96
96
  if (isAgentError(cause))
97
97
  return cause;
98
- const classified = cause instanceof E2EError ? cause : classifyError(cause);
98
+ const classified = classifyError(cause);
99
99
  if (AGENT_CODES.has(classified.code)) {
100
100
  return new AgentError(classified.code, classified.message, { cause });
101
101
  }
@@ -104,14 +104,13 @@ export class Invocation {
104
104
  /** Captures one raw observation through the shared race-hardened path. */
105
105
  captureObservation(pixels) {
106
106
  return retryingObserve({
107
- observe: (operation) => this.session.observe(operation, {
108
- pixels,
109
- pixelFallback: this.options.vision !== false && !this.runtime.exposure.withholdsPixels,
110
- }),
107
+ observe: (operation, pixelFallback) => this.session.observe(operation, { pixels, pixelFallback }),
111
108
  operation: () => this.operation(),
112
109
  guard: (cause) => this.checkDeadline(cause),
113
110
  signal: this.runtime.engine.signal,
114
111
  api: this.options.api,
112
+ fallback: this.options.vision !== false,
113
+ tainted: () => this.runtime.exposure.withholdsPixels,
115
114
  });
116
115
  }
117
116
  /**
@@ -33,6 +33,15 @@ export declare function isForcedToolChoiceDowngraded(warning: {
33
33
  export declare function isForcedToolCallSkipped(error: unknown): error is {
34
34
  readonly content?: readonly unknown[];
35
35
  };
36
+ /**
37
+ * Whether a turn forced to call `complete_step` called other tools only. The
38
+ * AI SDK throws `AI_ToolChoiceViolationError` for that too, with the reply's
39
+ * raw content; it is the model breaking the protocol, as when it names a tool
40
+ * the turn does not offer, not the provider failing.
41
+ */
42
+ export declare function isForcedToolCallMismatched(error: unknown): error is {
43
+ readonly content: readonly unknown[];
44
+ };
36
45
  /**
37
46
  * Whether a provider failure says the model does not accept a forced tool
38
47
  * choice. Reads the message and the raw response body of the error and of
@@ -42,6 +42,20 @@ export function isForcedToolCallSkipped(error) {
42
42
  const record = error;
43
43
  return record.name === 'AI_ToolChoiceViolationError' && record.finishReason === 'stop';
44
44
  }
45
+ /**
46
+ * Whether a turn forced to call `complete_step` called other tools only. The
47
+ * AI SDK throws `AI_ToolChoiceViolationError` for that too, with the reply's
48
+ * raw content; it is the model breaking the protocol, as when it names a tool
49
+ * the turn does not offer, not the provider failing.
50
+ */
51
+ export function isForcedToolCallMismatched(error) {
52
+ if (typeof error !== 'object' || error === null)
53
+ return false;
54
+ const record = error;
55
+ return (record.name === 'AI_ToolChoiceViolationError' &&
56
+ Array.isArray(record.content) &&
57
+ record.content.some((part) => typeof part === 'object' && part !== null && 'type' in part && part.type === 'tool-call'));
58
+ }
45
59
  /** Longest cause chain walked; a wrapped error rarely nests deeper. */
46
60
  const MAX_CAUSE_DEPTH = 8;
47
61
  /**
@@ -306,14 +306,13 @@ export class ObservationFeed {
306
306
  async capture(pixels) {
307
307
  this.opening = undefined;
308
308
  const raw = await retryingObserve({
309
- observe: (operation) => this.runtime.engine.session.observe(operation, {
310
- pixels,
311
- pixelFallback: !this.runtime.exposure.withholdsPixels,
312
- }),
309
+ observe: (operation, pixelFallback) => this.runtime.engine.session.observe(operation, { pixels, pixelFallback }),
313
310
  operation: () => this.accounting.operation(),
314
311
  guard: (cause) => this.accounting.checkpoint(cause),
315
312
  signal: this.runtime.engine.signal,
316
313
  api: this.accounting.api,
314
+ fallback: true,
315
+ tainted: () => this.runtime.exposure.withholdsPixels,
317
316
  });
318
317
  if (raw.kind === 'pixels') {
319
318
  this.semanticHistory = false;
@@ -67,11 +67,17 @@ export declare function checkStepClock(options: {
67
67
  * re-read rather than surfaced as a failed call. `guard` is the caller's
68
68
  * clock check, so a capture that outlives the deadline reports the step's own
69
69
  * timeout rather than whichever transport error the truncated budget produced.
70
+ * `fallback` asks the engine for fallback pixels, which a secret fill
71
+ * (`tainted`, read per capture) withholds; a capture that then times out
72
+ * before the step clock does is the policy denial it is, not an engine
73
+ * timeout, since only those pixels could have answered.
70
74
  */
71
75
  export declare function retryingObserve(options: {
72
- readonly observe: (operation: OperationContext) => Promise<Observation>;
76
+ readonly observe: (operation: OperationContext, pixelFallback: boolean) => Promise<Observation>;
73
77
  readonly operation: () => OperationContext;
74
78
  readonly guard: (cause?: unknown) => void;
75
79
  readonly signal: AbortSignal;
76
80
  readonly api: string;
81
+ readonly fallback: boolean;
82
+ readonly tainted: () => boolean;
77
83
  }): Promise<Observation>;
@@ -4,7 +4,7 @@
4
4
  * these, so event recording, `--debug` buckets, tracing, deadline semantics,
5
5
  * and observation race-hardening cannot drift between the two paths.
6
6
  */
7
- import { asEngineError, E2EError } from '../internal/errors.js';
7
+ import { asEngineError, isE2EError } from '../internal/errors.js';
8
8
  import { timestamp } from '../internal/ids.js';
9
9
  import { POLL_INTERVAL_MS, sleep } from '../internal/time.js';
10
10
  import { AgentError, toAgentError } from './error.js';
@@ -112,14 +112,23 @@ function timedOutAtDeadline(cause, deadline) {
112
112
  * re-read rather than surfaced as a failed call. `guard` is the caller's
113
113
  * clock check, so a capture that outlives the deadline reports the step's own
114
114
  * timeout rather than whichever transport error the truncated budget produced.
115
+ * `fallback` asks the engine for fallback pixels, which a secret fill
116
+ * (`tainted`, read per capture) withholds; a capture that then times out
117
+ * before the step clock does is the policy denial it is, not an engine
118
+ * timeout, since only those pixels could have answered.
115
119
  */
116
120
  export async function retryingObserve(options) {
117
121
  for (;;) {
122
+ const withheld = options.fallback && options.tainted();
118
123
  try {
119
- return await options.observe(options.operation());
124
+ return await options.observe(options.operation(), options.fallback && !withheld);
120
125
  }
121
126
  catch (cause) {
122
127
  options.guard(cause);
128
+ if (withheld && asEngineError(cause)?.code === 'OPERATION_TIMEOUT') {
129
+ throw new AgentError('POLICY_DENIED', `${options.api} could not read the semantic tree, and its screenshot fallback is denied: ` +
130
+ 'a secret was filled in this attempt, so no pixels leave the runner until it ends (PIXEL_TAINTED)', { cause });
131
+ }
123
132
  // Structural, not instanceof: an engine a config file imported lives in
124
133
  // another module registry, and its retryable race would otherwise fail
125
134
  // the observation the moment a page navigates under it.
@@ -133,7 +142,7 @@ export async function retryingObserve(options) {
133
142
  }
134
143
  /** Event code for a failed phase: the runner or engine code, or the error's name. */
135
144
  function phaseErrorCode(cause) {
136
- if (cause instanceof E2EError)
145
+ if (isE2EError(cause))
137
146
  return cause.code;
138
147
  const engineError = asEngineError(cause);
139
148
  if (engineError !== undefined)
@@ -95,6 +95,9 @@ export const JUDGMENT_REQUEST = [
95
95
  'Decide whether the instruction is true for the observation right now.',
96
96
  'Answer "holds" only when the observation shows it is true, and "fails" only when the observation shows it is false.',
97
97
  'Answer "inconclusive" when the observation does not contain enough evidence to decide either way: the relevant part is not on screen, is still loading, or cannot be read. Never guess.',
98
+ 'When the same value or state is shown in more than one place, such as an order total in a summary and again as the amount on a pay button, the instruction holds only when every place agrees with it; one place that contradicts it is a "fails", even if another agrees.',
99
+ 'This applies only when the instruction does not say where to look: an instruction that names the place, such as "the order summary total", is judged on that place alone.',
100
+ 'It is about one value shown twice, not about different items: a claim about some item, such as "a todo is marked done", holds when one item matches.',
98
101
  'Quotation marks in the instruction delimit the words to look for; the marks themselves, their style (straight or curly), and the punctuation around them are not part of those words, so text that reads the same holds.',
99
102
  'Write the explanation first and let the verdict follow from it: the two must agree.',
100
103
  'Respond with { "protocolVersion": "agent-judgment-2", "explanation": <short reason grounded in the observation>, "verdict": "holds" | "fails" | "inconclusive" }.',
@@ -150,22 +150,25 @@ export async function replayTrace(host, trace, options = {}) {
150
150
  await planned.invoke();
151
151
  break;
152
152
  case 'scroll': {
153
- if (planned.list === undefined) {
154
- // A viewport scroll relocates nothing, so each later repeat takes
155
- // a settled look of its own, as the live loop did between them.
156
- for (let index = 0; index < planned.times; index += 1) {
153
+ // A scroll on a list is paced by the relocation before each repeat.
154
+ // A viewport scroll relocates nothing, so each later repeat takes a
155
+ // settled look of its own, as the live loop did between them. A
156
+ // list judged lost scrolls as the viewport from then on, instead of
157
+ // waiting out the relocation backoff again on every repeat.
158
+ let list = planned.list;
159
+ for (let index = 0; index < planned.times; index += 1) {
160
+ if (list === undefined) {
157
161
  if (index > 0)
158
162
  await host.observe('held-still');
159
163
  await host.actions.scroll(planned.direction);
160
- repeated += 1;
161
164
  }
162
- break;
163
- }
164
- // A scroll on a list is paced by the relocation before each repeat.
165
- for (let index = 0; index < planned.times; index += 1) {
166
- const lost = await scrollOnce(host, planned.direction, planned.list, index === 0 ? look : HELD_STILL);
167
- if (lost !== undefined)
168
- return stop(lost, partial());
165
+ else {
166
+ const scrolled = await scrollOnce(host, planned.direction, list, index === 0 ? look : HELD_STILL);
167
+ if (scrolled.kind === 'failed')
168
+ return stop(scrolled.failure, partial());
169
+ if (scrolled.kind === 'viewport')
170
+ list = undefined;
171
+ }
169
172
  repeated += 1;
170
173
  }
171
174
  break;
@@ -377,12 +380,12 @@ async function scrollOnce(host, direction, list, look) {
377
380
  const relocated = await relocate(host, list.descriptor, look);
378
381
  if (relocated.kind === 'found') {
379
382
  await host.actions.scroll(direction, { id: relocated.id });
380
- return undefined;
383
+ return { kind: 'list' };
381
384
  }
382
385
  if ((list.spans ?? 0) < MAIN_LIST_SHARE)
383
- return relocated.failure;
386
+ return { kind: 'failed', failure: relocated.failure };
384
387
  await host.actions.scroll(direction);
385
- return undefined;
388
+ return { kind: 'viewport' };
386
389
  }
387
390
  /** A fresh look that proves the screen holds still: the first look of a replay without a start capture, and the look after an action that moved the screen. */
388
391
  const HELD_STILL = { kind: 'capture', settle: 'held-still' };
@@ -117,7 +117,8 @@ export declare class StepTraceSession {
117
117
  * self-finalized verdict on a full replay whose postcondition holds;
118
118
  * undefined dispatches the executor — after a miss from the top, after a
119
119
  * divergence mid-step with `replayedPrefix` set. Every failure to replay is
120
- * a miss, never an error; only runtime hard stops propagate.
120
+ * a miss, never an error; only runtime hard stops and a policy denial on
121
+ * the opening look propagate.
121
122
  */
122
123
  begin(): Promise<StepVerdict | undefined>;
123
124
  /**
@@ -136,7 +136,8 @@ export class StepTraceSession {
136
136
  * self-finalized verdict on a full replay whose postcondition holds;
137
137
  * undefined dispatches the executor — after a miss from the top, after a
138
138
  * divergence mid-step with `replayedPrefix` set. Every failure to replay is
139
- * a miss, never an error; only runtime hard stops propagate.
139
+ * a miss, never an error; only runtime hard stops and a policy denial on
140
+ * the opening look propagate.
140
141
  */
141
142
  async begin() {
142
143
  this.startedMs = Date.now();
@@ -297,7 +298,7 @@ export class StepTraceSession {
297
298
  * when this step may write. Settled as far as the capture's purpose asks.
298
299
  */
299
300
  async captureStart(purpose) {
300
- const observation = await probeScreen(this.host, purpose === 'path-only' ? 'raw' : 'held-still');
301
+ const observation = await probeScreen(this.host, purpose === 'path-only' ? 'raw' : 'held-still', true);
301
302
  this.startPath = observation?.path;
302
303
  if (this.recorder !== undefined && observation?.kind === 'semantic')
303
304
  this.startNodes = observation.nodes;
@@ -565,14 +566,17 @@ function baselineScreen(screens, endPath) {
565
566
  * One cache capture, settled as far as `mode` asks, or undefined when the
566
567
  * surface cannot be observed right now, which is the executor's business,
567
568
  * not the cache's. Runtime hard stops (timeout, cancellation) are the step's
568
- * truth even when they land during cache bookkeeping, and propagate.
569
+ * truth even when they land during cache bookkeeping, and propagate. So does
570
+ * a policy denial on the step's opening look (`opening`): the executor's
571
+ * first look reads the same screen under the same policy, and would spend
572
+ * another operation budget to be denied again.
569
573
  */
570
- async function probeScreen(host, mode) {
574
+ async function probeScreen(host, mode, opening = false) {
571
575
  try {
572
576
  return await host.observe(mode);
573
577
  }
574
578
  catch (cause) {
575
- if (isRuntimeHardStop(cause))
579
+ if (isRuntimeHardStop(cause) || (opening && isAgentError(cause) && cause.code === 'POLICY_DENIED'))
576
580
  throw cause;
577
581
  return undefined;
578
582
  }
@@ -15,7 +15,7 @@ import { MODEL_REQUEST_HEADERS } from '../internal/client-identity.js';
15
15
  import { ConfigurationError, withHint } from '../internal/errors.js';
16
16
  import { failureHint, isAbort, TRANSPORT_RETRIES } from './model/sdk.js';
17
17
  import { isContextOverflow } from './model/overflow.js';
18
- import { isForcedToolCallSkipped, isForcedToolChoiceDowngraded, isForcedToolChoiceRejected } from './model/tool-choice.js';
18
+ import { isForcedToolCallMismatched, isForcedToolCallSkipped, isForcedToolChoiceDowngraded, isForcedToolChoiceRejected, } from './model/tool-choice.js';
19
19
  import { withStallGuard } from './model/stall.js';
20
20
  import { providerHints } from './model/provider-hints.js';
21
21
  import { isScreenOutput, toolResultTexts } from './screen-update.js';
@@ -193,6 +193,13 @@ class LoopRun {
193
193
  prompt = shrunk;
194
194
  continue;
195
195
  }
196
+ const corrected = this.correctForcedTurn(cause, tracker);
197
+ if (corrected === 'out-of-turns')
198
+ break;
199
+ if (corrected !== undefined) {
200
+ prompt = corrected;
201
+ continue;
202
+ }
196
203
  const freed = this.freeToolChoiceRetry(cause, tracker);
197
204
  if (freed !== undefined) {
198
205
  prompt = freed;
@@ -275,6 +282,49 @@ class LoopRun {
275
282
  { role: 'user', content: TEXT_REPLY_NOTICE },
276
283
  ];
277
284
  }
285
+ /**
286
+ * A turn forced to call `complete_step` that called other tools instead:
287
+ * the SDK throws `ToolChoiceViolationError` before running any of them. It
288
+ * is handled like a call to a tool the turn does not offer: the turn counts,
289
+ * each call goes back to the model as an error result, and the model is
290
+ * asked again on the same turn budget. Out of turns, the step ends as
291
+ * `STEP_NO_CONCLUSION`, like any step that never concluded.
292
+ */
293
+ correctForcedTurn(cause, tracker) {
294
+ if (this.lastRequest === undefined || this.context.signal.aborted || this.hardStop !== undefined)
295
+ return undefined;
296
+ if (!isForcedToolCallMismatched(cause))
297
+ return undefined;
298
+ const reply = thrownReply(cause.content);
299
+ const results = reply.calls.map((call) => ({
300
+ type: 'tool-result',
301
+ toolCallId: call.toolCallId,
302
+ toolName: call.toolName,
303
+ output: {
304
+ type: 'error-text',
305
+ value: `${call.toolName} did not run: only complete_step is available now. Call complete_step with your verdict.`,
306
+ },
307
+ }));
308
+ this.recordThrownTurn(tracker, reply.calls.map((call) => `${call.toolName}(${truncate(safeJson(call.input), MAX_TURN_CALL_CHARS)})`), [
309
+ skippedReplyText(cause.content),
310
+ ...results.map((result) => `[${result.toolName}] error: ${result.output.value}`),
311
+ ].filter((line) => line !== '').join('\n'));
312
+ if (this.turnsUsed >= this.maxTurns)
313
+ return 'out-of-turns';
314
+ this.note(`turn ${String(this.turnsUsed)} called other tools in place of complete_step: asking again`);
315
+ this.turnOffset = this.turnsUsed;
316
+ return [...this.lastRequest, reply.message, { role: 'tool', content: results }];
317
+ }
318
+ /**
319
+ * Counts a turn the provider answered, and billed, before the SDK threw on
320
+ * it. The SDK reports no usage for such a turn.
321
+ */
322
+ recordThrownTurn(tracker, calls, outcome) {
323
+ tracker.onStepEnd({ usage: UNREPORTED_USAGE, providerMetadata: undefined, reasoningText: undefined });
324
+ this.turnsUsed += 1;
325
+ this.turns.push({ index: this.turnsUsed, calls, outcome, notes: [] });
326
+ this.attachTranscript();
327
+ }
278
328
  /**
279
329
  * A model that refuses a forced tool choice (HTTP 400 naming
280
330
  * `tool_choice`) is asked again with `auto` and a tool-calls-only rule in
@@ -290,10 +340,7 @@ class LoopRun {
290
340
  if (this.context.signal.aborted || this.hardStop !== undefined)
291
341
  return undefined;
292
342
  if (isForcedToolCallSkipped(cause)) {
293
- // The provider answered, and billed, before the SDK threw: the turn counts.
294
- tracker.onStepEnd({ usage: UNREPORTED_USAGE, providerMetadata: undefined, reasoningText: undefined });
295
- this.turnsUsed += 1;
296
- this.turns.push({ index: this.turnsUsed, calls: [], outcome: skippedReplyText(cause.content), notes: [] });
343
+ this.recordThrownTurn(tracker, [], skippedReplyText(cause.content));
297
344
  this.note(`turn ${String(this.turnsUsed)} answered without the forced tool call: retrying with auto`);
298
345
  }
299
346
  else if (isForcedToolChoiceRejected(cause)) {
@@ -619,6 +666,46 @@ function skippedReplyText(content) {
619
666
  .trim();
620
667
  return text === '' ? '' : `assistant: ${truncate(text, MAX_TURN_TEXT_CHARS)}`;
621
668
  }
669
+ /**
670
+ * A reply the SDK threw on, as the assistant message the SDK's own history
671
+ * would carry: its text, reasoning, and tool calls, each with the provider's
672
+ * metadata (reasoning signatures and Gemini's thought signatures ride there),
673
+ * and the tool calls apart, to answer each with a result.
674
+ */
675
+ function thrownReply(content) {
676
+ const parts = [];
677
+ const calls = [];
678
+ for (const part of content) {
679
+ const metadata = part.providerMetadata === undefined ? {} : { providerOptions: part.providerMetadata };
680
+ if (part.type === 'text' && typeof part.text === 'string' && part.text !== '') {
681
+ parts.push({ type: 'text', text: part.text, ...metadata });
682
+ }
683
+ else if (part.type === 'reasoning' && typeof part.text === 'string') {
684
+ parts.push({ type: 'reasoning', text: part.text, ...metadata });
685
+ }
686
+ else if (part.type === 'tool-call' && typeof part.toolCallId === 'string' && typeof part.toolName === 'string') {
687
+ const call = {
688
+ type: 'tool-call',
689
+ toolCallId: part.toolCallId,
690
+ toolName: part.toolName,
691
+ input: typeof part.input === 'string' ? parseToolInput(part.input) : part.input,
692
+ ...metadata,
693
+ };
694
+ parts.push(call);
695
+ calls.push(call);
696
+ }
697
+ }
698
+ return { message: { role: 'assistant', content: parts }, calls };
699
+ }
700
+ /** A raw tool call's input for the history: the parsed JSON, or the text itself when it does not parse. */
701
+ function parseToolInput(input) {
702
+ try {
703
+ return JSON.parse(input);
704
+ }
705
+ catch {
706
+ return input;
707
+ }
708
+ }
622
709
  function describeOutput(output) {
623
710
  if (isScreenOutput(output))
624
711
  return `${output.text}\n[screenshot, ${String(output.pixels.data.byteLength)} bytes]`;
@@ -203,7 +203,7 @@ function readRecordedAction(document) {
203
203
  switch (name) {
204
204
  case 'type': {
205
205
  const target = readDescriptor(raw['target']);
206
- const value = readInputText(raw['value']);
206
+ const value = readTypedValue(raw['value']);
207
207
  if (target === undefined || value === undefined)
208
208
  return undefined;
209
209
  return { name: 'type', summary, target, value };
@@ -310,7 +310,7 @@ function readRecordedAction(document) {
310
310
  case 'back':
311
311
  return { name: 'back', summary };
312
312
  case 'typeText': {
313
- const value = readInputText(raw['value']);
313
+ const value = readTypedValue(raw['value']);
314
314
  if (value === undefined || typeof raw['replace'] !== 'boolean')
315
315
  return undefined;
316
316
  return { name: 'typeText', summary, value, replace: raw['replace'] };
@@ -612,3 +612,7 @@ function readInputText(value) {
612
612
  }
613
613
  return value;
614
614
  }
615
+ /** A typed value: `readInputText`, except that `''` is valid, since typing it clears a field. */
616
+ function readTypedValue(value) {
617
+ return value === '' ? value : readInputText(value);
618
+ }
package/dist/cli/bin.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { unsupportedNodeMessage } from '../internal/node-version.js';
2
+ import { unsupportedRuntimeMessage } from '../internal/node-version.js';
3
3
  // Checked before the CLI module loads: that module and its dependencies are
4
4
  // what an old runtime would fail on, with a stack trace instead of a sentence.
5
- const unsupported = unsupportedNodeMessage(process.versions.node);
5
+ const unsupported = unsupportedRuntimeMessage();
6
6
  if (unsupported !== undefined) {
7
7
  process.stderr.write(`${unsupported}\n`);
8
8
  process.exit(2);
package/dist/cli/index.js CHANGED
@@ -7,6 +7,7 @@ import { detectPackageManager, execCommand, runScriptCommand } from '../internal
7
7
  import { packageVersion } from '../internal/package-version.js';
8
8
  import { classifyError, exitCodeForCategory } from '../internal/errors.js';
9
9
  import { list, run } from '../run/runner.js';
10
+ import { claimRunnerOutput } from './run-output.js';
10
11
  import { explore, STEP_BOUNDS, TIMEOUT_BOUNDS } from '../explore/index.js';
11
12
  import { BUILTIN_REPORTERS, isBuiltinReporter } from '../report/builtin.js';
12
13
  import { bounded } from '../report/format.js';
@@ -401,11 +402,14 @@ function createProgram(version, telemetry) {
401
402
  .description('Serve an MCP server over stdio for a coding agent such as Claude Code or Cursor: open_session opens a live session on one target of any config the agent names, call runs the session\'s tools (observe, tap, type, press, select, scroll, navigate, type_secret, locate, screenshot, and the project\'s own), tools describes them, close_session ends it. The agent explores the real app before writing a test. Register it with the client, e.g. claude mcp add e2e -- npx e2e mcp.')
402
403
  .option('--config <path>', 'config file (default: the nearest e2e.config.ts)')
403
404
  .option('--target <name>', 'target every session opens on (default: the only target, or the one open_session names)')
404
- .option('--headless', 'hide the UI during live sessions (default: headed outside CI)')
405
+ .option('--headed', 'show the UI in sessions whose open_session does not set headed, when the engine supports it')
406
+ // Sessions were headed by default before 0.18 and took --headless; a client config that still passes it
407
+ // asks for the default now, and a refused flag would surface as nothing but a failed connection.
408
+ .addOption(new Option('--headless').hideHelp())
405
409
  .option('--max-sessions <n>', `sessions open at once, each with its own browser or device, ${SESSION_BOUNDS.min} through ${SESSION_BOUNDS.max} (default: ${SESSION_BOUNDS.default})`, parseBoundedInt(SESSION_BOUNDS))
406
410
  .addHelpText('after', [
407
411
  '',
408
- examples(['e2e mcp', 'e2e mcp --target web --headless', 'e2e mcp --max-sessions 8', 'claude mcp add e2e -- npx e2e mcp']),
412
+ examples(['e2e mcp', 'e2e mcp --target web --headed', 'e2e mcp --max-sessions 8', 'claude mcp add e2e -- npx e2e mcp']),
409
413
  '',
410
414
  docsLine('/reference/mcp'),
411
415
  ].join('\n'))
@@ -466,28 +470,35 @@ function createProgram(version, telemetry) {
466
470
  // Each value is what its parser returned, so a bad one never reaches here.
467
471
  options, command) => {
468
472
  rejectForwardedFlags(command, files);
469
- return runToOutcome(telemetry, command, (signals) => run({
470
- files,
471
- configPath: options.config,
472
- targetIds: options.target,
473
- ...selectionRunOptions(options),
474
- headed: options.headed,
475
- agent: options.agent,
476
- retries: options.retries,
477
- workers: options.workers,
478
- maxFailures: options.maxFailures,
479
- repeatEach: options.repeatEach,
480
- reporters: options.reporter,
481
- output: options.output,
482
- noCache: options.cache === false,
483
- strictCache: options.strictCache,
484
- debug: options.debug,
485
- aiTrace: options.aiTrace,
486
- trace: recordingOption(options.trace),
487
- video: recordingOption(options.video),
488
- interruptSignal: signals.interruptSignal,
489
- forceSignal: signals.forceSignal,
490
- }));
473
+ const output = claimRunnerOutput();
474
+ try {
475
+ await runToOutcome(telemetry, command, (signals) => run({
476
+ files,
477
+ configPath: options.config,
478
+ targetIds: options.target,
479
+ ...selectionRunOptions(options),
480
+ headed: options.headed,
481
+ agent: options.agent,
482
+ retries: options.retries,
483
+ workers: options.workers,
484
+ maxFailures: options.maxFailures,
485
+ repeatEach: options.repeatEach,
486
+ reporters: options.reporter,
487
+ output: options.output,
488
+ noCache: options.cache === false,
489
+ strictCache: options.strictCache,
490
+ debug: options.debug,
491
+ aiTrace: options.aiTrace,
492
+ trace: recordingOption(options.trace),
493
+ video: recordingOption(options.video),
494
+ interruptSignal: signals.interruptSignal,
495
+ forceSignal: signals.forceSignal,
496
+ processOutput: output,
497
+ }));
498
+ }
499
+ finally {
500
+ output.end();
501
+ }
491
502
  });
492
503
  program
493
504
  .command('explore')
@@ -25,6 +25,7 @@ export declare function findRegisteredMcpFiles(cwd: string): string[];
25
25
  /**
26
26
  * Plans the writes for the given files. A file without the entry gets it
27
27
  * merged in; one whose entry already matches needs nothing and is left out.
28
- * Other servers, other keys, and the file's indentation are preserved.
28
+ * Extra settings on the e2e entry, other servers, other keys, and the file's
29
+ * indentation are preserved.
29
30
  */
30
31
  export declare function planMcpRegistration(cwd: string, files: readonly string[]): McpRegistration[];
@@ -45,7 +45,8 @@ export function findRegisteredMcpFiles(cwd) {
45
45
  /**
46
46
  * Plans the writes for the given files. A file without the entry gets it
47
47
  * merged in; one whose entry already matches needs nothing and is left out.
48
- * Other servers, other keys, and the file's indentation are preserved.
48
+ * Extra settings on the e2e entry, other servers, other keys, and the file's
49
+ * indentation are preserved.
49
50
  */
50
51
  export function planMcpRegistration(cwd, files) {
51
52
  const registrations = [];
@@ -55,9 +56,13 @@ export function planMcpRegistration(cwd, files) {
55
56
  const servers = typeof document.mcpServers === 'object' && document.mcpServers !== null && !Array.isArray(document.mcpServers)
56
57
  ? document.mcpServers
57
58
  : {};
58
- if (JSON.stringify(servers[MCP_SERVER_NAME]) === JSON.stringify(SERVER_ENTRY))
59
+ const existing = servers[MCP_SERVER_NAME];
60
+ const entry = typeof existing === 'object' && existing !== null && !Array.isArray(existing)
61
+ ? { ...existing, ...SERVER_ENTRY }
62
+ : SERVER_ENTRY;
63
+ if (JSON.stringify(existing) === JSON.stringify(entry))
59
64
  continue;
60
- const merged = { ...document, mcpServers: { ...servers, [MCP_SERVER_NAME]: SERVER_ENTRY } };
65
+ const merged = { ...document, mcpServers: { ...servers, [MCP_SERVER_NAME]: entry } };
61
66
  const original = existsSync(absolute) ? readFileSync(absolute, 'utf8') : undefined;
62
67
  const indent = original?.match(/\n([\t ]+)"/)?.[1] ?? ' ';
63
68
  registrations.push({
@@ -1 +1 @@
1
- {"@e2e-dev/web":"0.12.0","@e2e-dev/mobile":"0.9.2"}
1
+ {"@e2e-dev/web":"0.13.0","@e2e-dev/mobile":"0.10.0"}