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
@@ -9,17 +9,33 @@ import type { AttemptRecordings } from '../internal/recording-modes.ts';
9
9
  import type { ArtifactStore } from '../types.ts';
10
10
  import { type ArtifactSink } from './fixtures.ts';
11
11
  import { RealmManager, type FileRef, type Realm } from './realm.ts';
12
- import type { AttemptRecord, ResultRecord, RunError, SerialGroupRecord } from './records.ts';
13
- import { type SerialHost, type SharedSerialSession } from './serial.ts';
12
+ import type { AttemptRecord, ResultRecord, RunError, SerialGroupRecord, SerialMemberRecord } from './records.ts';
13
+ import { type SerialAttemptRun, type SerialAttemptStart, type SerialHost, type SharedSerialSession } from './serial.ts';
14
14
  import { SessionStaging, SessionStore } from './sessions.ts';
15
15
  import { type StepProgress } from './steps.ts';
16
16
  export interface ExecutionEvents {
17
17
  onResult?(result: ResultRecord): void;
18
18
  onSerialGroup?(group: SerialGroupRecord): void;
19
+ /** An attempt of an ordinary or setup pair begins, its realm and `beforeAll` hooks included. */
20
+ onAttemptStart?(pair: TestTargetPair, attemptIndex: number): void;
21
+ /** One finished attempt of an ordinary or setup pair, ahead of the pair's result. */
22
+ onAttempt?(pair: TestTargetPair, attempt: AttemptRecord): void;
23
+ /** One member that finished in a serial group attempt, ahead of the group. */
24
+ onSerialMember?(groupId: string, attempt: SerialAttemptStart, member: SerialMemberRecord): void;
25
+ /** One finished serial group attempt, ahead of the group. */
26
+ onSerialAttempt?(groupId: string, run: SerialAttemptRun): void;
19
27
  /** Fires before a runnable pair starts; a serial unit announces each member as it begins. */
20
28
  onPairStart?(pair: TestTargetPair): void;
21
29
  /** Live step progress of one running attempt, for reporters. */
22
30
  onProgress?(pair: TestTargetPair, progress: StepProgress): void;
31
+ /** An attempt's test timeout started, as its `beforeEach` hooks and body begin. */
32
+ onAttemptDeadline?(pair: TestTargetPair, attempt: {
33
+ readonly index: number;
34
+ readonly id: string;
35
+ readonly startedAt: string;
36
+ }): void;
37
+ /** The attempt `onAttemptDeadline` announced has ended, or one that never got that far has. */
38
+ onAttemptEnd?(): void;
23
39
  /** One line of progress the engine's `init` reported, already naming the target and worker slot. */
24
40
  onNotice?(message: string): void;
25
41
  /**
@@ -34,6 +50,12 @@ export interface TargetExecutorOptions {
34
50
  readonly target: ResolvedTarget;
35
51
  readonly runId: string;
36
52
  readonly artifactsRoot: string;
53
+ /**
54
+ * The directory under `artifactsRoot` a `--last-failed` rerun's attempts
55
+ * write in (`claimRerunDir`), beside the evidence of the run it reruns;
56
+ * undefined when the attempts fill the root themselves.
57
+ */
58
+ readonly rerunDir?: string | undefined;
37
59
  readonly sessionStore: SessionStore;
38
60
  readonly headed: boolean;
39
61
  /** This worker's slot among the target's workers; see `EngineInitInfo.workerSlot`. */
@@ -103,6 +125,7 @@ export declare class TargetExecutor implements SerialHost {
103
125
  get runId(): string;
104
126
  get artifactStore(): ArtifactStore | undefined;
105
127
  readonly artifactsRoot: string;
128
+ readonly rerunDir: string | undefined;
106
129
  readonly interruptSignal: AbortSignal;
107
130
  readonly realms: RealmManager;
108
131
  readonly debug: DebugTrace;
@@ -113,6 +136,8 @@ export declare class TargetExecutor implements SerialHost {
113
136
  private lastAttemptTestId;
114
137
  private readonly models;
115
138
  private readonly sessionIdentity;
139
+ /** The file store's listing under `cache.strict`, read once for every attempt here. */
140
+ private readonly storedRecordings;
116
141
  /** Resolves once the engine's init hook completed for this worker. */
117
142
  private engineReady;
118
143
  constructor(options: TargetExecutorOptions);
@@ -123,6 +148,10 @@ export declare class TargetExecutor implements SerialHost {
123
148
  pairStarted(pair: TestTargetPair): void;
124
149
  /** Emits one finished serial group, ahead of its members' results (SerialHost). */
125
150
  emitSerialGroup(group: SerialGroupRecord): void;
151
+ /** Streams one member finished in a serial group attempt (SerialHost). */
152
+ serialMemberFinished(groupId: string, attempt: SerialAttemptStart, member: SerialMemberRecord): void;
153
+ /** Streams one finished serial group attempt (SerialHost). */
154
+ serialAttemptFinished(groupId: string, run: SerialAttemptRun): void;
126
155
  /** Builds one engine operation context. */
127
156
  private op;
128
157
  /** Run-level errors recorded so far, in order. */
@@ -187,9 +216,9 @@ export declare class TargetExecutor implements SerialHost {
187
216
  * hands the app (basic-auth credentials). Registered for the session's and
188
217
  * the process's redaction by `resolveSecretValue`, with every value the
189
218
  * engine derives from it (the base64 credential an `Authorization` header
190
- * carries) under the same name. The session's exposure rises to `engine`:
191
- * its text is redacted and its trace and text downloads rewritten, while
192
- * its pixels stay as they are, since nothing was typed.
219
+ * carries) under the same name, so its text, trace, and text downloads
220
+ * are redacted of them. Its pixels stay as they are, since nothing was
221
+ * typed.
193
222
  */
194
223
  private resolveEngineSecret;
195
224
  /**
@@ -10,21 +10,23 @@ import { timestamp, uuidv7 } from '../internal/ids.js';
10
10
  import { engineAppInfo } from '../config/app.js';
11
11
  import { Deadline, NEVER_ABORTS, withAbort, withScopedBudget, withTimeout } from '../internal/time.js';
12
12
  import { createAgentCacheContext, flushStagedTraces } from '../cache/context.js';
13
+ import { storedRecordingsFor } from '../cache/rekeyed.js';
13
14
  import { pairRecordings } from '../collect/select.js';
14
- import { createAttemptArtifacts, sanitizePathSegment } from './artifacts.js';
15
+ import { attemptSegments, createAttemptArtifacts, sanitizePathSegment } from './artifacts.js';
15
16
  import { AttemptBudget } from './budget.js';
16
17
  import { createEngineSession } from '../engine/session.js';
17
18
  import { createExtendedFixtures } from './extended-fixtures.js';
18
19
  import { captureFailureEvidence } from './failure-evidence.js';
19
20
  import { createFixtures } from './fixtures.js';
20
21
  import { publishAttempt } from '../expect/attempt.js';
22
+ import { PollScope, runInPollScope } from '../expect/poll-scope.js';
21
23
  import { SoftFailures } from '../expect/soft.js';
22
24
  import { findRegistered, RealmManager, runHook } from './realm.js';
23
25
  import { isFailedStatus } from './records.js';
24
26
  import { runWithRetries } from './retry.js';
25
27
  import { runSerialUnit } from './serial.js';
26
28
  import { interruptedSkip, pairKey, pairResult, repeatSegment, unstartedResult } from './units.js';
27
- import { adoptSecrecy, carriedSecrecy, processSecrets, registerDerivedSecrets, redactForSession, resolveSecretValue, sessionSecrecy, staticSecretLedger } from './secrecy.js';
29
+ import { adoptSecrecy, carriedSecrecy, processSecrets, registerDerivedSecrets, redactForSession, redactsRecordings, resolveSecretValue, sessionSecrecy, staticSecretLedger } from './secrecy.js';
28
30
  import { isSecret } from '../secrets.js';
29
31
  import { SessionStaging, SessionStore, targetIdentity } from './sessions.js';
30
32
  import { redactTraceArchives } from './trace-redaction.js';
@@ -43,6 +45,7 @@ export class TargetExecutor {
43
45
  return this.config.artifactStore;
44
46
  }
45
47
  artifactsRoot;
48
+ rerunDir;
46
49
  interruptSignal;
47
50
  realms;
48
51
  debug;
@@ -53,12 +56,15 @@ export class TargetExecutor {
53
56
  lastAttemptTestId;
54
57
  models;
55
58
  sessionIdentity;
59
+ /** The file store's listing under `cache.strict`, read once for every attempt here. */
60
+ storedRecordings;
56
61
  /** Resolves once the engine's init hook completed for this worker. */
57
62
  engineReady;
58
63
  constructor(options) {
59
64
  this.options = options;
60
65
  this.target = options.target;
61
66
  this.artifactsRoot = options.artifactsRoot;
67
+ this.rerunDir = options.rerunDir;
62
68
  this.interruptSignal = options.interruptSignal;
63
69
  this.debug = options.debug ?? new DebugTrace(false);
64
70
  this.models = new WorkerModels((error) => {
@@ -74,6 +80,7 @@ export class TargetExecutor {
74
80
  debug: this.debug,
75
81
  });
76
82
  this.sessionIdentity = targetIdentity(options.target);
83
+ this.storedRecordings = storedRecordingsFor(options.config.cache);
77
84
  }
78
85
  get config() {
79
86
  return this.options.config;
@@ -90,6 +97,14 @@ export class TargetExecutor {
90
97
  emitSerialGroup(group) {
91
98
  this.options.events?.onSerialGroup?.(group);
92
99
  }
100
+ /** Streams one member finished in a serial group attempt (SerialHost). */
101
+ serialMemberFinished(groupId, attempt, member) {
102
+ this.options.events?.onSerialMember?.(groupId, attempt, member);
103
+ }
104
+ /** Streams one finished serial group attempt (SerialHost). */
105
+ serialAttemptFinished(groupId, run) {
106
+ this.options.events?.onSerialAttempt?.(groupId, run);
107
+ }
93
108
  /** Builds one engine operation context. */
94
109
  op(attemptId, timeoutMs, signal) {
95
110
  return { signal, timeoutMs, runId: this.options.runId, attemptId, origin: 'test' };
@@ -249,6 +264,7 @@ export class TargetExecutor {
249
264
  let realm = incomingRealm;
250
265
  let hookFailure;
251
266
  const finalStatus = await runWithRetries(pair.options.retries + 1, this.interruptSignal, async (attemptIndex) => {
267
+ this.options.events?.onAttemptStart?.(pair, attemptIndex);
252
268
  if (realm === null)
253
269
  realm = await this.realms.create(file);
254
270
  const registered = findRegistered(realm, pair.test);
@@ -263,6 +279,7 @@ export class TargetExecutor {
263
279
  kind: 'ordinary',
264
280
  });
265
281
  attempts.push(attempt);
282
+ this.options.events?.onAttempt?.(pair, attempt);
266
283
  if (isFailedStatus(attempt.status)) {
267
284
  // A failed realm is never reused, but afterAll still runs for every
268
285
  // scope whose beforeAll started in it. A body that skipped itself
@@ -309,6 +326,7 @@ export class TargetExecutor {
309
326
  const attempts = [];
310
327
  let hookFailure;
311
328
  const finalStatus = await runWithRetries(pair.options.retries + 1, this.interruptSignal, async (attemptIndex) => {
329
+ this.options.events?.onAttemptStart?.(pair, attemptIndex);
312
330
  const realm = attemptIndex === 0 && freshRegistration !== undefined
313
331
  ? this.realms.adopt(freshRegistration, file)
314
332
  : await this.realms.create(file);
@@ -327,15 +345,17 @@ export class TargetExecutor {
327
345
  kind: 'setup',
328
346
  staging,
329
347
  });
348
+ const missing = attempt.status === 'passed' ? staging.missing() : [];
349
+ if (missing.length > 0) {
350
+ attempt.status = 'failed';
351
+ attempt.error = serializeError(new E2EError('test', 'SESSION_CONTRACT', `setup must save each declared session exactly once; missing: [${missing.join(', ')}]`), { phase: 'body' });
352
+ }
330
353
  attempts.push(attempt);
354
+ // Sent before teardown and the session saves, as in runOrdinaryPair:
355
+ // a crash in either then lands on this attempt instead of erasing it.
356
+ this.options.events?.onAttempt?.(pair, attempt);
331
357
  await this.realms.leave(realm);
332
358
  if (attempt.status === 'passed') {
333
- const missing = staging.missing();
334
- if (missing.length > 0) {
335
- attempt.status = 'failed';
336
- attempt.error = serializeError(new E2EError('test', 'SESSION_CONTRACT', `setup must save each declared session exactly once; missing: [${missing.join(', ')}]`), { phase: 'body' });
337
- return attempt;
338
- }
339
359
  for (const [name, saved] of staging.entries()) {
340
360
  await this.options.sessionStore.save(name, this.sessionIdentity, saved);
341
361
  }
@@ -412,9 +432,9 @@ export class TargetExecutor {
412
432
  * hands the app (basic-auth credentials). Registered for the session's and
413
433
  * the process's redaction by `resolveSecretValue`, with every value the
414
434
  * engine derives from it (the base64 credential an `Authorization` header
415
- * carries) under the same name. The session's exposure rises to `engine`:
416
- * its text is redacted and its trace and text downloads rewritten, while
417
- * its pixels stay as they are, since nothing was typed.
435
+ * carries) under the same name, so its text, trace, and text downloads
436
+ * are redacted of them. Its pixels stay as they are, since nothing was
437
+ * typed.
418
438
  */
419
439
  async resolveEngineSecret(session, secret, options) {
420
440
  const engine = this.target.engine;
@@ -425,7 +445,6 @@ export class TargetExecutor {
425
445
  const secrecy = sessionSecrecy(session, this.config.allSecrets);
426
446
  const plaintext = await resolveSecretValue(secret, this.config.allSecrets, secrecy.ledger);
427
447
  registerDerivedSecrets(secret.name, options?.derived?.(plaintext) ?? [], secrecy.ledger);
428
- secrecy.exposure.raise('engine');
429
448
  return plaintext;
430
449
  }
431
450
  /**
@@ -472,14 +491,14 @@ export class TargetExecutor {
472
491
  }
473
492
  // An engine records what happened, filled secrets included, so the
474
493
  // trace is the runner's to redact before anything hashes or stores
475
- // it. Only a session a secret reached (filled, or held by the engine
476
- // for an option, which a trace records the attempt opening with) can
477
- // have recorded one: an unexposed trace needs no rewriting, and an
478
- // exposed one is kept only once rewritten. Its screencast frames go
479
- // only where pixels are withheld, after a fill.
494
+ // it. Any value the ledger holds may be in it, filled or not (a URL
495
+ // the test spelled it into, an engine option), so a trace is kept
496
+ // only once rewritten; only a session that knows no value skips it.
497
+ // Its screencast frames go only where pixels are withheld, after a
498
+ // fill.
480
499
  const secrecy = sessionSecrecy(session, this.config.allSecrets);
481
500
  let redaction = 'not-required';
482
- if (secrecy.exposure.redactsRecordings) {
501
+ if (redactsRecordings(secrecy)) {
483
502
  try {
484
503
  await redactTraceArchives(artifactSink.dir, archives, secrecy.ledger, { keepFrames: !secrecy.exposure.withholdsPixels });
485
504
  }
@@ -533,7 +552,14 @@ export class TargetExecutor {
533
552
  target: this.target.name,
534
553
  agent: pair.agent,
535
554
  attempt: attemptIndex,
536
- }, () => this.executeAttempt(pair, registered, realm, attemptIndex, context));
555
+ }, async () => {
556
+ try {
557
+ return await this.executeAttempt(pair, registered, realm, attemptIndex, context);
558
+ }
559
+ finally {
560
+ this.options.events?.onAttemptEnd?.();
561
+ }
562
+ });
537
563
  }
538
564
  async executeAttempt(pair, registered, realm, attemptIndex, context) {
539
565
  const attemptId = uuidv7();
@@ -588,7 +614,13 @@ export class TargetExecutor {
588
614
  // agent and repeat segments keep a test run as several agents, or
589
615
  // several times, from overwriting itself.
590
616
  segments: shared?.artifactSegments ??
591
- [this.target.name, sanitizePathSegment(registered.artifactName ?? pair.test.id), pair.agent, ...repeatSegment(pair.repeat), `attempt-${attemptIndex}`],
617
+ attemptSegments(this.rerunDir, [
618
+ this.target.name,
619
+ sanitizePathSegment(registered.artifactName ?? pair.test.id),
620
+ pair.agent,
621
+ ...repeatSegment(pair.repeat),
622
+ `attempt-${attemptIndex}`,
623
+ ]),
592
624
  attemptId,
593
625
  currentStepId: () => steps.currentStepId,
594
626
  ...(this.config.artifactStore === undefined ? {} : { store: this.config.artifactStore }),
@@ -614,6 +646,8 @@ export class TargetExecutor {
614
646
  let skipped;
615
647
  let phase = 'launch';
616
648
  let timedOut = false;
649
+ // The polls the fixtures, the beforeEach hooks, and the body start.
650
+ const bodyPolls = new PollScope('the test body');
617
651
  // Captured the moment the primary failure lands: steps that pass later —
618
652
  // afterEach cleanup, teardown — must not confirm traces the failure
619
653
  // implicated (a cleanup assertion says nothing about the failed flow).
@@ -639,9 +673,13 @@ export class TargetExecutor {
639
673
  // One more look at the app the moment the failure lands: what the screen
640
674
  // held then is the evidence the message lacks. Taken before teardown, so
641
675
  // an `afterEach` that navigates away or resets state cannot replace it.
676
+ // Once per attempt: a screen that yielded nothing within the budget is
677
+ // not asked again, so a hung app costs the budget once.
678
+ let evidenceTried = false;
642
679
  const captureEvidence = async () => {
643
- if (failure === undefined || record.failure !== undefined || openSession === null || this.interruptSignal.aborted)
680
+ if (failure === undefined || evidenceTried || openSession === null || this.interruptSignal.aborted)
644
681
  return;
682
+ evidenceTried = true;
645
683
  const evidence = await captureFailureEvidence({
646
684
  session: openSession,
647
685
  error: failure,
@@ -664,6 +702,7 @@ export class TargetExecutor {
664
702
  testId: pair.test.id,
665
703
  target: this.sessionIdentity,
666
704
  attemptIndex,
705
+ recordings: this.storedRecordings,
667
706
  })
668
707
  : undefined;
669
708
  // A rejection nobody caught, handed in by the process while this attempt
@@ -684,6 +723,7 @@ export class TargetExecutor {
684
723
  (await this.launchSession({ session: pair.options.session, recordings }, attemptId, artifacts.dir, attemptAbort.signal));
685
724
  openSession = session;
686
725
  const testDeadline = new Deadline(pair.options.timeout);
726
+ this.options.events?.onAttemptDeadline?.(pair, { index: attemptIndex, id: attemptId, startedAt });
687
727
  const budget = new AttemptBudget(attemptAbort.signal, testDeadline);
688
728
  const saveSession = context.kind !== 'setup'
689
729
  ? undefined
@@ -722,22 +762,25 @@ export class TargetExecutor {
722
762
  // object, whichever `test` they were registered through.
723
763
  const extended = createExtendedFixtures(registered.fixtures, fixtures, this.target.engine?.name ?? 'none');
724
764
  /**
725
- * Steps the body left running when it settled were not awaited. Each
726
- * is recorded as failed at its call, cancelled through the attempt
727
- * signal the way a timeout cancels the body, and waited for within one
728
- * cleanup budget, so teardown starts on a quiet session. Left alone,
729
- * such a step would fail once the session closed with nobody to catch
730
- * it and take the worker down.
765
+ * Steps and polls the body left running when it settled were not
766
+ * awaited. A step is recorded as failed at its call, cancelled through
767
+ * the attempt signal the way a timeout cancels the body, and waited for
768
+ * within one cleanup budget, so teardown starts on a quiet session.
769
+ * Left alone, such a step would fail once the session closed with
770
+ * nobody to catch it and take the worker down. A poll is cancelled
771
+ * alone; left alone, its timeout would fail whatever ran then.
772
+ * Returns their errors, steps first.
731
773
  */
732
774
  const abandonNotAwaited = async () => {
733
775
  if (attemptEnd.signal.aborted)
734
- return undefined;
735
- const notAwaited = steps.abandonRunning();
736
- if (notAwaited === undefined)
737
- return undefined;
738
- attemptAbort.abort();
739
- await withTimeout(steps.settleAbandoned(), this.config.cleanupTimeout, () => new Error('abandoned steps did not settle')).catch(() => undefined);
740
- return notAwaited;
776
+ return [];
777
+ const pollsNotAwaited = bodyPolls.close();
778
+ const stepsNotAwaited = steps.abandonRunning();
779
+ if (stepsNotAwaited !== undefined) {
780
+ attemptAbort.abort();
781
+ await withTimeout(steps.settleAbandoned(), this.config.cleanupTimeout, () => new Error('abandoned steps did not settle')).catch(() => undefined);
782
+ }
783
+ return [stepsNotAwaited, pollsNotAwaited].filter((error) => error !== undefined);
741
784
  };
742
785
  const mainWork = async () => {
743
786
  try {
@@ -751,19 +794,22 @@ export class TargetExecutor {
751
794
  }
752
795
  catch (cause) {
753
796
  // The body's own failure stays the verdict; the step it abandoned
754
- // and the soft failures it kept are noted beside it.
797
+ // and the soft failures it kept are noted beside it. A skip keeps
798
+ // only the soft failures: a step or poll it left running is not one.
755
799
  const notAwaited = await abandonNotAwaited();
756
800
  const softFailure = soft.close();
757
- if (!isRuntimeSkip(cause)) {
758
- for (const secondary of [notAwaited, softFailure]) {
759
- if (secondary !== undefined) {
760
- secondaryErrors.push(serializeError(secondary, { phase: 'body', projectRoot: this.config.projectRoot, redact }));
761
- }
801
+ const kept = isRuntimeSkip(cause) ? [softFailure] : [...notAwaited, softFailure];
802
+ for (const secondary of kept) {
803
+ if (secondary !== undefined) {
804
+ secondaryErrors.push(serializeError(secondary, { phase: 'body', projectRoot: this.config.projectRoot, redact }));
762
805
  }
763
806
  }
764
807
  throw cause;
765
808
  }
766
- const notAwaited = await abandonNotAwaited();
809
+ const [notAwaited, ...moreNotAwaited] = await abandonNotAwaited();
810
+ for (const secondary of moreNotAwaited) {
811
+ secondaryErrors.push(serializeError(secondary, { phase: 'body', projectRoot: this.config.projectRoot, redact }));
812
+ }
767
813
  if (notAwaited !== undefined)
768
814
  throw notAwaited;
769
815
  // Soft failures fail the attempt once the body has settled, before
@@ -779,8 +825,8 @@ export class TargetExecutor {
779
825
  // down with the worker process; the attempt records the interrupt and
780
826
  // moves to cleanup.
781
827
  const work = this.options.isolated
782
- ? withAbort(mainWork(), this.interruptSignal, () => new E2EError('interrupted', 'INTERRUPTED', `run interrupted in phase ${phase}`))
783
- : mainWork();
828
+ ? withAbort(runInPollScope(bodyPolls, mainWork), this.interruptSignal, () => new E2EError('interrupted', 'INTERRUPTED', `run interrupted in phase ${phase}`))
829
+ : runInPollScope(bodyPolls, mainWork);
784
830
  const body = Promise.race([
785
831
  work,
786
832
  new Promise((_, reject) => {
@@ -798,6 +844,8 @@ export class TargetExecutor {
798
844
  // The race has settled: a rejection surfacing while the evidence is
799
845
  // captured is recorded, not aimed at it.
800
846
  cutBody = undefined;
847
+ // A body cut short may have been awaiting its polls; they end with it.
848
+ bodyPolls.close();
801
849
  // `skipRunningTest` has already refused the cases that may not skip.
802
850
  if (isRuntimeSkip(cause))
803
851
  skipped = cause;
@@ -859,6 +907,7 @@ export class TargetExecutor {
859
907
  recordFailure(cause, phase);
860
908
  }
861
909
  finally {
910
+ bodyPolls.close();
862
911
  this.strayFailure = undefined;
863
912
  this.lastAttemptTestId = pair.test.id;
864
913
  attemptEnd.abort();
@@ -911,7 +960,7 @@ export class TargetExecutor {
911
960
  function classifyAttemptStatus(failure, timedOut, interrupted) {
912
961
  if (interrupted && !timedOut)
913
962
  return 'interrupted';
914
- if (timedOut || failure instanceof TestTimeoutError || failure.code === 'TEST_TIMEOUT') {
963
+ if (timedOut || failure.code === 'TEST_TIMEOUT') {
915
964
  return 'timed-out';
916
965
  }
917
966
  return 'failed';
@@ -1,4 +1,5 @@
1
1
  /** The fixtures a test's `test.extend()` chain adds to one attempt. */
2
+ import { pollLineage } from '../expect/poll-scope.js';
2
3
  import { ConfigurationError } from '../internal/errors.js';
3
4
  /**
4
5
  * Binds a chain of fixture definitions to one attempt's fixture object. The
@@ -50,8 +51,10 @@ export function createExtendedFixtures(definitions, fixtures, engineName) {
50
51
  ready();
51
52
  return released;
52
53
  };
54
+ // The function's polls are the body's until `use` returns, then its teardown's.
55
+ const polls = pollLineage();
53
56
  const run = Promise.resolve()
54
- .then(() => definition.fn(fixtures, use))
57
+ .then(() => polls.run(() => definition.fn(fixtures, use)))
55
58
  .then(() => {
56
59
  if (!used) {
57
60
  failed(new ConfigurationError('TEST_SETUP_FAILED', `fixture "${name}" returned without calling use(); a fixture awaits use(value) to hand its value to the test`));
@@ -66,7 +69,7 @@ export function createExtendedFixtures(definitions, fixtures, engineName) {
66
69
  run.catch(() => undefined);
67
70
  await provided;
68
71
  if (!abandoned)
69
- active.push({ name, run, release, misuse: () => misuse });
72
+ active.push({ name, run, release, polls, misuse: () => misuse });
70
73
  };
71
74
  return {
72
75
  async setUp() {
@@ -78,6 +81,7 @@ export function createExtendedFixtures(definitions, fixtures, engineName) {
78
81
  return active.toReversed().map((state) => ({
79
82
  name: state.name,
80
83
  run: async () => {
84
+ state.polls.handOver();
81
85
  state.release();
82
86
  await state.run;
83
87
  const misuse = state.misuse();
@@ -14,6 +14,7 @@ import path from 'node:path';
14
14
  import { formatNode, prepareObservation } from '../agent/observation.js';
15
15
  import { truncateUtf8 } from '../internal/errors.js';
16
16
  import { isTypoOf } from '../internal/suggest.js';
17
+ import { withAbort } from '../internal/time.js';
17
18
  /** The whole capture, observation and screenshot together, gets this long. */
18
19
  const EVIDENCE_TIMEOUT_MS = 5_000;
19
20
  /** Screen lines a locator failure lists as its nearest nodes, and the bytes each keeps; the wire schema caps both. */
@@ -37,9 +38,12 @@ export async function captureFailureEvidence(options) {
37
38
  return finish(evidence);
38
39
  const operation = options.operation(signal, EVIDENCE_TIMEOUT_MS);
39
40
  const { redact, redactCut } = options.secrecy.ledger;
41
+ // The engine is handed the budget, and the wait is abandoned at its end
42
+ // either way: an engine stuck on a hung app may never honor it.
43
+ const bounded = (work) => withAbort(work, signal, () => new Error('failure evidence ran out of budget'));
40
44
  let observation;
41
45
  try {
42
- const raw = await options.session.observe(operation);
46
+ const raw = await bounded(() => options.session.observe(operation));
43
47
  observation = prepareObservation(raw, {
44
48
  redact,
45
49
  redactCut,
@@ -73,7 +77,7 @@ export async function captureFailureEvidence(options) {
73
77
  // prove a tainted viewport redacted.
74
78
  if (!options.secrecy.exposure.withholdsPixels && !signal.aborted) {
75
79
  try {
76
- const relative = await options.session.artifacts.screenshot('failure', operation);
80
+ const relative = await bounded(() => options.session.artifacts.screenshot('failure', operation));
77
81
  evidence.screenshot = options.artifacts.register('screenshot', relative);
78
82
  }
79
83
  catch {
@@ -109,7 +113,9 @@ function locatorCandidates(error, observation, redact, appOrigin) {
109
113
  return [];
110
114
  if (error.code !== 'LOCATOR_NOT_FOUND' && error.code !== 'LOCATOR_AMBIGUOUS')
111
115
  return [];
112
- const { role, testId, name } = error.details ?? {};
116
+ const details = error.details ?? {};
117
+ // The nodes are redacted; the query is compared in the same form, so a secret test id still finds its node.
118
+ const [role, testId, name] = [details.role, details.testId, details.name].map((value) => (value === undefined ? undefined : redact(value)));
113
119
  const words = tokens(name ?? '');
114
120
  if (role === undefined && testId === undefined && words.length === 0)
115
121
  return [];
@@ -139,7 +145,7 @@ function locatorCandidates(error, observation, redact, appOrigin) {
139
145
  return scored
140
146
  .toSorted((a, b) => b.score - a.score)
141
147
  .slice(0, MAX_CANDIDATES)
142
- .map(({ node }) => truncateUtf8(formatNode(node, 0, redact, appOrigin), MAX_CANDIDATE_BYTES));
148
+ .map(({ node }) => truncateUtf8(formatNode(node, 0, appOrigin), MAX_CANDIDATE_BYTES));
143
149
  }
144
150
  /**
145
151
  * Whether a word on screen is a near miss for one asked for: a typo or an
@@ -311,20 +311,18 @@ function createApp(environment, engine, exposure) {
311
311
  * `app.open()` on a target with no URL and no `navigate`, a device: it
312
312
  * launches the pinned app fresh, the way Maestro's `launchApp` does, and is
313
313
  * what a test calls before it looks at the screen. A path has nowhere to
314
- * go there; a link goes through `device.openLink`.
314
+ * go there; a link goes through `device.openLink`. An engine without a
315
+ * relaunch hook is refused here; one with a hook reports its own reason it
316
+ * cannot launch, such as a build not installed yet.
315
317
  */
316
318
  const launchPinnedApp = (openPath) => steps.run('app', 'app.open', '', async () => {
317
319
  if (openPath !== undefined) {
318
320
  throw new ConfigurationError('APP_URL_REQUIRED', `app.open("${openPath}") needs an app URL and target "${target.name}" has none: on a device app.open() launches the pinned app, and a link goes through device.openLink`);
319
321
  }
320
- try {
321
- await engine.session.app.restart(engine.operation(config.timeout));
322
- }
323
- catch (cause) {
324
- if (cause.code !== 'UNSUPPORTED_CAPABILITY')
325
- throw cause;
326
- throw new ConfigurationError('UNSUPPORTED_CAPABILITY', `app.open() has no app to launch on target "${target.name}": pin one with the target's app.bundleId or app.appPath, or bring one up with device.openApp`);
322
+ if (target.engine?.session?.restart === undefined) {
323
+ throw new ConfigurationError('UNSUPPORTED_CAPABILITY', `app.open() has nothing to open on target "${target.name}": it has no app URL, and engine ${target.engine?.name ?? 'none'} does not relaunch a pinned app`);
327
324
  }
325
+ await engine.session.app.restart(engine.operation(config.timeout));
328
326
  });
329
327
  return {
330
328
  baseUrl: target.app.base?.href,
@@ -28,6 +28,8 @@ export interface InProcessRunnerOptions {
28
28
  }) | undefined;
29
29
  readonly runId: string;
30
30
  readonly artifactsRoot: string;
31
+ /** See `TargetExecutorOptions.rerunDir`. */
32
+ readonly rerunDir: string | undefined;
31
33
  readonly sessionStore: SessionStore;
32
34
  readonly headed: boolean;
33
35
  readonly debug: DebugTrace;
@@ -5,6 +5,7 @@
5
5
  * `TargetWorker` core; only the transport and pair resolution differ, and the
6
6
  * scheduler is capped at one worker so nothing overlaps in this process.
7
7
  */
8
+ import { isAbandonedRejection } from '../internal/abandoned.js';
8
9
  import { classifyError, ConfigurationError, serializeError } from '../internal/errors.js';
9
10
  import { pairKey } from './units.js';
10
11
  import { TargetWorker } from './worker/session.js';
@@ -18,6 +19,22 @@ class InProcessRunner {
18
19
  finish;
19
20
  exited = false;
20
21
  closing = false;
22
+ /**
23
+ * Set by a fatal error: the worker is dead to the scheduler from then on,
24
+ * as a crashed process would be, so nothing it reports about its unit
25
+ * while it winds down counts, and the unit's results are synthesized when
26
+ * it exits. Unlike a dead process it still disposes its engine, so the
27
+ * failures of that disposal (`shutdown-done`) are still reported.
28
+ */
29
+ crashed = false;
30
+ /** Takes what this process failed to catch while the runner lives; see `catchStrays`. */
31
+ onRejection = (cause) => {
32
+ if (isAbandonedRejection(cause))
33
+ return;
34
+ if (!this.worker.strayRejection(cause))
35
+ this.fail(cause);
36
+ };
37
+ onException = (cause) => this.fail(cause);
21
38
  constructor(targetName, workerSlot, events, options) {
22
39
  this.targetName = targetName;
23
40
  this.workerSlot = workerSlot;
@@ -30,20 +47,14 @@ class InProcessRunner {
30
47
  this.finish = finish;
31
48
  this.worker = new TargetWorker({
32
49
  emit: (message) => {
33
- if (!this.exited)
34
- this.events.onMessage(message);
35
- },
36
- fatal: (cause) => {
37
- if (this.exited)
50
+ if (this.exited || (this.crashed && message.type !== 'shutdown-done'))
38
51
  return;
39
- this.events.onMessage({
40
- type: 'fatal',
41
- error: serializeError(classifyError(cause)),
42
- });
43
- this.close();
52
+ this.events.onMessage(message);
44
53
  },
54
+ fatal: (cause) => this.fail(cause),
45
55
  finished: () => this.end('shut down'),
46
56
  }, () => this.bootstrap());
57
+ this.catchStrays();
47
58
  this.worker.start();
48
59
  }
49
60
  get alive() {
@@ -75,6 +86,34 @@ class InProcessRunner {
75
86
  kill() {
76
87
  this.close();
77
88
  }
89
+ /**
90
+ * Catches what a test leaves uncaught the way a worker process does
91
+ * (`worker/entry.ts`): a rejection nobody handled is charged to the attempt
92
+ * in flight, or recorded against the last test that finished, and is
93
+ * otherwise fatal to this worker, as an uncaught exception is (see
94
+ * `fail`). The
95
+ * listeners live exactly as long as the runner, so a host process keeps its
96
+ * own handling before and after the run. The scheduler keeps at most one
97
+ * in-process runner alive at a time, so nothing is charged twice.
98
+ */
99
+ catchStrays() {
100
+ process.on('unhandledRejection', this.onRejection);
101
+ process.on('uncaughtException', this.onException);
102
+ }
103
+ /**
104
+ * Reports an unrecoverable failure and ends the worker the way a process
105
+ * dies: its unit is interrupted and nothing more it reports is passed on,
106
+ * so on exit the scheduler fails the test in flight with `WORKER_CRASH`
107
+ * and skips the unit's others, as it does for a worker process.
108
+ */
109
+ fail(cause) {
110
+ if (this.exited || this.crashed)
111
+ return;
112
+ this.events.onMessage({ type: 'fatal', error: serializeError(classifyError(cause)) });
113
+ this.crashed = true;
114
+ this.worker.handle({ type: 'interrupt' });
115
+ this.close();
116
+ }
78
117
  /** Stops accepting work and asks the queue to wind down. */
79
118
  close() {
80
119
  if (this.exited || this.closing)
@@ -86,6 +125,8 @@ class InProcessRunner {
86
125
  if (this.exited)
87
126
  return;
88
127
  this.exited = true;
128
+ process.off('unhandledRejection', this.onRejection);
129
+ process.off('uncaughtException', this.onException);
89
130
  this.events.onExit(detail);
90
131
  this.finish();
91
132
  }
@@ -101,6 +142,7 @@ class InProcessRunner {
101
142
  sessionStore: this.options.sessionStore,
102
143
  runId: this.options.runId,
103
144
  artifactsRoot: this.options.artifactsRoot,
145
+ rerunDir: this.options.rerunDir,
104
146
  headed: this.options.headed,
105
147
  workerSlot: this.workerSlot,
106
148
  env: this.options.envFor(this.targetName),