e2e 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +10 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/ai-sdk.js +5 -4
  4. package/dist/agent/error.js +3 -3
  5. package/dist/agent/invocation.js +3 -4
  6. package/dist/agent/model/tool-choice.d.ts +9 -0
  7. package/dist/agent/model/tool-choice.js +14 -0
  8. package/dist/agent/observation-feed.js +3 -4
  9. package/dist/agent/phases.d.ts +7 -1
  10. package/dist/agent/phases.js +12 -3
  11. package/dist/agent/prompts.js +3 -0
  12. package/dist/agent/replay.js +18 -15
  13. package/dist/agent/step-cache.d.ts +2 -1
  14. package/dist/agent/step-cache.js +9 -5
  15. package/dist/agent/tool-loop.js +92 -5
  16. package/dist/cache/trace.js +6 -2
  17. package/dist/cli/bin.js +2 -2
  18. package/dist/cli/index.js +35 -24
  19. package/dist/cli/init/mcp-config.d.ts +2 -1
  20. package/dist/cli/init/mcp-config.js +8 -3
  21. package/dist/cli/init/sibling-versions.json +1 -1
  22. package/dist/cli/mcp.d.ts +3 -2
  23. package/dist/cli/mcp.js +52 -16
  24. package/dist/cli/run-output.d.ts +17 -0
  25. package/dist/cli/run-output.js +45 -0
  26. package/dist/collect/collect.js +8 -4
  27. package/dist/collect/registry.js +27 -16
  28. package/dist/config/compiled-files.d.ts +36 -0
  29. package/dist/config/compiled-files.js +50 -0
  30. package/dist/config/diagnose.js +21 -19
  31. package/dist/config/esm-hooks.d.ts +39 -44
  32. package/dist/config/esm-hooks.js +238 -63
  33. package/dist/config/load.d.ts +1 -7
  34. package/dist/config/load.js +23 -66
  35. package/dist/config/register.d.ts +8 -0
  36. package/dist/config/register.js +9 -0
  37. package/dist/config/specifiers.d.ts +19 -0
  38. package/dist/config/specifiers.js +33 -0
  39. package/dist/config/tsconfig.d.ts +56 -0
  40. package/dist/config/tsconfig.js +115 -0
  41. package/dist/config/typescript.d.ts +19 -0
  42. package/dist/config/typescript.js +253 -0
  43. package/dist/engine/contract.d.ts +1 -0
  44. package/dist/engine/index.d.ts +2 -0
  45. package/dist/engine/session.js +2 -2
  46. package/dist/expect/async.js +2 -1
  47. package/dist/expect/poll.js +2 -2
  48. package/dist/internal/errors.d.ts +10 -3
  49. package/dist/internal/errors.js +23 -8
  50. package/dist/internal/node-version.d.ts +16 -7
  51. package/dist/internal/node-version.js +50 -15
  52. package/dist/internal/package-manager.js +1 -1
  53. package/dist/internal/realm-slot.d.ts +5 -4
  54. package/dist/internal/redact.d.ts +2 -0
  55. package/dist/internal/redact.js +4 -0
  56. package/dist/internal/time.d.ts +21 -5
  57. package/dist/internal/time.js +35 -3
  58. package/dist/locator/engine.js +47 -11
  59. package/dist/locator/screen.js +31 -5
  60. package/dist/mcp/output.d.ts +59 -0
  61. package/dist/mcp/output.js +129 -0
  62. package/dist/mcp/server.d.ts +4 -0
  63. package/dist/mcp/server.js +9 -12
  64. package/dist/mcp/session.d.ts +8 -1
  65. package/dist/mcp/session.js +55 -14
  66. package/dist/mcp/tools.js +7 -5
  67. package/dist/report/list.d.ts +11 -0
  68. package/dist/report/list.js +23 -6
  69. package/dist/run/artifacts.d.ts +4 -4
  70. package/dist/run/artifacts.js +3 -2
  71. package/dist/run/execute.d.ts +25 -5
  72. package/dist/run/execute.js +44 -20
  73. package/dist/run/failure-evidence.js +6 -2
  74. package/dist/run/in-process.js +51 -10
  75. package/dist/run/process-output.d.ts +73 -0
  76. package/dist/run/process-output.js +130 -0
  77. package/dist/run/provision.d.ts +2 -0
  78. package/dist/run/provision.js +1 -0
  79. package/dist/run/records.d.ts +4 -4
  80. package/dist/run/retry.d.ts +7 -0
  81. package/dist/run/retry.js +27 -11
  82. package/dist/run/runner.d.ts +8 -0
  83. package/dist/run/runner.js +32 -9
  84. package/dist/run/scheduler.js +255 -19
  85. package/dist/run/secrecy.d.ts +22 -19
  86. package/dist/run/secrecy.js +22 -18
  87. package/dist/run/serial.d.ts +73 -2
  88. package/dist/run/serial.js +239 -58
  89. package/dist/run/standalone.js +1 -1
  90. package/dist/run/trace-redaction.d.ts +5 -6
  91. package/dist/run/trace-redaction.js +5 -6
  92. package/dist/run/worker/entry.js +35 -14
  93. package/dist/run/worker/handle.js +10 -5
  94. package/dist/run/worker/protocol.d.ts +83 -3
  95. package/dist/run/worker/session.js +28 -0
  96. package/dist/telemetry/environment.d.ts +2 -1
  97. package/dist/telemetry/environment.js +12 -2
  98. package/dist/telemetry/project.d.ts +5 -6
  99. package/dist/telemetry/project.js +6 -11
  100. package/dist/telemetry/store.d.ts +5 -5
  101. package/dist/telemetry/store.js +5 -7
  102. package/dist/telemetry/telemetry.d.ts +2 -3
  103. package/dist/telemetry/telemetry.js +2 -4
  104. package/dist/types.d.ts +6 -3
  105. package/docs/agent-steps.mdx +1 -0
  106. package/docs/agents.mdx +1 -0
  107. package/docs/api-testing.mdx +2 -1
  108. package/docs/authentication.mdx +3 -2
  109. package/docs/browser.mdx +7 -6
  110. package/docs/bug-bash.mdx +2 -1
  111. package/docs/cache.mdx +2 -1
  112. package/docs/ci.mdx +3 -2
  113. package/docs/coding-agents.mdx +2 -1
  114. package/docs/core-concepts.mdx +208 -0
  115. package/docs/debugging.mdx +19 -9
  116. package/docs/decision-models.mdx +142 -0
  117. package/docs/example-projects.mdx +32 -0
  118. package/docs/executors.mdx +3 -0
  119. package/docs/explore.mdx +1 -0
  120. package/docs/github.mdx +2 -1
  121. package/docs/index.mdx +9 -5
  122. package/docs/integrations/eas.mdx +1 -0
  123. package/docs/integrations/index.mdx +1 -0
  124. package/docs/integrations/kernel.mdx +2 -1
  125. package/docs/migrate/cypress.mdx +26 -3
  126. package/docs/migrate/detox.mdx +24 -1
  127. package/docs/migrate/maestro.mdx +28 -4
  128. package/docs/migrate/playwright.mdx +27 -4
  129. package/docs/migrate/selenium.mdx +27 -3
  130. package/docs/mobile-ci.mdx +6 -5
  131. package/docs/mobile.mdx +79 -8
  132. package/docs/models.mdx +7 -2
  133. package/docs/quickstart.mdx +26 -15
  134. package/docs/reference/agent.mdx +13 -3
  135. package/docs/reference/app.mdx +1 -0
  136. package/docs/reference/cli.mdx +11 -5
  137. package/docs/reference/config.mdx +42 -6
  138. package/docs/reference/engine.mdx +22 -10
  139. package/docs/reference/environment.mdx +2 -1
  140. package/docs/reference/errors.mdx +11 -8
  141. package/docs/reference/expect.mdx +2 -1
  142. package/docs/reference/mcp.mdx +21 -8
  143. package/docs/reference/mobile.mdx +1 -0
  144. package/docs/reference/reporters.mdx +1 -0
  145. package/docs/reference/screen.mdx +19 -9
  146. package/docs/reference/test.mdx +21 -1
  147. package/docs/reference/web.mdx +8 -2
  148. package/docs/security.mdx +33 -27
  149. package/docs/subscriptions.mdx +1 -0
  150. package/docs/telemetry.mdx +8 -5
  151. package/docs/tools.mdx +1 -0
  152. package/docs/{starting-your-app.mdx → web.mdx} +157 -11
  153. package/docs/writing-an-engine.mdx +2 -1
  154. package/package.json +11 -7
  155. package/skills/e2e/SKILL.md +3 -1
  156. package/skills/e2e/references/agent.md +11 -0
  157. package/skills/e2e/references/debugging.md +1 -1
  158. package/skills/e2e/references/mcp.md +8 -3
  159. package/skills/e2e/references/running.md +2 -1
  160. package/skills/e2e/references/setup.md +9 -1
  161. package/skills/e2e/references/writing-tests.md +1 -1
  162. package/docs/assertions.mdx +0 -94
  163. package/docs/goals.mdx +0 -176
  164. package/docs/locators.mdx +0 -64
  165. package/docs/physical-devices.mdx +0 -76
  166. package/docs/viewports.mdx +0 -146
@@ -6,56 +6,77 @@ import { pairRecordings } from '../collect/select.js';
6
6
  import { attemptSegments, createAttemptArtifacts, sanitizePathSegment } from './artifacts.js';
7
7
  import { findRegistered, RealmManager } from './realm.js';
8
8
  import { isFailedStatus } from './records.js';
9
- import { runWithRetries } from './retry.js';
9
+ import { isRetryEligible, retryVerdict, runWithRetries } from './retry.js';
10
10
  import { interruptedSkip, pairResult, repeatSegment } from './units.js';
11
+ /** The report id of one serial group variant: a group runs once per agent and per repeat. */
12
+ export function serialGroupId(serialId, targetName, agent, repeat) {
13
+ return canonicalDigest(repeat === 0 ? { serialId, targetId: targetName, agent } : { serialId, targetId: targetName, agent, repeat });
14
+ }
11
15
  /** Runs one serial unit to completion and emits every member result. */
12
16
  export async function runSerialUnit(host, members, file) {
17
+ const group = newSerialGroup(members, host.target);
18
+ const runs = [];
19
+ const finalStatus = await runWithRetries(members[0].options.retries + 1, host.interruptSignal, async (attemptIndex) => {
20
+ const run = await runSerialAttempt(host, group.id, members, file, attemptIndex);
21
+ runs.push(run);
22
+ group.attempts.push(run.record);
23
+ host.serialAttemptFinished(group.id, run);
24
+ // A beforeAll failure is not retry-eligible: the
25
+ // attempt stands as recorded and the retry loop stops here.
26
+ if (run.record.error?.code === 'HOOK_FAILED')
27
+ return undefined;
28
+ return run.record;
29
+ });
30
+ if (group.attempts.length === 0) {
31
+ group.status = 'skipped';
32
+ group.skip = interruptedSkip(host.interruptSignal);
33
+ }
34
+ else {
35
+ group.status = finalStatus;
36
+ }
37
+ host.emitSerialGroup(group);
38
+ for (const result of memberResults(group, members, runs))
39
+ host.emit(result);
40
+ return group;
41
+ }
42
+ /** A group record with no attempts yet. */
43
+ function newSerialGroup(members, target) {
13
44
  const first = members[0];
14
45
  const serialId = first.test.serialId;
15
46
  // Every member of a unit runs as the same agent (selection guarantees it),
16
47
  // so the group is one variant of the flow and is identified as such.
17
- const agent = first.agent;
18
- const { repeat } = first;
19
- const groupRecordId = canonicalDigest(repeat === 0 ? { serialId, targetId: host.target.name, agent } : { serialId, targetId: host.target.name, agent, repeat });
20
- const group = {
21
- id: groupRecordId,
48
+ const { agent, repeat } = first;
49
+ return {
50
+ id: serialGroupId(serialId, target.name, agent, repeat),
22
51
  serialId,
23
52
  declarationIndex: first.test.declarationIndex,
24
53
  file: first.test.file,
25
54
  titlePath: serialTitlePath(first.test),
26
- targetId: host.target.name,
27
- platform: host.target.platform,
55
+ targetId: target.name,
56
+ platform: target.platform,
28
57
  agent,
29
58
  repeat,
30
59
  memberTestIds: members.map((member) => member.test.id),
31
60
  status: 'failed',
32
61
  attempts: [],
33
62
  };
34
- const memberFinalStatus = new Map();
35
- const finalStatus = await runWithRetries(first.options.retries + 1, host.interruptSignal, async (attemptIndex) => {
36
- const attempt = await runSerialAttempt(host, members, file, attemptIndex);
37
- group.attempts.push(attempt);
38
- // A retry the run interrupted keeps the verdict before it, so each member keeps what that attempt said.
39
- const cutRetry = attemptIndex > 0 && attempt.status === 'interrupted';
63
+ }
64
+ /**
65
+ * Each member's result once its group is decided. Members carry no attempts
66
+ * of their own; the verdict is the member's record in the last attempt that
67
+ * counts. A retry the run interrupted, or one that never reached its
68
+ * members, keeps the verdict before it.
69
+ */
70
+ function memberResults(group, members, runs) {
71
+ const verdicts = new Map();
72
+ for (const [attemptIndex, run] of runs.entries()) {
73
+ const cutRetry = attemptIndex > 0 && (run.record.status === 'interrupted' || !run.reachedMembers);
40
74
  if (!cutRetry)
41
- for (const member of attempt.members)
42
- memberFinalStatus.set(member.testId, member);
43
- // A beforeAll failure is not retry-eligible: the
44
- // attempt stands as recorded and the retry loop stops here.
45
- if (attempt.error?.code === 'HOOK_FAILED')
46
- return undefined;
47
- return attempt;
48
- });
49
- if (group.attempts.length === 0) {
50
- group.status = 'skipped';
51
- group.skip = interruptedSkip(host.interruptSignal);
75
+ for (const member of run.record.members)
76
+ verdicts.set(member.testId, member);
52
77
  }
53
- else {
54
- group.status = finalStatus;
55
- }
56
- host.emitSerialGroup(group);
57
- for (const member of members) {
58
- const memberRecord = memberFinalStatus.get(member.test.id);
78
+ return members.map((member) => {
79
+ const memberRecord = verdicts.get(member.test.id);
59
80
  let status;
60
81
  let skip;
61
82
  // A member that skipped itself stays skipped whatever the group did: a
@@ -74,17 +95,140 @@ export async function runSerialUnit(host, members, file) {
74
95
  else {
75
96
  status = memberRecord.status;
76
97
  }
77
- host.emit(pairResult(member, {
98
+ return pairResult(member, {
78
99
  status,
79
100
  selected: true,
80
101
  ...(skip !== undefined ? { skip } : {}),
81
102
  attempts: [],
82
- serialGroupId: groupRecordId,
83
- }));
103
+ serialGroupId: group.id,
104
+ });
105
+ });
106
+ }
107
+ /**
108
+ * What the runner heard of one serial group a worker is running: the
109
+ * attempts it finished, and the members finished so far in the attempt
110
+ * running now. When the worker dies before reporting the group, this is
111
+ * what the report keeps.
112
+ */
113
+ export class SerialGroupProgress {
114
+ finished = [];
115
+ current;
116
+ currentMembers = [];
117
+ /** The member whose body is running now, by test id, until its record arrives. */
118
+ running;
119
+ /** A member of the group announced its start. */
120
+ memberStarted(testId) {
121
+ this.running = testId;
122
+ }
123
+ /** A member finished in the attempt running now. */
124
+ memberFinished(attempt, member) {
125
+ this.current = attempt;
126
+ this.currentMembers.push(member);
127
+ if (this.running === member.testId)
128
+ this.running = undefined;
129
+ }
130
+ /** A group attempt finished; the next one starts empty. */
131
+ attemptFinished(run) {
132
+ this.finished.push(run);
133
+ this.current = undefined;
134
+ this.currentMembers = [];
135
+ this.running = undefined;
136
+ }
137
+ /**
138
+ * The group after its worker died with `error`, or undefined when the
139
+ * runner heard nothing of it running. The finished attempts stand, and the
140
+ * attempt in flight fails: the member whose body was running ends `status`
141
+ * with `error` (`timed-out` when the watchdog killed a worker that hung in
142
+ * it) and the ones after it skip. A crash between members (a hook,
143
+ * the session closing) fails the attempt and leaves its finished members
144
+ * as they were; one in a retry before any member ran fails the first, as a
145
+ * launch failure does. A crash after the last attempt, when no retry
146
+ * follows it, is recorded on that attempt.
147
+ */
148
+ crashed(members, target, error, status) {
149
+ const last = this.finished.at(-1);
150
+ if (!this.inFlight) {
151
+ if (last === undefined)
152
+ return undefined;
153
+ if (!retryFollows(last.record, this.finished.length, members)) {
154
+ const closed = {
155
+ ...last,
156
+ record: { ...last.record, secondaryErrors: [...last.record.secondaryErrors, error], cleanup: 'forced' },
157
+ };
158
+ return this.settled([...this.finished.slice(0, -1), closed], members, target);
159
+ }
160
+ const record = this.attemptInFlight('failed', error);
161
+ endBeforeMembers(record, members, error, NEVER_INTERRUPTED);
162
+ return this.settled([...this.finished, { record, reachedMembers: false }], members, target);
163
+ }
164
+ const next = this.currentMembers.length;
165
+ const wasRunning = this.running !== undefined && this.running === members[next]?.test.id;
166
+ const record = this.attemptInFlight(wasRunning ? status : 'failed', error);
167
+ const skip = wasRunning
168
+ ? predecessorFailed(next)
169
+ : { cause: 'infrastructure-unavailable', reason: 'worker process exited during this group attempt' };
170
+ for (const [memberIndex, member] of members.entries()) {
171
+ if (memberIndex < next)
172
+ continue;
173
+ record.members.push(wasRunning && memberIndex === next
174
+ ? memberFailedWith(record, memberIndex, member.test.id, error, status)
175
+ : skippedMember(record.id, memberIndex, member.test.id, skip));
176
+ }
177
+ return this.settled([...this.finished, { record, reachedMembers: true }], members, target);
178
+ }
179
+ /**
180
+ * The group after the run's interrupt stopped its worker, or undefined
181
+ * when the runner heard nothing of it running. The finished attempts
182
+ * stand, and an attempt in flight is interrupted: the members it finished
183
+ * keep their records and the rest skip with `skip`. The verdict is the one
184
+ * those attempts reach, as for a retry the interrupt cut short.
185
+ */
186
+ interrupted(members, target, skip) {
187
+ if (!this.inFlight)
188
+ return this.finished.length === 0 ? undefined : this.settled(this.finished, members, target);
189
+ const record = this.attemptInFlight('interrupted', undefined);
190
+ for (const [memberIndex, member] of members.entries()) {
191
+ if (memberIndex >= this.currentMembers.length)
192
+ record.members.push(skippedMember(record.id, memberIndex, member.test.id, skip));
193
+ }
194
+ return this.settled([...this.finished, { record, reachedMembers: true }], members, target);
195
+ }
196
+ /** Whether a member of the attempt in flight started or finished. */
197
+ get inFlight() {
198
+ return this.currentMembers.length > 0 || this.running !== undefined;
199
+ }
200
+ /** The record of the attempt in flight as it stood, with the members it finished. */
201
+ attemptInFlight(status, error) {
202
+ return {
203
+ id: this.current?.id ?? uuidv7(),
204
+ index: this.finished.length,
205
+ status,
206
+ startedAt: this.current?.startedAt ?? timestamp(),
207
+ durationMs: 0,
208
+ members: [...this.currentMembers],
209
+ artifacts: [],
210
+ ...(error === undefined ? {} : { error }),
211
+ secondaryErrors: [],
212
+ cleanup: 'forced',
213
+ };
214
+ }
215
+ settled(runs, members, target) {
216
+ const group = newSerialGroup(members, target);
217
+ group.status = retryVerdict(runs.map((run) => run.record));
218
+ group.attempts = runs.map((run) => run.record);
219
+ return { group, results: memberResults(group, members, runs) };
84
220
  }
85
- return group;
86
221
  }
87
- async function runSerialAttempt(host, members, file, attemptIndex) {
222
+ /**
223
+ * Whether the group retries after `record`, its attempt number `attempts`:
224
+ * the retry policy allows it, and a hook failure never retries.
225
+ */
226
+ function retryFollows(record, attempts, members) {
227
+ return attempts < members[0].options.retries + 1 && isRetryEligible(record) && record.error?.code !== 'HOOK_FAILED';
228
+ }
229
+ /** An abort signal that never fires. */
230
+ const NEVER_INTERRUPTED = new AbortController().signal;
231
+ async function runSerialAttempt(host, groupId, members, file, attemptIndex) {
88
232
  const attemptId = uuidv7();
89
233
  const startedAt = timestamp();
90
234
  const startedMs = Date.now();
@@ -118,15 +262,20 @@ async function runSerialAttempt(host, members, file, attemptIndex) {
118
262
  secondaryErrors: [],
119
263
  cleanup: 'complete',
120
264
  };
265
+ const start = { id: attemptId, index: attemptIndex, startedAt };
266
+ /** Records one member and streams it, so a worker that dies later in the attempt does not take it along. */
267
+ const finishMember = (member) => {
268
+ memberRecords.push(member);
269
+ host.serialMemberFinished(groupId, start, member);
270
+ };
121
271
  let realm;
122
272
  try {
123
273
  realm = await host.realms.create(file);
124
274
  }
125
275
  catch (cause) {
126
- record.status = 'failed';
127
- record.error = serializeError(classifyError(cause), { phase: 'collection' });
276
+ endBeforeMembers(record, members, serializeError(classifyError(cause), { phase: 'collection' }), host.interruptSignal);
128
277
  record.durationMs = Date.now() - startedMs;
129
- return record;
278
+ return { record, reachedMembers: false };
130
279
  }
131
280
  let shared;
132
281
  try {
@@ -140,18 +289,10 @@ async function runSerialAttempt(host, members, file, attemptIndex) {
140
289
  };
141
290
  }
142
291
  catch (cause) {
143
- const error = classifyError(cause);
144
- record.status = 'failed';
145
- record.error = serializeError(error, { phase: 'launch' });
146
- for (let memberIndex = 0; memberIndex < members.length; memberIndex += 1) {
147
- memberRecords.push(skippedMember(attemptId, memberIndex, members[memberIndex].test.id, {
148
- cause: 'infrastructure-unavailable',
149
- reason: error.message,
150
- }));
151
- }
292
+ endBeforeMembers(record, members, serializeError(classifyError(cause), { phase: 'launch' }), host.interruptSignal);
152
293
  record.durationMs = Date.now() - startedMs;
153
294
  await host.realms.leave(realm);
154
- return record;
295
+ return { record, reachedMembers: false };
155
296
  }
156
297
  let skipRemaining;
157
298
  let hookFailure;
@@ -159,18 +300,15 @@ async function runSerialAttempt(host, members, file, attemptIndex) {
159
300
  const member = members[memberIndex];
160
301
  const memberId = `${attemptId}:member:${memberIndex}`;
161
302
  if (skipRemaining === undefined && host.interruptSignal.aborted) {
162
- skipRemaining = interruptedSkip(host.interruptSignal, {
163
- cause: 'infrastructure-unavailable',
164
- reason: 'run interrupted during this group attempt',
165
- });
303
+ skipRemaining = interruptedMemberSkip(host.interruptSignal);
166
304
  }
167
305
  if (skipRemaining !== undefined) {
168
- memberRecords.push(skippedMember(attemptId, memberIndex, member.test.id, skipRemaining));
306
+ finishMember(skippedMember(attemptId, memberIndex, member.test.id, skipRemaining));
169
307
  continue;
170
308
  }
171
309
  const registered = findRegistered(realm, member.test);
172
310
  if (registered === undefined) {
173
- memberRecords.push({
311
+ finishMember({
174
312
  id: memberId,
175
313
  index: memberIndex,
176
314
  testId: member.test.id,
@@ -190,7 +328,7 @@ async function runSerialAttempt(host, members, file, attemptIndex) {
190
328
  hookFailure = await host.realms.enterScopes(realm, registered);
191
329
  if (hookFailure !== undefined) {
192
330
  skipRemaining = { cause: 'hook-failed', reason: hookFailure.message };
193
- memberRecords.push(skippedMember(attemptId, memberIndex, member.test.id, skipRemaining));
331
+ finishMember(skippedMember(attemptId, memberIndex, member.test.id, skipRemaining));
194
332
  continue;
195
333
  }
196
334
  host.pairStarted(member);
@@ -199,7 +337,7 @@ async function runSerialAttempt(host, members, file, attemptIndex) {
199
337
  shared,
200
338
  });
201
339
  shared.priorSteps.push(...memberAttempt.steps);
202
- memberRecords.push({
340
+ finishMember({
203
341
  id: memberId,
204
342
  index: memberIndex,
205
343
  testId: member.test.id,
@@ -250,11 +388,54 @@ async function runSerialAttempt(host, members, file, attemptIndex) {
250
388
  await host.closeSession(shared.session, { attemptId, recordings }, record, artifacts.sink, record.secondaryErrors);
251
389
  await artifacts.settle();
252
390
  record.durationMs = Date.now() - startedMs;
253
- return record;
391
+ return { record, reachedMembers: true };
392
+ }
393
+ /**
394
+ * Ends a group attempt that stopped before any member ran (its file would not
395
+ * load, its session would not launch). The first member fails with the
396
+ * attempt's error, as an ordinary test whose attempt failed the same way
397
+ * would, and the rest skip behind it. When the run's interrupt is what
398
+ * stopped it, the attempt is interrupted and every member skips.
399
+ */
400
+ function endBeforeMembers(record, members, error, interruptSignal) {
401
+ record.error = error;
402
+ if (interruptSignal.aborted) {
403
+ record.status = 'interrupted';
404
+ const skip = interruptedMemberSkip(interruptSignal);
405
+ members.forEach((member, memberIndex) => record.members.push(skippedMember(record.id, memberIndex, member.test.id, skip)));
406
+ return;
407
+ }
408
+ record.status = 'failed';
409
+ members.forEach((member, memberIndex) => {
410
+ record.members.push(memberIndex === 0
411
+ ? memberFailedWith(record, 0, member.test.id, error, 'failed')
412
+ : skippedMember(record.id, memberIndex, member.test.id, predecessorFailed(0)));
413
+ });
414
+ }
415
+ /** A member that failed with an error of its group attempt's, having run no step of its own. */
416
+ function memberFailedWith(record, index, testId, error, status) {
417
+ return {
418
+ id: `${record.id}:member:${index}`,
419
+ index,
420
+ testId,
421
+ status,
422
+ startedAt: record.startedAt,
423
+ durationMs: 0,
424
+ steps: [],
425
+ error,
426
+ secondaryErrors: [],
427
+ };
254
428
  }
255
429
  function isFailedMember(member) {
256
430
  return isFailedStatus(member.status);
257
431
  }
432
+ /** Why a member the run's interrupt kept from running in this group attempt skipped. */
433
+ function interruptedMemberSkip(interruptSignal) {
434
+ return interruptedSkip(interruptSignal, {
435
+ cause: 'infrastructure-unavailable',
436
+ reason: 'run interrupted during this group attempt',
437
+ });
438
+ }
258
439
  function predecessorFailed(memberIndex) {
259
440
  return {
260
441
  cause: 'serial-predecessor-failed',
@@ -49,7 +49,7 @@ export async function openStandaloneAttempt(options) {
49
49
  const finishEngines = (onFailure) => engines.finish({ runId, env: options.env, timeoutMs: config.cleanupTimeout, notice, onFailure });
50
50
  try {
51
51
  // One worker on one target: one slot to provision.
52
- prepared = await engines.prepare(target, 1, { runId, projectRoot: config.projectRoot, env: options.env, signal, notice });
52
+ prepared = await engines.prepare(target, 1, { runId, projectRoot: config.projectRoot, env: options.env, signal, headed: options.headed, notice });
53
53
  const hooks = { ci: config.ci, notice: (message) => notice('app', message) };
54
54
  processes = await startDeclaredProcesses([target], config.projectRoot, () => hooks, signal, debug, options.processes);
55
55
  if (signal.aborted) {
@@ -12,12 +12,11 @@ import type { SecretLedger } from '../internal/redact.ts';
12
12
  * returned from `stopTrace`, relative to `dir`; one that resolves outside
13
13
  * `dir` is refused before anything is touched. An archive that cannot be
14
14
  * rewritten is deleted together with the rest of the trace, and the failure
15
- * is thrown: the runner keeps no trace it cannot vouch for. A trace comes
16
- * here from a session a secret was filled on, whose pixels are withheld, so
17
- * its screencast frames are dropped as a screenshot would be denied; or from
18
- * one whose engine holds a secret in its options and where nothing was
19
- * filled, which the caller passes `keepFrames` for: an engine-held secret is
20
- * protected as text only.
15
+ * is thrown: the runner keeps no trace it cannot vouch for. After a secret
16
+ * fill the session's pixels are withheld, so its screencast frames are
17
+ * dropped as a screenshot would be denied; with nothing filled the caller
18
+ * passes `keepFrames`: a value that reached the app otherwise (an engine
19
+ * option, a URL the test spelled it into) is protected as text only.
21
20
  */
22
21
  export declare function redactTraceArchives(dir: string, paths: readonly string[], ledger: SecretLedger, options?: {
23
22
  readonly keepFrames?: boolean;
@@ -16,12 +16,11 @@ import { inflateEntry, readZip, writeZip, zipEntry } from '../internal/zip.js';
16
16
  * returned from `stopTrace`, relative to `dir`; one that resolves outside
17
17
  * `dir` is refused before anything is touched. An archive that cannot be
18
18
  * rewritten is deleted together with the rest of the trace, and the failure
19
- * is thrown: the runner keeps no trace it cannot vouch for. A trace comes
20
- * here from a session a secret was filled on, whose pixels are withheld, so
21
- * its screencast frames are dropped as a screenshot would be denied; or from
22
- * one whose engine holds a secret in its options and where nothing was
23
- * filled, which the caller passes `keepFrames` for: an engine-held secret is
24
- * protected as text only.
19
+ * is thrown: the runner keeps no trace it cannot vouch for. After a secret
20
+ * fill the session's pixels are withheld, so its screencast frames are
21
+ * dropped as a screenshot would be denied; with nothing filled the caller
22
+ * passes `keepFrames`: a value that reached the app otherwise (an engine
23
+ * option, a URL the test spelled it into) is protected as text only.
25
24
  */
26
25
  export async function redactTraceArchives(dir, paths, ledger, options = {}) {
27
26
  const root = path.resolve(dir);
@@ -14,6 +14,7 @@ import { AiTraceRecorder, registerAiTraceRecorder } from '../../internal/ai-trac
14
14
  import { DebugTrace } from '../../internal/debug.js';
15
15
  import { classifyError, ConfigurationError, serializeError } from '../../internal/errors.js';
16
16
  import { StreamRedactor } from '../../internal/redact.js';
17
+ import { ConfigLoadHold } from '../process-output.js';
17
18
  import { processSecrets, registerStaticSecrets, staticSecretLedger } from '../secrecy.js';
18
19
  import { SessionStore } from '../sessions.js';
19
20
  import { TargetWorker } from './session.js';
@@ -24,11 +25,20 @@ import { isAbandonedRejection } from '../../internal/abandoned.js';
24
25
  * the queue to flush: the runner must see the worker's last word.
25
26
  */
26
27
  let outbox = Promise.resolve();
28
+ /** Holds what the process prints while `bootstrap` loads the config, until its secrets are known. */
29
+ const configLoad = new ConfigLoadHold();
27
30
  /** How long an exit waits for the outbox: a channel whose runner is gone may never acknowledge. */
28
31
  const FLUSH_GRACE_MS = 2_000;
29
- /** Queues one message; resolves once the channel has taken it (or is gone). */
32
+ /**
33
+ * Hands one message to the channel at once; resolves once the channel has
34
+ * taken it and every message before it (or is gone). The channel keeps the
35
+ * order. Waiting for the previous message's ack would take an event-loop
36
+ * turn: a `pair-start` sent right before a body that exits the process, or an
37
+ * `attempt-deadline` sent right before one that blocks the loop, would never
38
+ * leave.
39
+ */
30
40
  function send(message) {
31
- outbox = outbox.then(() => new Promise((resolve) => {
41
+ const taken = new Promise((resolve) => {
32
42
  try {
33
43
  if (process.send === undefined || !process.connected)
34
44
  resolve();
@@ -39,7 +49,8 @@ function send(message) {
39
49
  // channel already closed; nothing left to deliver
40
50
  resolve();
41
51
  }
42
- }));
52
+ });
53
+ outbox = outbox.then(() => taken);
43
54
  return outbox;
44
55
  }
45
56
  /**
@@ -79,6 +90,11 @@ function captureOutput(pairInFlight) {
79
90
  flushes.push(() => void output(redactor.flush(), heldFor));
80
91
  const write = (chunk, encoding, callback) => {
81
92
  const done = typeof encoding === 'function' ? encoding : callback;
93
+ if (configLoad.hold(name, chunk)) {
94
+ if (done !== undefined)
95
+ process.nextTick(done);
96
+ return true;
97
+ }
82
98
  const pair = pairInFlight();
83
99
  pending += 1;
84
100
  void output(redactor.push(chunk), pair).then(() => {
@@ -117,17 +133,22 @@ async function bootstrap(message, debug, aiTrace) {
117
133
  // process-wide, so calls from either module instance land in one trace.
118
134
  if (aiTrace !== undefined)
119
135
  await registerAiTraceRecorder(aiTrace, loadAiSdk);
120
- const raw = await loadConfigModule(message.configPath);
121
- const config = assignPorts(resolveConfig(raw, { projectRoot: message.projectRoot, configPath: message.configPath, env: process.env, cli: message.cli }), message.ports);
122
- if (config.configDigest !== message.configDigest) {
123
- throw new ConfigurationError('CONFIG_NOT_DETERMINISTIC', 'worker resolved a different config digest than the runner; config must be deterministic');
124
- }
125
- const target = config.targets.find((candidate) => candidate.name === message.targetName);
126
- if (target === undefined) {
127
- throw new ConfigurationError('UNKNOWN_TARGET', `unknown target "${message.targetName}"`);
128
- }
129
- setSecretRegistry(config);
130
- registerStaticSecrets(config.allSecrets);
136
+ // What the config's top-level code prints is held until its secrets are
137
+ // registered, so it is redacted like a test's output.
138
+ const { config, target } = await configLoad.during(async () => {
139
+ const raw = await loadConfigModule(message.configPath);
140
+ const resolved = assignPorts(resolveConfig(raw, { projectRoot: message.projectRoot, configPath: message.configPath, env: process.env, cli: message.cli }), message.ports);
141
+ if (resolved.configDigest !== message.configDigest) {
142
+ throw new ConfigurationError('CONFIG_NOT_DETERMINISTIC', 'worker resolved a different config digest than the runner; config must be deterministic');
143
+ }
144
+ const named = resolved.targets.find((candidate) => candidate.name === message.targetName);
145
+ if (named === undefined) {
146
+ throw new ConfigurationError('UNKNOWN_TARGET', `unknown target "${message.targetName}"`);
147
+ }
148
+ setSecretRegistry(resolved);
149
+ registerStaticSecrets(resolved.allSecrets);
150
+ return { config: resolved, target: named };
151
+ }, (stream, chunk) => process[stream].write(chunk), (line) => process.stderr.write(`e2e: [warning] ${line}\n`));
131
152
  let collectCounter = 0;
132
153
  const resolvePairs = async (unit) => {
133
154
  collectCounter += 1;
@@ -1,18 +1,22 @@
1
1
  /** Child-process `UnitRunner`: spawn, messaging, exit tracking. */
2
2
  import { fork } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
- import { createRequire } from 'node:module';
5
4
  import { fileURLToPath } from 'node:url';
5
+ import { WARNED_TSCONFIGS_ENV, warnedTsconfigs } from '../../config/tsconfig.js';
6
6
  import { InfrastructureError } from '../../internal/errors.js';
7
- /** Resolves the worker entry for both src (vitest, .ts) and dist (.js) layouts. */
7
+ /**
8
+ * Resolves the worker entry for both src (vitest, .ts) and dist (.js)
9
+ * layouts. From source, e2e's own loader is registered before the entry
10
+ * loads (`config/register.ts`): Node.js strips types but does not compile
11
+ * the rest of TypeScript.
12
+ */
8
13
  function resolveEntry() {
9
14
  const js = fileURLToPath(new URL('./entry.js', import.meta.url));
10
15
  if (existsSync(js))
11
16
  return { path: js, execArgv: [] };
12
17
  const ts = fileURLToPath(new URL('./entry.ts', import.meta.url));
13
18
  if (existsSync(ts)) {
14
- const require = createRequire(import.meta.url);
15
- return { path: ts, execArgv: ['--import', require.resolve('tsx')] };
19
+ return { path: ts, execArgv: ['--import', new URL('../../config/register.ts', import.meta.url).href] };
16
20
  }
17
21
  throw new InfrastructureError('WORKER_ENTRY_MISSING', 'e2e worker entry module not found');
18
22
  }
@@ -32,7 +36,8 @@ class ChildProcessRunner {
32
36
  cwd: spawn.projectRoot,
33
37
  execArgv: entry.execArgv,
34
38
  stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
35
- env: spawn.env,
39
+ // Collecting read the project's tsconfig.json files first; the worker warns only about ones it reaches on its own.
40
+ env: { ...spawn.env, [WARNED_TSCONFIGS_ENV]: warnedTsconfigs() },
36
41
  });
37
42
  this.child.on('message', (message) => events.onMessage(message));
38
43
  this.exit = new Promise((resolve) => {