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
@@ -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
@@ -21,7 +21,7 @@ import { LocatorEngine } from '../locator/engine.js';
21
21
  import { allocateAppPorts } from '../run/app-ports.js';
22
22
  import { SharedAppProcesses } from '../run/process-pool.js';
23
23
  import { outputLayout } from '../run/output.js';
24
- import { sessionSecrecy } from '../run/secrecy.js';
24
+ import { processSecrets, registerStaticSecrets, sessionSecrecy } from '../run/secrecy.js';
25
25
  import { openStandaloneAttempt } from '../run/standalone.js';
26
26
  import { createSessionCatalog, isGrammarVerb } from './catalog.js';
27
27
  import { describeRecording, SessionRecorder } from './recording.js';
@@ -55,9 +55,13 @@ export class SessionHost {
55
55
  get maxSessions() {
56
56
  return this.options.maxSessions ?? SESSION_BOUNDS.default;
57
57
  }
58
- /** The server's tools: the same four whatever the project, the config, or the target. */
58
+ /** The server's tools: the same four whatever the project, the config, or the target, each redacting what it returns. */
59
59
  toolSpecs() {
60
- return [this.openSpec(), this.catalogSpec(), this.callSpec(), this.closeSpec()];
60
+ return [this.openSpec(), this.catalogSpec(), this.callSpec(), this.closeSpec()].map(redacting);
61
+ }
62
+ /** Logs a line for the operator and the client, redacted with every secret value the process knows. */
63
+ log(level, message) {
64
+ this.options.log(level, processSecrets.redact(message));
61
65
  }
62
66
  /**
63
67
  * Opens a session and returns its opening text: the summary, the catalog,
@@ -65,8 +69,9 @@ export class SessionHost {
65
69
  * it, or disconnects, aborts the open and what it started.
66
70
  */
67
71
  open(options, signal) {
68
- const usage = new SessionUsage(this.sessions.liveCount, this.options.headed);
69
- return this.sessions.admit((id) => this.openSession(id, options, usage, signal)).catch((cause) => {
72
+ const headed = options.headed ?? this.options.headed;
73
+ const usage = new SessionUsage(this.sessions.liveCount, headed);
74
+ return this.sessions.admit((id) => this.openSession(id, options, headed, usage, signal)).catch((cause) => {
70
75
  this.options.onSessionEnd?.(usage.openFailed(classifyError(cause).code));
71
76
  throw cause;
72
77
  });
@@ -99,7 +104,8 @@ export class SessionHost {
99
104
  lines.push(`The session step ended with: ${errorMessage(value.outcome.error)}`);
100
105
  for (const error of cleanupErrors)
101
106
  lines.push(`Cleanup: ${error.code}: ${error.message}`);
102
- return lines.join('\n');
107
+ // Redacted here, not only at the tool boundary: closeAll writes it to stderr.
108
+ return processSecrets.redact(lines.join('\n'));
103
109
  }
104
110
  /**
105
111
  * Runs `settle` (ending the step, saving a recording), then closes the
@@ -149,7 +155,7 @@ export class SessionHost {
149
155
  return redactResult(errorResult(cause), redact);
150
156
  });
151
157
  }
152
- async openSession(id, options, usage, request) {
158
+ async openSession(id, options, headed, usage, request) {
153
159
  // The catalog renders synchronously, so the optional SDK is loaded once
154
160
  // here when installed. Without it the catalog reads the tools' Standard
155
161
  // Schemas, and only a model-backed call needs the package.
@@ -159,7 +165,16 @@ export class SessionHost {
159
165
  // knows that session's config.
160
166
  const configPath = this.options.locateConfig(options.config);
161
167
  this.sessions.claimConfig(id, configPath);
162
- const loaded = await this.options.loadConfig(configPath);
168
+ const withhold = this.options.withholdOutput ?? ((load) => load());
169
+ const loaded = await withhold(async () => {
170
+ const fresh = await this.options.loadConfig(configPath);
171
+ // Known to the process before anything can fail with one, so an open
172
+ // failure and what the config printed while it loaded are redacted
173
+ // like any other text; a session registers them only once its engine
174
+ // has launched.
175
+ registerStaticSecrets(fresh.allSecrets);
176
+ return fresh;
177
+ });
163
178
  // A session is its own run: a URL declared with port 0 gets a port here.
164
179
  const config = await allocateAppPorts(loaded);
165
180
  const target = this.resolveTarget(config, options.target);
@@ -184,12 +199,12 @@ export class SessionHost {
184
199
  // one attempt that closes as passed, so it traces under `on` only.
185
200
  config,
186
201
  target: { ...target, video: { mode: 'off', source: 'default' } },
187
- headed: this.options.headed,
202
+ headed,
188
203
  env: this.options.env,
189
204
  signal: abort.signal,
190
205
  timeoutMs: ttlMs + CLOSE_GRACE_MS,
191
206
  processes: this.apps,
192
- notice: (scope, message) => this.options.log('info', `${scope}: ${message}`),
207
+ notice: (scope, message) => this.log('info', `${scope}: ${message}`),
193
208
  });
194
209
  // The coding agent is the brain: the step is driven from here, and the
195
210
  // configured model stays out of the way.
@@ -217,13 +232,14 @@ export class SessionHost {
217
232
  actionTimeout: config.actionTimeout,
218
233
  assertionTimeout: config.assertionTimeout,
219
234
  }),
220
- warn: (message) => this.options.log('warning', message),
235
+ warn: (message) => this.log('warning', message),
221
236
  onActionFailed: (cause) => usage.failed(classifyError(cause).code),
222
237
  });
223
238
  const live = {
224
239
  id,
225
240
  target,
226
241
  configPath: loaded.configPath,
242
+ headed,
227
243
  attempt,
228
244
  step,
229
245
  screen,
@@ -257,8 +273,8 @@ export class SessionHost {
257
273
  endOnItsOwn(live, why, endedBy) {
258
274
  if (!this.sessions.isLive(live.id))
259
275
  return;
260
- this.options.log('warning', `session ${live.id} ended: ${why}`);
261
- this.close(why, live.id, endedBy).catch((cause) => this.options.log('error', `closing session ${live.id} failed: ${errorMessage(cause)}`));
276
+ this.log('warning', `session ${live.id} ended: ${why}`);
277
+ this.close(why, live.id, endedBy).catch((cause) => this.log('error', `closing session ${live.id} failed: ${errorMessage(cause)}`));
262
278
  }
263
279
  /** A recorder writing to `<output>/videos/<session>/`, when the engine records video. */
264
280
  recorder(id, config, attempt) {
@@ -315,7 +331,7 @@ export class SessionHost {
315
331
  openingText(live, config, screen) {
316
332
  const engine = live.target.engine;
317
333
  const lines = [
318
- `Session ${live.id} open on target "${live.target.name}" (platform ${live.target.platform}, engine ${engine === undefined ? 'none' : `${engine.name} ${engine.version}`}), ${this.options.headed ? 'headed' : 'headless'}; config ${live.configPath}.`,
334
+ `Session ${live.id} open on target "${live.target.name}" (platform ${live.target.platform}, engine ${engine === undefined ? 'none' : `${engine.name} ${engine.version}`}), ${live.headed ? 'headed' : 'headless'}; config ${live.configPath}.`,
319
335
  ];
320
336
  if (live.target.app.base !== undefined) {
321
337
  lines.push(`App: ${live.target.app.base.href}.`);
@@ -385,6 +401,10 @@ export class SessionHost {
385
401
  inputSchema: z.object({
386
402
  target: z.string().min(1).optional().describe('Target name from the config; required when the config declares several'),
387
403
  config: z.string().min(1).optional().describe("Path to an e2e config file, relative to the server's directory; default: the nearest e2e.config.ts"),
404
+ headed: z
405
+ .boolean()
406
+ .optional()
407
+ .describe('Show the browser or simulator, when the engine supports it; pass true when the user wants to watch. Default: headless, unless the server was started with --headed'),
388
408
  }).strict(),
389
409
  readOnly: false,
390
410
  call: async (args, extra) => textResult(await this.open(args, extra.signal)),
@@ -427,3 +447,24 @@ export class SessionHost {
427
447
  });
428
448
  }
429
449
  }
450
+ /**
451
+ * The tool with what it returns redacted with every secret value the process
452
+ * knows: the static values of each config a session loaded, and every value
453
+ * a session resolved or derived. A failure becomes a result the agent can
454
+ * react to, never a protocol error, and is redacted the same way.
455
+ */
456
+ function redacting(spec) {
457
+ return {
458
+ ...spec,
459
+ call: async (args, extra) => {
460
+ let result;
461
+ try {
462
+ result = await spec.call(args, extra);
463
+ }
464
+ catch (cause) {
465
+ result = errorResult(cause);
466
+ }
467
+ return redactResult(result, (text) => processSecrets.redact(text));
468
+ },
469
+ };
470
+ }
package/dist/mcp/tools.js CHANGED
@@ -192,13 +192,15 @@ async function resolveOutput(value) {
192
192
  * else is JSON, so a structured output is never lost.
193
193
  */
194
194
  export async function resultFromOutput(tool, output, input = undefined) {
195
- if (typeof output === 'string')
196
- return textResult(output);
197
195
  if (tool.toModelOutput !== undefined) {
198
- const content = contentFromModelOutput(await tool.toModelOutput({ toolCallId: 'mcp', input, output }));
199
- if (content !== undefined)
200
- return { content };
196
+ const rendered = await tool.toModelOutput({ toolCallId: 'mcp', input, output });
197
+ const content = contentFromModelOutput(rendered);
198
+ if (content !== undefined) {
199
+ return { content, ...(rendered.type === 'error-text' || rendered.type === 'error-json' ? { isError: true } : {}) };
200
+ }
201
201
  }
202
+ if (typeof output === 'string')
203
+ return textResult(output);
202
204
  return textResult(output === undefined ? 'Done.' : JSON.stringify(output, null, 2));
203
205
  }
204
206
  function contentFromModelOutput(output) {
@@ -120,6 +120,17 @@ export declare class ListReporter implements Reporter {
120
120
  * the test's result, or the end of the run.
121
121
  */
122
122
  private testOutput;
123
+ /**
124
+ * Text user code wrote in the runner's own process (the config's top
125
+ * level, a test file's while it is collected, a reporter), printed like a
126
+ * test's output under a `stdout | runner` heading. The runner hands it
127
+ * here already redacted, only while this reporter shows the run.
128
+ */
129
+ processOutput(stream: 'stdout' | 'stderr', text: string): void;
130
+ /** Prints what `processOutput` left unfinished: the runner hands nothing more once the run's events are all dispatched. */
131
+ endProcessOutput(): void;
132
+ /** Prints the lines `text` ends under the heading of `stream` and `source`, holding the unfinished rest. */
133
+ private sourcedOutput;
123
134
  /** Prints finished output lines under their heading, unless the heading is the one printed last. */
124
135
  private printOutput;
125
136
  /** Prints the unfinished output of `pair`, or of every source when undefined. */
@@ -272,20 +272,37 @@ export class ListReporter {
272
272
  * the test's result, or the end of the run.
273
273
  */
274
274
  testOutput(event) {
275
- const { pc } = this;
276
- if (event.text === '')
277
- return;
278
275
  const pair = event.pair === undefined ? undefined : pairKey(event.pair.testId, event.pair.agent, event.target, event.pair.repeat);
279
276
  const running = pair === undefined ? undefined : this.pairs.get(pair);
280
277
  const source = running === undefined ? this.badge(event.target) : `${this.badge(running.group.target)} ${bounded(running.group.file)}${this.separator}${running.title}`;
281
- const key = `${event.stream}\u0000${source}`;
278
+ this.sourcedOutput(event.stream, source, pair, event.text);
279
+ }
280
+ /**
281
+ * Text user code wrote in the runner's own process (the config's top
282
+ * level, a test file's while it is collected, a reporter), printed like a
283
+ * test's output under a `stdout | runner` heading. The runner hands it
284
+ * here already redacted, only while this reporter shows the run.
285
+ */
286
+ processOutput(stream, text) {
287
+ this.sourcedOutput(stream, this.pc.dim('runner'), undefined, text);
288
+ }
289
+ /** Prints what `processOutput` left unfinished: the runner hands nothing more once the run's events are all dispatched. */
290
+ endProcessOutput() {
291
+ this.flushOutput();
292
+ }
293
+ /** Prints the lines `text` ends under the heading of `stream` and `source`, holding the unfinished rest. */
294
+ sourcedOutput(stream, source, pair, text) {
295
+ const { pc } = this;
296
+ if (text === '')
297
+ return;
298
+ const key = `${stream}\u0000${source}`;
282
299
  let entry = this.pendingOutput.get(key);
283
300
  if (entry === undefined) {
284
- const label = event.stream === 'stderr' ? pc.yellow(event.stream) : pc.dim(event.stream);
301
+ const label = stream === 'stderr' ? pc.yellow(stream) : pc.dim(stream);
285
302
  entry = { heading: `${label} ${pc.dim('|')} ${source}`, pair, fragment: '' };
286
303
  this.pendingOutput.set(key, entry);
287
304
  }
288
- const lines = (entry.fragment + event.text).split('\n');
305
+ const lines = (entry.fragment + text).split('\n');
289
306
  entry.fragment = lines.pop() ?? '';
290
307
  if (entry.fragment === '')
291
308
  this.pendingOutput.delete(key);
@@ -2,7 +2,7 @@
2
2
  import type { ArtifactStore } from '../types.ts';
3
3
  import type { ArtifactSink } from './fixtures.ts';
4
4
  import type { ArtifactRecord } from './records.ts';
5
- import type { SessionSecrecy } from './secrecy.ts';
5
+ import { type SessionSecrecy } from './secrecy.ts';
6
6
  export interface AttemptArtifacts {
7
7
  /** Absolute attempt artifact directory, created eagerly. */
8
8
  readonly dir: string;
@@ -38,9 +38,9 @@ export declare function createAttemptArtifacts(options: {
38
38
  /**
39
39
  * The secrecy of the session the attempt runs on, read when a download is
40
40
  * registered; undefined (no session open yet) leaves every download as
41
- * served. Once a secret reached the session (filled, or held by the
42
- * engine), a text-like download is rewritten through its ledger before it
43
- * is hashed or stored.
41
+ * served. While its ledger holds a value (`redactsRecordings`), a
42
+ * text-like download is rewritten through it before it is hashed or
43
+ * stored.
44
44
  */
45
45
  secrecy?: () => SessionSecrecy | undefined;
46
46
  /**
@@ -5,6 +5,7 @@ import { mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { writeFileAtomic } from '../internal/atomic-write.js';
8
+ import { redactsRecordings } from './secrecy.js';
8
9
  /**
9
10
  * How much of each kind the runner masked, unless the registration says. A
10
11
  * screenshot masks secure fields at the source; a log passes through the
@@ -60,7 +61,7 @@ export function createAttemptArtifacts(options) {
60
61
  const reportPath = path.posix.join(...options.segments, relativePath);
61
62
  const secrecy = kind === 'download' && registration?.redaction === undefined ? options.secrecy?.() : undefined;
62
63
  pending.push((async () => {
63
- if (secrecy !== undefined && secrecy.exposure.redactsRecordings && isTextLike(record.mediaType)) {
64
+ if (secrecy !== undefined && redactsRecordings(secrecy) && isTextLike(record.mediaType)) {
64
65
  record.redaction = await redactDownload(absolute, secrecy);
65
66
  }
66
67
  // Without a store the file is streamed for its size and digest only;
@@ -235,7 +236,7 @@ async function redactDownload(absolute, secrecy) {
235
236
  try {
236
237
  const bytes = await readFile(absolute);
237
238
  const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
238
- const redacted = secrecy.ledger.redact(text);
239
+ const redacted = secrecy.ledger.redactFragments(text);
239
240
  if (redacted !== text)
240
241
  await writeFileAtomic(absolute, redacted);
241
242
  return 'complete';
@@ -9,17 +9,33 @@ import type { AttemptRecordings } from '../internal/recording-modes.ts';
9
9
  import type { ArtifactStore } from '../types.ts';
10
10
  import { type ArtifactSink } from './fixtures.ts';
11
11
  import { RealmManager, type FileRef, type Realm } from './realm.ts';
12
- import type { AttemptRecord, ResultRecord, RunError, SerialGroupRecord } from './records.ts';
13
- import { type SerialHost, type SharedSerialSession } from './serial.ts';
12
+ import type { AttemptRecord, ResultRecord, RunError, SerialGroupRecord, SerialMemberRecord } from './records.ts';
13
+ import { type SerialAttemptRun, type SerialAttemptStart, type SerialHost, type SharedSerialSession } from './serial.ts';
14
14
  import { SessionStaging, SessionStore } from './sessions.ts';
15
15
  import { type StepProgress } from './steps.ts';
16
16
  export interface ExecutionEvents {
17
17
  onResult?(result: ResultRecord): void;
18
18
  onSerialGroup?(group: SerialGroupRecord): void;
19
+ /** An attempt of an ordinary or setup pair begins, its realm and `beforeAll` hooks included. */
20
+ onAttemptStart?(pair: TestTargetPair, attemptIndex: number): void;
21
+ /** One finished attempt of an ordinary or setup pair, ahead of the pair's result. */
22
+ onAttempt?(pair: TestTargetPair, attempt: AttemptRecord): void;
23
+ /** One member that finished in a serial group attempt, ahead of the group. */
24
+ onSerialMember?(groupId: string, attempt: SerialAttemptStart, member: SerialMemberRecord): void;
25
+ /** One finished serial group attempt, ahead of the group. */
26
+ onSerialAttempt?(groupId: string, run: SerialAttemptRun): void;
19
27
  /** Fires before a runnable pair starts; a serial unit announces each member as it begins. */
20
28
  onPairStart?(pair: TestTargetPair): void;
21
29
  /** Live step progress of one running attempt, for reporters. */
22
30
  onProgress?(pair: TestTargetPair, progress: StepProgress): void;
31
+ /** An attempt's test timeout started, as its `beforeEach` hooks and body begin. */
32
+ onAttemptDeadline?(pair: TestTargetPair, attempt: {
33
+ readonly index: number;
34
+ readonly id: string;
35
+ readonly startedAt: string;
36
+ }): void;
37
+ /** The attempt `onAttemptDeadline` announced has ended, or one that never got that far has. */
38
+ onAttemptEnd?(): void;
23
39
  /** One line of progress the engine's `init` reported, already naming the target and worker slot. */
24
40
  onNotice?(message: string): void;
25
41
  /**
@@ -132,6 +148,10 @@ export declare class TargetExecutor implements SerialHost {
132
148
  pairStarted(pair: TestTargetPair): void;
133
149
  /** Emits one finished serial group, ahead of its members' results (SerialHost). */
134
150
  emitSerialGroup(group: SerialGroupRecord): void;
151
+ /** Streams one member finished in a serial group attempt (SerialHost). */
152
+ serialMemberFinished(groupId: string, attempt: SerialAttemptStart, member: SerialMemberRecord): void;
153
+ /** Streams one finished serial group attempt (SerialHost). */
154
+ serialAttemptFinished(groupId: string, run: SerialAttemptRun): void;
135
155
  /** Builds one engine operation context. */
136
156
  private op;
137
157
  /** Run-level errors recorded so far, in order. */
@@ -196,9 +216,9 @@ export declare class TargetExecutor implements SerialHost {
196
216
  * hands the app (basic-auth credentials). Registered for the session's and
197
217
  * the process's redaction by `resolveSecretValue`, with every value the
198
218
  * engine derives from it (the base64 credential an `Authorization` header
199
- * carries) under the same name. The session's exposure rises to `engine`:
200
- * its text is redacted and its trace and text downloads rewritten, while
201
- * its pixels stay as they are, since nothing was typed.
219
+ * carries) under the same name, so its text, trace, and text downloads
220
+ * are redacted of them. Its pixels stay as they are, since nothing was
221
+ * typed.
202
222
  */
203
223
  private resolveEngineSecret;
204
224
  /**
@@ -26,7 +26,7 @@ import { isFailedStatus } from './records.js';
26
26
  import { runWithRetries } from './retry.js';
27
27
  import { runSerialUnit } from './serial.js';
28
28
  import { interruptedSkip, pairKey, pairResult, repeatSegment, unstartedResult } from './units.js';
29
- import { adoptSecrecy, carriedSecrecy, processSecrets, registerDerivedSecrets, redactForSession, resolveSecretValue, sessionSecrecy, staticSecretLedger } from './secrecy.js';
29
+ import { adoptSecrecy, carriedSecrecy, processSecrets, registerDerivedSecrets, redactForSession, redactsRecordings, resolveSecretValue, sessionSecrecy, staticSecretLedger } from './secrecy.js';
30
30
  import { isSecret } from '../secrets.js';
31
31
  import { SessionStaging, SessionStore, targetIdentity } from './sessions.js';
32
32
  import { redactTraceArchives } from './trace-redaction.js';
@@ -97,6 +97,14 @@ export class TargetExecutor {
97
97
  emitSerialGroup(group) {
98
98
  this.options.events?.onSerialGroup?.(group);
99
99
  }
100
+ /** Streams one member finished in a serial group attempt (SerialHost). */
101
+ serialMemberFinished(groupId, attempt, member) {
102
+ this.options.events?.onSerialMember?.(groupId, attempt, member);
103
+ }
104
+ /** Streams one finished serial group attempt (SerialHost). */
105
+ serialAttemptFinished(groupId, run) {
106
+ this.options.events?.onSerialAttempt?.(groupId, run);
107
+ }
100
108
  /** Builds one engine operation context. */
101
109
  op(attemptId, timeoutMs, signal) {
102
110
  return { signal, timeoutMs, runId: this.options.runId, attemptId, origin: 'test' };
@@ -256,6 +264,7 @@ export class TargetExecutor {
256
264
  let realm = incomingRealm;
257
265
  let hookFailure;
258
266
  const finalStatus = await runWithRetries(pair.options.retries + 1, this.interruptSignal, async (attemptIndex) => {
267
+ this.options.events?.onAttemptStart?.(pair, attemptIndex);
259
268
  if (realm === null)
260
269
  realm = await this.realms.create(file);
261
270
  const registered = findRegistered(realm, pair.test);
@@ -270,6 +279,7 @@ export class TargetExecutor {
270
279
  kind: 'ordinary',
271
280
  });
272
281
  attempts.push(attempt);
282
+ this.options.events?.onAttempt?.(pair, attempt);
273
283
  if (isFailedStatus(attempt.status)) {
274
284
  // A failed realm is never reused, but afterAll still runs for every
275
285
  // scope whose beforeAll started in it. A body that skipped itself
@@ -316,6 +326,7 @@ export class TargetExecutor {
316
326
  const attempts = [];
317
327
  let hookFailure;
318
328
  const finalStatus = await runWithRetries(pair.options.retries + 1, this.interruptSignal, async (attemptIndex) => {
329
+ this.options.events?.onAttemptStart?.(pair, attemptIndex);
319
330
  const realm = attemptIndex === 0 && freshRegistration !== undefined
320
331
  ? this.realms.adopt(freshRegistration, file)
321
332
  : await this.realms.create(file);
@@ -334,15 +345,17 @@ export class TargetExecutor {
334
345
  kind: 'setup',
335
346
  staging,
336
347
  });
348
+ const missing = attempt.status === 'passed' ? staging.missing() : [];
349
+ if (missing.length > 0) {
350
+ attempt.status = 'failed';
351
+ attempt.error = serializeError(new E2EError('test', 'SESSION_CONTRACT', `setup must save each declared session exactly once; missing: [${missing.join(', ')}]`), { phase: 'body' });
352
+ }
337
353
  attempts.push(attempt);
354
+ // Sent before teardown and the session saves, as in runOrdinaryPair:
355
+ // a crash in either then lands on this attempt instead of erasing it.
356
+ this.options.events?.onAttempt?.(pair, attempt);
338
357
  await this.realms.leave(realm);
339
358
  if (attempt.status === 'passed') {
340
- const missing = staging.missing();
341
- if (missing.length > 0) {
342
- attempt.status = 'failed';
343
- attempt.error = serializeError(new E2EError('test', 'SESSION_CONTRACT', `setup must save each declared session exactly once; missing: [${missing.join(', ')}]`), { phase: 'body' });
344
- return attempt;
345
- }
346
359
  for (const [name, saved] of staging.entries()) {
347
360
  await this.options.sessionStore.save(name, this.sessionIdentity, saved);
348
361
  }
@@ -419,9 +432,9 @@ export class TargetExecutor {
419
432
  * hands the app (basic-auth credentials). Registered for the session's and
420
433
  * the process's redaction by `resolveSecretValue`, with every value the
421
434
  * engine derives from it (the base64 credential an `Authorization` header
422
- * carries) under the same name. The session's exposure rises to `engine`:
423
- * its text is redacted and its trace and text downloads rewritten, while
424
- * its pixels stay as they are, since nothing was typed.
435
+ * carries) under the same name, so its text, trace, and text downloads
436
+ * are redacted of them. Its pixels stay as they are, since nothing was
437
+ * typed.
425
438
  */
426
439
  async resolveEngineSecret(session, secret, options) {
427
440
  const engine = this.target.engine;
@@ -432,7 +445,6 @@ export class TargetExecutor {
432
445
  const secrecy = sessionSecrecy(session, this.config.allSecrets);
433
446
  const plaintext = await resolveSecretValue(secret, this.config.allSecrets, secrecy.ledger);
434
447
  registerDerivedSecrets(secret.name, options?.derived?.(plaintext) ?? [], secrecy.ledger);
435
- secrecy.exposure.raise('engine');
436
448
  return plaintext;
437
449
  }
438
450
  /**
@@ -479,14 +491,14 @@ export class TargetExecutor {
479
491
  }
480
492
  // An engine records what happened, filled secrets included, so the
481
493
  // trace is the runner's to redact before anything hashes or stores
482
- // it. Only a session a secret reached (filled, or held by the engine
483
- // for an option, which a trace records the attempt opening with) can
484
- // have recorded one: an unexposed trace needs no rewriting, and an
485
- // exposed one is kept only once rewritten. Its screencast frames go
486
- // only where pixels are withheld, after a fill.
494
+ // it. Any value the ledger holds may be in it, filled or not (a URL
495
+ // the test spelled it into, an engine option), so a trace is kept
496
+ // only once rewritten; only a session that knows no value skips it.
497
+ // Its screencast frames go only where pixels are withheld, after a
498
+ // fill.
487
499
  const secrecy = sessionSecrecy(session, this.config.allSecrets);
488
500
  let redaction = 'not-required';
489
- if (secrecy.exposure.redactsRecordings) {
501
+ if (redactsRecordings(secrecy)) {
490
502
  try {
491
503
  await redactTraceArchives(artifactSink.dir, archives, secrecy.ledger, { keepFrames: !secrecy.exposure.withholdsPixels });
492
504
  }
@@ -540,7 +552,14 @@ export class TargetExecutor {
540
552
  target: this.target.name,
541
553
  agent: pair.agent,
542
554
  attempt: attemptIndex,
543
- }, () => this.executeAttempt(pair, registered, realm, attemptIndex, context));
555
+ }, async () => {
556
+ try {
557
+ return await this.executeAttempt(pair, registered, realm, attemptIndex, context);
558
+ }
559
+ finally {
560
+ this.options.events?.onAttemptEnd?.();
561
+ }
562
+ });
544
563
  }
545
564
  async executeAttempt(pair, registered, realm, attemptIndex, context) {
546
565
  const attemptId = uuidv7();
@@ -654,9 +673,13 @@ export class TargetExecutor {
654
673
  // One more look at the app the moment the failure lands: what the screen
655
674
  // held then is the evidence the message lacks. Taken before teardown, so
656
675
  // an `afterEach` that navigates away or resets state cannot replace it.
676
+ // Once per attempt: a screen that yielded nothing within the budget is
677
+ // not asked again, so a hung app costs the budget once.
678
+ let evidenceTried = false;
657
679
  const captureEvidence = async () => {
658
- if (failure === undefined || record.failure !== undefined || openSession === null || this.interruptSignal.aborted)
680
+ if (failure === undefined || evidenceTried || openSession === null || this.interruptSignal.aborted)
659
681
  return;
682
+ evidenceTried = true;
660
683
  const evidence = await captureFailureEvidence({
661
684
  session: openSession,
662
685
  error: failure,
@@ -700,6 +723,7 @@ export class TargetExecutor {
700
723
  (await this.launchSession({ session: pair.options.session, recordings }, attemptId, artifacts.dir, attemptAbort.signal));
701
724
  openSession = session;
702
725
  const testDeadline = new Deadline(pair.options.timeout);
726
+ this.options.events?.onAttemptDeadline?.(pair, { index: attemptIndex, id: attemptId, startedAt });
703
727
  const budget = new AttemptBudget(attemptAbort.signal, testDeadline);
704
728
  const saveSession = context.kind !== 'setup'
705
729
  ? undefined
@@ -936,7 +960,7 @@ export class TargetExecutor {
936
960
  function classifyAttemptStatus(failure, timedOut, interrupted) {
937
961
  if (interrupted && !timedOut)
938
962
  return 'interrupted';
939
- if (timedOut || failure instanceof TestTimeoutError || failure.code === 'TEST_TIMEOUT') {
963
+ if (timedOut || failure.code === 'TEST_TIMEOUT') {
940
964
  return 'timed-out';
941
965
  }
942
966
  return 'failed';
@@ -14,6 +14,7 @@ import path from 'node:path';
14
14
  import { formatNode, prepareObservation } from '../agent/observation.js';
15
15
  import { truncateUtf8 } from '../internal/errors.js';
16
16
  import { isTypoOf } from '../internal/suggest.js';
17
+ import { withAbort } from '../internal/time.js';
17
18
  /** The whole capture, observation and screenshot together, gets this long. */
18
19
  const EVIDENCE_TIMEOUT_MS = 5_000;
19
20
  /** Screen lines a locator failure lists as its nearest nodes, and the bytes each keeps; the wire schema caps both. */
@@ -37,9 +38,12 @@ export async function captureFailureEvidence(options) {
37
38
  return finish(evidence);
38
39
  const operation = options.operation(signal, EVIDENCE_TIMEOUT_MS);
39
40
  const { redact, redactCut } = options.secrecy.ledger;
41
+ // The engine is handed the budget, and the wait is abandoned at its end
42
+ // either way: an engine stuck on a hung app may never honor it.
43
+ const bounded = (work) => withAbort(work, signal, () => new Error('failure evidence ran out of budget'));
40
44
  let observation;
41
45
  try {
42
- const raw = await options.session.observe(operation);
46
+ const raw = await bounded(() => options.session.observe(operation));
43
47
  observation = prepareObservation(raw, {
44
48
  redact,
45
49
  redactCut,
@@ -73,7 +77,7 @@ export async function captureFailureEvidence(options) {
73
77
  // prove a tainted viewport redacted.
74
78
  if (!options.secrecy.exposure.withholdsPixels && !signal.aborted) {
75
79
  try {
76
- const relative = await options.session.artifacts.screenshot('failure', operation);
80
+ const relative = await bounded(() => options.session.artifacts.screenshot('failure', operation));
77
81
  evidence.screenshot = options.artifacts.register('screenshot', relative);
78
82
  }
79
83
  catch {