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
@@ -7,14 +7,23 @@
7
7
  * gating, and reporting logic covers both child-process and in-process
8
8
  * execution.
9
9
  */
10
- import { InfrastructureError, serializeError } from '../internal/errors.js';
10
+ import { InfrastructureError, TestTimeoutError, serializeError } from '../internal/errors.js';
11
11
  import { timestamp, uuidv7 } from '../internal/ids.js';
12
+ import { retryVerdict } from './retry.js';
13
+ import { SerialGroupProgress, serialGroupId } from './serial.js';
12
14
  import { INTERRUPTED_BEFORE_START, countsTowardFailureLimit, failureLimitSkip, nonRunResult, pairResult, setupFailedSkip, unstartedResult, pairKey, } from './units.js';
13
15
  import { decodeResult, } from './worker/protocol.js';
14
16
  /** Consecutive worker-boot failures per target before its units are failed. */
15
17
  const MAX_INIT_FAILURES = 2;
16
18
  /** How long a retiring or draining worker gets before it is force-killed. */
17
19
  const SHUTDOWN_GRACE_MS = 10_000;
20
+ /** Why a pair of a target whose workers cannot boot never ran. */
21
+ const WORKER_START_FAILED = {
22
+ cause: 'infrastructure-unavailable',
23
+ reason: 'worker process failed to start',
24
+ };
25
+ /** The least time a worker past an attempt deadline gets to answer a ping, however small `cleanupTimeout` is. */
26
+ const MIN_PING_REPLY_MS = 5_000;
18
27
  /** Whether a worker process died of a signal that interrupts the run, as a Ctrl-C to its process group does. */
19
28
  function isInterruptSignal(signal) {
20
29
  return signal === 'SIGINT' || signal === 'SIGTERM' || signal === 'SIGHUP';
@@ -23,6 +32,16 @@ function isInterruptSignal(signal) {
23
32
  export async function runUnits(options) {
24
33
  await new Scheduler(options).run();
25
34
  }
35
+ /** The result of a pair from the attempts it finished, with the verdict they reach. */
36
+ function finishedResult(pair, attempts) {
37
+ const last = attempts.at(-1);
38
+ return pairResult(pair, {
39
+ status: retryVerdict(attempts),
40
+ selected: true,
41
+ attempts,
42
+ ...(last?.status === 'skipped' ? { skip: last.skip } : {}),
43
+ });
44
+ }
26
45
  /**
27
46
  * One worker and the unit it is executing. Owns its own lifecycle so the
28
47
  * scheduler never has to keep a separate record in sync with the transport.
@@ -39,13 +58,22 @@ class SchedulerWorker {
39
58
  reported = new Set();
40
59
  /** The pair (`pairKey`) executing now, when one is. */
41
60
  inFlightPair;
61
+ /** Attempts of `unit`'s pairs heard ahead of the pair's result, by `pairKey`: what a crash keeps. */
62
+ attempts = new Map();
63
+ /** What was heard of `unit`'s serial groups still running, by group report id: what a crash keeps. */
64
+ serialGroups = new Map();
42
65
  sawFailure = false;
43
66
  becameReady = false;
44
67
  /** Told to tear down at once by a forced interrupt; its exit is then the one asked for. */
45
68
  terminated = false;
46
69
  /** Killed by the scheduler, once a grace budget ran out or over a fatal it reported; its exit is then the one asked for too. */
47
70
  killed = false;
71
+ /** The attempt whose worker stopped answering past its deadline, once the watchdog killed the worker over it. */
72
+ hung;
48
73
  killTimer;
74
+ watched;
75
+ watchTimer;
76
+ awaitingPong = false;
49
77
  constructor(targetName, workerSlot, spawn, onMessage, onExit) {
50
78
  this.targetName = targetName;
51
79
  this.workerSlot = workerSlot;
@@ -53,6 +81,7 @@ class SchedulerWorker {
53
81
  onMessage: (message) => onMessage(this, message),
54
82
  onExit: (detail, signal) => {
55
83
  this.clearKillTimer();
84
+ this.unwatch();
56
85
  onExit(this, detail, signal ?? null);
57
86
  },
58
87
  });
@@ -62,6 +91,8 @@ class SchedulerWorker {
62
91
  this.state = 'busy';
63
92
  this.unit = unit;
64
93
  this.reported.clear();
94
+ this.attempts.clear();
95
+ this.serialGroups.clear();
65
96
  this.inFlightPair = undefined;
66
97
  this.sawFailure = false;
67
98
  const pairs = unit.pairs.map((pair) => ({
@@ -104,12 +135,79 @@ class SchedulerWorker {
104
135
  }, graceMs);
105
136
  this.killTimer.unref();
106
137
  }
138
+ /** The attempts heard of one of `unit`'s pairs, started on first word of them. */
139
+ pairAttempts(key) {
140
+ let attempts = this.attempts.get(key);
141
+ if (attempts === undefined) {
142
+ attempts = { finished: [], running: undefined };
143
+ this.attempts.set(key, attempts);
144
+ }
145
+ return attempts;
146
+ }
147
+ /** The progress of one of `unit`'s serial groups, started on first word of it. */
148
+ serialGroup(groupId) {
149
+ let progress = this.serialGroups.get(groupId);
150
+ if (progress === undefined) {
151
+ progress = new SerialGroupProgress();
152
+ this.serialGroups.set(groupId, progress);
153
+ }
154
+ return progress;
155
+ }
107
156
  clearKillTimer() {
108
157
  if (this.killTimer === undefined)
109
158
  return;
110
159
  clearTimeout(this.killTimer);
111
160
  this.killTimer = undefined;
112
161
  }
162
+ /**
163
+ * The watchdog over one attempt. The worker enforces the test timeout with
164
+ * timers in its own process, which a body that blocks the event loop
165
+ * (`while (true) {}`) never lets fire. From the deadline on, the worker is
166
+ * pinged once per cleanup budget (at least `MIN_PING_REPLY_MS`) for as
167
+ * long as the attempt lasts, teardown included: one still tearing it down
168
+ * answers and goes on, one that has not answered by the next ping is
169
+ * killed, and the attempt is the one it hung in.
170
+ */
171
+ watch(message) {
172
+ this.unwatch();
173
+ this.watched = {
174
+ pair: pairKey(message.testId, message.agent, message.repeat),
175
+ index: message.attempt,
176
+ id: message.attemptId,
177
+ startedAt: message.startedAt,
178
+ timeoutMs: message.timeoutMs,
179
+ };
180
+ const check = () => {
181
+ if (this.watched === undefined || !this.runner.alive)
182
+ return;
183
+ if (this.awaitingPong) {
184
+ this.hung = this.watched;
185
+ this.killed = true;
186
+ this.runner.kill();
187
+ return;
188
+ }
189
+ this.awaitingPong = true;
190
+ this.watchAfter(Math.max(message.graceMs, MIN_PING_REPLY_MS), check);
191
+ this.runner.send({ type: 'ping' });
192
+ };
193
+ this.watchAfter(message.timeoutMs, check);
194
+ }
195
+ /** The worker answered a ping: its event loop turns. */
196
+ pong() {
197
+ this.awaitingPong = false;
198
+ }
199
+ /** Stops watching the attempt: it ended, or the worker is gone. */
200
+ unwatch() {
201
+ if (this.watchTimer !== undefined)
202
+ clearTimeout(this.watchTimer);
203
+ this.watchTimer = undefined;
204
+ this.watched = undefined;
205
+ this.awaitingPong = false;
206
+ }
207
+ watchAfter(delayMs, check) {
208
+ this.watchTimer = setTimeout(check, delayMs);
209
+ this.watchTimer.unref();
210
+ }
113
211
  }
114
212
  class Scheduler {
115
213
  options;
@@ -356,8 +454,17 @@ class Scheduler {
356
454
  return runnable.length === unit.pairs.length ? unit : { ...unit, pairs: runnable };
357
455
  }
358
456
  }
359
- /** Puts an untaken unit back so capacity pressure never drops work. */
457
+ /**
458
+ * Puts an untaken unit back so capacity pressure never drops work. A
459
+ * failed target's queues were already drained and nothing takes from them
460
+ * again, so its unit skips instead.
461
+ */
360
462
  returnUnit(state, unit) {
463
+ if (state.failed) {
464
+ for (const pair of unit.pairs)
465
+ this.report(unstartedResult(pair, WORKER_START_FAILED));
466
+ return;
467
+ }
361
468
  if (unit.kind === 'setup')
362
469
  state.setupQueue.unshift(unit);
363
470
  else
@@ -478,10 +585,27 @@ class Scheduler {
478
585
  }
479
586
  case 'pair-start': {
480
587
  worker.inFlightPair = pairKey(message.testId, message.agent, message.repeat);
588
+ if (message.serialId !== undefined) {
589
+ worker
590
+ .serialGroup(serialGroupId(message.serialId, worker.targetName, message.agent, message.repeat))
591
+ .memberStarted(message.testId);
592
+ }
481
593
  const { type: _type, ...start } = message;
482
594
  this.options.events.onTestStart?.(start, worker.targetName);
483
595
  break;
484
596
  }
597
+ case 'attempt-deadline': {
598
+ worker.watch(message);
599
+ break;
600
+ }
601
+ case 'attempt-end': {
602
+ worker.unwatch();
603
+ break;
604
+ }
605
+ case 'pong': {
606
+ worker.pong();
607
+ break;
608
+ }
485
609
  case 'progress': {
486
610
  this.options.events.onProgress?.({ testId: message.testId, agent: message.agent, repeat: message.repeat }, worker.targetName, message.progress);
487
611
  break;
@@ -498,7 +622,9 @@ class Scheduler {
498
622
  case 'result': {
499
623
  const state = this.targetState(worker);
500
624
  const result = decodeResult(message.result, state.target);
501
- worker.reported.add(pairKey(result.test.id, result.agent, result.repeat));
625
+ const key = pairKey(result.test.id, result.agent, result.repeat);
626
+ worker.reported.add(key);
627
+ worker.attempts.delete(key);
502
628
  if (result.status !== 'passed' && result.status !== 'flaky' && result.status !== 'skipped') {
503
629
  worker.sawFailure = true;
504
630
  }
@@ -508,9 +634,28 @@ class Scheduler {
508
634
  break;
509
635
  }
510
636
  case 'serial-group': {
637
+ worker.serialGroups.delete(message.group.id);
511
638
  this.options.events.onSerialGroup(message.group);
512
639
  break;
513
640
  }
641
+ case 'attempt-start': {
642
+ worker.pairAttempts(pairKey(message.testId, message.agent, message.repeat)).running = message.index;
643
+ break;
644
+ }
645
+ case 'attempt': {
646
+ const attempts = worker.pairAttempts(pairKey(message.testId, message.agent, message.repeat));
647
+ attempts.finished.push(message.attempt);
648
+ attempts.running = undefined;
649
+ break;
650
+ }
651
+ case 'serial-member': {
652
+ worker.serialGroup(message.groupId).memberFinished(message.attempt, message.member);
653
+ break;
654
+ }
655
+ case 'serial-attempt': {
656
+ worker.serialGroup(message.groupId).attemptFinished(message.run);
657
+ break;
658
+ }
514
659
  case 'unit-done': {
515
660
  for (const runError of message.runErrors)
516
661
  this.options.events.onRunError(runError);
@@ -610,50 +755,121 @@ class Scheduler {
610
755
  this.signalledWave ??= new Set([...this.workers, worker]);
611
756
  return this.signalledWave.has(worker);
612
757
  }
613
- /** Emits records for a unit whose worker died before reporting it done. */
758
+ /**
759
+ * Emits records for a unit whose worker died before reporting it done.
760
+ * Attempts the worker finished stand. The test in flight gets a
761
+ * `WORKER_CRASH` attempt after them when the crash landed during an
762
+ * attempt; one between attempts (an `afterAll` after the last) is recorded
763
+ * on the last attempt instead, whose cleanup it forced. A serial group in
764
+ * flight gets a failed group attempt after its finished ones. A worker the
765
+ * run's interrupt stopped charges no crash: a test keeps the attempts it
766
+ * finished and the verdict they reach, as a retry the interrupt cut short
767
+ * does, and a serial group's attempt in flight is recorded interrupted.
768
+ *
769
+ * A worker the watchdog killed hung in one attempt: that attempt timed out
770
+ * (`TEST_TIMEOUT`, after the attempts before it, never retried), and the
771
+ * unit's tests it never reached run on a fresh worker, as they would have
772
+ * after a timeout the worker enforced itself. Only the rest of a serial
773
+ * group it hung in is lost with it.
774
+ */
614
775
  synthesizeCrashResults(state, worker, unit) {
615
- const interrupted = this.interrupting;
776
+ const interrupted = this.interrupting && (worker.terminated || worker.killed);
777
+ const crash = serializeError(new InfrastructureError('WORKER_CRASH', 'worker process exited during this test'));
778
+ const hung = worker.hung;
779
+ const hungPair = hung === undefined ? undefined : unit.pairs.find((pair) => pairKey(pair.test.id, pair.agent, pair.repeat) === hung.pair);
780
+ const hungGroupId = hungPair?.test.serialId === undefined
781
+ ? undefined
782
+ : serialGroupId(hungPair.test.serialId, worker.targetName, hungPair.agent, hungPair.repeat);
783
+ const settledGroups = new Set();
784
+ const rescheduled = [];
616
785
  for (const pair of unit.pairs) {
617
786
  const key = pairKey(pair.test.id, pair.agent, pair.repeat);
618
787
  if (worker.reported.has(key))
619
788
  continue;
789
+ const { serialId } = pair.test;
790
+ const groupId = serialId === undefined ? undefined : serialGroupId(serialId, worker.targetName, pair.agent, pair.repeat);
791
+ if (groupId !== undefined) {
792
+ if (settledGroups.has(groupId))
793
+ continue;
794
+ const members = unit.pairs.filter((member) => member.test.serialId === serialId && member.agent === pair.agent && member.repeat === pair.repeat);
795
+ const progress = worker.serialGroups.get(groupId);
796
+ let settled;
797
+ if (hung !== undefined && groupId === hungGroupId)
798
+ settled = progress?.crashed(members, state.target, hangError(hung), 'timed-out');
799
+ else if (interrupted)
800
+ settled = progress?.interrupted(members, state.target, this.stopSkip ?? INTERRUPTED_BEFORE_START);
801
+ else
802
+ settled = progress?.crashed(members, state.target, crash, 'failed');
803
+ if (settled !== undefined) {
804
+ settledGroups.add(groupId);
805
+ this.options.events.onSerialGroup(settled.group);
806
+ for (const result of settled.results)
807
+ this.report(result);
808
+ continue;
809
+ }
810
+ }
811
+ const { finished, running } = worker.attempts.get(key) ?? { finished: [], running: undefined };
812
+ if (hung !== undefined && key === hung.pair) {
813
+ this.markSessionsFailed(state, pair);
814
+ this.report(finishedResult(pair, [...finished, hungAttempt(hung)]));
815
+ continue;
816
+ }
620
817
  if (interrupted) {
621
- this.report(unstartedResult(pair, this.stopSkip ?? INTERRUPTED_BEFORE_START));
818
+ this.report(finished.length === 0
819
+ ? unstartedResult(pair, this.stopSkip ?? INTERRUPTED_BEFORE_START)
820
+ : finishedResult(pair, finished));
622
821
  continue;
623
822
  }
624
- // A crashed setup never persisted its sessions; dependents must skip.
625
- if (pair.test.kind === 'setup') {
626
- for (const session of pair.test.sessions) {
627
- state.failedSessions.set(session, pair.test.id);
628
- }
823
+ if (hung !== undefined && (hungGroupId === undefined || groupId !== hungGroupId)) {
824
+ rescheduled.push(pair);
825
+ continue;
629
826
  }
630
- const wasRunning = key === worker.inFlightPair && pair.test.serialId === undefined;
631
- if (!wasRunning) {
827
+ this.markSessionsFailed(state, pair);
828
+ if (key !== worker.inFlightPair) {
632
829
  this.report(unstartedResult(pair, {
633
830
  cause: 'infrastructure-unavailable',
634
831
  reason: 'worker process exited before this test started',
635
832
  }));
636
833
  continue;
637
834
  }
835
+ const last = finished.at(-1);
836
+ if (last !== undefined && running === undefined) {
837
+ this.report(finishedResult(pair, [
838
+ ...finished.slice(0, -1),
839
+ { ...last, secondaryErrors: [...last.secondaryErrors, crash], cleanup: 'forced' },
840
+ ]));
841
+ continue;
842
+ }
638
843
  this.report(pairResult(pair, {
639
844
  status: 'failed',
640
845
  selected: true,
641
846
  attempts: [
847
+ ...finished,
642
848
  {
643
849
  id: uuidv7(),
644
- index: 0,
850
+ index: running ?? finished.length,
645
851
  status: 'failed',
646
852
  startedAt: timestamp(),
647
853
  durationMs: 0,
648
854
  steps: [],
649
855
  artifacts: [],
650
- error: serializeError(new InfrastructureError('WORKER_CRASH', 'worker process exited during this test')),
856
+ error: crash,
651
857
  secondaryErrors: [],
652
858
  cleanup: 'forced',
653
859
  },
654
860
  ],
655
861
  }));
656
862
  }
863
+ if (rescheduled.length > 0)
864
+ this.returnUnit(state, { ...unit, pairs: rescheduled });
865
+ }
866
+ /** A crashed setup never persisted its sessions; dependents must skip. */
867
+ markSessionsFailed(state, pair) {
868
+ if (pair.test.kind !== 'setup')
869
+ return;
870
+ for (const session of pair.test.sessions) {
871
+ state.failedSessions.set(session, pair.test.id);
872
+ }
657
873
  }
658
874
  /**
659
875
  * Fails a target whose workers cannot boot; drains its queues with skips.
@@ -667,10 +883,7 @@ class Scheduler {
667
883
  error: serializeError(new InfrastructureError('WORKER_INIT_FAILED', `workers for target "${state.target.name}" failed to start ${MAX_INIT_FAILURES} times; remaining units skipped`)),
668
884
  });
669
885
  }
670
- this.skipQueues(state, {
671
- cause: 'infrastructure-unavailable',
672
- reason: 'worker process failed to start',
673
- });
886
+ this.skipQueues(state, WORKER_START_FAILED);
674
887
  }
675
888
  /** Shuts every remaining worker down, force-killing stragglers. */
676
889
  async drain() {
@@ -682,3 +895,26 @@ class Scheduler {
682
895
  await Promise.all(remaining.map((worker) => worker.runner.exit));
683
896
  }
684
897
  }
898
+ /** Why the watchdog killed a worker that hung in `hung`. */
899
+ function hangError(hung) {
900
+ return serializeError(new TestTimeoutError(`test ran past its ${hung.timeoutMs} ms timeout and its worker stopped responding, so the worker was killed`));
901
+ }
902
+ /**
903
+ * The attempt a worker the watchdog killed hung in. It ends the pair's
904
+ * attempts: a body that blocked the event loop once does so again, so it is
905
+ * not retried.
906
+ */
907
+ function hungAttempt(hung) {
908
+ return {
909
+ id: hung.id,
910
+ index: hung.index,
911
+ status: 'timed-out',
912
+ startedAt: hung.startedAt,
913
+ durationMs: Date.now() - Date.parse(hung.startedAt),
914
+ steps: [],
915
+ artifacts: [],
916
+ error: hangError(hung),
917
+ secondaryErrors: [],
918
+ cleanup: 'forced',
919
+ };
920
+ }
@@ -6,40 +6,43 @@ import type { Secret } from '../types.ts';
6
6
  export interface SessionSecrecy {
7
7
  /** Every secret value the session may have seen; live, so a provider's value joins the moment it exists. */
8
8
  readonly ledger: SecretLedger;
9
- /** How far a secret reached the session, and what that withholds and rewrites. */
9
+ /** Whether a secret was filled into the session, and what that withholds. */
10
10
  readonly exposure: SecretExposure;
11
11
  }
12
12
  /**
13
- * How far a secret reached one session. It only rises, and is never cleared:
14
- * `none`, no secret reached it; `engine`, the engine resolved one for an
15
- * option it hands the app (basic-auth credentials), so the app has the value
16
- * and a page can render it (an echo of the `Authorization` header) though
17
- * nothing was typed; `filled`, a secret was typed into the app.
13
+ * Whether a secret was typed into one session's app. It only rises, and is
14
+ * never cleared: `none`, nothing was filled; `filled`, a secret was typed
15
+ * into the app.
18
16
  */
19
- export type SecretExposureLevel = 'none' | 'engine' | 'filled';
17
+ export type SecretExposureLevel = 'none' | 'filled';
20
18
  /**
21
19
  * One session's exposure level, and the one place that maps it to what each
22
- * consumer does. Either level above `none` rewrites what text can carry the
23
- * value (a trace's text entries, a text-like download) through the ledger,
24
- * as every report and observation already is. Only a fill withholds pixels
25
- * (failure screenshots, `app.screenshot()`, assert evidence, model input,
26
- * and a trace's screencast frames): an engine-held secret is protected as
27
- * text only, so a page that renders it on screen is not masked. Only a fill
20
+ * consumer withholds. Only a fill withholds pixels (failure screenshots,
21
+ * `app.screenshot()`, assert evidence, model input, and a trace's
22
+ * screencast frames): a value that reached the app otherwise (an engine
23
+ * option, a URL or a `fill` the test spelled it into) is protected as text
24
+ * only, so a page that renders it on screen is not masked. Only a fill
28
25
  * carries into a saved session too: the value then lives in the app's
29
- * state, while an engine-held one is resolved again by whatever engine opens
30
- * the restoring session.
26
+ * state. Text is not decided here: see `redactsRecordings`.
31
27
  */
32
28
  export declare class SecretExposure {
33
29
  private current;
34
- /** Records that a secret reached the session at `level`; a lower level never replaces a higher one. */
30
+ /** Records that a secret was filled into the session. */
35
31
  raise(level: Exclude<SecretExposureLevel, 'none'>): void;
36
32
  /** Whether no pixels may leave the session: no screenshot or screencast frame is taken, kept, or handed to a model. */
37
33
  get withholdsPixels(): boolean;
38
- /** Whether a text recording of the session (a trace, a text-like download) is rewritten through the ledger before it is kept. */
39
- get redactsRecordings(): boolean;
40
34
  /** Whether a session saved from this one carries the taint to the sessions that restore it. */
41
35
  get carriesTaint(): boolean;
42
36
  }
37
+ /**
38
+ * Whether a text recording of the session (a trace, a text-like download) is
39
+ * rewritten through its ledger before it is kept: whenever the ledger holds
40
+ * a value, filled or not. A value reaches the app in ways the runner never
41
+ * sees (a URL or a `fill` the test spelled it into, the app's own config),
42
+ * so recordings are redacted like every report and observation, not only
43
+ * after a fill.
44
+ */
45
+ export declare function redactsRecordings(secrecy: SessionSecrecy): boolean;
43
46
  /**
44
47
  * Every secret value any session in this process has seen, for text the
45
48
  * process itself emits rather than a session: a test's console output leaves
@@ -56,7 +59,7 @@ export declare const processSecrets: SecretLedger;
56
59
  * handed out read-only: nothing registers into it.
57
60
  */
58
61
  export declare function staticSecretLedger(secrets: ResolvedConfig['allSecrets']): Pick<SecretLedger, 'redact' | 'entries'>;
59
- /** Seeds `processSecrets` with the static values of `secrets`, so output before any session opens is covered too. */
62
+ /** Seeds `processSecrets` with the static values of `secrets` and makes it `serializeError`'s default redactor, so output before any session opens is covered too. */
60
63
  export declare function registerStaticSecrets(secrets: ResolvedConfig['allSecrets']): void;
61
64
  /**
62
65
  * The plaintext of one configured secret, a provider's value computed fresh.
@@ -1,41 +1,44 @@
1
1
  /** What one live session knows about secrets: the values to redact, and whether one reached it. */
2
2
  import { MIN_SECRET_LENGTH, secretLength } from '../config/secrets.js';
3
- import { ConfigurationError } from '../internal/errors.js';
3
+ import { ConfigurationError, setErrorRedactor } from '../internal/errors.js';
4
4
  import { SecretLedger } from '../internal/redact.js';
5
5
  import { unavailableCode } from '../secrets.js';
6
- const EXPOSURE_RANK = { none: 0, engine: 1, filled: 2 };
7
6
  /**
8
7
  * One session's exposure level, and the one place that maps it to what each
9
- * consumer does. Either level above `none` rewrites what text can carry the
10
- * value (a trace's text entries, a text-like download) through the ledger,
11
- * as every report and observation already is. Only a fill withholds pixels
12
- * (failure screenshots, `app.screenshot()`, assert evidence, model input,
13
- * and a trace's screencast frames): an engine-held secret is protected as
14
- * text only, so a page that renders it on screen is not masked. Only a fill
8
+ * consumer withholds. Only a fill withholds pixels (failure screenshots,
9
+ * `app.screenshot()`, assert evidence, model input, and a trace's
10
+ * screencast frames): a value that reached the app otherwise (an engine
11
+ * option, a URL or a `fill` the test spelled it into) is protected as text
12
+ * only, so a page that renders it on screen is not masked. Only a fill
15
13
  * carries into a saved session too: the value then lives in the app's
16
- * state, while an engine-held one is resolved again by whatever engine opens
17
- * the restoring session.
14
+ * state. Text is not decided here: see `redactsRecordings`.
18
15
  */
19
16
  export class SecretExposure {
20
17
  current = 'none';
21
- /** Records that a secret reached the session at `level`; a lower level never replaces a higher one. */
18
+ /** Records that a secret was filled into the session. */
22
19
  raise(level) {
23
- if (EXPOSURE_RANK[level] > EXPOSURE_RANK[this.current])
24
- this.current = level;
20
+ this.current = level;
25
21
  }
26
22
  /** Whether no pixels may leave the session: no screenshot or screencast frame is taken, kept, or handed to a model. */
27
23
  get withholdsPixels() {
28
24
  return this.current === 'filled';
29
25
  }
30
- /** Whether a text recording of the session (a trace, a text-like download) is rewritten through the ledger before it is kept. */
31
- get redactsRecordings() {
32
- return this.current !== 'none';
33
- }
34
26
  /** Whether a session saved from this one carries the taint to the sessions that restore it. */
35
27
  get carriesTaint() {
36
28
  return this.current === 'filled';
37
29
  }
38
30
  }
31
+ /**
32
+ * Whether a text recording of the session (a trace, a text-like download) is
33
+ * rewritten through its ledger before it is kept: whenever the ledger holds
34
+ * a value, filled or not. A value reaches the app in ways the runner never
35
+ * sees (a URL or a `fill` the test spelled it into, the app's own config),
36
+ * so recordings are redacted like every report and observation, not only
37
+ * after a fill.
38
+ */
39
+ export function redactsRecordings(secrecy) {
40
+ return !secrecy.ledger.isEmpty;
41
+ }
39
42
  /**
40
43
  * Every secret value any session in this process has seen, for text the
41
44
  * process itself emits rather than a session: a test's console output leaves
@@ -61,8 +64,9 @@ export function staticSecretLedger(secrets) {
61
64
  }
62
65
  return ledger;
63
66
  }
64
- /** Seeds `processSecrets` with the static values of `secrets`, so output before any session opens is covered too. */
67
+ /** Seeds `processSecrets` with the static values of `secrets` and makes it `serializeError`'s default redactor, so output before any session opens is covered too. */
65
68
  export function registerStaticSecrets(secrets) {
69
+ setErrorRedactor(processSecrets.redact);
66
70
  for (const [name, { value }] of secrets) {
67
71
  if (typeof value === 'string')
68
72
  processSecrets.register(name, value);
@@ -2,14 +2,14 @@
2
2
  import type { TargetSession } from '../engine/surface.ts';
3
3
  import { type SerializedError } from '../internal/errors.ts';
4
4
  import { type RegisteredTest } from '../collect/registry.ts';
5
- import { type TestTargetPair } from '../collect/select.ts';
5
+ import { type SkipInfo, type TestTargetPair } from '../collect/select.ts';
6
6
  import type { ResolvedTarget } from '../config/resolve.ts';
7
7
  import type { ArtifactStore } from '../types.ts';
8
8
  import type { AttemptContext, ClosingRecord, SessionClose, SessionPlan } from './execute.ts';
9
9
  import type { ArtifactSink } from './fixtures.ts';
10
10
  import type { StepRecord } from './steps.ts';
11
11
  import { type FileRef, type Realm, RealmManager } from './realm.ts';
12
- import type { AttemptRecord, ResultRecord, SerialGroupRecord } from './records.ts';
12
+ import type { AttemptRecord, ResultRecord, SerialAttemptRecord, SerialGroupRecord, SerialMemberRecord } from './records.ts';
13
13
  /**
14
14
  * One shared session for a serial-group attempt: members preserve app state,
15
15
  * so a screen an earlier member reached stays current for later members.
@@ -37,6 +37,8 @@ export interface SharedSerialSession {
37
37
  export interface SerialHost {
38
38
  readonly target: ResolvedTarget;
39
39
  readonly artifactsRoot: string;
40
+ /** See `TargetExecutorOptions.rerunDir`. */
41
+ readonly rerunDir: string | undefined;
40
42
  readonly runId: string;
41
43
  /** The configured artifact store, so group-owned artifacts (the shared trace) upload like any other. */
42
44
  readonly artifactStore: ArtifactStore | undefined;
@@ -54,6 +56,77 @@ export interface SerialHost {
54
56
  * steps, duration, and error from the group it has already seen.
55
57
  */
56
58
  emitSerialGroup(group: SerialGroupRecord): void;
59
+ /**
60
+ * Streams one member that finished in the group attempt running now, so a
61
+ * worker that dies before the group ends does not take it along.
62
+ */
63
+ serialMemberFinished(groupId: string, attempt: SerialAttemptStart, member: SerialMemberRecord): void;
64
+ /** Streams one finished group attempt, for the same reason. */
65
+ serialAttemptFinished(groupId: string, run: SerialAttemptRun): void;
57
66
  }
67
+ /** The identity of a group attempt, known from its start. */
68
+ export type SerialAttemptStart = Pick<SerialAttemptRecord, 'id' | 'index' | 'startedAt'>;
69
+ /** One group attempt as it ended, and whether it got as far as its members. */
70
+ export interface SerialAttemptRun {
71
+ readonly record: SerialAttemptRecord;
72
+ /**
73
+ * False when the attempt stopped before any member ran (its file would not
74
+ * load, its session would not launch). Such a retry keeps the members'
75
+ * verdicts from the attempt before it.
76
+ */
77
+ readonly reachedMembers: boolean;
78
+ }
79
+ /** The report id of one serial group variant: a group runs once per agent and per repeat. */
80
+ export declare function serialGroupId(serialId: string, targetName: string, agent: string, repeat: number): string;
58
81
  /** Runs one serial unit to completion and emits every member result. */
59
82
  export declare function runSerialUnit(host: SerialHost, members: readonly TestTargetPair[], file: FileRef): Promise<SerialGroupRecord>;
83
+ /** A serial group decided without its worker's word: the record and its members' results. */
84
+ interface SettledSerialGroup {
85
+ readonly group: SerialGroupRecord;
86
+ readonly results: readonly ResultRecord[];
87
+ }
88
+ /**
89
+ * What the runner heard of one serial group a worker is running: the
90
+ * attempts it finished, and the members finished so far in the attempt
91
+ * running now. When the worker dies before reporting the group, this is
92
+ * what the report keeps.
93
+ */
94
+ export declare class SerialGroupProgress {
95
+ private readonly finished;
96
+ private current;
97
+ private currentMembers;
98
+ /** The member whose body is running now, by test id, until its record arrives. */
99
+ private running;
100
+ /** A member of the group announced its start. */
101
+ memberStarted(testId: string): void;
102
+ /** A member finished in the attempt running now. */
103
+ memberFinished(attempt: SerialAttemptStart, member: SerialMemberRecord): void;
104
+ /** A group attempt finished; the next one starts empty. */
105
+ attemptFinished(run: SerialAttemptRun): void;
106
+ /**
107
+ * The group after its worker died with `error`, or undefined when the
108
+ * runner heard nothing of it running. The finished attempts stand, and the
109
+ * attempt in flight fails: the member whose body was running ends `status`
110
+ * with `error` (`timed-out` when the watchdog killed a worker that hung in
111
+ * it) and the ones after it skip. A crash between members (a hook,
112
+ * the session closing) fails the attempt and leaves its finished members
113
+ * as they were; one in a retry before any member ran fails the first, as a
114
+ * launch failure does. A crash after the last attempt, when no retry
115
+ * follows it, is recorded on that attempt.
116
+ */
117
+ crashed(members: readonly TestTargetPair[], target: ResolvedTarget, error: SerializedError, status: 'failed' | 'timed-out'): SettledSerialGroup | undefined;
118
+ /**
119
+ * The group after the run's interrupt stopped its worker, or undefined
120
+ * when the runner heard nothing of it running. The finished attempts
121
+ * stand, and an attempt in flight is interrupted: the members it finished
122
+ * keep their records and the rest skip with `skip`. The verdict is the one
123
+ * those attempts reach, as for a retry the interrupt cut short.
124
+ */
125
+ interrupted(members: readonly TestTargetPair[], target: ResolvedTarget, skip: SkipInfo): SettledSerialGroup | undefined;
126
+ /** Whether a member of the attempt in flight started or finished. */
127
+ private get inFlight();
128
+ /** The record of the attempt in flight as it stood, with the members it finished. */
129
+ private attemptInFlight;
130
+ private settled;
131
+ }
132
+ export {};