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
@@ -12,7 +12,7 @@ import { obj } from '../internal/objects.js';
12
12
  import { isTextMatch, normalizeText } from '../internal/text.js';
13
13
  import { attributeOf, denySecureRead, isNodeVisible, locatorDetails } from './engine.js';
14
14
  import { describeExpression, filterExpression, indexExpression, roleQuery, testIdQuery, textQuery, } from './expression.js';
15
- import { POLL_INTERVAL_MS, pollCondition, sleep } from '../internal/time.js';
15
+ import { cutOffAtDeadline, POLL_INTERVAL_MS, pollCondition, sleep } from '../internal/time.js';
16
16
  /**
17
17
  * Internals hang off the locator object itself under a global symbol so that
18
18
  * an expect() imported in an isolated test-module realm can still reach them.
@@ -55,6 +55,27 @@ const TEXT_OPTION_KEYS = ['exact', 'visible'];
55
55
  const ROLE_FILTER_KEYS = [...TEXT_OPTION_KEYS, 'checked', 'disabled', 'selected', 'expanded', 'pressed', 'level'];
56
56
  /** The keys of `RoleOptions`: the accessible name and the filters. */
57
57
  const ROLE_OPTION_KEYS = ['name', ...ROLE_FILTER_KEYS];
58
+ /** The states `locator.waitFor` waits for, Playwright's four. */
59
+ const WAIT_FOR_STATES = ['attached', 'detached', 'visible', 'hidden'];
60
+ /** The states absence satisfies: a frame missing from the document reads as zero matches for them. */
61
+ const ABSENCE_STATES = new Set(['detached', 'hidden']);
62
+ /**
63
+ * Whether the one match, or its absence, is in `state`: `attached` is any
64
+ * match, `detached` none, `visible` a match that is not hidden, and `hidden`
65
+ * a hidden match or none.
66
+ */
67
+ function inWaitForState(node, state) {
68
+ switch (state) {
69
+ case 'attached':
70
+ return node !== null;
71
+ case 'detached':
72
+ return node === null;
73
+ case 'visible':
74
+ return isNodeVisible(node);
75
+ case 'hidden':
76
+ return !isNodeVisible(node);
77
+ }
78
+ }
58
79
  class ScreenImpl {
59
80
  context;
60
81
  scope;
@@ -106,10 +127,15 @@ class ScreenImpl {
106
127
  }
107
128
  async swipe(options) {
108
129
  if (isSwipePath(options)) {
109
- rejectUnknownOptions('swipe', options, ['from', 'to']);
130
+ rejectUnknownOptions('swipe', options, ['from', 'to', 'duration']);
110
131
  const from = requirePoint(options.from, 'swipe from');
111
132
  const to = requirePoint(options.to, 'swipe to');
112
- await this.context.steps.run('screen', 'screen.swipe', `${describePoint(from)} → ${describePoint(to)}`, () => this.context.engine.performAt(from, { kind: 'swipeTo', target: to }));
133
+ const durationMs = validateSwipeDuration(options.duration);
134
+ await this.context.steps.run('screen', 'screen.swipe', `${describePoint(from)} → ${describePoint(to)}`, () => this.context.engine.performAt(from, obj({
135
+ kind: 'swipeTo',
136
+ target: to,
137
+ durationMs,
138
+ })));
113
139
  return;
114
140
  }
115
141
  rejectUnknownOptions('swipe', options, ['direction', 'momentum']);
@@ -139,8 +165,19 @@ class ScreenImpl {
139
165
  details: locatorDetails(internals.expression, Date.now() - startedMs),
140
166
  ...(cause === undefined ? {} : { cause }),
141
167
  });
168
+ let sampled = false;
142
169
  for (;;) {
143
- const { node } = await engine.tryRead(internals.expression, deadline);
170
+ const startedWithMs = deadline.remaining();
171
+ let node;
172
+ try {
173
+ ({ node } = await engine.tryRead(internals.expression, deadline));
174
+ }
175
+ catch (cause) {
176
+ if (sampled && cutOffAtDeadline(cause, startedWithMs))
177
+ throw notVisible(cause);
178
+ throw cause;
179
+ }
180
+ sampled = true;
144
181
  if (isNodeVisible(node))
145
182
  return;
146
183
  if (deadline.expired())
@@ -361,7 +398,11 @@ class LocatorImpl extends ScreenImpl {
361
398
  return nodes.map((node) => normalizeText(node.text ?? ''));
362
399
  }
363
400
  async waitFor(options) {
364
- const state = options?.state ?? 'visible';
401
+ rejectUnknownOptions('waitFor', options, ['state', 'timeout']);
402
+ const state = options?.state === undefined ? 'visible' : options.state;
403
+ if (!WAIT_FOR_STATES.includes(state)) {
404
+ throw new TestError('INVALID_ARGUMENT', `waitFor state must be one of ${WAIT_FOR_STATES.join(', ')}, got ${JSON.stringify(state)}`);
405
+ }
365
406
  await this.context.steps.run('locator', 'locator.waitFor', `${this.label} → ${state}`, async () => {
366
407
  const { engine } = this.context;
367
408
  const deadline = engine.deadline(options?.timeout);
@@ -371,12 +412,12 @@ class LocatorImpl extends ScreenImpl {
371
412
  signal: engine.signal,
372
413
  negated: false,
373
414
  evaluate: async () => {
374
- const { node } = await engine.tryRead(this.expression, deadline);
375
- const visible = isNodeVisible(node);
376
- return state === 'visible' ? visible : !visible;
415
+ const { node } = await engine.tryRead(this.expression, deadline, ABSENCE_STATES.has(state) ? 'empty' : 'wait');
416
+ return inWaitForState(node, state);
377
417
  },
378
- onTimeout: () => new TestError('LOCATOR_NOT_FOUND', `locator did not become ${state}: ${this.label}`, {
418
+ onTimeout: (cause) => new TestError('LOCATOR_NOT_FOUND', `locator did not become ${state}: ${this.label}`, {
379
419
  details: locatorDetails(this.expression, Date.now() - startedMs),
420
+ ...(cause === undefined ? {} : { cause }),
380
421
  }),
381
422
  });
382
423
  }, { verifies: true });
@@ -461,6 +502,15 @@ function validateLongPress(durationMs) {
461
502
  }
462
503
  return durationMs;
463
504
  }
505
+ /** Validates the shared swipe duration bound; none named leaves the gesture to the engine's default. */
506
+ function validateSwipeDuration(duration) {
507
+ if (duration === undefined)
508
+ return undefined;
509
+ if (!Number.isInteger(duration) || duration < 16 || duration > 10_000) {
510
+ throw new TestError('INVALID_ARGUMENT', `swipe duration must be an integer from 16 through 10000, got ${String(duration)}`);
511
+ }
512
+ return duration;
513
+ }
464
514
  /**
465
515
  * The `modifiers` of a click, validated: a list of distinct key modifiers,
466
516
  * spread into the action only when it names one.
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import type { ToolSet } from 'ai';
14
14
  import type { StepExecutorContext } from '../agent/executor.ts';
15
+ import { type NodeRedaction } from '../agent/observation.ts';
15
16
  import type { ScreenPresenter } from '../agent/screen-update.ts';
16
17
  import type { LocatorExpression, SemanticNode, TargetSession } from '../engine/surface.ts';
17
18
  import type { LocatorEngine } from '../locator/engine.ts';
@@ -35,7 +36,7 @@ export interface CatalogOptions {
35
36
  /** The agent's project tools (`agents.<name>.tools`), served beside the built-in ones. */
36
37
  readonly tools: Readonly<Record<string, AgentTool>>;
37
38
  /** The attempt's secret ledger: what `locate` shows of a node passes through it, as `observe` does. */
38
- readonly redact: (text: string) => string;
39
+ readonly redaction: NodeRedaction;
39
40
  /** The session's recorder, when the engine records video. */
40
41
  readonly recorder: SessionRecorder | undefined;
41
42
  readonly warn: (message: string) => void;
@@ -60,4 +61,4 @@ export interface LocateQuery {
60
61
  /** Builds the expression and the matching `screen.*` call from the tool arguments. */
61
62
  export declare function locateQuery(args: LocateArgs): LocateQuery;
62
63
  /** Renders a locate result: the count, the verdict a test would get, and the nodes, each through the attempt's redactor. */
63
- export declare function describeLocate(query: LocateQuery, count: number, nodes: readonly SemanticNode[], redact: (text: string) => string): string;
64
+ export declare function describeLocate(query: LocateQuery, count: number, nodes: readonly SemanticNode[], redaction: NodeRedaction): string;
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { z } from 'zod';
14
14
  import { projectTools } from '../agent/default-agent.js';
15
- import { projectTree } from '../agent/observation.js';
15
+ import { projectTree, redactNode } from '../agent/observation.js';
16
16
  import { GRAMMAR_TOOL_NAMES } from '../agent/action-names.js';
17
17
  import { createGrammarTools } from '../agent/primitives.js';
18
18
  import { ConfigurationError } from '../internal/errors.js';
@@ -34,7 +34,7 @@ export function createSessionCatalog(options) {
34
34
  const builtIn = {
35
35
  observe: fullObserveTool(context, screen),
36
36
  ...verbs,
37
- locate: locateTool(options.locator, options.session, options.redact),
37
+ locate: locateTool(options.locator, options.session, options.redaction),
38
38
  ...recording,
39
39
  };
40
40
  const defined = options.tools;
@@ -65,7 +65,7 @@ function fullObserveTool(context, screen) {
65
65
  };
66
66
  }
67
67
  /** The session's `locate`: a semantic locator tried against the live screen, with the verdict a test would get. */
68
- function locateTool(locator, session, redact) {
68
+ function locateTool(locator, session, redaction) {
69
69
  return {
70
70
  description: 'Try a semantic locator against the live screen before writing it into a test: screen.getByRole(role, name), getByText, getByLabel, getByPlaceholder, or getByTestId. Returns how many nodes match and which, plus the test code to use. Exactly one of role, text, label, placeholder, or testId; name narrows a role query. Matching is exact unless exact is false.',
71
71
  inputSchema: z.object({
@@ -84,7 +84,7 @@ function locateTool(locator, session, redact) {
84
84
  for (const ref of refs.slice(0, MAX_LOCATE_NODES)) {
85
85
  read.push(await session.read(ref, locator.operation()));
86
86
  }
87
- return describeLocate(query, refs.length, read, redact);
87
+ return describeLocate(query, refs.length, read, redaction);
88
88
  },
89
89
  };
90
90
  }
@@ -115,7 +115,7 @@ export function locateQuery(args) {
115
115
  return { expression: textQuery('placeholder', placeholder, { exact }, undefined), code: `screen.getByPlaceholder(${JSON.stringify(placeholder)}${options})` };
116
116
  }
117
117
  /** Renders a locate result: the count, the verdict a test would get, and the nodes, each through the attempt's redactor. */
118
- export function describeLocate(query, count, nodes, redact) {
118
+ export function describeLocate(query, count, nodes, redaction) {
119
119
  const lines = [`${count === 1 ? '1 node matches' : `${count} nodes match`} ${describeExpression(query.expression)}.`];
120
120
  if (count === 1)
121
121
  lines.push(`Use: ${query.code}`);
@@ -124,7 +124,7 @@ export function describeLocate(query, count, nodes, redact) {
124
124
  else
125
125
  lines.push(`A test action on ${query.code} would fail with LOCATOR_AMBIGUOUS. Narrow it with a name, getByRole(role, name), .filter({ hasText }), .first(), or .nth(i), or scope it under a container.`);
126
126
  for (const node of nodes)
127
- lines.push(`- ${describeNode(projectTree(node, redact))}`);
127
+ lines.push(`- ${describeNode(projectTree(redactNode(node, redaction)))}`);
128
128
  if (count > nodes.length)
129
129
  lines.push(`- and ${count - nodes.length} more`);
130
130
  return lines.join('\n');
@@ -0,0 +1,59 @@
1
+ /**
2
+ * What the `e2e mcp` process prints besides the protocol. User code runs in
3
+ * this process (the config's top-level code, project tools, engines), and
4
+ * what it writes to stdout or stderr reaches the operator's stderr redacted
5
+ * with every secret value the process knows, as a test's output leaves an
6
+ * `e2e run` worker. The server's own diagnostics are redacted where they are
7
+ * written and pass as they are.
8
+ */
9
+ import { type SecretLedger } from '../internal/redact.ts';
10
+ export type OutputStream = 'stdout' | 'stderr';
11
+ /** Writes text to the operator's stderr; the callback fires once the stream took it. Answers false while the stream is backed up, like `Writable.write`. */
12
+ export type OutputSink = (text: string, callback?: () => void) => boolean;
13
+ export declare class McpOutput {
14
+ private readonly sink;
15
+ /** Each stream's bytes decoded on their own, so a character split across writes stays whole. */
16
+ private readonly decoders;
17
+ /** One redactor for both streams: they land on one stderr, where a value split across them reads whole. */
18
+ private readonly redactor;
19
+ /** Config loads in flight: until each ends, what anyone prints is held. */
20
+ private loading;
21
+ /** Whether a load in flight failed, so its secrets are unknown and what is held cannot be redacted. */
22
+ private loadFailed;
23
+ /** Tool calls in flight: a held tail is released only once none is, since any of them may be partway through a value. */
24
+ private calls;
25
+ private held;
26
+ /** Whether the last text passed on ended its line. */
27
+ private atLineStart;
28
+ constructor(sink: OutputSink, ledger: SecretLedger);
29
+ /**
30
+ * One write of user code to `stream`, redacted across writes: the tail a
31
+ * later write could complete into a value waits for it, or for the end of
32
+ * the tool calls in flight. Held while a config loads. Answers what the
33
+ * sink did.
34
+ */
35
+ write(stream: OutputStream, chunk: string | Uint8Array, callback?: () => void): boolean;
36
+ /** One line of the server's own, already redacted where it was written; on a line of its own even when user code left one unfinished. */
37
+ log(line: string): void;
38
+ /** A line of the server's that carries user text (an error nobody caught): redacted and held like what user code writes. */
39
+ report(line: string): void;
40
+ /**
41
+ * Runs `load`, a config load that registers the config's secrets, holding
42
+ * what anyone prints until it ends: the config's top-level code may print
43
+ * a value before the process knows it is one. Released redacted once every
44
+ * load in flight succeeded; withheld when one failed, since the secrets of
45
+ * a config that did not load are unknown.
46
+ */
47
+ withholdDuring<T>(load: () => Promise<T>): Promise<T>;
48
+ /**
49
+ * Runs one tool call. Once no call is in flight, the tail held for a later
50
+ * write is released, redacted: user code that printed without a newline is
51
+ * seen when its call ends, not when the server exits.
52
+ */
53
+ duringCall<T>(call: () => Promise<T>): Promise<T>;
54
+ /** Releases what is still held, redacted; nothing more will follow. Output held for a load still in flight is withheld. */
55
+ end(): void;
56
+ private releaseHeld;
57
+ private withhold;
58
+ private emit;
59
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * What the `e2e mcp` process prints besides the protocol. User code runs in
3
+ * this process (the config's top-level code, project tools, engines), and
4
+ * what it writes to stdout or stderr reaches the operator's stderr redacted
5
+ * with every secret value the process knows, as a test's output leaves an
6
+ * `e2e run` worker. The server's own diagnostics are redacted where they are
7
+ * written and pass as they are.
8
+ */
9
+ import { StringDecoder } from 'node:string_decoder';
10
+ import { StreamRedactor } from '../internal/redact.js';
11
+ export class McpOutput {
12
+ sink;
13
+ /** Each stream's bytes decoded on their own, so a character split across writes stays whole. */
14
+ decoders = {
15
+ stdout: new StringDecoder('utf8'),
16
+ stderr: new StringDecoder('utf8'),
17
+ };
18
+ /** One redactor for both streams: they land on one stderr, where a value split across them reads whole. */
19
+ redactor;
20
+ /** Config loads in flight: until each ends, what anyone prints is held. */
21
+ loading = 0;
22
+ /** Whether a load in flight failed, so its secrets are unknown and what is held cannot be redacted. */
23
+ loadFailed = false;
24
+ /** Tool calls in flight: a held tail is released only once none is, since any of them may be partway through a value. */
25
+ calls = 0;
26
+ held = [];
27
+ /** Whether the last text passed on ended its line. */
28
+ atLineStart = true;
29
+ constructor(sink, ledger) {
30
+ this.sink = sink;
31
+ this.redactor = new StreamRedactor(ledger);
32
+ }
33
+ /**
34
+ * One write of user code to `stream`, redacted across writes: the tail a
35
+ * later write could complete into a value waits for it, or for the end of
36
+ * the tool calls in flight. Held while a config loads. Answers what the
37
+ * sink did.
38
+ */
39
+ write(stream, chunk, callback) {
40
+ const text = typeof chunk === 'string' ? chunk : this.decoders[stream].write(chunk);
41
+ if (this.loading > 0) {
42
+ this.held.push(text);
43
+ if (callback !== undefined)
44
+ process.nextTick(callback);
45
+ return true;
46
+ }
47
+ return this.emit(this.redactor.push(text), callback);
48
+ }
49
+ /** One line of the server's own, already redacted where it was written; on a line of its own even when user code left one unfinished. */
50
+ log(line) {
51
+ this.emit(`${this.atLineStart ? '' : '\n'}${line}\n`);
52
+ }
53
+ /** A line of the server's that carries user text (an error nobody caught): redacted and held like what user code writes. */
54
+ report(line) {
55
+ this.write('stderr', `${this.atLineStart ? '' : '\n'}${line}\n`);
56
+ }
57
+ /**
58
+ * Runs `load`, a config load that registers the config's secrets, holding
59
+ * what anyone prints until it ends: the config's top-level code may print
60
+ * a value before the process knows it is one. Released redacted once every
61
+ * load in flight succeeded; withheld when one failed, since the secrets of
62
+ * a config that did not load are unknown.
63
+ */
64
+ async withholdDuring(load) {
65
+ this.loading += 1;
66
+ try {
67
+ return await load();
68
+ }
69
+ catch (cause) {
70
+ this.loadFailed = true;
71
+ throw cause;
72
+ }
73
+ finally {
74
+ this.loading -= 1;
75
+ if (this.loading === 0)
76
+ this.releaseHeld();
77
+ }
78
+ }
79
+ /**
80
+ * Runs one tool call. Once no call is in flight, the tail held for a later
81
+ * write is released, redacted: user code that printed without a newline is
82
+ * seen when its call ends, not when the server exits.
83
+ */
84
+ async duringCall(call) {
85
+ this.calls += 1;
86
+ try {
87
+ return await call();
88
+ }
89
+ finally {
90
+ this.calls -= 1;
91
+ if (this.calls === 0 && this.loading === 0)
92
+ this.emit(this.redactor.flush());
93
+ }
94
+ }
95
+ /** Releases what is still held, redacted; nothing more will follow. Output held for a load still in flight is withheld. */
96
+ end() {
97
+ this.write('stdout', this.decoders.stdout.end());
98
+ this.write('stderr', this.decoders.stderr.end());
99
+ if (this.loading > 0)
100
+ this.withhold();
101
+ this.emit(this.redactor.flush());
102
+ }
103
+ releaseHeld() {
104
+ if (this.loadFailed) {
105
+ this.withhold();
106
+ return;
107
+ }
108
+ const held = this.held;
109
+ this.held = [];
110
+ for (const text of held)
111
+ this.emit(this.redactor.push(text));
112
+ }
113
+ withhold() {
114
+ const bytes = this.held.reduce((total, text) => total + Buffer.byteLength(text), 0);
115
+ this.held = [];
116
+ this.loadFailed = false;
117
+ if (bytes > 0)
118
+ this.log(`e2e mcp: [warning] withheld ${bytes} bytes of output printed while a config failed to load: its secrets are unknown, so the output cannot be redacted`);
119
+ }
120
+ emit(text, callback) {
121
+ if (text === '') {
122
+ if (callback !== undefined)
123
+ process.nextTick(callback);
124
+ return true;
125
+ }
126
+ this.atLineStart = text.endsWith('\n');
127
+ return this.sink(text, callback);
128
+ }
129
+ }
@@ -7,6 +7,7 @@
7
7
  * it the process's and a test hands it pipes.
8
8
  */
9
9
  import type { Readable, Writable } from 'node:stream';
10
+ import type { McpOutput } from './output.ts';
10
11
  import type { McpSessionSummary } from './usage.ts';
11
12
  /** The client as it named itself in `initialize`. */
12
13
  export interface McpClient {
@@ -19,6 +20,7 @@ export interface ServeOptions {
19
20
  readonly configPath?: string | undefined;
20
21
  /** The target every session opens on; otherwise a call names one, or the only one is used. */
21
22
  readonly target?: string | undefined;
23
+ /** Whether a session shows its UI when `open_session` does not set `headed`. */
22
24
  readonly headed: boolean;
23
25
  /** How many sessions may be open at once, from `--max-sessions`. */
24
26
  readonly maxSessions?: number | undefined;
@@ -29,6 +31,8 @@ export interface ServeOptions {
29
31
  readonly stdout: Writable;
30
32
  /** Diagnostics for the operator, normally stderr. */
31
33
  readonly log: (line: string) => void;
34
+ /** What user code in this process prints: held while a config loads, and its unfinished lines released once no tool call is in flight. */
35
+ readonly output?: Pick<McpOutput, 'withholdDuring' | 'duringCall'> | undefined;
32
36
  /** Ends the server from outside: a process signal. */
33
37
  readonly signal?: AbortSignal | undefined;
34
38
  /** Told once per `open_session`, when its session closes or its open fails, with the client that asked; undefined before `initialize`. */
@@ -10,11 +10,11 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
11
11
  import { readGuide, skillTopics } from '../cli/skill.js';
12
12
  import { ConfigurationError, errorMessage } from '../internal/errors.js';
13
+ import { processSecrets } from '../run/secrecy.js';
13
14
  import { loadProjectConfig, locateProjectConfig } from './config.js';
14
15
  import { SessionHost } from './session.js';
15
- import { errorResult } from './tools.js';
16
16
  const INSTRUCTIONS = `e2e is a local-first end-to-end test runner; this server drives an e2e project's app (its e2e.config.ts) live.
17
- Call open_session (optionally with a target and a config path) to get a session, its tool catalog, and the first observation. Then call {tool, args} runs any catalog tool: observe, the grammar its engine honors, type_secret, locate, screenshot, start_recording and stop_recording when the engine records video (a video of the app for a pull request), and the project's own tools; tools lists them, tools {tool} shows one tool's arguments. close_session when done.
17
+ Call open_session (optionally with a target, a config path, and headed: true when the user wants to watch) to get a session, its tool catalog, and the first observation. Then call {tool, args} runs any catalog tool: observe, the grammar its engine honors, type_secret, locate, screenshot, start_recording and stop_recording when the engine records video (a video of the app for a pull request), and the project's own tools; tools lists them, tools {tool} shows one tool's arguments. close_session when done.
18
18
  Several sessions can be open at once, each with its own browser or device, so parallel agents (subagents) each open their own: pass the session id from open_session to every tools, call, and close_session.
19
19
  Write deterministic tests (tests/*.e2e.ts) from what you saw and run them with the CLI: npx e2e run <file>. Resources e2e://guide and e2e://guide/{topic} hold the writing guide.`;
20
20
  /** Serves until the client disconnects or `signal` aborts; resolves with the exit code. */
@@ -26,9 +26,11 @@ export async function serveMcp(options) {
26
26
  if (!disconnected && server.isConnected())
27
27
  void server.sendLoggingMessage({ level, logger: 'e2e', data: message }).catch(() => undefined);
28
28
  };
29
+ const output = options.output;
29
30
  const host = new SessionHost({
30
31
  locateConfig: (configPath) => locateProjectConfig({ cwd: options.cwd, configPath: configPath ?? options.configPath }),
31
32
  loadConfig: (configPath) => loadProjectConfig(configPath, options.env),
33
+ withholdOutput: output === undefined ? undefined : (load) => output.withholdDuring(load),
32
34
  env: options.env,
33
35
  headed: options.headed,
34
36
  maxSessions: options.maxSessions,
@@ -44,14 +46,9 @@ export async function serveMcp(options) {
44
46
  description: spec.description,
45
47
  inputSchema: spec.inputSchema,
46
48
  annotations: { readOnlyHint: spec.readOnly, openWorldHint: false },
47
- }, async (args, ctx) => {
48
- // A failure is a result the agent can react to, never a protocol error.
49
- try {
50
- return await spec.call(args, { signal: ctx.mcpReq.signal });
51
- }
52
- catch (cause) {
53
- return errorResult(cause);
54
- }
49
+ }, (args, ctx) => {
50
+ const call = () => spec.call(args, { signal: ctx.mcpReq.signal });
51
+ return output === undefined ? call() : output.duringCall(call);
55
52
  });
56
53
  }
57
54
  registerGuide(server);
@@ -84,9 +81,9 @@ export async function serveMcp(options) {
84
81
  options.log(summary);
85
82
  }
86
83
  catch (cause) {
87
- options.log(`session teardown failed: ${errorMessage(cause)}`);
84
+ options.log(`session teardown failed: ${processSecrets.redact(errorMessage(cause))}`);
88
85
  }
89
- await server.close().catch((cause) => options.log(`server close failed: ${errorMessage(cause)}`));
86
+ await server.close().catch((cause) => options.log(`server close failed: ${processSecrets.redact(errorMessage(cause))}`));
90
87
  return 0;
91
88
  }
92
89
  /** The skill as resources, for clients that read context without a tool call. */
@@ -25,13 +25,18 @@ export interface OpenSessionOptions {
25
25
  readonly target?: string | undefined;
26
26
  /** A config file to load instead of the server's default, relative to the server's directory. */
27
27
  readonly config?: string | undefined;
28
+ /** Whether this session shows its UI; otherwise the server's `headed`. */
29
+ readonly headed?: boolean | undefined;
28
30
  }
29
31
  export interface SessionHostOptions {
30
32
  /** The absolute path of the config a session loads, without evaluating it; `configPath` overrides the server's default. */
31
33
  readonly locateConfig: (configPath: string | undefined) => string;
32
34
  /** Loads the config at an absolute path fresh for each session, so an edited config applies without a restart. */
33
35
  readonly loadConfig: (configPath: string) => Promise<LoadedConfig>;
36
+ /** Holds what the process prints while `load` evaluates a config and registers its secrets; by default nothing is held. */
37
+ readonly withholdOutput?: (<T>(load: () => Promise<T>) => Promise<T>) | undefined;
34
38
  readonly env: NodeJS.ProcessEnv;
39
+ /** Whether a session shows its UI when `open_session` does not say, from `--headed`. */
35
40
  readonly headed: boolean;
36
41
  /** The target every session opens on, from `--target`; a call may still name one. */
37
42
  readonly defaultTarget?: string | undefined;
@@ -54,8 +59,10 @@ export declare class SessionHost {
54
59
  constructor(options: SessionHostOptions);
55
60
  get isOpen(): boolean;
56
61
  private get maxSessions();
57
- /** The server's tools: the same four whatever the project, the config, or the target. */
62
+ /** The server's tools: the same four whatever the project, the config, or the target, each redacting what it returns. */
58
63
  toolSpecs(): readonly McpToolSpec[];
64
+ /** Logs a line for the operator and the client, redacted with every secret value the process knows. */
65
+ private log;
59
66
  /**
60
67
  * Opens a session and returns its opening text: the summary, the catalog,
61
68
  * and the first screen. `signal` is the request's: a client that cancels