@e2edev/e2e 0.6.0 → 0.7.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 (201) hide show
  1. package/README.md +7 -8
  2. package/dist/agent/act-validation.d.ts +4 -1
  3. package/dist/agent/act-validation.d.ts.map +1 -1
  4. package/dist/agent/act-validation.js +7 -3
  5. package/dist/agent/act-validation.js.map +1 -1
  6. package/dist/agent/act.d.ts.map +1 -1
  7. package/dist/agent/act.js +16 -2
  8. package/dist/agent/act.js.map +1 -1
  9. package/dist/agent/actions.d.ts +14 -14
  10. package/dist/agent/actions.d.ts.map +1 -1
  11. package/dist/agent/actions.js +12 -2
  12. package/dist/agent/actions.js.map +1 -1
  13. package/dist/agent/executor.d.ts +7 -0
  14. package/dist/agent/executor.d.ts.map +1 -1
  15. package/dist/agent/executor.js.map +1 -1
  16. package/dist/agent/invocation.d.ts.map +1 -1
  17. package/dist/agent/invocation.js +2 -1
  18. package/dist/agent/invocation.js.map +1 -1
  19. package/dist/agent/model/sdk.js +1 -1
  20. package/dist/agent/model/sdk.js.map +1 -1
  21. package/dist/agent/step-cache.d.ts +8 -8
  22. package/dist/agent/step-cache.d.ts.map +1 -1
  23. package/dist/agent/step-cache.js +14 -15
  24. package/dist/agent/step-cache.js.map +1 -1
  25. package/dist/agent/tool-loop.d.ts.map +1 -1
  26. package/dist/agent/tool-loop.js +1 -0
  27. package/dist/agent/tool-loop.js.map +1 -1
  28. package/dist/agent/usage.d.ts +6 -1
  29. package/dist/agent/usage.d.ts.map +1 -1
  30. package/dist/agent/usage.js +12 -0
  31. package/dist/agent/usage.js.map +1 -1
  32. package/dist/cache/anchors.d.ts.map +1 -1
  33. package/dist/cache/anchors.js +21 -1
  34. package/dist/cache/anchors.js.map +1 -1
  35. package/dist/cache/decide.d.ts +9 -0
  36. package/dist/cache/decide.d.ts.map +1 -1
  37. package/dist/cache/decide.js +35 -0
  38. package/dist/cache/decide.js.map +1 -1
  39. package/dist/cache/relocate.d.ts +11 -1
  40. package/dist/cache/relocate.d.ts.map +1 -1
  41. package/dist/cache/relocate.js +48 -17
  42. package/dist/cache/relocate.js.map +1 -1
  43. package/dist/cache/store.d.ts.map +1 -1
  44. package/dist/cache/store.js +3 -14
  45. package/dist/cache/store.js.map +1 -1
  46. package/dist/cache/trace.d.ts +12 -0
  47. package/dist/cache/trace.d.ts.map +1 -1
  48. package/dist/cache/trace.js +21 -0
  49. package/dist/cache/trace.js.map +1 -1
  50. package/dist/cli/docs-url.d.ts +1 -1
  51. package/dist/cli/docs-url.d.ts.map +1 -1
  52. package/dist/cli/docs-url.js +1 -1
  53. package/dist/cli/docs-url.js.map +1 -1
  54. package/dist/cli/index.d.ts.map +1 -1
  55. package/dist/cli/index.js +58 -9
  56. package/dist/cli/index.js.map +1 -1
  57. package/dist/cli/init/scaffold.d.ts.map +1 -1
  58. package/dist/cli/init/scaffold.js +3 -0
  59. package/dist/cli/init/scaffold.js.map +1 -1
  60. package/dist/cli/telemetry.d.ts +12 -0
  61. package/dist/cli/telemetry.d.ts.map +1 -0
  62. package/dist/cli/telemetry.js +40 -0
  63. package/dist/cli/telemetry.js.map +1 -0
  64. package/dist/collect/select.d.ts +2 -0
  65. package/dist/collect/select.d.ts.map +1 -1
  66. package/dist/collect/select.js +15 -11
  67. package/dist/collect/select.js.map +1 -1
  68. package/dist/config/agent.d.ts.map +1 -1
  69. package/dist/config/agent.js +41 -6
  70. package/dist/config/agent.js.map +1 -1
  71. package/dist/config/diagnose.d.ts.map +1 -1
  72. package/dist/config/diagnose.js +1 -0
  73. package/dist/config/diagnose.js.map +1 -1
  74. package/dist/config/resolve.d.ts.map +1 -1
  75. package/dist/config/resolve.js +2 -5
  76. package/dist/config/resolve.js.map +1 -1
  77. package/dist/engine/index.d.ts +5 -2
  78. package/dist/engine/index.d.ts.map +1 -1
  79. package/dist/engine/index.js.map +1 -1
  80. package/dist/index.d.ts +1 -1
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +1 -1
  83. package/dist/index.js.map +1 -1
  84. package/dist/internal/ai-trace.d.ts +1 -5
  85. package/dist/internal/ai-trace.d.ts.map +1 -1
  86. package/dist/internal/ai-trace.js +82 -11
  87. package/dist/internal/ai-trace.js.map +1 -1
  88. package/dist/internal/atomic-write.d.ts +17 -0
  89. package/dist/internal/atomic-write.d.ts.map +1 -0
  90. package/dist/internal/atomic-write.js +44 -0
  91. package/dist/internal/atomic-write.js.map +1 -0
  92. package/dist/internal/env.d.ts +6 -0
  93. package/dist/internal/env.d.ts.map +1 -0
  94. package/dist/internal/env.js +12 -0
  95. package/dist/internal/env.js.map +1 -0
  96. package/dist/report/build.d.ts.map +1 -1
  97. package/dist/report/build.js +9 -4
  98. package/dist/report/build.js.map +1 -1
  99. package/dist/report/format.d.ts +9 -1
  100. package/dist/report/format.d.ts.map +1 -1
  101. package/dist/report/format.js +17 -4
  102. package/dist/report/format.js.map +1 -1
  103. package/dist/report/list-model.d.ts +85 -0
  104. package/dist/report/list-model.d.ts.map +1 -0
  105. package/dist/report/list-model.js +10 -0
  106. package/dist/report/list-model.js.map +1 -0
  107. package/dist/report/list-steps.d.ts +27 -0
  108. package/dist/report/list-steps.d.ts.map +1 -0
  109. package/dist/report/list-steps.js +65 -0
  110. package/dist/report/list-steps.js.map +1 -0
  111. package/dist/report/list.d.ts +73 -27
  112. package/dist/report/list.d.ts.map +1 -1
  113. package/dist/report/list.js +201 -171
  114. package/dist/report/list.js.map +1 -1
  115. package/dist/report/live-window.d.ts +11 -0
  116. package/dist/report/live-window.d.ts.map +1 -1
  117. package/dist/report/live-window.js +12 -5
  118. package/dist/report/live-window.js.map +1 -1
  119. package/dist/report/running-tree.d.ts +46 -0
  120. package/dist/report/running-tree.d.ts.map +1 -0
  121. package/dist/report/running-tree.js +186 -0
  122. package/dist/report/running-tree.js.map +1 -0
  123. package/dist/report/write.d.ts +1 -1
  124. package/dist/report/write.d.ts.map +1 -1
  125. package/dist/report/write.js +3 -6
  126. package/dist/report/write.js.map +1 -1
  127. package/dist/run/events.d.ts +61 -8
  128. package/dist/run/events.d.ts.map +1 -1
  129. package/dist/run/events.js.map +1 -1
  130. package/dist/run/execute.d.ts +7 -0
  131. package/dist/run/execute.d.ts.map +1 -1
  132. package/dist/run/execute.js +6 -0
  133. package/dist/run/execute.js.map +1 -1
  134. package/dist/run/fixtures.d.ts +3 -0
  135. package/dist/run/fixtures.d.ts.map +1 -1
  136. package/dist/run/fixtures.js +5 -3
  137. package/dist/run/fixtures.js.map +1 -1
  138. package/dist/run/managed-process.d.ts +8 -2
  139. package/dist/run/managed-process.d.ts.map +1 -1
  140. package/dist/run/managed-process.js +12 -0
  141. package/dist/run/managed-process.js.map +1 -1
  142. package/dist/run/public.d.ts +1 -1
  143. package/dist/run/public.d.ts.map +1 -1
  144. package/dist/run/records.d.ts +2 -1
  145. package/dist/run/records.d.ts.map +1 -1
  146. package/dist/run/runner.d.ts +3 -2
  147. package/dist/run/runner.d.ts.map +1 -1
  148. package/dist/run/runner.js +129 -48
  149. package/dist/run/runner.js.map +1 -1
  150. package/dist/run/scheduler.d.ts +2 -0
  151. package/dist/run/scheduler.d.ts.map +1 -1
  152. package/dist/run/scheduler.js +4 -0
  153. package/dist/run/scheduler.js.map +1 -1
  154. package/dist/run/sessions.d.ts.map +1 -1
  155. package/dist/run/sessions.js +3 -4
  156. package/dist/run/sessions.js.map +1 -1
  157. package/dist/run/steps.d.ts +9 -1
  158. package/dist/run/steps.d.ts.map +1 -1
  159. package/dist/run/steps.js +2 -0
  160. package/dist/run/steps.js.map +1 -1
  161. package/dist/run/worker/protocol.d.ts +10 -1
  162. package/dist/run/worker/protocol.d.ts.map +1 -1
  163. package/dist/run/worker/protocol.js.map +1 -1
  164. package/dist/run/worker/session.d.ts.map +1 -1
  165. package/dist/run/worker/session.js +4 -0
  166. package/dist/run/worker/session.js.map +1 -1
  167. package/dist/run/worker-models.d.ts +27 -0
  168. package/dist/run/worker-models.d.ts.map +1 -0
  169. package/dist/run/worker-models.js +49 -0
  170. package/dist/run/worker-models.js.map +1 -0
  171. package/dist/telemetry/environment.d.ts +33 -0
  172. package/dist/telemetry/environment.d.ts.map +1 -0
  173. package/dist/telemetry/environment.js +96 -0
  174. package/dist/telemetry/environment.js.map +1 -0
  175. package/dist/telemetry/events.d.ts +25 -0
  176. package/dist/telemetry/events.d.ts.map +1 -0
  177. package/dist/telemetry/events.js +107 -0
  178. package/dist/telemetry/events.js.map +1 -0
  179. package/dist/telemetry/posthog.d.ts +31 -0
  180. package/dist/telemetry/posthog.d.ts.map +1 -0
  181. package/dist/telemetry/posthog.js +29 -0
  182. package/dist/telemetry/posthog.js.map +1 -0
  183. package/dist/telemetry/project.d.ts +18 -0
  184. package/dist/telemetry/project.d.ts.map +1 -0
  185. package/dist/telemetry/project.js +55 -0
  186. package/dist/telemetry/project.js.map +1 -0
  187. package/dist/telemetry/store.d.ts +53 -0
  188. package/dist/telemetry/store.d.ts.map +1 -0
  189. package/dist/telemetry/store.js +153 -0
  190. package/dist/telemetry/store.js.map +1 -0
  191. package/dist/telemetry/telemetry.d.ts +90 -0
  192. package/dist/telemetry/telemetry.d.ts.map +1 -0
  193. package/dist/telemetry/telemetry.js +188 -0
  194. package/dist/telemetry/telemetry.js.map +1 -0
  195. package/dist/types.d.ts +6 -9
  196. package/dist/types.d.ts.map +1 -1
  197. package/package.json +5 -5
  198. package/skills/e2e/references/agent.md +13 -3
  199. package/skills/e2e/references/debugging.md +1 -1
  200. package/skills/e2e/references/running.md +3 -2
  201. package/skills/e2e/references/setup.md +6 -1
@@ -1,67 +1,35 @@
1
1
  /**
2
- * Human-readable list reporter, styled after vitest's
3
- * default reporter: one block per test file and target, a `Failed Tests`
4
- * section with code frames, and a padded summary. On a TTY a live window
5
- * below the log shows the running files, their tests, and the counters.
2
+ * Human-readable list reporter, styled after vitest's default reporter: one
3
+ * block per test file and target with each test's agent steps nested, a
4
+ * `Failed Tests` section with code frames, and a padded summary. On a TTY a
5
+ * live window below the log shows the running tree (`RunningTree`) and the
6
+ * counters.
6
7
  */
7
8
  import path from 'node:path';
8
9
  import picocolors from 'picocolors';
9
- import { sanitizeText, truncateUtf8 } from '../internal/errors.js';
10
10
  import { packageVersion } from '../internal/package-version.js';
11
- import { collapseText } from '../internal/text.js';
12
11
  import { codeFrame, userFrame } from './code-frame.js';
13
- import { addUsage, aiSegment, bounded, ellipsize, emptyCounters, emptyUsage, fileOutcome, formatClock, formatTime, formatTokens, padTitle, rule, stateString, statusBucket, stepsUsage, sumUsage, tally, terminalColumns, terminalRows, } from './format.js';
12
+ import { addUsage, aiSegment, bounded, ellipsize, emptyCounters, emptyUsage, F_CHECK, F_CROSS, F_POINTER, fileOutcome, formatClock, formatTime, padTitle, rule, stateString, statusBucket, stepsUsage, sumUsage, tally, terminalColumns, usageText, } from './format.js';
13
+ import { isShownEvent } from './list-model.js';
14
+ import { stepLine } from './list-steps.js';
14
15
  import { LiveWindow } from './live-window.js';
15
- /** Step labels stay one glanceable line; the report holds the full text. */
16
- const MAX_STEP_LABEL_BYTES = 72;
17
- const F_POINTER = '❯';
18
- const F_CHECK = '✓';
19
- const F_CROSS = '×';
16
+ import { RunningTree } from './running-tree.js';
20
17
  const F_DOWN = '↓';
21
18
  const F_RIGHT = '→';
22
- const F_DOWN_RIGHT = '↳';
23
- const F_TREE_MIDDLE = '├──';
24
- const F_TREE_END = '└──';
25
19
  /** Indentation under a badge line, matching vitest's banner padding. */
26
20
  const BADGE_PADDING = ' ';
21
+ /** Indentation of a test line under its file line. */
22
+ const TEST_INDENT = ' ';
23
+ /** Indentation of a finished agent step under its test line in a file block. */
24
+ const STEP_INDENT = ' ';
27
25
  /**
28
26
  * Badge backgrounds, assigned to targets in declaration order. Bright variants
29
27
  * because GitHub Actions renders plain yellow as dark brown, which swallows the
30
28
  * black label.
31
29
  */
32
30
  const BADGE_COLORS = ['bgYellowBright', 'bgCyanBright', 'bgGreenBright', 'bgMagentaBright'];
33
- /**
34
- * Rows the live window spends outside the running tests and the summary: its
35
- * blank lines, the `… more running` marker, and a margin above the prompt.
36
- */
37
- const WINDOW_CHROME_ROWS = 6;
38
- /** Rows one running test takes before its calls: the test line and its current step. */
39
- const WINDOW_ROWS_PER_TEST = 2;
40
31
  /** Indentation of the `→ first error line` under a failed test in a file block. */
41
32
  const ERROR_GLANCE_INDENT = 7;
42
- /** One-line, quoted step label bounded for the live view. */
43
- function stepLabel(label) {
44
- const flat = collapseText(label);
45
- const shown = truncateUtf8(flat, MAX_STEP_LABEL_BYTES);
46
- return `"${shown}${shown === flat ? '' : '…'}"`;
47
- }
48
- /**
49
- * Live tail for one step event. Model and engine calls are the ones worth a
50
- * glance (`tool:` prefixes come from executor tool accounting); polls and
51
- * policy decisions stay quiet.
52
- */
53
- function eventTail(event) {
54
- if (event.kind === 'model') {
55
- const tokens = event.count !== undefined && event.count > 0 ? ` · ${formatTokens(event.count)} tokens` : '';
56
- return `model turn ${formatTime(event.durationMs)}${tokens}`;
57
- }
58
- if (event.kind === 'engine') {
59
- const name = sanitizeText(event.name ?? 'engine').replace(/^tool:/, '');
60
- const failed = event.status === 'passed' ? '' : ` ${F_CROSS}`;
61
- return `${truncateUtf8(name, 40)} ${formatTime(event.durationMs)}${failed}`;
62
- }
63
- return undefined;
64
- }
65
33
  /** The identity of one test-target pair across `test-started`, `step`, and `test-finished`. */
66
34
  function pairKey(testId, target) {
67
35
  return `${testId}@${target}`;
@@ -108,16 +76,25 @@ export class ListReporter {
108
76
  output;
109
77
  pc;
110
78
  window;
79
+ tree;
111
80
  separator;
112
81
  projectRoot;
113
82
  /** Selected targets in declaration order; decides each badge's color. */
114
83
  targets = [];
115
- /** When the run began: the summary's `Start at` and the origin of `Duration`. */
116
- startedAt = new Date();
84
+ /** When the run was launched (`run-started`): what a run that never reached `plan` reports. */
85
+ launchedAt = new Date();
86
+ /**
87
+ * When the run began executing (`plan`): the summary's `Start at` and the
88
+ * origin of `Duration`. Undefined until then, so the clock never ticks
89
+ * while a first-run download narrates above the live window.
90
+ */
91
+ startedAt;
117
92
  /** File groups keyed `${target}\u0000${file}`, in first-seen order. */
118
93
  groups = new Map();
119
- /** Pairs executing right now, keyed by `pairKey`, in start order. */
120
- running = new Map();
94
+ /** Pairs that started and whose result is still to come, keyed by `pairKey`, in start order. */
95
+ pairs = new Map();
96
+ /** Whether a live window paints; without one, finished steps stream permanently. */
97
+ live;
121
98
  failures = [];
122
99
  /**
123
100
  * Serial groups whose members' results are still to come, by group id. The
@@ -127,13 +104,29 @@ export class ListReporter {
127
104
  pendingSerial = new Map();
128
105
  /** Run-level errors, printed once at the end; the stream carries them as they happen. */
129
106
  errors = [];
107
+ /**
108
+ * Whether the run was cut short. An interrupt during service or app startup
109
+ * lands before discovery, so the counters stay at zero; the summary must not
110
+ * read that as "no test files".
111
+ */
112
+ interrupted = false;
113
+ /**
114
+ * The setup step in flight, ticking in the live window. `narrated` once a
115
+ * notice printed under it: a step that said something (a browser download)
116
+ * prints its total when done, a silent one (an instant `prepare`) does not.
117
+ */
118
+ inFlight;
119
+ /** Time spent starting services and app commands, split out of the summary's `Duration`. */
120
+ startupMs = 0;
130
121
  /** Run-wide model usage, summed from every reported result and serial group. */
131
122
  runUsage = emptyUsage();
132
123
  constructor(output = DEFAULT_OUTPUT, options = {}) {
133
124
  this.output = output;
134
125
  const live = (options.live ?? process.stdout.isTTY === true) && output.raw !== undefined;
126
+ this.live = live;
135
127
  this.pc = picocolors.createColors(options.colors ?? (live || picocolors.isColorSupported));
136
128
  this.separator = this.pc.dim(' > ');
129
+ this.tree = new RunningTree(this.pc, (target) => this.badge(target));
137
130
  this.window = new LiveWindow(live ? output.raw?.bind(output) : undefined, () => this.renderWindow());
138
131
  }
139
132
  /**
@@ -149,8 +142,13 @@ export class ListReporter {
149
142
  this.plan(event);
150
143
  break;
151
144
  case 'notice':
145
+ if (this.inFlight !== undefined)
146
+ this.inFlight.narrated = true;
152
147
  this.print(`${this.pc.dim('ℹ')} ${bounded(event.message)}`);
153
148
  break;
149
+ case 'setup':
150
+ this.setupStep(event);
151
+ break;
154
152
  case 'test-started':
155
153
  this.testStarted(event);
156
154
  break;
@@ -198,7 +196,7 @@ export class ListReporter {
198
196
  const key = `${target}\u0000${file}`;
199
197
  let group = this.groups.get(key);
200
198
  if (group === undefined) {
201
- group = { file, target, planned: undefined, lines: [], streamed: false, printed: false };
199
+ group = { file, target, planned: undefined, lines: [], printed: false };
202
200
  this.groups.set(key, group);
203
201
  }
204
202
  return group;
@@ -207,7 +205,7 @@ export class ListReporter {
207
205
  const { pc } = this;
208
206
  this.projectRoot = event.projectRoot;
209
207
  this.targets = event.targets;
210
- this.startedAt = new Date();
208
+ this.launchedAt = new Date();
211
209
  const version = packageVersion(import.meta.url, '../../package.json', '0.0.0');
212
210
  this.output.write('');
213
211
  this.output.write(`${pc.bold(pc.black(pc.bgCyan(' RUN ')))} ${pc.cyan(`e2e v${version}`)} ${pc.gray(event.projectRoot)}`);
@@ -215,97 +213,144 @@ export class ListReporter {
215
213
  if (event.ci)
216
214
  details.push('CI');
217
215
  this.output.write(BADGE_PADDING + pc.dim(details.join(' · ')));
216
+ if (event.model !== undefined) {
217
+ this.output.write(BADGE_PADDING + pc.dim(`model ${bounded(event.model)}`));
218
+ }
218
219
  this.output.write('');
219
220
  this.window.start();
220
221
  }
222
+ /**
223
+ * The plan is settled once collection and every target's `prepare` are
224
+ * done, so its arrival is when the run starts executing and the clock
225
+ * starts: a browser downloaded moments earlier is not on it.
226
+ */
221
227
  plan(event) {
228
+ this.startedAt = new Date();
222
229
  for (const planned of event.files) {
223
230
  this.group(planned.file, planned.target).planned = planned.tests;
224
231
  }
225
232
  this.window.redraw();
226
233
  }
227
- /** Acknowledged immediately, so a bounded teardown is not mistaken for a hang. */
234
+ /**
235
+ * The setup stretch before any test: collection, each target's `prepare`,
236
+ * then the services and app commands. Without this the window showed only
237
+ * zero counters while a database booted or a browser downloaded. The step
238
+ * in flight ticks in the live window; a finished process prints once with
239
+ * its wait, a finished collection or provisioning only when it narrated;
240
+ * the process waits sum into the summary's startup split.
241
+ */
242
+ setupStep(event) {
243
+ const { pc } = this;
244
+ const { verb, subject, done } = setupWording(event.step);
245
+ if (event.state === 'started') {
246
+ this.inFlight = { step: event.step, verb, subject, startedMs: Date.now(), narrated: false };
247
+ this.window.redraw();
248
+ return;
249
+ }
250
+ const narrated = this.inFlight?.narrated ?? false;
251
+ this.inFlight = undefined;
252
+ const process = event.step.kind === 'service' || event.step.kind === 'app';
253
+ if (process)
254
+ this.startupMs += event.durationMs;
255
+ if (!process && !narrated) {
256
+ this.window.redraw();
257
+ return;
258
+ }
259
+ const outcome = event.outcome ?? done;
260
+ this.print(` ${pc.green(F_CHECK)} ${subject} ${pc.dim(`${outcome} ${formatTime(event.durationMs)}`)}`);
261
+ }
262
+ /**
263
+ * Acknowledged immediately, so a bounded teardown is not mistaken for a
264
+ * hang. An interrupt that lands during setup names the step it cut short
265
+ * (`interrupted while starting service "postgres"`), since no test was
266
+ * running to stop.
267
+ */
228
268
  runInterrupted(event) {
229
- this.print(this.pc.yellow(event.mode === 'graceful'
230
- ? 'interrupted: stopping the running test and tearing down (interrupt again to force)'
231
- : 'interrupted again: tearing every worker down now'));
269
+ this.interrupted = true;
270
+ const during = this.inFlight === undefined ? undefined : `${this.inFlight.verb} ${this.inFlight.subject}`;
271
+ this.print(this.pc.yellow(event.mode === 'forced'
272
+ ? 'interrupted again: tearing every worker down now'
273
+ : during === undefined
274
+ ? 'interrupted: stopping the running test and tearing down (interrupt again to force)'
275
+ : `interrupted while ${during}: tearing down (interrupt again to force)`));
232
276
  }
233
277
  /**
234
278
  * A worker began one test-target pair: show it in the live window. A serial
235
279
  * member replaces the previous member of its group, which has finished
236
- * executing even though its result only arrives with the whole group.
280
+ * executing even though its result only arrives with the whole group. A
281
+ * serial group retries as a whole, so a member starts once per group
282
+ * attempt; its steps from earlier attempts stay with it, as an ordinary
283
+ * test's do.
237
284
  */
238
285
  testStarted(event) {
239
286
  if (event.serialId !== undefined) {
240
- for (const [key, test] of this.running) {
241
- if (test.serialId === event.serialId && test.group.target === event.target) {
242
- this.running.delete(key);
243
- }
287
+ for (const test of this.pairs.values()) {
288
+ if (test.serialId === event.serialId && test.group.target === event.target)
289
+ test.executing = false;
244
290
  }
245
291
  }
246
- this.running.set(pairKey(event.testId, event.target), {
292
+ const key = pairKey(event.testId, event.target);
293
+ this.pairs.set(key, {
247
294
  group: this.group(event.file, event.target),
248
295
  serialId: event.serialId,
249
296
  title: bounded(event.title),
250
297
  startedMs: Date.now(),
251
- step: undefined,
252
- events: [],
253
- streaming: false,
298
+ executing: true,
299
+ current: undefined,
300
+ steps: this.pairs.get(key)?.steps ?? [],
254
301
  });
255
302
  this.window.redraw();
256
303
  }
257
304
  /**
258
- * Streams step progress of a running pair. With a single test running, its
259
- * agent steps print permanently and chronologically under a header naming
260
- * the file and test - the scrollback of a long agentic run reads without
261
- * `--debug`. Deterministic steps are fast and many, so they only ever show
262
- * in the live window. With parallel tests the stream would interleave, so
263
- * each pair instead shows its current step and latest calls transiently in
264
- * the live window, and finished agent steps print with the test they
265
- * belong to.
305
+ * Tracks step progress of a running pair. With a live window the pair's
306
+ * finished agent steps accumulate under its row there and print once, nested
307
+ * under its line, when the file block prints - so every name appears exactly
308
+ * once in the scrollback. Without a window (CI logs) nothing is transient, so
309
+ * each finished agent step prints at once, prefixed with its test. Deterministic
310
+ * steps are fast and many, so they only ever show in the live window.
266
311
  */
267
312
  step(event) {
268
- const { pc } = this;
269
- const running = this.running.get(pairKey(event.testId, event.target));
313
+ const running = this.pairs.get(pairKey(event.testId, event.target));
270
314
  if (running === undefined)
271
315
  return;
272
316
  const { progress } = event;
273
317
  switch (progress.phase) {
274
318
  case 'start': {
275
- running.step = `${progress.api} ${stepLabel(progress.label)}`;
276
- running.events = [];
277
- if (progress.kind === 'agent' && this.running.size === 1 && !running.streaming) {
278
- running.streaming = true;
279
- const { group } = running;
280
- group.streamed = true;
281
- this.print(` ${pc.yellow(F_POINTER)} ${this.badge(group.target)} ${pc.dim(bounded(group.file))}${this.separator}${running.title}`);
282
- }
319
+ const { api, label, kind } = progress;
320
+ running.current = { api, label, kind, events: [], turnStart: 0 };
283
321
  this.window.redraw();
284
322
  break;
285
323
  }
286
324
  case 'event': {
287
- const tail = eventTail(progress.event);
288
- if (running.step === undefined || tail === undefined)
325
+ const { current } = running;
326
+ if (current === undefined || !isShownEvent(progress.event))
289
327
  break;
290
- running.events.push(tail);
328
+ if (progress.event.kind === 'model') {
329
+ current.events.splice(current.turnStart, 0, progress.event);
330
+ current.turnStart = current.events.length;
331
+ }
332
+ else {
333
+ current.events.push(progress.event);
334
+ }
291
335
  this.window.redraw();
292
336
  break;
293
337
  }
294
338
  case 'end': {
295
- running.step = undefined;
296
- running.events = [];
339
+ running.current = undefined;
297
340
  if (progress.kind !== 'agent') {
298
341
  this.window.redraw();
299
342
  break;
300
343
  }
301
- const glyph = progress.status === 'passed' ? pc.green(F_CHECK) : pc.red(F_CROSS);
302
- const calls = progress.modelCalls > 0
303
- ? ` · ${progress.modelCalls} model call${progress.modelCalls === 1 ? '' : 's'}`
304
- : '';
305
- const outcome = progress.status === 'passed' ? '' : ` ${progress.status}`;
306
- const context = running.streaming ? '' : `${pc.dim(running.title)}${this.separator}`;
307
- this.print(` ${glyph} ${context}${pc.dim(progress.api)} ${stepLabel(progress.label)} ` +
308
- pc.dim(`${formatTime(progress.durationMs)}${calls}${outcome}`));
344
+ const { api, label, status, durationMs, modelCalls } = progress;
345
+ const step = { api, label, status, durationMs, modelCalls };
346
+ if (this.live) {
347
+ running.steps.push(step);
348
+ this.window.redraw();
349
+ }
350
+ else {
351
+ const context = `${this.pc.dim(running.title)}${this.separator}`;
352
+ this.print(`${TEST_INDENT}${stepLine(this.pc, step, { context })}`);
353
+ }
309
354
  break;
310
355
  }
311
356
  }
@@ -335,12 +380,14 @@ export class ListReporter {
335
380
  // counted them.
336
381
  if (!result.selected)
337
382
  return;
338
- this.running.delete(pairKey(result.test.id, result.target.name));
383
+ const key = pairKey(result.test.id, result.target.name);
384
+ const steps = this.pairs.get(key)?.steps ?? [];
385
+ this.pairs.delete(key);
339
386
  const group = this.group(result.test.file, result.target.name);
340
387
  const { durationMs, usage, error } = this.detailsOf(result);
341
388
  addUsage(this.runUsage, usage);
342
389
  const title = bounded(result.test.titlePath.join(' > '));
343
- group.lines.push({
390
+ const line = {
344
391
  title,
345
392
  declarationIndex: result.test.declarationIndex,
346
393
  status: result.status,
@@ -348,7 +395,9 @@ export class ListReporter {
348
395
  usage,
349
396
  firstErrorLine: error === undefined ? undefined : bounded(error.message).split('\n')[0],
350
397
  skipReason: result.skip === undefined ? undefined : bounded(result.skip.reason),
351
- });
398
+ steps,
399
+ };
400
+ group.lines.push(line);
352
401
  if (statusBucket(result.status) === 'failed') {
353
402
  this.failures.push({ group, title, status: result.status, error });
354
403
  }
@@ -376,8 +425,11 @@ export class ListReporter {
376
425
  }
377
426
  /**
378
427
  * Prints one file's block: the file line with its counts, then - when the
379
- * file failed, had a flaky pass, streamed its steps, or is the run's only
380
- * file - one line per test, as vitest lists tests for a failed module.
428
+ * file failed, had a flaky pass, or is the run's only file - one line per
429
+ * test with its finished agent steps nested, as vitest lists tests for a
430
+ * failed module. With a live window the block is also the only permanent
431
+ * record of a file's agent steps, so a file that has any lists its tests
432
+ * too; without one the steps already printed as they finished.
381
433
  */
382
434
  printGroup(group) {
383
435
  if (group.printed)
@@ -413,14 +465,16 @@ export class ListReporter {
413
465
  this.print(parts.join(' '));
414
466
  const verbose = counts.failed > 0 ||
415
467
  counts.flaky > 0 ||
416
- group.streamed ||
417
- this.groups.size === 1;
468
+ this.groups.size === 1 ||
469
+ (this.live && group.lines.some((line) => line.steps.length > 0));
418
470
  if (!verbose)
419
471
  return;
420
472
  const ordered = group.lines.toSorted((a, b) => a.declarationIndex - b.declarationIndex);
421
473
  for (const line of ordered) {
422
474
  for (const rendered of this.testLine(line))
423
475
  this.print(rendered);
476
+ for (const step of line.steps)
477
+ this.print(`${STEP_INDENT}${stepLine(pc, step)}`);
424
478
  }
425
479
  }
426
480
  /** One test row under its file line, plus the first error line for a failure. */
@@ -455,89 +509,43 @@ export class ListReporter {
455
509
  }
456
510
  /**
457
511
  * vitest's padded summary, shared by the live window and the final report:
458
- * files, tests, model usage, run errors, start time, and elapsed time.
512
+ * files, tests, model usage, run errors, start time, and elapsed time. The
513
+ * clock rows wait for `plan`; the final summary of a run that never got
514
+ * there (a config or collection failure, a failed download) counts from
515
+ * the launch instead, so the time it took to fail is still on record.
459
516
  */
460
- summaryRows() {
517
+ summaryRows(final) {
461
518
  const { pc } = this;
462
519
  const files = this.fileCounters();
463
520
  const tests = this.testCounters();
464
521
  const rows = [
465
- padTitle(pc, 'Test Files') + (files.total === 0 ? pc.dim('no test files') : stateString(pc, files)),
466
- padTitle(pc, 'Tests') + (tests.total === 0 ? pc.dim('no tests executed') : stateString(pc, tests)),
522
+ padTitle(pc, 'Test Files') + (files.total === 0 ? this.emptyState('no test files', 'none started') : stateString(pc, files)),
523
+ padTitle(pc, 'Tests') + (tests.total === 0 ? this.emptyState('no tests executed', 'none executed') : stateString(pc, tests)),
467
524
  ];
468
- const ai = aiSegment(this.runUsage);
525
+ const ai = usageText(this.runUsage);
469
526
  if (ai !== undefined)
470
527
  rows.push(padTitle(pc, 'AI') + `${ai} · ${this.runUsage.calls} model calls`);
471
528
  if (this.errors.length > 0) {
472
529
  const count = this.errors.length;
473
530
  rows.push(padTitle(pc, 'Errors') + pc.bold(pc.red(`${count} error${count === 1 ? '' : 's'}`)));
474
531
  }
475
- rows.push(padTitle(pc, 'Start at') + formatClock(this.startedAt));
476
- rows.push(padTitle(pc, 'Duration') + formatTime(Date.now() - this.startedAt.getTime()));
532
+ const startedAt = this.startedAt ?? (final ? this.launchedAt : undefined);
533
+ if (startedAt !== undefined) {
534
+ rows.push(padTitle(pc, 'Start at') + formatClock(startedAt));
535
+ const startup = this.startupMs > 0 ? pc.dim(` (startup ${formatTime(this.startupMs)})`) : '';
536
+ rows.push(padTitle(pc, 'Duration') + formatTime(Date.now() - startedAt.getTime()) + startup);
537
+ }
477
538
  return rows;
478
539
  }
479
- /**
480
- * The live window: running files and tests, then the summary. The block
481
- * must fit the screen, because repainting more rows than the terminal has
482
- * breaks the cursor-up erase: rows left after the summary go to the running
483
- * tests, each test's calls share what remains once every test has its own
484
- * line, and tests that still do not fit fold into one `more running` marker.
485
- */
486
- renderWindow() {
540
+ /** A zero counter's label: the plain text, or the interrupt when that is why nothing is counted. */
541
+ emptyState(none, cut) {
487
542
  const { pc } = this;
488
- const now = Date.now();
489
- const active = new Map();
490
- for (const test of this.running.values()) {
491
- const tests = active.get(test.group);
492
- if (tests === undefined)
493
- active.set(test.group, [test]);
494
- else
495
- tests.push(test);
496
- }
497
- const summary = this.summaryRows();
498
- let budget = terminalRows() - summary.length - WINDOW_CHROME_ROWS;
499
- const spare = budget - active.size - this.running.size * WINDOW_ROWS_PER_TEST;
500
- const maxEvents = Math.max(0, Math.floor(spare / Math.max(1, this.running.size)));
501
- const lines = [''];
502
- let hidden = 0;
503
- for (const [group, tests] of active) {
504
- if (budget < 2) {
505
- hidden += tests.length;
506
- continue;
507
- }
508
- const progress = pc.dim(` ${group.lines.length}/${group.planned ?? '?'}`);
509
- lines.push(`${pc.bold(pc.yellow(` ${F_POINTER} `))}${this.badge(group.target)} ${bounded(group.file)}${progress}`);
510
- budget -= 1;
511
- tests.forEach((test, index) => {
512
- if (budget < 1) {
513
- hidden += 1;
514
- return;
515
- }
516
- const glyph = index === tests.length - 1 ? F_TREE_END : F_TREE_MIDDLE;
517
- const elapsed = pc.bold(pc.yellow(formatTime(Math.max(0, now - test.startedMs))));
518
- lines.push(`${pc.bold(pc.yellow(` ${glyph} `))}${test.title} ${elapsed}`);
519
- budget -= 1;
520
- if (test.step === undefined)
521
- return;
522
- const detail = [` ${pc.dim(`${F_DOWN_RIGHT} ${test.step}`)}`];
523
- const overflow = test.events.length - maxEvents;
524
- if (overflow > 0) {
525
- detail.push(` ${pc.dim(`… ${overflow} earlier call${overflow === 1 ? '' : 's'}`)}`);
526
- }
527
- for (const tail of overflow > 0 ? test.events.slice(overflow) : test.events) {
528
- detail.push(` ${pc.dim(tail)}`);
529
- }
530
- const shown = detail.slice(0, budget);
531
- lines.push(...shown);
532
- budget -= shown.length;
533
- });
534
- }
535
- if (hidden > 0)
536
- lines.push(pc.dim(` … ${hidden} more running`));
537
- if (active.size > 0)
538
- lines.push('');
539
- lines.push(...summary, '');
540
- return lines;
543
+ return this.interrupted ? pc.yellow(`${cut} (interrupted)`) : pc.dim(none);
544
+ }
545
+ /** The live window: the running tree, then the summary. */
546
+ renderWindow() {
547
+ const running = [...this.pairs.values()].filter((test) => test.executing);
548
+ return this.tree.render(running, this.summaryRows(false), Date.now(), this.inFlight);
541
549
  }
542
550
  /** vitest's `Failed Tests` section: a banner, then each failure with its code frame. */
543
551
  printFailures() {
@@ -605,6 +613,8 @@ export class ListReporter {
605
613
  }
606
614
  runFinished(event) {
607
615
  const { pc } = this;
616
+ if (event.status === 'interrupted')
617
+ this.interrupted = true;
608
618
  this.window.stop();
609
619
  // An interrupted or crashed run leaves files without their full result
610
620
  // set; print what they have so nothing that ran goes unreported.
@@ -615,7 +625,7 @@ export class ListReporter {
615
625
  this.printFailures();
616
626
  this.printErrors();
617
627
  this.print('');
618
- for (const row of this.summaryRows())
628
+ for (const row of this.summaryRows(true))
619
629
  this.print(row);
620
630
  this.print(padTitle(pc, 'Report') +
621
631
  (event.reportPath === undefined ? pc.dim('(not written)') : this.displayPath(event.reportPath)));
@@ -629,4 +639,24 @@ export class ListReporter {
629
639
  this.print('');
630
640
  }
631
641
  }
642
+ /**
643
+ * How the reporter phrases one setup step: the verb and subject while it
644
+ * runs (`starting service "postgres"`), and the word for its completion
645
+ * (`ready`); a reused process says `reused` instead.
646
+ */
647
+ function setupWording(step) {
648
+ switch (step.kind) {
649
+ case 'collect':
650
+ return { verb: 'collecting', subject: 'tests', done: 'collected' };
651
+ case 'prepare':
652
+ return {
653
+ verb: 'preparing',
654
+ subject: `${bounded(step.engine)} engine for target "${bounded(step.target)}"`,
655
+ done: 'prepared',
656
+ };
657
+ case 'service':
658
+ case 'app':
659
+ return { verb: 'starting', subject: bounded(step.label), done: 'ready' };
660
+ }
661
+ }
632
662
  //# sourceMappingURL=list.js.map