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
@@ -5,6 +5,7 @@
5
5
  * `TargetWorker` core; only the transport and pair resolution differ, and the
6
6
  * scheduler is capped at one worker so nothing overlaps in this process.
7
7
  */
8
+ import { isAbandonedRejection } from '../internal/abandoned.js';
8
9
  import { classifyError, ConfigurationError, serializeError } from '../internal/errors.js';
9
10
  import { pairKey } from './units.js';
10
11
  import { TargetWorker } from './worker/session.js';
@@ -18,6 +19,22 @@ class InProcessRunner {
18
19
  finish;
19
20
  exited = false;
20
21
  closing = false;
22
+ /**
23
+ * Set by a fatal error: the worker is dead to the scheduler from then on,
24
+ * as a crashed process would be, so nothing it reports about its unit
25
+ * while it winds down counts, and the unit's results are synthesized when
26
+ * it exits. Unlike a dead process it still disposes its engine, so the
27
+ * failures of that disposal (`shutdown-done`) are still reported.
28
+ */
29
+ crashed = false;
30
+ /** Takes what this process failed to catch while the runner lives; see `catchStrays`. */
31
+ onRejection = (cause) => {
32
+ if (isAbandonedRejection(cause))
33
+ return;
34
+ if (!this.worker.strayRejection(cause))
35
+ this.fail(cause);
36
+ };
37
+ onException = (cause) => this.fail(cause);
21
38
  constructor(targetName, workerSlot, events, options) {
22
39
  this.targetName = targetName;
23
40
  this.workerSlot = workerSlot;
@@ -30,20 +47,14 @@ class InProcessRunner {
30
47
  this.finish = finish;
31
48
  this.worker = new TargetWorker({
32
49
  emit: (message) => {
33
- if (!this.exited)
34
- this.events.onMessage(message);
35
- },
36
- fatal: (cause) => {
37
- if (this.exited)
50
+ if (this.exited || (this.crashed && message.type !== 'shutdown-done'))
38
51
  return;
39
- this.events.onMessage({
40
- type: 'fatal',
41
- error: serializeError(classifyError(cause)),
42
- });
43
- this.close();
52
+ this.events.onMessage(message);
44
53
  },
54
+ fatal: (cause) => this.fail(cause),
45
55
  finished: () => this.end('shut down'),
46
56
  }, () => this.bootstrap());
57
+ this.catchStrays();
47
58
  this.worker.start();
48
59
  }
49
60
  get alive() {
@@ -75,6 +86,34 @@ class InProcessRunner {
75
86
  kill() {
76
87
  this.close();
77
88
  }
89
+ /**
90
+ * Catches what a test leaves uncaught the way a worker process does
91
+ * (`worker/entry.ts`): a rejection nobody handled is charged to the attempt
92
+ * in flight, or recorded against the last test that finished, and is
93
+ * otherwise fatal to this worker, as an uncaught exception is (see
94
+ * `fail`). The
95
+ * listeners live exactly as long as the runner, so a host process keeps its
96
+ * own handling before and after the run. The scheduler keeps at most one
97
+ * in-process runner alive at a time, so nothing is charged twice.
98
+ */
99
+ catchStrays() {
100
+ process.on('unhandledRejection', this.onRejection);
101
+ process.on('uncaughtException', this.onException);
102
+ }
103
+ /**
104
+ * Reports an unrecoverable failure and ends the worker the way a process
105
+ * dies: its unit is interrupted and nothing more it reports is passed on,
106
+ * so on exit the scheduler fails the test in flight with `WORKER_CRASH`
107
+ * and skips the unit's others, as it does for a worker process.
108
+ */
109
+ fail(cause) {
110
+ if (this.exited || this.crashed)
111
+ return;
112
+ this.events.onMessage({ type: 'fatal', error: serializeError(classifyError(cause)) });
113
+ this.crashed = true;
114
+ this.worker.handle({ type: 'interrupt' });
115
+ this.close();
116
+ }
78
117
  /** Stops accepting work and asks the queue to wind down. */
79
118
  close() {
80
119
  if (this.exited || this.closing)
@@ -86,6 +125,8 @@ class InProcessRunner {
86
125
  if (this.exited)
87
126
  return;
88
127
  this.exited = true;
128
+ process.off('unhandledRejection', this.onRejection);
129
+ process.off('uncaughtException', this.onException);
89
130
  this.events.onExit(detail);
90
131
  this.finish();
91
132
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * What user code in an `e2e run` process prints outside a test body: the
3
+ * config's top-level code, a test file's top level while it is collected,
4
+ * and reporters in the runner; the config's top-level code again in each
5
+ * worker. It is redacted with every secret value the process knows, as a
6
+ * test's output is. Until a config has loaded its secrets are unknown, so
7
+ * what is printed meanwhile is held, and withheld if the load fails.
8
+ */
9
+ import { type SecretLedger } from '../internal/redact.ts';
10
+ import type { ListReporterOutput } from '../report/list.ts';
11
+ export type OutputStream = 'stdout' | 'stderr';
12
+ /** Writes text to one stream of the terminal; the callback fires once the stream took it. Answers false while the stream is backed up, like `Writable.write`. */
13
+ export type TerminalWrite = (text: string, callback?: () => void) => boolean;
14
+ /** Where runner output goes instead of the terminal while the list reporter shows the run. */
15
+ export interface OutputView {
16
+ /** Prints redacted text from `stream`. */
17
+ show(stream: OutputStream, text: string): void;
18
+ /** Prints what `show` left unfinished; nothing more follows. */
19
+ end(): void;
20
+ }
21
+ /**
22
+ * Holds what is written while a config loads. Released once the load
23
+ * registered the config's secrets; dropped for a warning when it failed.
24
+ */
25
+ export declare class ConfigLoadHold {
26
+ private held;
27
+ /** Holds `chunk` if a load is in flight; answers whether it did. */
28
+ hold(stream: OutputStream, chunk: string | Uint8Array): boolean;
29
+ /**
30
+ * Runs `load`, which loads a config and registers its secrets, holding
31
+ * what is written meanwhile. Then hands each held write to `release` in
32
+ * order, or, if `load` threw, `warn`s with how much was withheld.
33
+ */
34
+ during<T>(load: () => Promise<T>, release: (stream: OutputStream, chunk: string | Uint8Array) => void, warn: (line: string) => void): Promise<T>;
35
+ }
36
+ /**
37
+ * The runner process's user output, on its way to the terminal or, while
38
+ * the list reporter shows it, through the reporter, which prints it above
39
+ * its live window instead of letting it land inside.
40
+ */
41
+ export declare class RunnerOutput {
42
+ private readonly terminal;
43
+ /** Each stream redacted on its own, across writes: a value split over two writes is still caught. */
44
+ private readonly redactors;
45
+ private readonly loadHold;
46
+ private view;
47
+ /** The terminal's stdout for the list reporter: its own lines and live window go straight there. */
48
+ readonly listOutput: ListReporterOutput;
49
+ constructor(terminal: Readonly<Record<OutputStream, TerminalWrite>>, ledger: SecretLedger);
50
+ /**
51
+ * One write of user code to `stream`, redacted: the tail a later write
52
+ * could complete into a value waits for it, or for `end`. Held while a
53
+ * config loads. Answers what the terminal did.
54
+ */
55
+ write(stream: OutputStream, chunk: string | Uint8Array, callback?: () => void): boolean;
56
+ /**
57
+ * Runs `load`, a config load that registers the config's secrets, holding
58
+ * what is printed until it ends: the config's top-level code may print a
59
+ * value before the process knows it is one. Released redacted once it
60
+ * loaded; withheld with a warning when it failed.
61
+ */
62
+ withholdDuring<T>(load: () => Promise<T>): Promise<T>;
63
+ /**
64
+ * Passes what is printed to `view` from now on, or to the terminal again
65
+ * when undefined. What the previous view was given is released to it
66
+ * first, unfinished lines included, and that view ended.
67
+ */
68
+ showThrough(view: OutputView | undefined): void;
69
+ /** Releases what is still held, redacted, to the terminal. Later writes stay redacted; only a tail a later write could complete into a value is held. */
70
+ end(): void;
71
+ private release;
72
+ private emit;
73
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * What user code in an `e2e run` process prints outside a test body: the
3
+ * config's top-level code, a test file's top level while it is collected,
4
+ * and reporters in the runner; the config's top-level code again in each
5
+ * worker. It is redacted with every secret value the process knows, as a
6
+ * test's output is. Until a config has loaded its secrets are unknown, so
7
+ * what is printed meanwhile is held, and withheld if the load fails.
8
+ */
9
+ import { StreamRedactor } from '../internal/redact.js';
10
+ /** The warning that stands in for what a config printed before it failed to load. */
11
+ function withheldWarning(bytes) {
12
+ return `withheld ${bytes} bytes of output printed while the config failed to load: its secrets are unknown, so the output cannot be redacted`;
13
+ }
14
+ /**
15
+ * Holds what is written while a config loads. Released once the load
16
+ * registered the config's secrets; dropped for a warning when it failed.
17
+ */
18
+ export class ConfigLoadHold {
19
+ held;
20
+ /** Holds `chunk` if a load is in flight; answers whether it did. */
21
+ hold(stream, chunk) {
22
+ if (this.held === undefined)
23
+ return false;
24
+ // A copy: the writer may reuse its buffer once the write returns.
25
+ this.held.push({ stream, chunk: typeof chunk === 'string' ? chunk : Buffer.from(chunk) });
26
+ return true;
27
+ }
28
+ /**
29
+ * Runs `load`, which loads a config and registers its secrets, holding
30
+ * what is written meanwhile. Then hands each held write to `release` in
31
+ * order, or, if `load` threw, `warn`s with how much was withheld.
32
+ */
33
+ async during(load, release, warn) {
34
+ this.held = [];
35
+ let loaded = false;
36
+ try {
37
+ const value = await load();
38
+ loaded = true;
39
+ return value;
40
+ }
41
+ finally {
42
+ const held = this.held;
43
+ this.held = undefined;
44
+ if (loaded) {
45
+ for (const { stream, chunk } of held)
46
+ release(stream, chunk);
47
+ }
48
+ else {
49
+ const bytes = held.reduce((total, { chunk }) => total + Buffer.byteLength(chunk), 0);
50
+ if (bytes > 0)
51
+ warn(withheldWarning(bytes));
52
+ }
53
+ }
54
+ }
55
+ }
56
+ /**
57
+ * The runner process's user output, on its way to the terminal or, while
58
+ * the list reporter shows it, through the reporter, which prints it above
59
+ * its live window instead of letting it land inside.
60
+ */
61
+ export class RunnerOutput {
62
+ terminal;
63
+ /** Each stream redacted on its own, across writes: a value split over two writes is still caught. */
64
+ redactors;
65
+ loadHold = new ConfigLoadHold();
66
+ view;
67
+ /** The terminal's stdout for the list reporter: its own lines and live window go straight there. */
68
+ listOutput;
69
+ constructor(terminal, ledger) {
70
+ this.terminal = terminal;
71
+ this.redactors = { stdout: new StreamRedactor(ledger), stderr: new StreamRedactor(ledger) };
72
+ this.listOutput = {
73
+ write: (line) => void terminal.stdout(`${line}\n`),
74
+ raw: (text) => void terminal.stdout(text),
75
+ };
76
+ }
77
+ /**
78
+ * One write of user code to `stream`, redacted: the tail a later write
79
+ * could complete into a value waits for it, or for `end`. Held while a
80
+ * config loads. Answers what the terminal did.
81
+ */
82
+ write(stream, chunk, callback) {
83
+ if (this.loadHold.hold(stream, chunk)) {
84
+ if (callback !== undefined)
85
+ process.nextTick(callback);
86
+ return true;
87
+ }
88
+ return this.emit(stream, this.redactors[stream].push(chunk), callback);
89
+ }
90
+ /**
91
+ * Runs `load`, a config load that registers the config's secrets, holding
92
+ * what is printed until it ends: the config's top-level code may print a
93
+ * value before the process knows it is one. Released redacted once it
94
+ * loaded; withheld with a warning when it failed.
95
+ */
96
+ withholdDuring(load) {
97
+ return this.loadHold.during(load, (stream, chunk) => this.write(stream, chunk), (line) => this.emit('stderr', `e2e: [warning] ${line}\n`));
98
+ }
99
+ /**
100
+ * Passes what is printed to `view` from now on, or to the terminal again
101
+ * when undefined. What the previous view was given is released to it
102
+ * first, unfinished lines included, and that view ended.
103
+ */
104
+ showThrough(view) {
105
+ if (this.view !== undefined) {
106
+ this.release();
107
+ this.view.end();
108
+ }
109
+ this.view = view;
110
+ }
111
+ /** Releases what is still held, redacted, to the terminal. Later writes stay redacted; only a tail a later write could complete into a value is held. */
112
+ end() {
113
+ this.showThrough(undefined);
114
+ this.release();
115
+ }
116
+ release() {
117
+ this.emit('stdout', this.redactors.stdout.flush());
118
+ this.emit('stderr', this.redactors.stderr.flush());
119
+ }
120
+ emit(stream, text, callback) {
121
+ if (text === '' || this.view !== undefined) {
122
+ if (text !== '')
123
+ this.view?.show(stream, text);
124
+ if (callback !== undefined)
125
+ process.nextTick(callback);
126
+ return true;
127
+ }
128
+ return this.terminal[stream](text, callback);
129
+ }
130
+ }
@@ -55,6 +55,8 @@ export interface PrepareScope {
55
55
  /** The same `env` the workers are started with: what prepare provisions must be where a launch will look for it. */
56
56
  readonly env: NodeJS.ProcessEnv;
57
57
  readonly signal: AbortSignal;
58
+ /** Whether the run asked for a visible surface, so a hook provisions for the browser it will launch. */
59
+ readonly headed: boolean;
58
60
  /** Where a hook's progress lines go, under the target they concern. */
59
61
  readonly notice: (targetName: string, line: string) => void;
60
62
  }
@@ -129,6 +129,7 @@ export class PreparedEngines {
129
129
  runId: scope.runId,
130
130
  targetName: target.name,
131
131
  projectRoot: scope.projectRoot,
132
+ headed: scope.headed,
132
133
  app: engineAppInfo(target.app),
133
134
  slots,
134
135
  env: scope.env,
@@ -30,13 +30,13 @@ export interface ArtifactRecord {
30
30
  /**
31
31
  * Mirrors report-1: how much of the file the runner masked. A screenshot is
32
32
  * `complete`; a trace is `complete` once every registered secret value was
33
- * rewritten out of its text, and `not-required` when no secret reached its
34
- * session (filled, or held by the engine); a video is `incomplete`, since a recording masks nothing
33
+ * rewritten out of its text, and `not-required` when the run has no
34
+ * secret value to rewrite; a video is `incomplete`, since a recording masks nothing
35
35
  * (a secure field renders its own dots, but anything else the screen
36
36
  * showed is in the frames), and is kept as it is; a download is
37
37
  * `incomplete` too, bytes the app served and the runner did not rewrite,
38
- * unless a secret reached the session and the file is text the
39
- * ledger was run over, which makes it `complete`. report-1 also admits an
38
+ * unless the file is text the session's ledger was run over, which makes
39
+ * it `complete`. report-1 also admits an
40
40
  * `incomplete` artifact without a `path`: a video a hosted service keeps,
41
41
  * recorded by `url`, or one its producer withheld, which this runner never
42
42
  * writes.
@@ -17,3 +17,10 @@ export declare function isRetryEligible(attempt: RetryAttempt): boolean;
17
17
  * did fail, and only its second chance was cut short.
18
18
  */
19
19
  export declare function runWithRetries(maxAttempts: number, interruptSignal: AbortSignal, runOnce: (attemptIndex: number) => Promise<RetryAttempt | undefined>): Promise<ResultStatus>;
20
+ /**
21
+ * The verdict a sequence of finished attempts reaches: a pass is `flaky`
22
+ * after a failure, an interrupt during a retry keeps the verdict before it,
23
+ * and a self-skip decides. Also the verdict of attempts a worker finished
24
+ * before the run's interrupt stopped it, so both read alike.
25
+ */
26
+ export declare function retryVerdict(attempts: readonly RetryAttempt[]): ResultStatus;
package/dist/run/retry.js CHANGED
@@ -13,25 +13,41 @@ export function isRetryEligible(attempt) {
13
13
  * did fail, and only its second chance was cut short.
14
14
  */
15
15
  export async function runWithRetries(maxAttempts, interruptSignal, runOnce) {
16
- let finalStatus = 'failed';
17
- let attemptCount = 0;
16
+ const attempts = [];
18
17
  for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex += 1) {
19
18
  if (interruptSignal.aborted)
20
19
  break;
21
20
  const attempt = await runOnce(attemptIndex);
22
21
  if (attempt === undefined)
23
22
  break;
24
- attemptCount += 1;
25
- if (attempt.status === 'passed')
26
- return attemptCount > 1 ? 'flaky' : 'passed';
27
- if (attempt.status === 'interrupted')
28
- return attemptCount > 1 ? finalStatus : 'interrupted';
23
+ attempts.push(attempt);
29
24
  // A body that skipped itself has decided; a retry would only ask again.
30
- if (attempt.status === 'skipped')
31
- return 'skipped';
32
- finalStatus = attempt.status;
25
+ if (attempt.status === 'passed' || attempt.status === 'interrupted' || attempt.status === 'skipped')
26
+ break;
33
27
  if (!isRetryEligible(attempt))
34
28
  break;
35
29
  }
36
- return finalStatus;
30
+ return retryVerdict(attempts);
31
+ }
32
+ /**
33
+ * The verdict a sequence of finished attempts reaches: a pass is `flaky`
34
+ * after a failure, an interrupt during a retry keeps the verdict before it,
35
+ * and a self-skip decides. Also the verdict of attempts a worker finished
36
+ * before the run's interrupt stopped it, so both read alike.
37
+ */
38
+ export function retryVerdict(attempts) {
39
+ let verdict = 'failed';
40
+ for (const [index, attempt] of attempts.entries()) {
41
+ switch (attempt.status) {
42
+ case 'passed':
43
+ return index > 0 ? 'flaky' : 'passed';
44
+ case 'interrupted':
45
+ return index > 0 ? verdict : 'interrupted';
46
+ case 'skipped':
47
+ return 'skipped';
48
+ default:
49
+ verdict = attempt.status;
50
+ }
51
+ }
52
+ return verdict;
37
53
  }
@@ -6,6 +6,7 @@ import type { ExploreProgress } from '../explore/progress.ts';
6
6
  import { type Report1Document, type ReportExplore } from '../report/build.ts';
7
7
  import { type RunEventSink, type RunExitCode, type RunStatus } from './events.ts';
8
8
  import { type ResultRecord } from './records.ts';
9
+ import type { RunnerOutput } from './process-output.ts';
9
10
  import type { BuiltinReporter, E2EConfig, RecordingMode } from '../types.ts';
10
11
  export interface RunOptions {
11
12
  cwd?: string | undefined;
@@ -109,6 +110,13 @@ export interface RunOptions {
109
110
  onEvent?: RunEventSink | undefined;
110
111
  /** Budget for each reporter's `onRunFinished`, in ms. Only the test harness sets it; there is no flag. */
111
112
  reporterTimeout?: number | undefined;
113
+ /**
114
+ * The process's stdout and stderr, when the caller claimed them (the CLI):
115
+ * what user code in this process prints is held while the config loads
116
+ * and shown through the list reporter while the run reports, and the list
117
+ * reporter writes to the terminal past it.
118
+ */
119
+ processOutput?: RunnerOutput | undefined;
112
120
  }
113
121
  /**
114
122
  * A registration supplied in memory, under the virtual file name the report
@@ -23,6 +23,7 @@ import { runUnits } from './scheduler.js';
23
23
  import { buildWorkPlans, plannedSlots } from './units.js';
24
24
  import { SessionStore } from './sessions.js';
25
25
  import { outputLayout } from './output.js';
26
+ import { registerStaticSecrets } from './secrecy.js';
26
27
  import { claimRerunDir, pruneArtifacts } from './artifacts.js';
27
28
  import { carryForward, lastFailedIds, readLastRun, reportArtifactPaths } from './last-run.js';
28
29
  import { childProcessSpawner } from './worker/handle.js';
@@ -131,14 +132,27 @@ export async function run(options = {}) {
131
132
  //
132
133
  // Free ports for URLs declared with port 0 are chosen here, once: workers
133
134
  // re-resolve the config and get the assignments in their bootstrap.
135
+ //
136
+ // The config's secrets are known to the process before what its top-level
137
+ // code printed is released, so that output is redacted like any other.
138
+ const loadConfig = async () => {
139
+ const config = await loadRunConfig(options, cwd, env, cli);
140
+ registerStaticSecrets(config.allSecrets);
141
+ return allocateAppPorts(config);
142
+ };
143
+ const processOutput = options.processOutput;
134
144
  const loaded = await debug
135
- .time('config.load', () => loadRunConfig(options, cwd, env, cli).then(allocateAppPorts))
145
+ .time('config.load', () => (processOutput === undefined ? loadConfig() : processOutput.withholdDuring(loadConfig)))
136
146
  .then((config) => ({ config, error: undefined }), (cause) => ({ config: undefined, error: classifyError(cause) }));
137
147
  // Every reporter, built-in or configured, is one `Reporter` on one spine:
138
148
  // the ids name the shipped ones, and a config failure still leaves the ids
139
149
  // the CLI asked for, so the failure renders through them.
140
150
  const reporterIds = loaded.config?.reporters ?? options.reporters ?? ['list'];
141
- const listReporter = options.quiet === true || !reporterIds.includes('list') ? undefined : new ListReporter();
151
+ const listReporter = options.quiet === true || !reporterIds.includes('list') ? undefined : new ListReporter(processOutput?.listOutput);
152
+ // Until the run is over, what user code prints lands above the live window.
153
+ processOutput?.showThrough(listReporter === undefined
154
+ ? undefined
155
+ : { show: (stream, text) => listReporter.processOutput(stream, text), end: () => listReporter.endProcessOutput() });
142
156
  const activeReporters = [
143
157
  ...(listReporter === undefined ? [] : [listReporter]),
144
158
  ...reporterIds.filter((id) => id !== 'list').map((id) => STATELESS_REPORTERS[id]),
@@ -182,13 +196,13 @@ export async function run(options = {}) {
182
196
  emit({ type: 'run-stopped', failures, limit });
183
197
  };
184
198
  /**
185
- * The exit code is a fold over run state — every result, every run error,
199
+ * The exit code is a fold over run state — every result and serial group, every run error,
186
200
  * the interrupt — never threaded through by hand. A run error recorded
187
201
  * anywhere, including during teardown or the report write, reaches the exit
188
202
  * code the same way.
189
203
  */
190
204
  const currentExitCode = () => combineExitCodes([
191
- ...resultExitCodes(results),
205
+ ...verdictExitCodes(results, serialGroups),
192
206
  ...(loaded.config?.failOnSkippedFailure === true && someSkippedAfterFailure(results, serialGroups) ? [1] : []),
193
207
  ...runErrors.map((runError) => exitCodeForCategory(runError.error.category)),
194
208
  ...(interruptController.signal.aborted ? [130] : []),
@@ -293,6 +307,9 @@ export async function run(options = {}) {
293
307
  ...(reportPath === undefined ? {} : { reportPath }),
294
308
  ...(aiTracePath === undefined ? {} : { aiTracePath }),
295
309
  });
310
+ // The list reporter has printed its summary and stopped its window; a
311
+ // line another reporter left unfinished on `run-finished` prints too.
312
+ processOutput?.showThrough(undefined);
296
313
  if (debug.enabled) {
297
314
  process.stderr.write(debug.summary());
298
315
  process.stderr.write(agentStepTable(results, serialGroups));
@@ -425,7 +442,7 @@ export async function run(options = {}) {
425
442
  // stream as `notice` events under it, so the reporter prints them instead
426
443
  // of a worker's stderr fighting the live status block.
427
444
  try {
428
- plans = await debug.time('engine.prepare', () => prepareEngines(plans, runWorkers, engines, { runId, projectRoot: config.projectRoot, env, signal: interrupted, notice }, emit));
445
+ plans = await debug.time('engine.prepare', () => prepareEngines(plans, runWorkers, engines, { runId, projectRoot: config.projectRoot, env, signal: interrupted, headed: options.headed ?? false, notice }, emit));
429
446
  }
430
447
  catch (cause) {
431
448
  if (!interrupted.aborted)
@@ -752,14 +769,20 @@ function plannedFiles(selection) {
752
769
  function statusOf(exitCode) {
753
770
  return exitCode === 0 ? 'passed' : exitCode === 1 ? 'failed' : exitCode === 130 ? 'interrupted' : 'error';
754
771
  }
755
- function resultExitCodes(results) {
772
+ /**
773
+ * The exit codes the verdicts imply: a failed test or serial group exits 1,
774
+ * or its attempts' error category when that is worse. A serial group answers
775
+ * for its members, whose results carry no attempts, and for a group attempt
776
+ * that failed before any member ran (a launch), whose members are skipped.
777
+ */
778
+ function verdictExitCodes(results, serialGroups) {
756
779
  const codes = [0];
757
- for (const result of results) {
758
- switch (result.status) {
780
+ for (const verdict of [...results, ...serialGroups]) {
781
+ switch (verdict.status) {
759
782
  case 'failed':
760
783
  case 'timed-out':
761
784
  codes.push(1);
762
- for (const attempt of result.attempts) {
785
+ for (const attempt of verdict.attempts) {
763
786
  if (attempt.error !== undefined)
764
787
  codes.push(exitCodeForCategory(attempt.error.category));
765
788
  }