e2e 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/README.md +10 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/act.js +7 -4
  4. package/dist/agent/action-dispatcher.js +4 -5
  5. package/dist/agent/actions.d.ts +32 -23
  6. package/dist/agent/actions.js +38 -24
  7. package/dist/agent/ai-sdk.js +5 -4
  8. package/dist/agent/error.js +3 -3
  9. package/dist/agent/invocation.js +3 -4
  10. package/dist/agent/model/sdk.js +2 -0
  11. package/dist/agent/model/tool-choice.d.ts +9 -0
  12. package/dist/agent/model/tool-choice.js +14 -0
  13. package/dist/agent/observation-feed.d.ts +5 -6
  14. package/dist/agent/observation-feed.js +8 -11
  15. package/dist/agent/observation.d.ts +40 -11
  16. package/dist/agent/observation.js +109 -59
  17. package/dist/agent/phases.d.ts +7 -1
  18. package/dist/agent/phases.js +12 -3
  19. package/dist/agent/point-tap.d.ts +8 -8
  20. package/dist/agent/point-tap.js +1 -1
  21. package/dist/agent/prompts.js +3 -0
  22. package/dist/agent/replay.d.ts +19 -8
  23. package/dist/agent/replay.js +29 -29
  24. package/dist/agent/scroll-target.d.ts +2 -3
  25. package/dist/agent/step-cache.d.ts +43 -15
  26. package/dist/agent/step-cache.js +156 -67
  27. package/dist/agent/tool-loop.js +94 -5
  28. package/dist/cache/anchors.d.ts +72 -23
  29. package/dist/cache/anchors.js +233 -60
  30. package/dist/cache/context.d.ts +25 -14
  31. package/dist/cache/context.js +50 -8
  32. package/dist/cache/decide.d.ts +2 -2
  33. package/dist/cache/decide.js +4 -6
  34. package/dist/cache/identity.d.ts +18 -4
  35. package/dist/cache/identity.js +9 -4
  36. package/dist/cache/recorder.d.ts +15 -1
  37. package/dist/cache/recorder.js +36 -23
  38. package/dist/cache/rekeyed.d.ts +46 -0
  39. package/dist/cache/rekeyed.js +89 -0
  40. package/dist/cache/relocate.d.ts +14 -14
  41. package/dist/cache/relocate.js +33 -25
  42. package/dist/cache/route.d.ts +32 -33
  43. package/dist/cache/route.js +94 -50
  44. package/dist/cache/store.d.ts +6 -0
  45. package/dist/cache/store.js +21 -1
  46. package/dist/cache/trace.d.ts +53 -14
  47. package/dist/cache/trace.js +76 -30
  48. package/dist/cli/bin.js +2 -2
  49. package/dist/cli/index.js +43 -32
  50. package/dist/cli/init/engines.d.ts +1 -3
  51. package/dist/cli/init/engines.js +2 -2
  52. package/dist/cli/init/gateways.d.ts +1 -1
  53. package/dist/cli/init/gateways.js +11 -1
  54. package/dist/cli/init/mcp-config.d.ts +2 -1
  55. package/dist/cli/init/mcp-config.js +8 -3
  56. package/dist/cli/init/sibling-versions.json +1 -1
  57. package/dist/cli/init/versions.d.ts +0 -8
  58. package/dist/cli/init/versions.js +1 -10
  59. package/dist/cli/mcp.d.ts +3 -2
  60. package/dist/cli/mcp.js +52 -16
  61. package/dist/cli/run-output.d.ts +17 -0
  62. package/dist/cli/run-output.js +45 -0
  63. package/dist/collect/collect.js +8 -4
  64. package/dist/collect/registry.js +27 -16
  65. package/dist/config/compiled-files.d.ts +36 -0
  66. package/dist/config/compiled-files.js +50 -0
  67. package/dist/config/diagnose.js +21 -19
  68. package/dist/config/esm-hooks.d.ts +39 -44
  69. package/dist/config/esm-hooks.js +238 -63
  70. package/dist/config/load.d.ts +1 -7
  71. package/dist/config/load.js +23 -66
  72. package/dist/config/register.d.ts +8 -0
  73. package/dist/config/register.js +9 -0
  74. package/dist/config/resolve.d.ts +1 -0
  75. package/dist/config/resolve.js +33 -1
  76. package/dist/config/specifiers.d.ts +19 -0
  77. package/dist/config/specifiers.js +33 -0
  78. package/dist/config/tsconfig.d.ts +56 -0
  79. package/dist/config/tsconfig.js +115 -0
  80. package/dist/config/typescript.d.ts +19 -0
  81. package/dist/config/typescript.js +253 -0
  82. package/dist/engine/contract.d.ts +1 -0
  83. package/dist/engine/index.d.ts +4 -2
  84. package/dist/engine/session.js +2 -2
  85. package/dist/expect/async.js +4 -2
  86. package/dist/expect/poll-scope.d.ts +52 -0
  87. package/dist/expect/poll-scope.js +113 -0
  88. package/dist/expect/poll.js +33 -6
  89. package/dist/expect/values.js +11 -4
  90. package/dist/internal/abandoned.d.ts +11 -0
  91. package/dist/internal/abandoned.js +23 -0
  92. package/dist/internal/brands.d.ts +2 -0
  93. package/dist/internal/brands.js +2 -0
  94. package/dist/internal/client-identity.d.ts +9 -0
  95. package/dist/internal/client-identity.js +14 -0
  96. package/dist/internal/errors.d.ts +10 -3
  97. package/dist/internal/errors.js +23 -8
  98. package/dist/internal/node-version.d.ts +16 -7
  99. package/dist/internal/node-version.js +50 -15
  100. package/dist/internal/package-manager.js +1 -1
  101. package/dist/internal/realm-slot.d.ts +5 -4
  102. package/dist/internal/redact.d.ts +9 -4
  103. package/dist/internal/redact.js +120 -23
  104. package/dist/internal/time.d.ts +26 -4
  105. package/dist/internal/time.js +58 -14
  106. package/dist/internal/urls.d.ts +9 -5
  107. package/dist/internal/urls.js +19 -10
  108. package/dist/locator/engine.d.ts +12 -2
  109. package/dist/locator/engine.js +55 -14
  110. package/dist/locator/screen.js +59 -9
  111. package/dist/mcp/catalog.d.ts +3 -2
  112. package/dist/mcp/catalog.js +6 -6
  113. package/dist/mcp/output.d.ts +59 -0
  114. package/dist/mcp/output.js +129 -0
  115. package/dist/mcp/server.d.ts +4 -0
  116. package/dist/mcp/server.js +9 -12
  117. package/dist/mcp/session.d.ts +8 -1
  118. package/dist/mcp/session.js +56 -15
  119. package/dist/mcp/tools.js +7 -5
  120. package/dist/oauth/chatgpt.js +3 -23
  121. package/dist/oauth/cli.js +1 -0
  122. package/dist/oauth/copilot.d.ts +5 -4
  123. package/dist/oauth/copilot.js +80 -7
  124. package/dist/oauth/grok.js +2 -1
  125. package/dist/oauth/models.js +2 -1
  126. package/dist/oauth/opencode-console.d.ts +10 -0
  127. package/dist/oauth/opencode-console.js +133 -0
  128. package/dist/oauth/providers/github-copilot.d.ts +12 -0
  129. package/dist/oauth/providers/github-copilot.js +77 -23
  130. package/dist/oauth/providers/opencode-console.d.ts +31 -0
  131. package/dist/oauth/providers/opencode-console.js +148 -0
  132. package/dist/oauth/providers.d.ts +2 -6
  133. package/dist/oauth/providers.js +3 -7
  134. package/dist/oauth/responses.d.ts +9 -0
  135. package/dist/oauth/responses.js +23 -0
  136. package/dist/report/build.d.ts +26 -0
  137. package/dist/report/build.js +16 -8
  138. package/dist/report/failure-text.d.ts +6 -1
  139. package/dist/report/failure-text.js +14 -4
  140. package/dist/report/format.d.ts +10 -5
  141. package/dist/report/format.js +12 -5
  142. package/dist/report/junit.js +11 -12
  143. package/dist/report/list.d.ts +12 -0
  144. package/dist/report/list.js +61 -24
  145. package/dist/report/markdown.d.ts +1 -1
  146. package/dist/report/markdown.js +37 -14
  147. package/dist/report/repeats.d.ts +6 -1
  148. package/dist/report/repeats.js +12 -3
  149. package/dist/run/artifacts.d.ts +22 -4
  150. package/dist/run/artifacts.js +71 -3
  151. package/dist/run/execute.d.ts +34 -5
  152. package/dist/run/execute.js +93 -44
  153. package/dist/run/extended-fixtures.js +6 -2
  154. package/dist/run/failure-evidence.js +10 -4
  155. package/dist/run/fixtures.js +6 -8
  156. package/dist/run/in-process.d.ts +2 -0
  157. package/dist/run/in-process.js +52 -10
  158. package/dist/run/last-run.d.ts +42 -8
  159. package/dist/run/last-run.js +121 -14
  160. package/dist/run/process-output.d.ts +73 -0
  161. package/dist/run/process-output.js +130 -0
  162. package/dist/run/provision.d.ts +2 -0
  163. package/dist/run/provision.js +1 -0
  164. package/dist/run/realm.d.ts +4 -1
  165. package/dist/run/realm.js +20 -6
  166. package/dist/run/records.d.ts +20 -4
  167. package/dist/run/records.js +26 -0
  168. package/dist/run/retry.d.ts +10 -1
  169. package/dist/run/retry.js +30 -12
  170. package/dist/run/runner.d.ts +9 -1
  171. package/dist/run/runner.js +101 -39
  172. package/dist/run/scheduler.js +255 -19
  173. package/dist/run/secrecy.d.ts +22 -19
  174. package/dist/run/secrecy.js +22 -18
  175. package/dist/run/serial.d.ts +75 -2
  176. package/dist/run/serial.js +243 -59
  177. package/dist/run/standalone.js +1 -1
  178. package/dist/run/steps.d.ts +0 -6
  179. package/dist/run/steps.js +3 -20
  180. package/dist/run/trace-redaction.d.ts +5 -6
  181. package/dist/run/trace-redaction.js +5 -6
  182. package/dist/run/worker/entry.js +40 -18
  183. package/dist/run/worker/handle.js +10 -5
  184. package/dist/run/worker/protocol.d.ts +85 -3
  185. package/dist/run/worker/session.d.ts +2 -0
  186. package/dist/run/worker/session.js +29 -0
  187. package/dist/telemetry/environment.d.ts +2 -1
  188. package/dist/telemetry/environment.js +12 -2
  189. package/dist/telemetry/events.js +1 -0
  190. package/dist/telemetry/project.d.ts +5 -6
  191. package/dist/telemetry/project.js +6 -11
  192. package/dist/telemetry/store.d.ts +5 -5
  193. package/dist/telemetry/store.js +5 -7
  194. package/dist/telemetry/telemetry.d.ts +2 -3
  195. package/dist/telemetry/telemetry.js +2 -4
  196. package/dist/types.d.ts +15 -6
  197. package/docs/agent-steps.mdx +1 -0
  198. package/docs/agents.mdx +7 -3
  199. package/docs/api-testing.mdx +2 -1
  200. package/docs/authentication.mdx +6 -3
  201. package/docs/browser.mdx +49 -12
  202. package/docs/bug-bash.mdx +2 -1
  203. package/docs/cache.mdx +66 -19
  204. package/docs/ci.mdx +14 -9
  205. package/docs/coding-agents.mdx +2 -1
  206. package/docs/core-concepts.mdx +208 -0
  207. package/docs/debugging.mdx +25 -12
  208. package/docs/decision-models.mdx +142 -0
  209. package/docs/example-projects.mdx +32 -0
  210. package/docs/executors.mdx +7 -2
  211. package/docs/explore.mdx +1 -0
  212. package/docs/github.mdx +14 -5
  213. package/docs/index.mdx +9 -5
  214. package/docs/integrations/eas.mdx +1 -0
  215. package/docs/integrations/index.mdx +1 -0
  216. package/docs/integrations/kernel.mdx +3 -2
  217. package/docs/migrate/cypress.mdx +26 -3
  218. package/docs/migrate/detox.mdx +24 -1
  219. package/docs/migrate/maestro.mdx +28 -4
  220. package/docs/migrate/playwright.mdx +44 -18
  221. package/docs/migrate/selenium.mdx +27 -3
  222. package/docs/mobile-ci.mdx +6 -5
  223. package/docs/mobile.mdx +96 -8
  224. package/docs/models.mdx +60 -9
  225. package/docs/quickstart.mdx +28 -16
  226. package/docs/reference/agent.mdx +13 -3
  227. package/docs/reference/app.mdx +2 -1
  228. package/docs/reference/cli.mdx +41 -18
  229. package/docs/reference/config.mdx +67 -13
  230. package/docs/reference/engine.mdx +25 -13
  231. package/docs/reference/environment.mdx +10 -2
  232. package/docs/reference/errors.mdx +13 -10
  233. package/docs/reference/expect.mdx +36 -13
  234. package/docs/reference/mcp.mdx +21 -8
  235. package/docs/reference/mobile.mdx +24 -13
  236. package/docs/reference/reporters.mdx +9 -2
  237. package/docs/reference/screen.mdx +49 -16
  238. package/docs/reference/test.mdx +32 -5
  239. package/docs/reference/web.mdx +133 -23
  240. package/docs/security.mdx +58 -42
  241. package/docs/subscriptions.mdx +92 -12
  242. package/docs/telemetry.mdx +9 -6
  243. package/docs/tools.mdx +1 -0
  244. package/docs/{starting-your-app.mdx → web.mdx} +157 -11
  245. package/docs/writing-an-engine.mdx +2 -1
  246. package/package.json +25 -8
  247. package/schema/report-v1.schema.json +14 -1
  248. package/skills/e2e/SKILL.md +5 -3
  249. package/skills/e2e/references/agent.md +33 -11
  250. package/skills/e2e/references/debugging.md +4 -4
  251. package/skills/e2e/references/mcp.md +8 -3
  252. package/skills/e2e/references/running.md +15 -9
  253. package/skills/e2e/references/setup.md +45 -16
  254. package/skills/e2e/references/writing-tests.md +35 -18
  255. package/docs/viewports.mdx +0 -146
  256. package/docs/writing-tests.mdx +0 -256
@@ -1,6 +1,7 @@
1
1
  /** JUnit XML reporter: the report-1 document as `.e2e/junit.xml`. */
2
2
  import { sanitizeText } from '../internal/errors.js';
3
3
  import { repeatSuffix } from './format.js';
4
+ import { toldAttempt } from './failure-text.js';
4
5
  import { outcome } from './outcome.js';
5
6
  import { fileReporter } from './write.js';
6
7
  /**
@@ -60,11 +61,9 @@ function addCounts(total, part) {
60
61
  total.skipped += part.skipped;
61
62
  total.timeMs += part.timeMs;
62
63
  }
63
- /** `<failure>` for a product verdict, `<error>` for anything that prevented one. */
64
- function verdictElement(error, status) {
65
- if (error !== undefined)
66
- return error.category === 'test' ? 'failure' : 'error';
67
- return status === 'interrupted' ? 'error' : 'failure';
64
+ /** `<failure>` for a product verdict, `<error>` for anything else that prevented one. */
65
+ function verdictElement(error) {
66
+ return error === undefined || error.category === 'test' ? 'failure' : 'error';
68
67
  }
69
68
  function errorBody(error) {
70
69
  const phase = error.phase === undefined ? '' : ` (${error.phase})`;
@@ -93,18 +92,18 @@ function renderResult(result, groups) {
93
92
  };
94
93
  }
95
94
  case 'skipped':
95
+ case 'interrupted': {
96
+ // JUnit has no interrupted outcome; a test the run stopped reached no verdict, so it reads as skipped with why.
97
+ const message = result.status === 'skipped' ? (result.skip?.reason ?? 'skipped') : `interrupted: ${final.final.error?.message ?? 'the run was stopped'}`;
96
98
  return {
97
- lines: [
98
- `${open}>`,
99
- ` <skipped ${attributes({ message: result.skip?.reason ?? 'skipped' })}/>`,
100
- '</testcase>',
101
- ],
99
+ lines: [`${open}>`, ` <skipped ${attributes({ message })}/>`, '</testcase>'],
102
100
  outcome: 'skipped',
103
101
  durationMs: final.durationMs,
104
102
  };
103
+ }
105
104
  default: {
106
- const element = verdictElement(final.final.error, result.status);
107
- const error = final.final.error;
105
+ const error = toldAttempt(result, final).error;
106
+ const element = verdictElement(error);
108
107
  const message = error?.message ?? result.status;
109
108
  const type = error?.code ?? result.status;
110
109
  const body = error === undefined ? result.status : errorBody(error);
@@ -49,6 +49,7 @@ export declare class ListReporter implements Reporter {
49
49
  /** Whether a live window paints; without one, finished steps stream permanently. */
50
50
  private readonly live;
51
51
  private readonly failures;
52
+ private readonly skippedFailures;
52
53
  /** Every finished test's run, for the `Repeats` summary of a `--repeat-each` run. */
53
54
  private readonly runs;
54
55
  /**
@@ -119,6 +120,17 @@ export declare class ListReporter implements Reporter {
119
120
  * the test's result, or the end of the run.
120
121
  */
121
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;
122
134
  /** Prints finished output lines under their heading, unless the heading is the one printed last. */
123
135
  private printOutput;
124
136
  /** Prints the unfinished output of `pair`, or of every source when undefined. */
@@ -8,6 +8,7 @@
8
8
  import path from 'node:path';
9
9
  import picocolors from 'picocolors';
10
10
  import { packageVersion } from '../internal/package-version.js';
11
+ import { failureBeforeSkip } from '../run/records.js';
11
12
  import { codeFrame, userFrame } from './code-frame.js';
12
13
  import { addCacheTally, addModelTally, addUsage, aiSegment, bounded, cacheText, durationText, fitColumns, emptyCacheTally, emptyCounters, emptyUsage, F_CHECK, F_CROSS, F_POINTER, fileOutcome, formatClock, formatTime, modelsText, padTitle, rule, stateString, statusBucket, stepsCacheTally, repeatSuffix, stepsModelTally, stepsUsage, sumUsage, tally, terminalColumns, usageText, } from './format.js';
13
14
  import { ExploreView } from './list-explore.js';
@@ -43,14 +44,16 @@ function pairKey(testId, agent, target, repeat) {
43
44
  /** Details of an ordinary result: summed over its attempts, the error from the last. */
44
45
  function attemptDetails(attempts) {
45
46
  const steps = attempts.map((attempt) => attempt.steps);
47
+ // A retry the run interrupted tells nothing; the failure before it is the verdict.
48
+ const told = attempts.findLast((attempt) => attempt.status !== 'interrupted') ?? attempts.at(-1);
46
49
  return {
47
50
  durationMs: attempts.reduce((total, attempt) => total + attempt.durationMs, 0),
48
51
  usage: stepsUsage(steps),
49
52
  models: stepsModelTally(steps),
50
53
  cache: stepsCacheTally(steps),
51
- error: attempts[attempts.length - 1]?.error,
54
+ error: told?.error,
52
55
  videos: videoPaths(attempts),
53
- ...failureOf(attempts[attempts.length - 1]),
56
+ ...failureOf(told),
54
57
  };
55
58
  }
56
59
  /** The failure evidence of one attempt, and the path of the screen text it points at. */
@@ -85,7 +88,7 @@ function serialMemberDetails(group, testId) {
85
88
  attempt,
86
89
  member: attempt.members.find((member) => member.testId === testId),
87
90
  }));
88
- const last = runs[runs.length - 1];
91
+ const last = runs.findLast((run) => run.attempt.status !== 'interrupted') ?? runs.at(-1);
89
92
  const own = last?.member;
90
93
  const neverRan = own === undefined || own.status === 'skipped';
91
94
  const steps = runs.map((run) => run.member?.steps ?? []);
@@ -135,6 +138,7 @@ export class ListReporter {
135
138
  /** Whether a live window paints; without one, finished steps stream permanently. */
136
139
  live;
137
140
  failures = [];
141
+ skippedFailures = [];
138
142
  /** Every finished test's run, for the `Repeats` summary of a `--repeat-each` run. */
139
143
  runs = [];
140
144
  /**
@@ -268,20 +272,37 @@ export class ListReporter {
268
272
  * the test's result, or the end of the run.
269
273
  */
270
274
  testOutput(event) {
271
- const { pc } = this;
272
- if (event.text === '')
273
- return;
274
275
  const pair = event.pair === undefined ? undefined : pairKey(event.pair.testId, event.pair.agent, event.target, event.pair.repeat);
275
276
  const running = pair === undefined ? undefined : this.pairs.get(pair);
276
277
  const source = running === undefined ? this.badge(event.target) : `${this.badge(running.group.target)} ${bounded(running.group.file)}${this.separator}${running.title}`;
277
- 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}`;
278
299
  let entry = this.pendingOutput.get(key);
279
300
  if (entry === undefined) {
280
- const label = event.stream === 'stderr' ? pc.yellow(event.stream) : pc.dim(event.stream);
301
+ const label = stream === 'stderr' ? pc.yellow(stream) : pc.dim(stream);
281
302
  entry = { heading: `${label} ${pc.dim('|')} ${source}`, pair, fragment: '' };
282
303
  this.pendingOutput.set(key, entry);
283
304
  }
284
- const lines = (entry.fragment + event.text).split('\n');
305
+ const lines = (entry.fragment + text).split('\n');
285
306
  entry.fragment = lines.pop() ?? '';
286
307
  if (entry.fragment === '')
287
308
  this.pendingOutput.delete(key);
@@ -550,15 +571,17 @@ export class ListReporter {
550
571
  const steps = this.pairs.get(key)?.steps ?? [];
551
572
  this.pairs.delete(key);
552
573
  const group = this.group(result.test.file, result.target.name);
574
+ // Read before `detailsOf` releases the result's serial group.
575
+ const skippedAfter = failureBeforeSkip(result, (id) => this.pendingSerial.get(id)?.group);
553
576
  const { durationMs, usage, models, cache, error, videos, failure, screenPath } = this.detailsOf(result);
554
577
  addUsage(this.runUsage, usage);
555
578
  addModelTally(this.runModels, models);
556
579
  addCacheTally(this.runCache, cache);
557
580
  const title = this.titledAs(result.test.titlePath.join(' > '), result.agent, result.repeat);
558
581
  if (this.explore !== undefined) {
559
- // The exploration's verdict is its findings; any other error is a failure of its own.
582
+ // The exploration's verdict is its findings; any other error is a failure of its own, unless the run stopped it.
560
583
  this.explore.result(result.attempts.flatMap((attempt) => attempt.artifacts));
561
- if (error !== undefined && !this.explore.isVerdict(error)) {
584
+ if (error !== undefined && result.status !== 'interrupted' && !this.explore.isVerdict(error)) {
562
585
  this.failures.push({ group, title, status: result.status, error, videos, failure, screenPath });
563
586
  }
564
587
  this.window.redraw();
@@ -584,6 +607,9 @@ export class ListReporter {
584
607
  if (statusBucket(result.status) === 'failed') {
585
608
  this.failures.push({ group, title, status: result.status, error, videos, failure, screenPath });
586
609
  }
610
+ if (skippedAfter !== undefined) {
611
+ this.skippedFailures.push({ group, title, status: result.status, error: skippedAfter.error, videos, ...failureOf(skippedAfter) });
612
+ }
587
613
  if (group.planned !== undefined && group.lines.length >= group.planned)
588
614
  this.printGroup(group);
589
615
  this.window.redraw();
@@ -623,12 +649,15 @@ export class ListReporter {
623
649
  const outcome = fileOutcome(counts);
624
650
  const symbol = outcome === 'failed'
625
651
  ? pc.red(F_POINTER)
626
- : outcome === 'skipped'
627
- ? pc.dim(pc.gray(F_DOWN))
628
- : pc.green(F_CHECK);
652
+ : outcome === 'interrupted'
653
+ ? pc.yellow(F_POINTER)
654
+ : outcome === 'skipped'
655
+ ? pc.dim(pc.gray(F_DOWN))
656
+ : pc.green(F_CHECK);
629
657
  const state = [
630
658
  pc.dim(`${counts.total} test${counts.total === 1 ? '' : 's'}`),
631
659
  counts.failed > 0 ? pc.red(`${counts.failed} failed`) : undefined,
660
+ counts.interrupted > 0 ? pc.yellow(`${counts.interrupted} interrupted`) : undefined,
632
661
  counts.flaky > 0 ? pc.yellow(`${counts.flaky} flaky`) : undefined,
633
662
  counts.skipped > 0 ? pc.yellow(`${counts.skipped} skipped`) : undefined,
634
663
  ]
@@ -647,6 +676,7 @@ export class ListReporter {
647
676
  parts.push(pc.dim(ai));
648
677
  this.print(parts.join(' '));
649
678
  const verbose = counts.failed > 0 ||
679
+ counts.interrupted > 0 ||
650
680
  counts.flaky > 0 ||
651
681
  this.groups.size === 1 ||
652
682
  (this.live && group.lines.some((line) => line.steps.length > 0));
@@ -678,6 +708,8 @@ export class ListReporter {
678
708
  : pc.dim(pc.gray(` [${line.skipReason}]`));
679
709
  return [` ${pc.dim(pc.gray(F_DOWN))} ${line.title}${reason}`];
680
710
  }
711
+ case 'interrupted':
712
+ return [` ${pc.yellow(F_CROSS)} ${line.title} ${pc.yellow('(interrupted)')} ${duration}${ai}`];
681
713
  default: {
682
714
  const status = line.status === 'failed' ? '' : pc.red(` (${line.status})`);
683
715
  const rows = [` ${pc.red(`${F_CROSS} ${line.title}`)}${status} ${duration}${ai}`];
@@ -716,6 +748,9 @@ export class ListReporter {
716
748
  rows.push(padTitle(pc, 'Cache') + cache);
717
749
  if (final)
718
750
  rows.push(...this.repeatRows());
751
+ if (this.skippedFailures.length > 0) {
752
+ rows.push(padTitle(pc, 'Warnings') + pc.yellow(`${this.skippedFailures.length} skipped after failure`));
753
+ }
719
754
  if (this.errors.length > 0) {
720
755
  const count = this.errors.length;
721
756
  rows.push(padTitle(pc, 'Errors') + pc.bold(pc.red(`${count} error${count === 1 ? '' : 's'}`)));
@@ -736,11 +771,12 @@ export class ListReporter {
736
771
  const groups = repeatGroups(this.runs, (entry) => entry);
737
772
  if (groups.length === 0)
738
773
  return [];
739
- const unstable = groups.filter((group) => group.passed < group.runs.length);
774
+ // A test is listed when it missed a run: a flake first, then one the run cut short.
775
+ const missed = groups.filter((group) => group.passed < group.runs.length).toSorted((a, b) => Number(b.unstable) - Number(a.unstable));
740
776
  const summary = repeatSummary(groups);
741
777
  return [
742
- padTitle(pc, 'Repeats') + (unstable.length === 0 ? pc.green(summary) : pc.yellow(summary)),
743
- ...unstable.map((group) => `${padTitle(pc, '')}${pc.yellow(F_CROSS)} ${group.label} ${pc.dim(repeatLine(group))}`),
778
+ padTitle(pc, 'Repeats') + (missed.some((group) => group.unstable) ? pc.yellow(summary) : pc.green(summary)),
779
+ ...missed.map((group) => `${padTitle(pc, '')}${group.unstable ? pc.yellow(F_CROSS) : pc.dim(pc.gray(F_DOWN))} ${group.label} ${pc.dim(repeatLine(group))}`),
744
780
  ];
745
781
  }
746
782
  /** A zero counter's label: the plain text, or the interrupt when that is why nothing is counted. */
@@ -808,15 +844,15 @@ export class ListReporter {
808
844
  this.print(line);
809
845
  }
810
846
  /** vitest's `Failed Tests` section: a banner, then each failure with its code frame. */
811
- printFailures() {
847
+ printFailures(heading, failures) {
812
848
  const { pc } = this;
813
- if (this.failures.length === 0)
849
+ if (failures.length === 0)
814
850
  return;
815
851
  this.print('');
816
- this.print(this.errorBanner(`Failed Tests ${this.failures.length}`));
852
+ this.print(this.errorBanner(`${heading} ${failures.length}`));
817
853
  this.print('');
818
- this.failures.forEach(({ group, title, status, error, videos, failure, screenPath }, index) => {
819
- this.print(`${pc.bold(pc.bgRed(' FAIL '))} ${this.badge(group.target)} ${bounded(group.file)}${this.separator}${title}`);
854
+ failures.forEach(({ group, title, status, error, videos, failure, screenPath }, index) => {
855
+ this.print(`${pc.bold(pc.bgRed(status === 'skipped' ? ' SKIP ' : ' FAIL '))} ${this.badge(group.target)} ${bounded(group.file)}${this.separator}${title}`);
820
856
  if (error === undefined) {
821
857
  this.print(pc.red(`${pc.bold(status)}: no error was recorded`));
822
858
  }
@@ -829,7 +865,7 @@ export class ListReporter {
829
865
  }
830
866
  this.printEvidence(failure, screenPath);
831
867
  this.printVideos(videos);
832
- const marker = `[${index + 1}/${this.failures.length}]`;
868
+ const marker = `[${index + 1}/${failures.length}]`;
833
869
  const { before, after } = rule(marker, 'right');
834
870
  this.print('');
835
871
  this.print(pc.red(pc.dim(`${before}${marker}${after}`)));
@@ -925,7 +961,8 @@ export class ListReporter {
925
961
  this.printGroup(group);
926
962
  }
927
963
  this.printExplore();
928
- this.printFailures();
964
+ this.printFailures('Failed Tests', this.failures);
965
+ this.printFailures('Skipped After Failure', this.skippedFailures);
929
966
  this.printErrors();
930
967
  this.print('');
931
968
  for (const row of this.summaryRows(true))
@@ -50,7 +50,7 @@ export declare function renderMarkdownReport(report: Report1Document, options?:
50
50
  * `summary.md` beside `report.json`, with evidence listed as paths from the
51
51
  * project root, for a reader with the checkout in front of it: a pull
52
52
  * request description, a coding agent's handoff, a wiki page. Every test
53
- * that did not pass gets a page of its own under `failures/`, with the
53
+ * that failed or was flaky gets a page of its own under `failures/`, with the
54
54
  * screen at failure inline; the run page links each block to its page. The
55
55
  * directory is the reporter's: what an earlier run left there is removed
56
56
  * first, so a stale page never describes a failure this run did not have.
@@ -45,11 +45,11 @@ const MAX_GOAL_CHARS = 400;
45
45
  const TRUNCATED_NOTE = '_Truncated to fit a pull request comment; the full report is in `report.json`._';
46
46
  /** Screenshots first, then the recording, then the trace; the compiler fails when a kind is missing here. */
47
47
  const KIND_RANK = { screenshot: 0, video: 1, trace: 2, download: 3, log: 4 };
48
- const ICON = { failed: '🔴', flaky: '⚠️', skipped: '⏭️', passed: '🟢' };
48
+ const ICON = { failed: '🔴', interrupted: '⏹️', flaky: '⚠️', skipped: '⏭️', passed: '🟢' };
49
49
  /** Worst first: the order failures are listed, files are sorted, and a file's glyph is chosen in. */
50
- const WORST_FIRST = ['failed', 'flaky', 'skipped', 'passed'];
51
- /** The order counts read in: `1 failed, 1 flaky, 3 passed, 1 skipped`. */
52
- const COUNT_ORDER = ['failed', 'flaky', 'passed', 'skipped'];
50
+ const WORST_FIRST = ['failed', 'interrupted', 'flaky', 'skipped', 'passed'];
51
+ /** The order counts read in: `1 failed, 2 interrupted, 1 flaky, 3 passed, 1 skipped`. */
52
+ const COUNT_ORDER = ['failed', 'interrupted', 'flaky', 'passed', 'skipped'];
53
53
  function worstBucket(results) {
54
54
  const present = new Set(results.map((result) => statusBucket(result.status)));
55
55
  return WORST_FIRST.find((key) => present.has(key)) ?? 'passed';
@@ -65,9 +65,25 @@ function pageName(options) {
65
65
  const title = options.title?.trim() ?? '';
66
66
  return title === '' ? 'e2e' : `e2e ${cell(title, MAX_ID_CHARS)}`;
67
67
  }
68
+ /**
69
+ * The headline's glyph: red for a failed run or any failed test, the
70
+ * interrupted glyph for a run or a listed test the run stopped, else green.
71
+ * A `--last-failed` rerun that passed can still list a test the first pass
72
+ * never finished, and the page must not read as all green.
73
+ */
74
+ function runIcon(run, results) {
75
+ if (run.status !== 'passed' && run.status !== 'interrupted')
76
+ return ICON.failed;
77
+ const worst = worstBucket(results);
78
+ if (worst === 'failed')
79
+ return ICON.failed;
80
+ if (worst === 'interrupted' || run.status === 'interrupted')
81
+ return ICON.interrupted;
82
+ return ICON.passed;
83
+ }
68
84
  function headline(run, results, options) {
69
85
  const summary = countText(tally(results)) || (run.errors.length > 0 ? 'no tests ran' : 'no tests selected');
70
- return `### ${run.status === 'passed' ? ICON.passed : ICON.failed} ${pageName(options)}: ${summary}`;
86
+ return `### ${runIcon(run, results)} ${pageName(options)}: ${summary}`;
71
87
  }
72
88
  /** The model calls an agent step made; the step's metrics are the source of truth. */
73
89
  function modelCalls(step) {
@@ -236,6 +252,12 @@ function flakyFold(entries, manyTargets, options) {
236
252
  const summary = `${ICON.flaky} ${plural(entries.length, 'flaky test')} passed on a retry`;
237
253
  return [['<details>', `<summary>${summary}</summary>`, '', shown.join('\n\n'), '</details>'].join('\n')];
238
254
  }
255
+ /** A test that missed a run: flaky when a run failed, else interrupted when the run stopped one, else skipped. */
256
+ function missIcon(group) {
257
+ if (group.unstable)
258
+ return ICON.flaky;
259
+ return group.runs.some((run) => run.status === 'interrupted') ? ICON.interrupted : ICON.skipped;
260
+ }
239
261
  /**
240
262
  * A `--repeat-each` run's tally: how many tests passed every run, then each
241
263
  * test that did not, with the runs that failed. Empty for a run that
@@ -254,10 +276,11 @@ function repeatsSection(entries, manyTargets) {
254
276
  }));
255
277
  if (groups.length === 0)
256
278
  return [];
257
- const unstable = groups.filter((group) => group.passed < group.runs.length);
279
+ // A test is listed when it missed a run: a flake first, then one the run cut short.
280
+ const missed = groups.filter((group) => group.passed < group.runs.length).toSorted((a, b) => Number(b.unstable) - Number(a.unstable));
258
281
  return [
259
282
  `**Repeats:** ${repeatSummary(groups)}`,
260
- ...(unstable.length === 0 ? [] : ['', ...unstable.map((group) => `- ${ICON.flaky} ${group.label}: ${cell(repeatLine(group))}`)]),
283
+ ...(missed.length === 0 ? [] : ['', ...missed.map((group) => `- ${missIcon(group)} ${group.label}: ${cell(repeatLine(group))}`)]),
261
284
  ];
262
285
  }
263
286
  /** Results grouped by file and target, the worst file first, then by path. */
@@ -353,12 +376,12 @@ function footer(run, targets, options) {
353
376
  return [`<sub>${parts.join(' · ')}</sub>`];
354
377
  }
355
378
  // --- the exploration record ---
356
- function exploreHeadline(run, explore, options) {
379
+ function exploreHeadline(run, results, explore, options) {
357
380
  const issues = explore.findings.filter((finding) => finding.kind === 'issue').length;
358
381
  const warnings = explore.findings.length - issues;
359
382
  const counts = [...(issues > 0 ? [plural(issues, 'issue')] : []), ...(warnings > 0 ? [plural(warnings, 'warning')] : [])];
360
383
  const summary = counts.length > 0 ? counts.join(', ') : run.status === 'blocked' ? 'explored nothing' : run.status === 'passed' ? 'no findings' : 'did not finish';
361
- return `### ${run.status === 'passed' ? ICON.passed : ICON.failed} ${pageName(options)} explore: ${summary}`;
384
+ return `### ${runIcon(run, results)} ${pageName(options)} explore: ${summary}`;
362
385
  }
363
386
  /** `3 of 8 steps · 2 passed · 1 failed · the agent covered the goal`. */
364
387
  function exploreSteps(explore) {
@@ -448,7 +471,7 @@ export function renderMarkdownReport(report, options = {}) {
448
471
  failures.push([`and ${failed.length - failures.length} more failed`]);
449
472
  const flaky = flakyFold(notPassed.filter(({ result }) => statusBucket(result.status) === 'flaky'), manyTargets, options);
450
473
  const spend = spendLine(run, entries);
451
- const head = [explore === undefined ? headline(run, selected, options) : exploreHeadline(run, explore, options), ...(spend === undefined ? [] : [spend]), ''];
474
+ const head = [explore === undefined ? headline(run, selected, options) : exploreHeadline(run, selected, explore, options), ...(spend === undefined ? [] : [spend]), ''];
452
475
  const groups = fileGroups(entries);
453
476
  const sections = explore === undefined
454
477
  ? [errors, ...failures, flaky, repeatsSection(entries, manyTargets), allTests(groups, selected.length, manyTargets)]
@@ -468,9 +491,9 @@ function failurePageName(result) {
468
491
  return `${sanitizePathSegment(`${result.file}-${result.titlePath.join('-')}`)}-${result.id.slice(0, 8)}.md`;
469
492
  }
470
493
  /**
471
- * The results that get a page: every one that failed, timed out, was
472
- * interrupted, or was flaky, except an exploration's own verdict, which its
473
- * findings already tell.
494
+ * The results that get a page: every one that failed, timed out, or was
495
+ * flaky, except an exploration's own verdict, which its findings already
496
+ * tell. An interrupted test reached no verdict, so it has no failure to tell.
474
497
  */
475
498
  function pagedResults(report) {
476
499
  const explore = report.run.explore;
@@ -487,7 +510,7 @@ function pagedResults(report) {
487
510
  * `summary.md` beside `report.json`, with evidence listed as paths from the
488
511
  * project root, for a reader with the checkout in front of it: a pull
489
512
  * request description, a coding agent's handoff, a wiki page. Every test
490
- * that did not pass gets a page of its own under `failures/`, with the
513
+ * that failed or was flaky gets a page of its own under `failures/`, with the
491
514
  * screen at failure inline; the run page links each block to its page. The
492
515
  * directory is the reporter's: what an earlier run left there is removed
493
516
  * first, so a stale page never describes a failure this run did not have.
@@ -15,6 +15,11 @@ export interface RepeatGroup {
15
15
  readonly runs: readonly RepeatRun[];
16
16
  /** Runs that passed on their first attempt; a flaky run needed a retry and does not count. */
17
17
  readonly passed: number;
18
+ /**
19
+ * Whether a run that reached a verdict did not pass: a flake. A run the
20
+ * run interrupted, or never started, says nothing about the test.
21
+ */
22
+ readonly unstable: boolean;
18
23
  }
19
24
  /**
20
25
  * Groups runs by `key`, keeping only tests that ran more than once and did
@@ -27,5 +32,5 @@ export declare function repeatGroups<T>(items: readonly T[], describe: (item: T)
27
32
  }): RepeatGroup[];
28
33
  /** `2 of 3 tests passed all 5 runs`; the run count is the most any test had. */
29
34
  export declare function repeatSummary(groups: readonly RepeatGroup[]): string;
30
- /** `3/5 passed · repeat 0 ASSERTION_FAILED · repeat 2 flaky`, naming each run that did not pass. */
35
+ /** `3/5 passed · repeat 0 ASSERTION_FAILED · repeat 2 flaky · repeat 4 interrupted`, naming each run that did not pass. */
31
36
  export declare function repeatLine(group: RepeatGroup): string;
@@ -3,6 +3,8 @@
3
3
  * of one test, target, and agent side by side, so a flake reads as `3/5
4
4
  * passed` with the runs that did not, instead of five results to count.
5
5
  */
6
+ /** Statuses of a run that reached no verdict: stopped by the run, or never started. */
7
+ const UNDECIDED = new Set(['interrupted', 'skipped']);
6
8
  /**
7
9
  * Groups runs by `key`, keeping only tests that ran more than once and did
8
10
  * not skip every run, in first-seen order.
@@ -19,7 +21,12 @@ export function repeatGroups(items, describe) {
19
21
  .filter((group) => group.runs.length > 1 && group.runs.some((run) => run.status !== 'skipped'))
20
22
  .map((group) => {
21
23
  const runs = group.runs.toSorted((a, b) => a.repeat - b.repeat);
22
- return { label: group.label, runs, passed: runs.filter((run) => run.status === 'passed').length };
24
+ return {
25
+ label: group.label,
26
+ runs,
27
+ passed: runs.filter((run) => run.status === 'passed').length,
28
+ unstable: runs.some((run) => run.status !== 'passed' && !UNDECIDED.has(run.status)),
29
+ };
23
30
  });
24
31
  }
25
32
  /** `2 of 3 tests passed all 5 runs`; the run count is the most any test had. */
@@ -28,13 +35,15 @@ export function repeatSummary(groups) {
28
35
  const runs = Math.max(...groups.map((group) => group.runs.length));
29
36
  return `${stable} of ${groups.length} test${groups.length === 1 ? '' : 's'} passed all ${runs} runs`;
30
37
  }
31
- /** A run that did not pass, in a word: its error code, or `flaky` with the code its retry recovered from. */
38
+ /** A run that did not pass, in a word: its error code, `flaky` with the code its retry recovered from, or why it reached no verdict. */
32
39
  function outcomeOf(run) {
33
40
  if (run.status === 'flaky')
34
41
  return run.code === undefined ? 'flaky' : `flaky (${run.code})`;
42
+ if (UNDECIDED.has(run.status))
43
+ return run.status;
35
44
  return run.code ?? run.status;
36
45
  }
37
- /** `3/5 passed · repeat 0 ASSERTION_FAILED · repeat 2 flaky`, naming each run that did not pass. */
46
+ /** `3/5 passed · repeat 0 ASSERTION_FAILED · repeat 2 flaky · repeat 4 interrupted`, naming each run that did not pass. */
38
47
  export function repeatLine(group) {
39
48
  const misses = group.runs
40
49
  .filter((run) => run.status !== 'passed')
@@ -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
  /**
@@ -56,6 +56,24 @@ export declare function createAttemptArtifacts(options: {
56
56
  readonly attemptId?: string;
57
57
  };
58
58
  }): AttemptArtifacts;
59
+ /**
60
+ * Empties an artifact tree down to the files `keep` names, by their report
61
+ * paths, and removes the directories left empty. A `--last-failed` rerun
62
+ * starts this way rather than from an empty tree: the evidence the report it
63
+ * reruns names stays, since that report's results fold into the rerun's and
64
+ * its carried tests are not run again.
65
+ */
66
+ export declare function pruneArtifacts(root: string, keep: ReadonlySet<string>): Promise<void>;
67
+ /**
68
+ * Claims the directory a `--last-failed` rerun's attempts write under, beside
69
+ * the evidence it kept, and returns its name: `rerun-<n>`, past every number
70
+ * already there, so the tree reads in run order. The directory is created
71
+ * exclusively, so no attempt of the rerun writes into a directory an earlier
72
+ * run's report still names.
73
+ */
74
+ export declare function claimRerunDir(root: string): Promise<string>;
75
+ /** An attempt's report segments, under the rerun's directory on a `--last-failed` rerun. */
76
+ export declare function attemptSegments(rerunDir: string | undefined, segments: readonly string[]): readonly string[];
59
77
  /**
60
78
  * Restricts a report path segment to a safe filename alphabet and length. A
61
79
  * value that is already safe and within the cap is unchanged. Any other value
@@ -1,10 +1,11 @@
1
1
  /** Attempt-scoped artifact directory and report registration. */
2
2
  import { createHash } from 'node:crypto';
3
3
  import { createReadStream, mkdirSync } from 'node:fs';
4
- import { readFile, stat } from 'node:fs/promises';
4
+ 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;
@@ -156,6 +157,73 @@ export function createAttemptArtifacts(options) {
156
157
  };
157
158
  return { dir, records, sink, settle };
158
159
  }
160
+ /** What a `--last-failed` rerun's attempt directory is called, `rerun-<n>`, numbered from 1. */
161
+ const RERUN_DIR = /^rerun-([1-9][0-9]*)$/;
162
+ /**
163
+ * Empties an artifact tree down to the files `keep` names, by their report
164
+ * paths, and removes the directories left empty. A `--last-failed` rerun
165
+ * starts this way rather than from an empty tree: the evidence the report it
166
+ * reruns names stays, since that report's results fold into the rerun's and
167
+ * its carried tests are not run again.
168
+ */
169
+ export async function pruneArtifacts(root, keep) {
170
+ const prune = async (dir, relative) => {
171
+ let entries;
172
+ try {
173
+ entries = await readdir(dir, { withFileTypes: true });
174
+ }
175
+ catch (cause) {
176
+ if (cause.code === 'ENOENT')
177
+ return true;
178
+ throw cause;
179
+ }
180
+ let empty = true;
181
+ for (const entry of entries) {
182
+ const reportPath = relative === '' ? entry.name : `${relative}/${entry.name}`;
183
+ const absolute = path.join(dir, entry.name);
184
+ if (entry.isDirectory()) {
185
+ if (await prune(absolute, reportPath))
186
+ await rmdir(absolute);
187
+ else
188
+ empty = false;
189
+ }
190
+ else if (keep.has(reportPath)) {
191
+ empty = false;
192
+ }
193
+ else {
194
+ await rm(absolute, { force: true });
195
+ }
196
+ }
197
+ return empty;
198
+ };
199
+ await prune(root, '');
200
+ }
201
+ /**
202
+ * Claims the directory a `--last-failed` rerun's attempts write under, beside
203
+ * the evidence it kept, and returns its name: `rerun-<n>`, past every number
204
+ * already there, so the tree reads in run order. The directory is created
205
+ * exclusively, so no attempt of the rerun writes into a directory an earlier
206
+ * run's report still names.
207
+ */
208
+ export async function claimRerunDir(root) {
209
+ await mkdir(root, { recursive: true });
210
+ const taken = (await readdir(root)).map((name) => Number(RERUN_DIR.exec(name)?.[1] ?? 0));
211
+ for (let n = Math.max(0, ...taken) + 1;; n += 1) {
212
+ const name = `rerun-${n}`;
213
+ try {
214
+ await mkdir(path.join(root, name));
215
+ return name;
216
+ }
217
+ catch (cause) {
218
+ if (cause.code !== 'EEXIST')
219
+ throw cause;
220
+ }
221
+ }
222
+ }
223
+ /** An attempt's report segments, under the rerun's directory on a `--last-failed` rerun. */
224
+ export function attemptSegments(rerunDir, segments) {
225
+ return rerunDir === undefined ? segments : [rerunDir, ...segments];
226
+ }
159
227
  /**
160
228
  * Rewrites a text-like download through the session's ledger, the way a
161
229
  * trace's text entries are, and returns the redaction the record can claim:
@@ -168,7 +236,7 @@ async function redactDownload(absolute, secrecy) {
168
236
  try {
169
237
  const bytes = await readFile(absolute);
170
238
  const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
171
- const redacted = secrecy.ledger.redact(text);
239
+ const redacted = secrecy.ledger.redactFragments(text);
172
240
  if (redacted !== text)
173
241
  await writeFileAtomic(absolute, redacted);
174
242
  return 'complete';