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.
- package/README.md +10 -0
- package/dist/.build.json +1 -1
- package/dist/agent/ai-sdk.js +5 -4
- package/dist/agent/error.js +3 -3
- package/dist/agent/invocation.js +3 -4
- package/dist/agent/model/tool-choice.d.ts +9 -0
- package/dist/agent/model/tool-choice.js +14 -0
- package/dist/agent/observation-feed.js +3 -4
- package/dist/agent/phases.d.ts +7 -1
- package/dist/agent/phases.js +12 -3
- package/dist/agent/prompts.js +3 -0
- package/dist/agent/replay.js +18 -15
- package/dist/agent/step-cache.d.ts +2 -1
- package/dist/agent/step-cache.js +9 -5
- package/dist/agent/tool-loop.js +92 -5
- package/dist/cache/trace.js +6 -2
- package/dist/cli/bin.js +2 -2
- package/dist/cli/index.js +35 -24
- package/dist/cli/init/mcp-config.d.ts +2 -1
- package/dist/cli/init/mcp-config.js +8 -3
- package/dist/cli/init/sibling-versions.json +1 -1
- package/dist/cli/mcp.d.ts +3 -2
- package/dist/cli/mcp.js +52 -16
- package/dist/cli/run-output.d.ts +17 -0
- package/dist/cli/run-output.js +45 -0
- package/dist/collect/collect.js +8 -4
- package/dist/collect/registry.js +27 -16
- package/dist/config/compiled-files.d.ts +36 -0
- package/dist/config/compiled-files.js +50 -0
- package/dist/config/diagnose.js +21 -19
- package/dist/config/esm-hooks.d.ts +39 -44
- package/dist/config/esm-hooks.js +238 -63
- package/dist/config/load.d.ts +1 -7
- package/dist/config/load.js +23 -66
- package/dist/config/register.d.ts +8 -0
- package/dist/config/register.js +9 -0
- package/dist/config/specifiers.d.ts +19 -0
- package/dist/config/specifiers.js +33 -0
- package/dist/config/tsconfig.d.ts +56 -0
- package/dist/config/tsconfig.js +115 -0
- package/dist/config/typescript.d.ts +19 -0
- package/dist/config/typescript.js +253 -0
- package/dist/engine/contract.d.ts +1 -0
- package/dist/engine/index.d.ts +2 -0
- package/dist/engine/session.js +2 -2
- package/dist/expect/async.js +2 -1
- package/dist/expect/poll.js +2 -2
- package/dist/internal/errors.d.ts +10 -3
- package/dist/internal/errors.js +23 -8
- package/dist/internal/node-version.d.ts +16 -7
- package/dist/internal/node-version.js +50 -15
- package/dist/internal/package-manager.js +1 -1
- package/dist/internal/realm-slot.d.ts +5 -4
- package/dist/internal/redact.d.ts +2 -0
- package/dist/internal/redact.js +4 -0
- package/dist/internal/time.d.ts +21 -5
- package/dist/internal/time.js +35 -3
- package/dist/locator/engine.js +47 -11
- package/dist/locator/screen.js +31 -5
- package/dist/mcp/output.d.ts +59 -0
- package/dist/mcp/output.js +129 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.js +9 -12
- package/dist/mcp/session.d.ts +8 -1
- package/dist/mcp/session.js +55 -14
- package/dist/mcp/tools.js +7 -5
- package/dist/report/list.d.ts +11 -0
- package/dist/report/list.js +23 -6
- package/dist/run/artifacts.d.ts +4 -4
- package/dist/run/artifacts.js +3 -2
- package/dist/run/execute.d.ts +25 -5
- package/dist/run/execute.js +44 -20
- package/dist/run/failure-evidence.js +6 -2
- package/dist/run/in-process.js +51 -10
- package/dist/run/process-output.d.ts +73 -0
- package/dist/run/process-output.js +130 -0
- package/dist/run/provision.d.ts +2 -0
- package/dist/run/provision.js +1 -0
- package/dist/run/records.d.ts +4 -4
- package/dist/run/retry.d.ts +7 -0
- package/dist/run/retry.js +27 -11
- package/dist/run/runner.d.ts +8 -0
- package/dist/run/runner.js +32 -9
- package/dist/run/scheduler.js +255 -19
- package/dist/run/secrecy.d.ts +22 -19
- package/dist/run/secrecy.js +22 -18
- package/dist/run/serial.d.ts +73 -2
- package/dist/run/serial.js +239 -58
- package/dist/run/standalone.js +1 -1
- package/dist/run/trace-redaction.d.ts +5 -6
- package/dist/run/trace-redaction.js +5 -6
- package/dist/run/worker/entry.js +35 -14
- package/dist/run/worker/handle.js +10 -5
- package/dist/run/worker/protocol.d.ts +83 -3
- package/dist/run/worker/session.js +28 -0
- package/dist/telemetry/environment.d.ts +2 -1
- package/dist/telemetry/environment.js +12 -2
- package/dist/telemetry/project.d.ts +5 -6
- package/dist/telemetry/project.js +6 -11
- package/dist/telemetry/store.d.ts +5 -5
- package/dist/telemetry/store.js +5 -7
- package/dist/telemetry/telemetry.d.ts +2 -3
- package/dist/telemetry/telemetry.js +2 -4
- package/dist/types.d.ts +6 -3
- package/docs/agent-steps.mdx +1 -0
- package/docs/agents.mdx +1 -0
- package/docs/api-testing.mdx +2 -1
- package/docs/authentication.mdx +3 -2
- package/docs/browser.mdx +7 -6
- package/docs/bug-bash.mdx +2 -1
- package/docs/cache.mdx +2 -1
- package/docs/ci.mdx +3 -2
- package/docs/coding-agents.mdx +2 -1
- package/docs/core-concepts.mdx +208 -0
- package/docs/debugging.mdx +19 -9
- package/docs/decision-models.mdx +142 -0
- package/docs/example-projects.mdx +32 -0
- package/docs/executors.mdx +3 -0
- package/docs/explore.mdx +1 -0
- package/docs/github.mdx +2 -1
- package/docs/index.mdx +9 -5
- package/docs/integrations/eas.mdx +1 -0
- package/docs/integrations/index.mdx +1 -0
- package/docs/integrations/kernel.mdx +2 -1
- package/docs/migrate/cypress.mdx +26 -3
- package/docs/migrate/detox.mdx +24 -1
- package/docs/migrate/maestro.mdx +28 -4
- package/docs/migrate/playwright.mdx +27 -4
- package/docs/migrate/selenium.mdx +27 -3
- package/docs/mobile-ci.mdx +6 -5
- package/docs/mobile.mdx +79 -8
- package/docs/models.mdx +7 -2
- package/docs/quickstart.mdx +26 -15
- package/docs/reference/agent.mdx +13 -3
- package/docs/reference/app.mdx +1 -0
- package/docs/reference/cli.mdx +11 -5
- package/docs/reference/config.mdx +42 -6
- package/docs/reference/engine.mdx +22 -10
- package/docs/reference/environment.mdx +2 -1
- package/docs/reference/errors.mdx +11 -8
- package/docs/reference/expect.mdx +2 -1
- package/docs/reference/mcp.mdx +21 -8
- package/docs/reference/mobile.mdx +1 -0
- package/docs/reference/reporters.mdx +1 -0
- package/docs/reference/screen.mdx +19 -9
- package/docs/reference/test.mdx +21 -1
- package/docs/reference/web.mdx +8 -2
- package/docs/security.mdx +33 -27
- package/docs/subscriptions.mdx +1 -0
- package/docs/telemetry.mdx +8 -5
- package/docs/tools.mdx +1 -0
- package/docs/{starting-your-app.mdx → web.mdx} +157 -11
- package/docs/writing-an-engine.mdx +2 -1
- package/package.json +11 -7
- package/skills/e2e/SKILL.md +3 -1
- package/skills/e2e/references/agent.md +11 -0
- package/skills/e2e/references/debugging.md +1 -1
- package/skills/e2e/references/mcp.md +8 -3
- package/skills/e2e/references/running.md +2 -1
- package/skills/e2e/references/setup.md +9 -1
- package/skills/e2e/references/writing-tests.md +1 -1
- package/docs/assertions.mdx +0 -94
- package/docs/goals.mdx +0 -176
- package/docs/locators.mdx +0 -64
- package/docs/physical-devices.mdx +0 -76
- package/docs/viewports.mdx +0 -146
package/dist/run/serial.js
CHANGED
|
@@ -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
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
27
|
-
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
98
|
+
return pairResult(member, {
|
|
78
99
|
status,
|
|
79
100
|
selected: true,
|
|
80
101
|
...(skip !== undefined ? { skip } : {}),
|
|
81
102
|
attempts: [],
|
|
82
|
-
serialGroupId:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
package/dist/run/standalone.js
CHANGED
|
@@ -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.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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);
|
package/dist/run/worker/entry.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|