e2e 0.16.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 (256) hide show
  1. package/README.md +10 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/act.js +7 -4
  4. package/dist/agent/action-dispatcher.js +4 -5
  5. package/dist/agent/actions.d.ts +32 -23
  6. package/dist/agent/actions.js +38 -24
  7. package/dist/agent/ai-sdk.js +5 -4
  8. package/dist/agent/error.js +3 -3
  9. package/dist/agent/invocation.js +3 -4
  10. package/dist/agent/model/sdk.js +2 -0
  11. package/dist/agent/model/tool-choice.d.ts +9 -0
  12. package/dist/agent/model/tool-choice.js +14 -0
  13. package/dist/agent/observation-feed.d.ts +5 -6
  14. package/dist/agent/observation-feed.js +8 -11
  15. package/dist/agent/observation.d.ts +40 -11
  16. package/dist/agent/observation.js +109 -59
  17. package/dist/agent/phases.d.ts +7 -1
  18. package/dist/agent/phases.js +12 -3
  19. package/dist/agent/point-tap.d.ts +8 -8
  20. package/dist/agent/point-tap.js +1 -1
  21. package/dist/agent/prompts.js +3 -0
  22. package/dist/agent/replay.d.ts +19 -8
  23. package/dist/agent/replay.js +29 -29
  24. package/dist/agent/scroll-target.d.ts +2 -3
  25. package/dist/agent/step-cache.d.ts +43 -15
  26. package/dist/agent/step-cache.js +156 -67
  27. package/dist/agent/tool-loop.js +94 -5
  28. package/dist/cache/anchors.d.ts +72 -23
  29. package/dist/cache/anchors.js +233 -60
  30. package/dist/cache/context.d.ts +25 -14
  31. package/dist/cache/context.js +50 -8
  32. package/dist/cache/decide.d.ts +2 -2
  33. package/dist/cache/decide.js +4 -6
  34. package/dist/cache/identity.d.ts +18 -4
  35. package/dist/cache/identity.js +9 -4
  36. package/dist/cache/recorder.d.ts +15 -1
  37. package/dist/cache/recorder.js +36 -23
  38. package/dist/cache/rekeyed.d.ts +46 -0
  39. package/dist/cache/rekeyed.js +89 -0
  40. package/dist/cache/relocate.d.ts +14 -14
  41. package/dist/cache/relocate.js +33 -25
  42. package/dist/cache/route.d.ts +32 -33
  43. package/dist/cache/route.js +94 -50
  44. package/dist/cache/store.d.ts +6 -0
  45. package/dist/cache/store.js +21 -1
  46. package/dist/cache/trace.d.ts +53 -14
  47. package/dist/cache/trace.js +76 -30
  48. package/dist/cli/bin.js +2 -2
  49. package/dist/cli/index.js +43 -32
  50. package/dist/cli/init/engines.d.ts +1 -3
  51. package/dist/cli/init/engines.js +2 -2
  52. package/dist/cli/init/gateways.d.ts +1 -1
  53. package/dist/cli/init/gateways.js +11 -1
  54. package/dist/cli/init/mcp-config.d.ts +2 -1
  55. package/dist/cli/init/mcp-config.js +8 -3
  56. package/dist/cli/init/sibling-versions.json +1 -1
  57. package/dist/cli/init/versions.d.ts +0 -8
  58. package/dist/cli/init/versions.js +1 -10
  59. package/dist/cli/mcp.d.ts +3 -2
  60. package/dist/cli/mcp.js +52 -16
  61. package/dist/cli/run-output.d.ts +17 -0
  62. package/dist/cli/run-output.js +45 -0
  63. package/dist/collect/collect.js +8 -4
  64. package/dist/collect/registry.js +27 -16
  65. package/dist/config/compiled-files.d.ts +36 -0
  66. package/dist/config/compiled-files.js +50 -0
  67. package/dist/config/diagnose.js +21 -19
  68. package/dist/config/esm-hooks.d.ts +39 -44
  69. package/dist/config/esm-hooks.js +238 -63
  70. package/dist/config/load.d.ts +1 -7
  71. package/dist/config/load.js +23 -66
  72. package/dist/config/register.d.ts +8 -0
  73. package/dist/config/register.js +9 -0
  74. package/dist/config/resolve.d.ts +1 -0
  75. package/dist/config/resolve.js +33 -1
  76. package/dist/config/specifiers.d.ts +19 -0
  77. package/dist/config/specifiers.js +33 -0
  78. package/dist/config/tsconfig.d.ts +56 -0
  79. package/dist/config/tsconfig.js +115 -0
  80. package/dist/config/typescript.d.ts +19 -0
  81. package/dist/config/typescript.js +253 -0
  82. package/dist/engine/contract.d.ts +1 -0
  83. package/dist/engine/index.d.ts +4 -2
  84. package/dist/engine/session.js +2 -2
  85. package/dist/expect/async.js +4 -2
  86. package/dist/expect/poll-scope.d.ts +52 -0
  87. package/dist/expect/poll-scope.js +113 -0
  88. package/dist/expect/poll.js +33 -6
  89. package/dist/expect/values.js +11 -4
  90. package/dist/internal/abandoned.d.ts +11 -0
  91. package/dist/internal/abandoned.js +23 -0
  92. package/dist/internal/brands.d.ts +2 -0
  93. package/dist/internal/brands.js +2 -0
  94. package/dist/internal/client-identity.d.ts +9 -0
  95. package/dist/internal/client-identity.js +14 -0
  96. package/dist/internal/errors.d.ts +10 -3
  97. package/dist/internal/errors.js +23 -8
  98. package/dist/internal/node-version.d.ts +16 -7
  99. package/dist/internal/node-version.js +50 -15
  100. package/dist/internal/package-manager.js +1 -1
  101. package/dist/internal/realm-slot.d.ts +5 -4
  102. package/dist/internal/redact.d.ts +9 -4
  103. package/dist/internal/redact.js +120 -23
  104. package/dist/internal/time.d.ts +26 -4
  105. package/dist/internal/time.js +58 -14
  106. package/dist/internal/urls.d.ts +9 -5
  107. package/dist/internal/urls.js +19 -10
  108. package/dist/locator/engine.d.ts +12 -2
  109. package/dist/locator/engine.js +55 -14
  110. package/dist/locator/screen.js +59 -9
  111. package/dist/mcp/catalog.d.ts +3 -2
  112. package/dist/mcp/catalog.js +6 -6
  113. package/dist/mcp/output.d.ts +59 -0
  114. package/dist/mcp/output.js +129 -0
  115. package/dist/mcp/server.d.ts +4 -0
  116. package/dist/mcp/server.js +9 -12
  117. package/dist/mcp/session.d.ts +8 -1
  118. package/dist/mcp/session.js +56 -15
  119. package/dist/mcp/tools.js +7 -5
  120. package/dist/oauth/chatgpt.js +3 -23
  121. package/dist/oauth/cli.js +1 -0
  122. package/dist/oauth/copilot.d.ts +5 -4
  123. package/dist/oauth/copilot.js +80 -7
  124. package/dist/oauth/grok.js +2 -1
  125. package/dist/oauth/models.js +2 -1
  126. package/dist/oauth/opencode-console.d.ts +10 -0
  127. package/dist/oauth/opencode-console.js +133 -0
  128. package/dist/oauth/providers/github-copilot.d.ts +12 -0
  129. package/dist/oauth/providers/github-copilot.js +77 -23
  130. package/dist/oauth/providers/opencode-console.d.ts +31 -0
  131. package/dist/oauth/providers/opencode-console.js +148 -0
  132. package/dist/oauth/providers.d.ts +2 -6
  133. package/dist/oauth/providers.js +3 -7
  134. package/dist/oauth/responses.d.ts +9 -0
  135. package/dist/oauth/responses.js +23 -0
  136. package/dist/report/build.d.ts +26 -0
  137. package/dist/report/build.js +16 -8
  138. package/dist/report/failure-text.d.ts +6 -1
  139. package/dist/report/failure-text.js +14 -4
  140. package/dist/report/format.d.ts +10 -5
  141. package/dist/report/format.js +12 -5
  142. package/dist/report/junit.js +11 -12
  143. package/dist/report/list.d.ts +12 -0
  144. package/dist/report/list.js +61 -24
  145. package/dist/report/markdown.d.ts +1 -1
  146. package/dist/report/markdown.js +37 -14
  147. package/dist/report/repeats.d.ts +6 -1
  148. package/dist/report/repeats.js +12 -3
  149. package/dist/run/artifacts.d.ts +22 -4
  150. package/dist/run/artifacts.js +71 -3
  151. package/dist/run/execute.d.ts +34 -5
  152. package/dist/run/execute.js +93 -44
  153. package/dist/run/extended-fixtures.js +6 -2
  154. package/dist/run/failure-evidence.js +10 -4
  155. package/dist/run/fixtures.js +6 -8
  156. package/dist/run/in-process.d.ts +2 -0
  157. package/dist/run/in-process.js +52 -10
  158. package/dist/run/last-run.d.ts +42 -8
  159. package/dist/run/last-run.js +121 -14
  160. package/dist/run/process-output.d.ts +73 -0
  161. package/dist/run/process-output.js +130 -0
  162. package/dist/run/provision.d.ts +2 -0
  163. package/dist/run/provision.js +1 -0
  164. package/dist/run/realm.d.ts +4 -1
  165. package/dist/run/realm.js +20 -6
  166. package/dist/run/records.d.ts +20 -4
  167. package/dist/run/records.js +26 -0
  168. package/dist/run/retry.d.ts +10 -1
  169. package/dist/run/retry.js +30 -12
  170. package/dist/run/runner.d.ts +9 -1
  171. package/dist/run/runner.js +101 -39
  172. package/dist/run/scheduler.js +255 -19
  173. package/dist/run/secrecy.d.ts +22 -19
  174. package/dist/run/secrecy.js +22 -18
  175. package/dist/run/serial.d.ts +75 -2
  176. package/dist/run/serial.js +243 -59
  177. package/dist/run/standalone.js +1 -1
  178. package/dist/run/steps.d.ts +0 -6
  179. package/dist/run/steps.js +3 -20
  180. package/dist/run/trace-redaction.d.ts +5 -6
  181. package/dist/run/trace-redaction.js +5 -6
  182. package/dist/run/worker/entry.js +40 -18
  183. package/dist/run/worker/handle.js +10 -5
  184. package/dist/run/worker/protocol.d.ts +85 -3
  185. package/dist/run/worker/session.d.ts +2 -0
  186. package/dist/run/worker/session.js +29 -0
  187. package/dist/telemetry/environment.d.ts +2 -1
  188. package/dist/telemetry/environment.js +12 -2
  189. package/dist/telemetry/events.js +1 -0
  190. package/dist/telemetry/project.d.ts +5 -6
  191. package/dist/telemetry/project.js +6 -11
  192. package/dist/telemetry/store.d.ts +5 -5
  193. package/dist/telemetry/store.js +5 -7
  194. package/dist/telemetry/telemetry.d.ts +2 -3
  195. package/dist/telemetry/telemetry.js +2 -4
  196. package/dist/types.d.ts +15 -6
  197. package/docs/agent-steps.mdx +1 -0
  198. package/docs/agents.mdx +7 -3
  199. package/docs/api-testing.mdx +2 -1
  200. package/docs/authentication.mdx +6 -3
  201. package/docs/browser.mdx +49 -12
  202. package/docs/bug-bash.mdx +2 -1
  203. package/docs/cache.mdx +66 -19
  204. package/docs/ci.mdx +14 -9
  205. package/docs/coding-agents.mdx +2 -1
  206. package/docs/core-concepts.mdx +208 -0
  207. package/docs/debugging.mdx +25 -12
  208. package/docs/decision-models.mdx +142 -0
  209. package/docs/example-projects.mdx +32 -0
  210. package/docs/executors.mdx +7 -2
  211. package/docs/explore.mdx +1 -0
  212. package/docs/github.mdx +14 -5
  213. package/docs/index.mdx +9 -5
  214. package/docs/integrations/eas.mdx +1 -0
  215. package/docs/integrations/index.mdx +1 -0
  216. package/docs/integrations/kernel.mdx +3 -2
  217. package/docs/migrate/cypress.mdx +26 -3
  218. package/docs/migrate/detox.mdx +24 -1
  219. package/docs/migrate/maestro.mdx +28 -4
  220. package/docs/migrate/playwright.mdx +44 -18
  221. package/docs/migrate/selenium.mdx +27 -3
  222. package/docs/mobile-ci.mdx +6 -5
  223. package/docs/mobile.mdx +96 -8
  224. package/docs/models.mdx +60 -9
  225. package/docs/quickstart.mdx +28 -16
  226. package/docs/reference/agent.mdx +13 -3
  227. package/docs/reference/app.mdx +2 -1
  228. package/docs/reference/cli.mdx +41 -18
  229. package/docs/reference/config.mdx +67 -13
  230. package/docs/reference/engine.mdx +25 -13
  231. package/docs/reference/environment.mdx +10 -2
  232. package/docs/reference/errors.mdx +13 -10
  233. package/docs/reference/expect.mdx +36 -13
  234. package/docs/reference/mcp.mdx +21 -8
  235. package/docs/reference/mobile.mdx +24 -13
  236. package/docs/reference/reporters.mdx +9 -2
  237. package/docs/reference/screen.mdx +49 -16
  238. package/docs/reference/test.mdx +32 -5
  239. package/docs/reference/web.mdx +133 -23
  240. package/docs/security.mdx +58 -42
  241. package/docs/subscriptions.mdx +92 -12
  242. package/docs/telemetry.mdx +9 -6
  243. package/docs/tools.mdx +1 -0
  244. package/docs/{starting-your-app.mdx → web.mdx} +157 -11
  245. package/docs/writing-an-engine.mdx +2 -1
  246. package/package.json +25 -8
  247. package/schema/report-v1.schema.json +14 -1
  248. package/skills/e2e/SKILL.md +5 -3
  249. package/skills/e2e/references/agent.md +33 -11
  250. package/skills/e2e/references/debugging.md +4 -4
  251. package/skills/e2e/references/mcp.md +8 -3
  252. package/skills/e2e/references/running.md +15 -9
  253. package/skills/e2e/references/setup.md +45 -16
  254. package/skills/e2e/references/writing-tests.md +35 -18
  255. package/docs/viewports.mdx +0 -146
  256. package/docs/writing-tests.mdx +0 -256
@@ -34,10 +34,11 @@ export function bound(text, maxChars) {
34
34
  return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`;
35
35
  }
36
36
  /**
37
- * Cap on recorded end anchors. Anchors are the step's own delta — what
38
- * appeared on screen between the first observation and the passing one — so
39
- * a same-screen mutation rarely has more than a handful; a step that changes
40
- * the whole screen keeps the first few in document order.
37
+ * Cap on recorded anchors, per side of the delta. Anchors are the step's own
38
+ * delta, what appeared on screen between the first observation and the
39
+ * passing one and what vanished, so a same-screen mutation rarely has more
40
+ * than a handful; a step that changes the whole screen keeps the first few,
41
+ * announcements first (`cache/anchors.ts`).
41
42
  */
42
43
  export const MAX_TRACE_ANCHORS = 8;
43
44
  /** Longest a replay waits for the recorded end state to return. */
@@ -66,6 +67,9 @@ function each(items, read) {
66
67
  const SHA256_HEX = /^[a-f0-9]{64}$/u;
67
68
  /** Most twins a positional recording distinguishes between; beyond it a list is data, not a control set. */
68
69
  const MAX_TRACE_POSITION_OF = 1000;
70
+ /** The node states an anchor records: the ones a step sets on a control it leaves on screen. */
71
+ export const TRACE_ANCHOR_STATES = ['checked', 'expanded', 'pressed', 'selected'];
72
+ const isTraceAnchorState = oneOf(TRACE_ANCHOR_STATES);
69
73
  /** The verbs that act on one node and carry no input: tap and its variants, hover, scroll into view. */
70
74
  const NODE_ACTION_NAMES = ['tap', 'doubleTap', 'longPress', 'secondaryTap', 'hover', 'scrollTo'];
71
75
  const isNodeActionName = oneOf(NODE_ACTION_NAMES);
@@ -131,18 +135,13 @@ function readActionTrace(document) {
131
135
  const summary = readBoundedText(raw['summary'], MAX_TRACE_SUMMARY_CHARS);
132
136
  if (summary === undefined)
133
137
  return undefined;
134
- const startPath = raw['startPath'];
135
- if (startPath !== undefined) {
136
- if (typeof startPath !== 'string' || startPath === '' || startPath.length > MAX_TRACE_DESCRIPTOR_CHARS) {
137
- return undefined;
138
- }
139
- }
140
- const endPath = raw['endPath'];
141
- if (endPath !== undefined) {
142
- if (typeof endPath !== 'string' || endPath === '' || endPath.length > MAX_TRACE_DESCRIPTOR_CHARS) {
143
- return undefined;
144
- }
145
- }
138
+ // A location is a replay precondition, kept whole like a typed value.
139
+ const startPath = raw['startPath'] === undefined ? undefined : readInputText(raw['startPath']);
140
+ if (raw['startPath'] !== undefined && startPath === undefined)
141
+ return undefined;
142
+ const endPath = raw['endPath'] === undefined ? undefined : readInputText(raw['endPath']);
143
+ if (raw['endPath'] !== undefined && endPath === undefined)
144
+ return undefined;
146
145
  const truncated = raw['truncated'];
147
146
  if (truncated !== undefined && typeof truncated !== 'boolean')
148
147
  return undefined;
@@ -151,15 +150,12 @@ function readActionTrace(document) {
151
150
  (typeof endWaitMs !== 'number' || !Number.isInteger(endWaitMs) || endWaitMs < 0 || endWaitMs > MAX_TRACE_END_WAIT_MS)) {
152
151
  return undefined;
153
152
  }
154
- const anchorsRaw = raw['endAnchors'];
155
- let endAnchors;
156
- if (anchorsRaw !== undefined) {
157
- if (!Array.isArray(anchorsRaw) || anchorsRaw.length > MAX_TRACE_ANCHORS)
158
- return undefined;
159
- endAnchors = each(anchorsRaw, readDescriptor);
160
- if (endAnchors === undefined)
161
- return undefined;
162
- }
153
+ const endAnchors = readAnchors(raw['endAnchors']);
154
+ if (endAnchors === undefined)
155
+ return undefined;
156
+ const goneAnchors = readAnchors(raw['goneAnchors']);
157
+ if (goneAnchors === undefined)
158
+ return undefined;
163
159
  const actionsRaw = raw['actions'];
164
160
  if (!Array.isArray(actionsRaw) || actionsRaw.length === 0 || actionsRaw.length > MAX_TRACE_ACTIONS) {
165
161
  return undefined;
@@ -177,11 +173,20 @@ function readActionTrace(document) {
177
173
  summary,
178
174
  ...(startPath === undefined ? {} : { startPath }),
179
175
  ...(endPath === undefined ? {} : { endPath }),
180
- ...(endAnchors === undefined || endAnchors.length === 0 ? {} : { endAnchors }),
176
+ ...(endAnchors.length === 0 ? {} : { endAnchors }),
177
+ ...(goneAnchors.length === 0 ? {} : { goneAnchors }),
181
178
  ...(endWaitMs === undefined ? {} : { endWaitMs }),
182
179
  ...(truncated === undefined ? {} : { truncated }),
183
180
  };
184
181
  }
182
+ /** One side of the recorded delta: absent reads as empty, anything malformed or over the cap as undefined. */
183
+ function readAnchors(document) {
184
+ if (document === undefined)
185
+ return [];
186
+ if (!Array.isArray(document) || document.length > MAX_TRACE_ANCHORS)
187
+ return undefined;
188
+ return each(document, readDescriptor);
189
+ }
185
190
  function readRecordedAction(document) {
186
191
  if (typeof document !== 'object' || document === null || Array.isArray(document)) {
187
192
  return undefined;
@@ -198,7 +203,7 @@ function readRecordedAction(document) {
198
203
  switch (name) {
199
204
  case 'type': {
200
205
  const target = readDescriptor(raw['target']);
201
- const value = readInputText(raw['value']);
206
+ const value = readTypedValue(raw['value']);
202
207
  if (target === undefined || value === undefined)
203
208
  return undefined;
204
209
  return { name: 'type', summary, target, value };
@@ -305,7 +310,7 @@ function readRecordedAction(document) {
305
310
  case 'back':
306
311
  return { name: 'back', summary };
307
312
  case 'typeText': {
308
- const value = readInputText(raw['value']);
313
+ const value = readTypedValue(raw['value']);
309
314
  if (value === undefined || typeof raw['replace'] !== 'boolean')
310
315
  return undefined;
311
316
  return { name: 'typeText', summary, value, replace: raw['replace'] };
@@ -370,13 +375,14 @@ export const DESCRIPTOR_FIELDS = [
370
375
  'selector',
371
376
  'inputPurpose',
372
377
  'within',
378
+ 'value',
373
379
  ];
374
380
  /**
375
381
  * The descriptor fields that carry text read off the screen, where a value
376
382
  * a test supplied can reappear. `role`, `selector`, and `inputPurpose` are
377
383
  * vocabulary, never screen text.
378
384
  */
379
- const DESCRIPTOR_TEXT_FIELDS = ['name', 'text', 'testId', 'placeholder', 'within'];
385
+ const DESCRIPTOR_TEXT_FIELDS = ['name', 'text', 'testId', 'placeholder', 'within', 'value'];
380
386
  function mapDescriptorText(descriptor, map) {
381
387
  const changes = {};
382
388
  for (const field of DESCRIPTOR_TEXT_FIELDS) {
@@ -486,6 +492,9 @@ export function mapTraceText(trace, map) {
486
492
  const endAnchors = trace.endAnchors === undefined ? undefined : mapDescriptors(trace.endAnchors, map);
487
493
  if (trace.endAnchors !== undefined && endAnchors === undefined)
488
494
  return undefined;
495
+ const goneAnchors = trace.goneAnchors === undefined ? undefined : mapDescriptors(trace.goneAnchors, map);
496
+ if (trace.goneAnchors !== undefined && goneAnchors === undefined)
497
+ return undefined;
489
498
  return {
490
499
  ...trace,
491
500
  actions,
@@ -493,6 +502,7 @@ export function mapTraceText(trace, map) {
493
502
  ...(startPath === undefined ? {} : { startPath }),
494
503
  ...(endPath === undefined ? {} : { endPath }),
495
504
  ...(endAnchors === undefined ? {} : { endAnchors }),
505
+ ...(goneAnchors === undefined ? {} : { goneAnchors }),
496
506
  };
497
507
  }
498
508
  function mapDescriptors(descriptors, map) {
@@ -521,8 +531,23 @@ function readDescriptor(document) {
521
531
  return undefined;
522
532
  descriptor['position'] = position;
523
533
  }
534
+ if (raw['states'] !== undefined) {
535
+ const states = readStates(raw['states']);
536
+ if (states === undefined)
537
+ return undefined;
538
+ descriptor['states'] = states;
539
+ }
524
540
  return descriptor;
525
541
  }
542
+ /** Known anchor states, each once, in sorted order, as `anchors.ts` writes them; an empty list is never written. */
543
+ function readStates(document) {
544
+ if (!Array.isArray(document) || document.length === 0)
545
+ return undefined;
546
+ const states = each(document, (state) => (isTraceAnchorState(state) ? state : undefined));
547
+ if (states === undefined)
548
+ return undefined;
549
+ return states.every((state, index) => index === 0 || states[index - 1] < state) ? states : undefined;
550
+ }
526
551
  /** A well-formed position: two safe integers with the index inside the count. */
527
552
  function readPosition(document) {
528
553
  if (typeof document !== 'object' || document === null || Array.isArray(document))
@@ -549,7 +574,24 @@ function readProvenance(document) {
549
574
  return undefined;
550
575
  if (typeof instructionDigest !== 'string' || !SHA256_HEX.test(instructionDigest))
551
576
  return undefined;
552
- return { testId, targetId, instructionDigest };
577
+ const paramsDigest = raw['paramsDigest'];
578
+ if (paramsDigest !== undefined && (typeof paramsDigest !== 'string' || !SHA256_HEX.test(paramsDigest)))
579
+ return undefined;
580
+ const callIndex = raw['callIndex'];
581
+ if (callIndex !== undefined && (typeof callIndex !== 'number' || !Number.isSafeInteger(callIndex) || callIndex < 0)) {
582
+ return undefined;
583
+ }
584
+ const agent = raw['agent'] === undefined ? undefined : readBoundedText(raw['agent'], MAX_TRACE_DESCRIPTOR_CHARS);
585
+ if (raw['agent'] !== undefined && agent === undefined)
586
+ return undefined;
587
+ return {
588
+ testId,
589
+ targetId,
590
+ instructionDigest,
591
+ ...(paramsDigest === undefined ? {} : { paramsDigest }),
592
+ ...(callIndex === undefined ? {} : { callIndex }),
593
+ ...(agent === undefined ? {} : { agent }),
594
+ };
553
595
  }
554
596
  function readBoundedText(value, maxChars) {
555
597
  if (typeof value !== 'string' || value.trim() === '' || value.length > maxChars) {
@@ -570,3 +612,7 @@ function readInputText(value) {
570
612
  }
571
613
  return value;
572
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';
@@ -357,22 +358,22 @@ function createProgram(version, telemetry) {
357
358
  });
358
359
  program
359
360
  .command('login')
360
- .summary('sign in to a ChatGPT, GitHub Copilot, or SuperGrok subscription for agent steps')
361
- .description('Sign in once to a personal subscription and store the login for the e2e/oauth models: openai (ChatGPT Plus/Pro, the Codex sign-in), github-copilot (GitHub Copilot; reuses the GitHub CLI login or runs a device flow for your OAuth App), spacexai (SuperGrok or X Premium+, device code). The config then constructs the model with chatgpt(), copilot(), or grok() from e2e/oauth/<provider>.')
362
- .addArgument(new Argument('[provider]', 'openai, github-copilot, or spacexai; omitted, a picker').choices(PROVIDER_IDS))
361
+ .summary('sign in to a ChatGPT, GitHub Copilot, OpenCode Console, or SuperGrok subscription for agent steps')
362
+ .description('Sign in once to a personal subscription and store the login for the e2e/oauth models: openai (ChatGPT Plus/Pro, the Codex sign-in), github-copilot (GitHub Copilot; reuses the GitHub CLI login or runs a device flow for your OAuth App), opencode-console (OpenCode Console, device code), spacexai (SuperGrok or X Premium+, device code). The config then constructs the model with chatgpt(), copilot(), opencodeConsole(), or grok() from e2e/oauth/<provider>.')
363
+ .addArgument(new Argument('[provider]', 'openai, github-copilot, opencode-console, or spacexai; omitted, a picker').choices(PROVIDER_IDS))
363
364
  .option('--device', 'ChatGPT: show a code to enter on another device instead of opening a browser')
364
365
  .option('--client-id <id>', 'GitHub Copilot: the client id of your GitHub OAuth App with the device flow enabled')
365
366
  .option('--from-gh', 'GitHub Copilot: reuse the token of the signed-in GitHub CLI')
366
367
  .option('--enterprise-url <host>', 'GitHub Copilot: the GitHub Enterprise host')
367
- .addHelpText('after', ['', examples(['e2e login openai', 'e2e login github-copilot --from-gh', 'e2e login spacexai', 'e2e login']), '', docsLine('/subscriptions')].join('\n'))
368
+ .addHelpText('after', ['', examples(['e2e login openai', 'e2e login github-copilot --from-gh', 'e2e login opencode-console', 'e2e login spacexai', 'e2e login']), '', docsLine('/subscriptions')].join('\n'))
368
369
  .action(async (provider, options) => {
369
370
  process.exitCode = await runLogin(provider, options);
370
371
  });
371
372
  program
372
373
  .command('logout')
373
374
  .summary('forget a stored subscription login')
374
- .description('Remove the stored login of one provider (openai, github-copilot, spacexai). Without a provider, a picker over the stored logins.')
375
- .addArgument(new Argument('[provider]', 'openai, github-copilot, or spacexai; omitted, a picker').choices(PROVIDER_IDS))
375
+ .description('Remove the stored login of one provider (openai, github-copilot, opencode-console, spacexai). Without a provider, a picker over the stored logins.')
376
+ .addArgument(new Argument('[provider]', 'openai, github-copilot, opencode-console, or spacexai; omitted, a picker').choices(PROVIDER_IDS))
376
377
  .addHelpText('after', ['', examples(['e2e logout', 'e2e logout openai']), '', docsLine('/subscriptions')].join('\n'))
377
378
  .action(async (provider) => {
378
379
  process.exitCode = await runLogout(provider);
@@ -380,8 +381,8 @@ function createProgram(version, telemetry) {
380
381
  program
381
382
  .command('models')
382
383
  .summary('list the models a stored subscription login serves')
383
- .description('Ask the vendor which models the stored login serves and print their ids, the ones chatgpt(), copilot(), and grok() take. Without a provider, every stored login in turn.')
384
- .addArgument(new Argument('[provider]', 'openai, github-copilot, or spacexai; omitted, every stored login').choices(PROVIDER_IDS))
384
+ .description('Ask the vendor which models the stored login serves and print their ids, the ones chatgpt(), copilot(), opencodeConsole(), and grok() take. Without a provider, every stored login in turn.')
385
+ .addArgument(new Argument('[provider]', 'openai, github-copilot, opencode-console, or spacexai; omitted, every stored login').choices(PROVIDER_IDS))
385
386
  .addHelpText('after', ['', examples(['e2e models', 'e2e models openai']), '', docsLine('/subscriptions#pick-a-model')].join('\n'))
386
387
  .action(async (provider) => {
387
388
  process.exitCode = await runModels(provider);
@@ -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')
@@ -16,9 +16,7 @@ export declare function getEnginePresets(): readonly [{
16
16
  readonly id: 'web';
17
17
  readonly label: 'Web';
18
18
  readonly hint: 'Playwright';
19
- readonly dependencies: {
20
- readonly playwright: string;
21
- };
19
+ readonly dependencies: Readonly<Record<string, string>>;
22
20
  readonly imports: readonly ["import { web } from '@e2e-dev/web';"];
23
21
  readonly config: ` targets: [{
24
22
  engine: web(),
@@ -1,6 +1,6 @@
1
1
  /** CLI scaffold presets. Engine packages are referenced as generated source, never imported. */
2
2
  import os from 'node:os';
3
- import { playwrightRange, siblingDependency, SIBLING_VERSIONS } from './versions.js';
3
+ import { siblingDependency } from './versions.js';
4
4
  /** Builds prompt choices and scaffolds with defaults for the machine running init. */
5
5
  export function getEnginePresets() {
6
6
  const ios = os.platform() === 'darwin';
@@ -9,7 +9,7 @@ export function getEnginePresets() {
9
9
  id: 'web',
10
10
  label: 'Web',
11
11
  hint: 'Playwright',
12
- dependencies: { ...siblingDependency('@e2e-dev/web'), playwright: playwrightRange(SIBLING_VERSIONS?.['playwright']) },
12
+ dependencies: siblingDependency('@e2e-dev/web'),
13
13
  imports: ["import { web } from '@e2e-dev/web';"],
14
14
  config: ` targets: [{
15
15
  engine: web(),
@@ -4,7 +4,7 @@
4
4
  * of them and takes the constructed instance. Adding a gateway is adding an
5
5
  * entry here.
6
6
  */
7
- export type GatewayId = 'vercel' | 'openrouter' | 'openai-compatible' | 'chatgpt' | 'copilot' | 'grok';
7
+ export type GatewayId = 'vercel' | 'openrouter' | 'openai-compatible' | 'chatgpt' | 'copilot' | 'opencode-console' | 'grok';
8
8
  export interface GatewayPreset {
9
9
  readonly id: GatewayId;
10
10
  readonly label: string;
@@ -50,12 +50,22 @@ export const GATEWAYS = [
50
50
  id: 'copilot',
51
51
  label: 'GitHub Copilot subscription',
52
52
  hint: 'your Copilot plan: OpenAI, Anthropic, Google, and SpaceXAI models',
53
- dependencies: { '@ai-sdk/openai-compatible': '^3.0.0' },
53
+ dependencies: { '@ai-sdk/openai-compatible': '^3.0.0', '@ai-sdk/openai': '^4.0.0' },
54
54
  import: "import { copilot } from 'e2e/oauth/copilot';",
55
55
  comment: 'Your GitHub Copilot subscription serves the model; sign in once with `e2e login github-copilot`, `e2e models github-copilot` lists the ids.',
56
56
  login: 'github-copilot',
57
57
  model: () => "copilot('claude-sonnet-5')",
58
58
  },
59
+ {
60
+ id: 'opencode-console',
61
+ label: 'OpenCode Console',
62
+ hint: 'the models your OpenCode Console workspace serves',
63
+ dependencies: { '@ai-sdk/openai-compatible': '^3.0.0', '@ai-sdk/openai': '^4.0.0', '@ai-sdk/anthropic': '^4.0.0', '@ai-sdk/google': '^4.0.0' },
64
+ import: "import { opencodeConsole } from 'e2e/oauth/opencode-console';",
65
+ comment: 'Your OpenCode Console workspace serves the model; sign in once with `e2e login opencode-console`, `e2e models opencode-console` lists the ids.',
66
+ login: 'opencode-console',
67
+ model: () => "opencodeConsole('deepseek-v4.1-flash')",
68
+ },
59
69
  {
60
70
  id: 'grok',
61
71
  label: 'SuperGrok subscription',
@@ -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.11.2","@e2e-dev/mobile":"0.9.1","playwright":"1.63.0"}
1
+ {"@e2e-dev/web":"0.13.0","@e2e-dev/mobile":"0.10.0"}
@@ -3,7 +3,6 @@
3
3
  * build in `sibling-versions.json` next to this module from their manifests.
4
4
  * Absent when running from source. Both preset tables read from here.
5
5
  */
6
- export declare const SIBLING_VERSIONS: Readonly<Record<string, string>> | undefined;
7
6
  /**
8
7
  * The range init writes for the runner and for an engine. Engines version
9
8
  * independently of the runner and pin it through their own peer range, so init
@@ -20,10 +19,3 @@ export declare const SIBLING_VERSIONS: Readonly<Record<string, string>> | undefi
20
19
  export declare function dependencyRange(version: string | undefined): string;
21
20
  /** The range init writes for a sibling `@e2e-dev/*` package released alongside this runner. */
22
21
  export declare function siblingDependency(name: string): Readonly<Record<string, string>>;
23
- /**
24
- * The range init writes for `playwright`, which `@e2e-dev/web` peers on
25
- * rather than installs, so an app that already ships Playwright keeps one copy
26
- * and one browser cache. The build records the version the engine was built
27
- * and tested against; from source, where nothing is recorded, any 1.x will do.
28
- */
29
- export declare function playwrightRange(version: string | undefined): string;
@@ -4,7 +4,7 @@
4
4
  * Absent when running from source. Both preset tables read from here.
5
5
  */
6
6
  import { readJson } from '../../internal/package-version.js';
7
- export const SIBLING_VERSIONS = readJson(import.meta.url, './sibling-versions.json');
7
+ const SIBLING_VERSIONS = readJson(import.meta.url, './sibling-versions.json');
8
8
  /**
9
9
  * The range init writes for the runner and for an engine. Engines version
10
10
  * independently of the runner and pin it through their own peer range, so init
@@ -27,12 +27,3 @@ export function dependencyRange(version) {
27
27
  export function siblingDependency(name) {
28
28
  return { [name]: dependencyRange(SIBLING_VERSIONS?.[name]) };
29
29
  }
30
- /**
31
- * The range init writes for `playwright`, which `@e2e-dev/web` peers on
32
- * rather than installs, so an app that already ships Playwright keeps one copy
33
- * and one browser cache. The build records the version the engine was built
34
- * and tested against; from source, where nothing is recorded, any 1.x will do.
35
- */
36
- export function playwrightRange(version) {
37
- return version === undefined ? '^1' : `^${version}`;
38
- }
package/dist/cli/mcp.d.ts CHANGED
@@ -3,14 +3,15 @@
3
3
  * stdio, stdout is the JSON-RPC stream, so anything else that writes there
4
4
  * (a stray console.log, a library banner) would corrupt the handshake. The
5
5
  * transport keeps the process's own stdout writer, and everything else that
6
- * reaches process.stdout is redirected to stderr for the whole process.
6
+ * reaches process.stdout or process.stderr goes to stderr, redacted, for the
7
+ * whole process: user code runs here and may print a secret.
7
8
  */
8
9
  import { type ServeOptions } from '../mcp/server.ts';
9
10
  import type { Telemetry } from '../telemetry/telemetry.ts';
10
11
  export interface McpCommandOptions {
11
12
  config?: string | undefined;
12
13
  target?: string | undefined;
13
- headless?: boolean | undefined;
14
+ headed?: boolean | undefined;
14
15
  maxSessions?: number | undefined;
15
16
  }
16
17
  /**
package/dist/cli/mcp.js CHANGED
@@ -3,30 +3,48 @@
3
3
  * stdio, stdout is the JSON-RPC stream, so anything else that writes there
4
4
  * (a stray console.log, a library banner) would corrupt the handshake. The
5
5
  * transport keeps the process's own stdout writer, and everything else that
6
- * reaches process.stdout is redirected to stderr for the whole process.
6
+ * reaches process.stdout or process.stderr goes to stderr, redacted, for the
7
+ * whole process: user code runs here and may print a secret.
7
8
  */
8
9
  import { Writable } from 'node:stream';
9
- import { isCiMode } from '../config/resolve.js';
10
10
  import { errorMessage, exitCodeForCategory, classifyError } from '../internal/errors.js';
11
+ import { McpOutput } from '../mcp/output.js';
11
12
  import { serveMcp } from '../mcp/server.js';
13
+ import { processSecrets } from '../run/secrecy.js';
12
14
  import { mcpSessionEvent } from '../telemetry/events.js';
13
15
  /**
14
- * Diverts every later `process.stdout` write to stderr and returns a stream
15
- * that still reaches the real stdout. The protocol stream wraps the original
16
- * writer rather than opening file descriptor 1 again: once Node has set up
16
+ * Takes over the process's stdout and stderr: the returned protocol stream
17
+ * still reaches the real stdout, and every other write to either stream goes
18
+ * through `output` to stderr. The protocol stream wraps the original writer
19
+ * rather than opening file descriptor 1 again: once Node has set up
17
20
  * `process.stdout` on a pipe, the descriptor is non-blocking, and a plain
18
21
  * `fs` write to it fails with EAGAIN as soon as the client reads slower than
19
22
  * the server writes (a screenshot result), killing the server. The socket
20
23
  * writer queues and retries instead.
21
24
  */
22
- function claimStdout() {
25
+ function claimStdio() {
23
26
  const stdout = process.stdout;
24
27
  const stderr = process.stderr;
25
- const write = stdout.write.bind(stdout);
26
- stdout.write = ((chunk, encoding, callback) => stderr.write(chunk, encoding, callback));
28
+ const writeStdout = stdout.write.bind(stdout);
29
+ const writeStderr = stderr.write.bind(stderr);
30
+ const output = new McpOutput(writeStderr, processSecrets);
31
+ for (const [stream, name] of [
32
+ [stdout, 'stdout'],
33
+ [stderr, 'stderr'],
34
+ ]) {
35
+ stream.write = ((chunk, encoding, callback) => {
36
+ const done = typeof encoding === 'function' ? encoding : callback;
37
+ const text = typeof chunk === 'string' && typeof encoding === 'string' ? Buffer.from(chunk, encoding) : chunk;
38
+ const accepted = output.write(name, text, done);
39
+ // stderr drains on its own; a writer waiting on stdout is told when stderr has.
40
+ if (!accepted && stream === stdout)
41
+ stderr.once('drain', () => stdout.emit('drain'));
42
+ return accepted;
43
+ });
44
+ }
27
45
  const protocol = new Writable({
28
46
  write(chunk, _encoding, callback) {
29
- write(chunk, callback);
47
+ writeStdout(chunk, callback);
30
48
  },
31
49
  });
32
50
  // A client that went away fails the pipe with EPIPE; the protocol stream
@@ -36,7 +54,7 @@ function claimStdout() {
36
54
  // before its app processes stop.
37
55
  stdout.on('error', (error) => protocol.destroy(error));
38
56
  stderr.on('error', () => undefined);
39
- return protocol;
57
+ return { protocol, output };
40
58
  }
41
59
  /**
42
60
  * Records each MCP session as it ends and sends it at once: a server lives
@@ -51,34 +69,52 @@ export function sessionTelemetry(telemetry) {
51
69
  }
52
70
  /** Runs the server until the client disconnects or a signal arrives; returns the exit code. */
53
71
  export async function mcp(version, options, telemetry) {
54
- const stdout = claimStdout();
72
+ const { protocol, output } = claimStdio();
55
73
  const stop = new AbortController();
56
74
  const onSignal = () => stop.abort();
75
+ // Node would print an error nobody caught (a project tool's unawaited
76
+ // promise) straight to the stderr descriptor, past the redaction, and
77
+ // exit with every session's app still running. It is logged redacted, and
78
+ // held like user output while a config loads, and the server shuts down as
79
+ // on a signal.
80
+ let crashed = false;
81
+ const onUncaught = (cause) => {
82
+ crashed = true;
83
+ const text = cause instanceof Error ? (cause.stack ?? errorMessage(cause)) : errorMessage(cause);
84
+ output.report(`e2e mcp: [error] uncaught: ${text}`);
85
+ stop.abort();
86
+ };
57
87
  process.once('SIGINT', onSignal);
58
88
  process.once('SIGTERM', onSignal);
89
+ // A rejection nobody handled arrives here too: with no `unhandledRejection` listener, Node raises it as uncaught.
90
+ process.on('uncaughtException', onUncaught);
59
91
  try {
60
- return await serveMcp({
92
+ const code = await serveMcp({
61
93
  cwd: process.cwd(),
62
94
  configPath: options.config,
63
95
  target: options.target,
64
- headed: options.headless !== true && !isCiMode(process.env),
96
+ headed: options.headed === true,
65
97
  maxSessions: options.maxSessions,
66
98
  env: process.env,
67
99
  version,
68
100
  stdin: process.stdin,
69
- stdout,
70
- log: (line) => process.stderr.write(`e2e mcp: ${line}\n`),
101
+ stdout: protocol,
102
+ log: (line) => output.log(`e2e mcp: ${line}`),
103
+ output,
71
104
  signal: stop.signal,
72
105
  onSessionEnd: sessionTelemetry(telemetry),
73
106
  });
107
+ return crashed ? 1 : code;
74
108
  }
75
109
  catch (cause) {
76
110
  const error = classifyError(cause);
77
- process.stderr.write(`e2e mcp: ${error.code}: ${errorMessage(cause)}\n`);
111
+ output.log(`e2e mcp: ${error.code}: ${processSecrets.redact(errorMessage(cause))}`);
78
112
  return exitCodeForCategory(error.category);
79
113
  }
80
114
  finally {
115
+ output.end();
81
116
  process.off('SIGINT', onSignal);
82
117
  process.off('SIGTERM', onSignal);
118
+ process.off('uncaughtException', onUncaught);
83
119
  }
84
120
  }