e2e 0.16.0 → 0.17.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 (174) hide show
  1. package/dist/.build.json +1 -1
  2. package/dist/agent/act.js +7 -4
  3. package/dist/agent/action-dispatcher.js +4 -5
  4. package/dist/agent/actions.d.ts +32 -23
  5. package/dist/agent/actions.js +38 -24
  6. package/dist/agent/model/sdk.js +2 -0
  7. package/dist/agent/observation-feed.d.ts +5 -6
  8. package/dist/agent/observation-feed.js +5 -7
  9. package/dist/agent/observation.d.ts +40 -11
  10. package/dist/agent/observation.js +109 -59
  11. package/dist/agent/point-tap.d.ts +8 -8
  12. package/dist/agent/point-tap.js +1 -1
  13. package/dist/agent/replay.d.ts +19 -8
  14. package/dist/agent/replay.js +11 -14
  15. package/dist/agent/scroll-target.d.ts +2 -3
  16. package/dist/agent/step-cache.d.ts +41 -14
  17. package/dist/agent/step-cache.js +147 -62
  18. package/dist/agent/tool-loop.js +2 -0
  19. package/dist/cache/anchors.d.ts +72 -23
  20. package/dist/cache/anchors.js +233 -60
  21. package/dist/cache/context.d.ts +25 -14
  22. package/dist/cache/context.js +50 -8
  23. package/dist/cache/decide.d.ts +2 -2
  24. package/dist/cache/decide.js +4 -6
  25. package/dist/cache/identity.d.ts +18 -4
  26. package/dist/cache/identity.js +9 -4
  27. package/dist/cache/recorder.d.ts +15 -1
  28. package/dist/cache/recorder.js +36 -23
  29. package/dist/cache/rekeyed.d.ts +46 -0
  30. package/dist/cache/rekeyed.js +89 -0
  31. package/dist/cache/relocate.d.ts +14 -14
  32. package/dist/cache/relocate.js +33 -25
  33. package/dist/cache/route.d.ts +32 -33
  34. package/dist/cache/route.js +94 -50
  35. package/dist/cache/store.d.ts +6 -0
  36. package/dist/cache/store.js +21 -1
  37. package/dist/cache/trace.d.ts +53 -14
  38. package/dist/cache/trace.js +70 -28
  39. package/dist/cli/index.js +8 -8
  40. package/dist/cli/init/engines.d.ts +1 -3
  41. package/dist/cli/init/engines.js +2 -2
  42. package/dist/cli/init/gateways.d.ts +1 -1
  43. package/dist/cli/init/gateways.js +11 -1
  44. package/dist/cli/init/sibling-versions.json +1 -1
  45. package/dist/cli/init/versions.d.ts +0 -8
  46. package/dist/cli/init/versions.js +1 -10
  47. package/dist/config/resolve.d.ts +1 -0
  48. package/dist/config/resolve.js +33 -1
  49. package/dist/engine/index.d.ts +2 -2
  50. package/dist/expect/async.js +2 -1
  51. package/dist/expect/poll-scope.d.ts +52 -0
  52. package/dist/expect/poll-scope.js +113 -0
  53. package/dist/expect/poll.js +31 -4
  54. package/dist/expect/values.js +11 -4
  55. package/dist/internal/abandoned.d.ts +11 -0
  56. package/dist/internal/abandoned.js +23 -0
  57. package/dist/internal/brands.d.ts +2 -0
  58. package/dist/internal/brands.js +2 -0
  59. package/dist/internal/client-identity.d.ts +9 -0
  60. package/dist/internal/client-identity.js +14 -0
  61. package/dist/internal/redact.d.ts +7 -4
  62. package/dist/internal/redact.js +116 -23
  63. package/dist/internal/time.d.ts +6 -0
  64. package/dist/internal/time.js +25 -13
  65. package/dist/internal/urls.d.ts +9 -5
  66. package/dist/internal/urls.js +19 -10
  67. package/dist/locator/engine.d.ts +12 -2
  68. package/dist/locator/engine.js +8 -3
  69. package/dist/locator/screen.js +28 -4
  70. package/dist/mcp/catalog.d.ts +3 -2
  71. package/dist/mcp/catalog.js +6 -6
  72. package/dist/mcp/session.js +1 -1
  73. package/dist/oauth/chatgpt.js +3 -23
  74. package/dist/oauth/cli.js +1 -0
  75. package/dist/oauth/copilot.d.ts +5 -4
  76. package/dist/oauth/copilot.js +80 -7
  77. package/dist/oauth/grok.js +2 -1
  78. package/dist/oauth/models.js +2 -1
  79. package/dist/oauth/opencode-console.d.ts +10 -0
  80. package/dist/oauth/opencode-console.js +133 -0
  81. package/dist/oauth/providers/github-copilot.d.ts +12 -0
  82. package/dist/oauth/providers/github-copilot.js +77 -23
  83. package/dist/oauth/providers/opencode-console.d.ts +31 -0
  84. package/dist/oauth/providers/opencode-console.js +148 -0
  85. package/dist/oauth/providers.d.ts +2 -6
  86. package/dist/oauth/providers.js +3 -7
  87. package/dist/oauth/responses.d.ts +9 -0
  88. package/dist/oauth/responses.js +23 -0
  89. package/dist/report/build.d.ts +26 -0
  90. package/dist/report/build.js +16 -8
  91. package/dist/report/failure-text.d.ts +6 -1
  92. package/dist/report/failure-text.js +14 -4
  93. package/dist/report/format.d.ts +10 -5
  94. package/dist/report/format.js +12 -5
  95. package/dist/report/junit.js +11 -12
  96. package/dist/report/list.d.ts +1 -0
  97. package/dist/report/list.js +38 -18
  98. package/dist/report/markdown.d.ts +1 -1
  99. package/dist/report/markdown.js +37 -14
  100. package/dist/report/repeats.d.ts +6 -1
  101. package/dist/report/repeats.js +12 -3
  102. package/dist/run/artifacts.d.ts +18 -0
  103. package/dist/run/artifacts.js +68 -1
  104. package/dist/run/execute.d.ts +9 -0
  105. package/dist/run/execute.js +49 -24
  106. package/dist/run/extended-fixtures.js +6 -2
  107. package/dist/run/failure-evidence.js +4 -2
  108. package/dist/run/fixtures.js +6 -8
  109. package/dist/run/in-process.d.ts +2 -0
  110. package/dist/run/in-process.js +1 -0
  111. package/dist/run/last-run.d.ts +42 -8
  112. package/dist/run/last-run.js +121 -14
  113. package/dist/run/realm.d.ts +4 -1
  114. package/dist/run/realm.js +20 -6
  115. package/dist/run/records.d.ts +16 -0
  116. package/dist/run/records.js +26 -0
  117. package/dist/run/retry.d.ts +3 -1
  118. package/dist/run/retry.js +4 -2
  119. package/dist/run/runner.d.ts +1 -1
  120. package/dist/run/runner.js +69 -30
  121. package/dist/run/serial.d.ts +2 -0
  122. package/dist/run/serial.js +8 -5
  123. package/dist/run/steps.d.ts +0 -6
  124. package/dist/run/steps.js +3 -20
  125. package/dist/run/worker/entry.js +5 -4
  126. package/dist/run/worker/protocol.d.ts +2 -0
  127. package/dist/run/worker/session.d.ts +2 -0
  128. package/dist/run/worker/session.js +1 -0
  129. package/dist/telemetry/events.js +1 -0
  130. package/dist/types.d.ts +9 -3
  131. package/docs/agents.mdx +6 -3
  132. package/docs/assertions.mdx +94 -0
  133. package/docs/authentication.mdx +3 -1
  134. package/docs/browser.mdx +42 -6
  135. package/docs/cache.mdx +64 -18
  136. package/docs/ci.mdx +11 -7
  137. package/docs/debugging.mdx +6 -3
  138. package/docs/executors.mdx +4 -2
  139. package/docs/github.mdx +12 -4
  140. package/docs/{writing-tests.mdx → goals.mdx} +21 -101
  141. package/docs/index.mdx +4 -4
  142. package/docs/integrations/kernel.mdx +1 -1
  143. package/docs/locators.mdx +64 -0
  144. package/docs/migrate/cypress.mdx +2 -2
  145. package/docs/migrate/maestro.mdx +2 -2
  146. package/docs/migrate/playwright.mdx +19 -16
  147. package/docs/migrate/selenium.mdx +2 -2
  148. package/docs/mobile.mdx +21 -4
  149. package/docs/models.mdx +53 -7
  150. package/docs/physical-devices.mdx +76 -0
  151. package/docs/quickstart.mdx +4 -3
  152. package/docs/reference/app.mdx +1 -1
  153. package/docs/reference/cli.mdx +30 -13
  154. package/docs/reference/config.mdx +25 -7
  155. package/docs/reference/engine.mdx +3 -3
  156. package/docs/reference/environment.mdx +8 -1
  157. package/docs/reference/errors.mdx +5 -5
  158. package/docs/reference/expect.mdx +34 -12
  159. package/docs/reference/mobile.mdx +23 -13
  160. package/docs/reference/reporters.mdx +8 -2
  161. package/docs/reference/screen.mdx +30 -7
  162. package/docs/reference/test.mdx +11 -4
  163. package/docs/reference/web.mdx +125 -21
  164. package/docs/security.mdx +26 -16
  165. package/docs/subscriptions.mdx +91 -12
  166. package/docs/telemetry.mdx +1 -1
  167. package/package.json +16 -3
  168. package/schema/report-v1.schema.json +14 -1
  169. package/skills/e2e/SKILL.md +2 -2
  170. package/skills/e2e/references/agent.md +22 -11
  171. package/skills/e2e/references/debugging.md +4 -4
  172. package/skills/e2e/references/running.md +13 -8
  173. package/skills/e2e/references/setup.md +36 -15
  174. package/skills/e2e/references/writing-tests.md +34 -17
@@ -8,7 +8,16 @@ const UNDELIVERED_SKIPS = new Set([
8
8
  'serial-predecessor-failed',
9
9
  'hook-failed',
10
10
  'infrastructure-unavailable',
11
+ 'failure-limit',
11
12
  ]);
13
+ /** The id `--last-failed` names a result by: its test, target, and agent, whichever its repeat. */
14
+ function rerunId(result) {
15
+ return resultId(result.testId, result.targetId, result.agent);
16
+ }
17
+ /** Whether a run error is a failed `beforeAll` or `afterAll`, the failures `--last-failed` runs the scope of again. */
18
+ function isSuiteHookFailure(error) {
19
+ return error.phase === 'beforeAll' || error.phase === 'afterAll';
20
+ }
12
21
  /** Whether a result is one `--last-failed` runs again: it did not pass, or the run never got to it. */
13
22
  function didNotPass(result) {
14
23
  switch (result.status) {
@@ -72,19 +81,88 @@ export async function readLastRun(reportPath) {
72
81
  }
73
82
  /**
74
83
  * The result ids of a report's tests that did not pass: failed, timed out,
75
- * interrupted, or skipped because a setup, a serial predecessor, a hook, or
76
- * the worker failed, and every selected test in the scope of a `beforeAll` or
77
- * `afterAll` that failed, until a run where that hook passes. A report older
78
- * than `selected` counts every result as selected. An explicit
79
- * skip and a filtered test passed in the sense that matters here: nothing to
80
- * run again. The ids are `resultId(testId, target, agent)`, so a test is
81
- * named per target and agent whichever of its `--repeat-each` runs did not
82
- * pass.
84
+ * interrupted, or skipped because a setup, a serial predecessor, a hook, the
85
+ * worker, or the failure limit stopped it, every selected test in the scope
86
+ * of a `beforeAll` or `afterAll` that failed, until a run where that hook
87
+ * passes, and every test the report carries from an earlier run that it did
88
+ * not run again. A report older than `selected` counts every result as
89
+ * selected. An explicit skip and a filtered test passed in the sense that
90
+ * matters here: nothing to run again. The ids are
91
+ * `resultId(testId, target, agent)`, so a test is named per target and agent
92
+ * whichever of its `--repeat-each` runs did not pass.
83
93
  */
84
94
  export function lastFailedIds(document) {
85
- const hookFailures = document.run.errors.filter((error) => error.phase === 'beforeAll' || error.phase === 'afterAll');
95
+ const hookFailures = document.run.errors.filter(isSuiteHookFailure);
86
96
  const rerun = (result) => didNotPass(result) || (result.selected !== false && hookFailures.some((error) => inHookScope(result, error)));
87
- return new Set(document.run.results.filter(rerun).map((result) => resultId(result.testId, result.targetId, result.agent)));
97
+ const ids = new Set(document.run.results.filter(rerun).map(rerunId));
98
+ for (const result of document.run.carried?.results ?? [])
99
+ ids.add(rerunId(result));
100
+ return ids;
101
+ }
102
+ /** Whether two suite hook failures are the same hook's: one phase, one scope. */
103
+ function sameHook(a, b) {
104
+ if (a.phase !== b.phase)
105
+ return false;
106
+ if (a.scope === undefined || b.scope === undefined)
107
+ return a.scope === b.scope;
108
+ return (a.scope.file === b.scope.file &&
109
+ a.scope.targetId === b.scope.targetId &&
110
+ a.scope.titlePath.length === b.scope.titlePath.length &&
111
+ a.scope.titlePath.every((title, index) => b.scope.titlePath[index] === title));
112
+ }
113
+ /**
114
+ * What a `--last-failed` rerun carries forward from the report it selected
115
+ * from: every result that report owed a rerun and this run did not run, as
116
+ * the run that last ran it reported it; the serial groups those results'
117
+ * attempts live on; and the suite hook failures whose scope still holds one
118
+ * of them. A result is owed when it did not pass, or when it is in the scope
119
+ * of a failed `beforeAll` or `afterAll` this run did not see pass: a hook
120
+ * whose scope this run ran again without it failing is resolved, and one
121
+ * that failed again is this run's own error, which still makes its left-out
122
+ * tests owed. A test this run did not run is one it collected (`collected`,
123
+ * by test id), on a target the config still has, that it did not select:
124
+ * left out by a filter, on a target or agent the run did not ask for, or a
125
+ * setup no selected test needed, which the report lists no row for. A test in
126
+ * a file the run found but could not collect (`uncollectedFiles`, a narrowed
127
+ * run past a file that fails to import) is still there and stays carried. A
128
+ * test the run no longer collects cannot run again and is dropped. A test
129
+ * this run selected is no longer carried, whatever its outcome: its own
130
+ * result says it now. Undefined when nothing is carried.
131
+ */
132
+ export function carryForward(lastRun, current, collected) {
133
+ const selected = new Set(current.run.results.filter((result) => result.selected !== false).map(rerunId));
134
+ const targets = new Set(current.run.targets.map((target) => target.id));
135
+ const stillThere = (result) => collected.testIds.has(result.testId) || collected.uncollectedFiles.has(result.file);
136
+ const notRun = (result) => !selected.has(rerunId(result)) && stillThere(result) && targets.has(result.targetId);
137
+ const failedAgain = current.run.errors.filter(isSuiteHookFailure);
138
+ const hooks = [...lastRun.run.errors.filter(isSuiteHookFailure), ...(lastRun.run.carried?.errors ?? [])];
139
+ const reportedAgain = (error) => failedAgain.some((again) => sameHook(again, error));
140
+ const passedAgain = (error) => !reportedAgain(error) &&
141
+ current.run.results.some((result) => result.selected !== false && result.status !== 'skipped' && inHookScope(result, error));
142
+ const unresolved = hooks.filter((error) => !passedAgain(error));
143
+ // A test the run before left out is known by the row it carried, not by its filtered one.
144
+ const history = [...lastRun.run.results.filter((result) => result.selected !== false), ...(lastRun.run.carried?.results ?? [])];
145
+ const results = history.filter((result) => notRun(result) && (didNotPass(result) || unresolved.some((error) => inHookScope(result, error))));
146
+ if (results.length === 0)
147
+ return undefined;
148
+ const groupIds = new Set(results.flatMap((result) => (result.serialGroupId === undefined ? [] : [result.serialGroupId])));
149
+ const currentGroupIds = new Set(current.run.serialGroups.map((group) => group.id));
150
+ const serialGroups = [...lastRun.run.serialGroups, ...(lastRun.run.carried?.serialGroups ?? [])].filter((group) => groupIds.has(group.id) && !currentGroupIds.has(group.id));
151
+ const errors = unresolved.filter((error) => !reportedAgain(error) && results.some((result) => inHookScope(result, error)));
152
+ return { results, serialGroups, errors };
153
+ }
154
+ /**
155
+ * The artifact paths a report names, relative to the artifacts root: every
156
+ * attempt's of its results and serial groups, and of what it carries. What a
157
+ * `--last-failed` rerun keeps of the tree when it starts.
158
+ */
159
+ export function reportArtifactPaths(document) {
160
+ const parts = [document.run, ...(document.run.carried === undefined ? [] : [document.run.carried])];
161
+ const attempts = parts.flatMap((part) => [
162
+ ...part.results.flatMap((result) => result.attempts),
163
+ ...part.serialGroups.flatMap((group) => group.attempts),
164
+ ]);
165
+ return new Set(attempts.flatMap((attempt) => attempt.artifacts.flatMap((artifact) => (artifact.path === undefined ? [] : [artifact.path]))));
88
166
  }
89
167
  /**
90
168
  * The shape the read relies on: a report-1 envelope whose every result has
@@ -96,10 +174,38 @@ function isReport(value) {
96
174
  return false;
97
175
  const document = value;
98
176
  return (document.schemaVersion === 'report-1' &&
99
- Array.isArray(document.run?.results) &&
100
- document.run.results.every(isResult) &&
101
- Array.isArray(document.run.errors) &&
102
- document.run.errors.every(isError));
177
+ isRunPart(document.run) &&
178
+ (document.run.carried === undefined || isRunPart(document.run.carried)));
179
+ }
180
+ /** A run, or what one carries: results and errors of the shape read here, and its serial groups. */
181
+ function isRunPart(value) {
182
+ if (typeof value !== 'object' || value === null)
183
+ return false;
184
+ const part = value;
185
+ return (Array.isArray(part.results) &&
186
+ part.results.every(isResult) &&
187
+ Array.isArray(part.errors) &&
188
+ part.errors.every(isError) &&
189
+ Array.isArray(part.serialGroups) &&
190
+ part.serialGroups.every((group) => typeof group === 'object' && group !== null && hasAttempts(group)));
191
+ }
192
+ /** Whether a result or serial group lists its attempts, each with its artifacts, the way a rerun reads them. */
193
+ function hasAttempts(value) {
194
+ const { attempts } = value;
195
+ return Array.isArray(attempts) && attempts.every(hasArtifacts);
196
+ }
197
+ /** Whether an attempt lists its artifacts as records whose path, when there is one, is a string. */
198
+ function hasArtifacts(attempt) {
199
+ if (typeof attempt !== 'object' || attempt === null)
200
+ return false;
201
+ const { artifacts } = attempt;
202
+ return (Array.isArray(artifacts) &&
203
+ artifacts.every((artifact) => {
204
+ if (typeof artifact !== 'object' || artifact === null)
205
+ return false;
206
+ const { path } = artifact;
207
+ return path === undefined || typeof path === 'string';
208
+ }));
103
209
  }
104
210
  function isResult(value) {
105
211
  if (typeof value !== 'object' || value === null)
@@ -110,6 +216,7 @@ function isResult(value) {
110
216
  typeof result.status === 'string' &&
111
217
  typeof result.file === 'string' &&
112
218
  Array.isArray(result.titlePath) &&
219
+ hasAttempts(result) &&
113
220
  (skip === undefined || (typeof skip === 'object' && skip !== null && typeof skip.cause === 'string')));
114
221
  }
115
222
  function isError(value) {
@@ -33,7 +33,10 @@ export declare function findRegistered(realm: Realm, test: CollectedTest): Regis
33
33
  /**
34
34
  * Runs one hook, or a fixture teardown, within its budget. One that overruns
35
35
  * fails with the runner's timeout error naming `label`; `onTimeout` lets the
36
- * caller cancel what it started.
36
+ * caller cancel what it started. The hook owns the `expect.poll` calls it
37
+ * starts: one still running when it returns fails it with
38
+ * `STEP_NOT_AWAITED` and is cancelled, and one left by a hook that failed is
39
+ * cancelled beside that failure.
37
40
  */
38
41
  export declare function runHook(label: string, run: () => void | Promise<void>, timeoutMs: number, onTimeout?: () => void): Promise<void>;
39
42
  export interface RealmManagerOptions {
package/dist/run/realm.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Suite realm lifecycle: module re-import, beforeAll/afterAll scope tracking. */
2
2
  import { importModule } from '../config/load.js';
3
+ import { PollScope, runInPollScope } from '../expect/poll-scope.js';
3
4
  import { classifyError, E2EError, serializeError, TestTimeoutError, } from '../internal/errors.js';
4
5
  import { DebugTrace } from '../internal/debug.js';
5
6
  import { titlePathKey } from '../internal/ids.js';
@@ -12,13 +13,26 @@ export function findRegistered(realm, test) {
12
13
  /**
13
14
  * Runs one hook, or a fixture teardown, within its budget. One that overruns
14
15
  * fails with the runner's timeout error naming `label`; `onTimeout` lets the
15
- * caller cancel what it started.
16
+ * caller cancel what it started. The hook owns the `expect.poll` calls it
17
+ * starts: one still running when it returns fails it with
18
+ * `STEP_NOT_AWAITED` and is cancelled, and one left by a hook that failed is
19
+ * cancelled beside that failure.
16
20
  */
17
- export function runHook(label, run, timeoutMs, onTimeout) {
18
- return withTimeout(Promise.resolve().then(run), timeoutMs, () => {
19
- onTimeout?.();
20
- return new TestTimeoutError(`${label} timed out`);
21
- });
21
+ export async function runHook(label, run, timeoutMs, onTimeout) {
22
+ const polls = new PollScope(`the ${label}`);
23
+ try {
24
+ await withTimeout(runInPollScope(polls, () => Promise.resolve().then(run)), timeoutMs, () => {
25
+ onTimeout?.();
26
+ return new TestTimeoutError(`${label} timed out`);
27
+ });
28
+ }
29
+ catch (cause) {
30
+ polls.close();
31
+ throw cause;
32
+ }
33
+ const notAwaited = polls.close();
34
+ if (notAwaited !== undefined)
35
+ throw notAwaited;
22
36
  }
23
37
  /** Creates realms and runs suite-scope hooks with per-realm entry tracking. */
24
38
  export class RealmManager {
@@ -148,6 +148,22 @@ export interface ResultRecord {
148
148
  * every other field is plain data.
149
149
  */
150
150
  export type WireResultRecord = Omit<ResultRecord, 'target'>;
151
+ /** The failure a runtime skip followed, with the evidence its attempt captured. */
152
+ export interface FailureBeforeSkip {
153
+ readonly error: SerializedError;
154
+ readonly failure: FailureEvidence | undefined;
155
+ /** The failing attempt's artifacts; for a serial member, the group attempt's. */
156
+ readonly artifacts: readonly ArtifactRecord[];
157
+ }
158
+ /**
159
+ * The failure a test's `test.skip(...)` followed: the last attempt that
160
+ * failed before a skipped retry, or a soft failure the skipping attempt kept.
161
+ * Engine cleanup diagnostics after the skip are not one. A serial member's
162
+ * attempts live on its group, which `groupOf` looks up only for such a member.
163
+ */
164
+ export declare function failureBeforeSkip(result: Pick<ResultRecord, 'status' | 'skip' | 'attempts' | 'serialGroupId' | 'test'>, groupOf: (id: string) => SerialGroupRecord | undefined): FailureBeforeSkip | undefined;
165
+ /** Whether any skipped result followed a failure: what `failOnSkippedFailure` fails a run for. */
166
+ export declare function someSkippedAfterFailure(results: readonly ResultRecord[], serialGroups: readonly SerialGroupRecord[]): boolean;
151
167
  /** Strips the live target from a result for transport. */
152
168
  export declare function encodeResult(record: ResultRecord): WireResultRecord;
153
169
  export interface RunError {
@@ -7,6 +7,32 @@
7
7
  export function isFailedStatus(status) {
8
8
  return status !== 'passed' && status !== 'skipped' && status !== 'flaky';
9
9
  }
10
+ /**
11
+ * The failure a test's `test.skip(...)` followed: the last attempt that
12
+ * failed before a skipped retry, or a soft failure the skipping attempt kept.
13
+ * Engine cleanup diagnostics after the skip are not one. A serial member's
14
+ * attempts live on its group, which `groupOf` looks up only for such a member.
15
+ */
16
+ export function failureBeforeSkip(result, groupOf) {
17
+ if (result.status !== 'skipped' || result.skip?.cause !== 'explicit')
18
+ return undefined;
19
+ const runs = result.serialGroupId === undefined
20
+ ? result.attempts
21
+ : (groupOf(result.serialGroupId)?.attempts ?? []).flatMap((attempt) => attempt.members
22
+ .filter((member) => member.testId === result.test.id)
23
+ .map((member) => ({ ...member, artifacts: attempt.artifacts })));
24
+ for (const run of runs.toReversed()) {
25
+ const error = run.error ?? run.secondaryErrors.find((secondary) => secondary.phase !== 'cleanup');
26
+ if (error !== undefined)
27
+ return { error, failure: run.failure, artifacts: run.artifacts };
28
+ }
29
+ return undefined;
30
+ }
31
+ /** Whether any skipped result followed a failure: what `failOnSkippedFailure` fails a run for. */
32
+ export function someSkippedAfterFailure(results, serialGroups) {
33
+ const groups = new Map(serialGroups.map((group) => [group.id, group]));
34
+ return results.some((result) => failureBeforeSkip(result, (id) => groups.get(id)) !== undefined);
35
+ }
10
36
  /** Strips the live target from a result for transport. */
11
37
  export function encodeResult(record) {
12
38
  const { target: _target, ...rest } = record;
@@ -12,6 +12,8 @@ export declare function isRetryEligible(attempt: RetryAttempt): boolean;
12
12
  * Runs up to `maxAttempts` attempts, stopping on pass, interrupt, or a
13
13
  * failure that does not consume retry budget. `runOnce` returns the
14
14
  * finished attempt, or undefined when the attempt could not start (the
15
- * loop stops and the last observed status stands).
15
+ * loop stops and the last observed status stands). An interrupt during a
16
+ * retry keeps the verdict the failed attempt before it reached: the test
17
+ * did fail, and only its second chance was cut short.
16
18
  */
17
19
  export declare function runWithRetries(maxAttempts: number, interruptSignal: AbortSignal, runOnce: (attemptIndex: number) => Promise<RetryAttempt | undefined>): Promise<ResultStatus>;
package/dist/run/retry.js CHANGED
@@ -8,7 +8,9 @@ export function isRetryEligible(attempt) {
8
8
  * Runs up to `maxAttempts` attempts, stopping on pass, interrupt, or a
9
9
  * failure that does not consume retry budget. `runOnce` returns the
10
10
  * finished attempt, or undefined when the attempt could not start (the
11
- * loop stops and the last observed status stands).
11
+ * loop stops and the last observed status stands). An interrupt during a
12
+ * retry keeps the verdict the failed attempt before it reached: the test
13
+ * did fail, and only its second chance was cut short.
12
14
  */
13
15
  export async function runWithRetries(maxAttempts, interruptSignal, runOnce) {
14
16
  let finalStatus = 'failed';
@@ -23,7 +25,7 @@ export async function runWithRetries(maxAttempts, interruptSignal, runOnce) {
23
25
  if (attempt.status === 'passed')
24
26
  return attemptCount > 1 ? 'flaky' : 'passed';
25
27
  if (attempt.status === 'interrupted')
26
- return 'interrupted';
28
+ return attemptCount > 1 ? finalStatus : 'interrupted';
27
29
  // A body that skipped itself has decided; a retry would only ask again.
28
30
  if (attempt.status === 'skipped')
29
31
  return 'skipped';
@@ -5,7 +5,7 @@ import { type Shard, type TagMode } from '../collect/select.ts';
5
5
  import type { ExploreProgress } from '../explore/progress.ts';
6
6
  import { type Report1Document, type ReportExplore } from '../report/build.ts';
7
7
  import { type RunEventSink, type RunExitCode, type RunStatus } from './events.ts';
8
- import type { ResultRecord } from './records.ts';
8
+ import { type ResultRecord } from './records.ts';
9
9
  import type { BuiltinReporter, E2EConfig, RecordingMode } from '../types.ts';
10
10
  export interface RunOptions {
11
11
  cwd?: string | undefined;
@@ -18,11 +18,13 @@ import { writeJsonReport } from '../report/write.js';
18
18
  import { createRunEventEmitter, toEventResult } from './events.js';
19
19
  import { allocateAppPorts } from './app-ports.js';
20
20
  import { inProcessSpawner } from './in-process.js';
21
+ import { someSkippedAfterFailure } from './records.js';
21
22
  import { runUnits } from './scheduler.js';
22
23
  import { buildWorkPlans, plannedSlots } from './units.js';
23
24
  import { SessionStore } from './sessions.js';
24
25
  import { outputLayout } from './output.js';
25
- import { lastFailedIds, readLastRun } from './last-run.js';
26
+ import { claimRerunDir, pruneArtifacts } from './artifacts.js';
27
+ import { carryForward, lastFailedIds, readLastRun, reportArtifactPaths } from './last-run.js';
26
28
  import { childProcessSpawner } from './worker/handle.js';
27
29
  import { setSecretRegistry } from '../secrets.js';
28
30
  import { withAbort } from '../internal/time.js';
@@ -187,25 +189,38 @@ export async function run(options = {}) {
187
189
  */
188
190
  const currentExitCode = () => combineExitCodes([
189
191
  ...resultExitCodes(results),
192
+ ...(loaded.config?.failOnSkippedFailure === true && someSkippedAfterFailure(results, serialGroups) ? [1] : []),
190
193
  ...runErrors.map((runError) => exitCodeForCategory(runError.error.category)),
191
194
  ...(interruptController.signal.aborted ? [130] : []),
192
195
  ].filter((code) => code !== 130 || !(runAborted || stoppedEarly)));
193
196
  // Detected once the config names the project root; a report written before
194
197
  // that (a config failure) has no checkout to describe.
195
198
  let vcs;
196
- const buildRunReport = (exitCode) => buildReport({
197
- runId,
198
- config: loaded.config,
199
- vcs,
200
- startedAt,
201
- status: statusOf(exitCode),
202
- exitCode,
203
- results,
204
- serialGroups,
205
- runErrors,
206
- targetProvenance,
207
- explore: options.tests?.explore?.snapshot(),
208
- });
199
+ // The report `--last-failed` selected from, once collection has read it;
200
+ // reporters get it beside this run's report to fold the rerun into it, and
201
+ // this run's report carries what it owed that this run left out.
202
+ let lastRun;
203
+ // The ids of every test collection found, a setup no selected test needed
204
+ // among them, and the files it could not collect: what a rerun can still
205
+ // carry when the report lists no row.
206
+ let rerunCollection = { testIds: new Set(), uncollectedFiles: new Set() };
207
+ const buildRunReport = (exitCode) => {
208
+ const document = buildReport({
209
+ runId,
210
+ config: loaded.config,
211
+ vcs,
212
+ startedAt,
213
+ status: statusOf(exitCode),
214
+ exitCode,
215
+ results,
216
+ serialGroups,
217
+ runErrors,
218
+ targetProvenance,
219
+ explore: options.tests?.explore?.snapshot(),
220
+ });
221
+ const carried = lastRun === undefined ? undefined : carryForward(lastRun, document, rerunCollection);
222
+ return carried === undefined ? document : { ...document, run: { ...document.run, carried } };
223
+ };
209
224
  /**
210
225
  * Writes the canonical report and returns its path only once the file
211
226
  * exists: a host must never be handed a path to a report that was not
@@ -249,9 +264,6 @@ export async function run(options = {}) {
249
264
  return undefined;
250
265
  }
251
266
  };
252
- // The report `--last-failed` selected from, once collection has read it;
253
- // reporters get it beside this run's report to fold the rerun into it.
254
- let lastRun;
255
267
  /**
256
268
  * Whether the run got as far as its tests. Only such a run writes into the
257
269
  * output directory: one that stopped before leaves the previous run's
@@ -364,6 +376,10 @@ export async function run(options = {}) {
364
376
  : collectInMemory(config.projectRoot, options.tests.file, options.tests.registration, consumesSession(options.tests.registration) ? await collectSetups(config) : undefined);
365
377
  const inputs = await selectionInputs(options, config);
366
378
  lastRun = inputs.lastRun;
379
+ rerunCollection = {
380
+ testIds: new Set(collection.tests.map((test) => test.id)),
381
+ uncollectedFiles: new Set(collection.uncollected.map((skipped) => skipped.file)),
382
+ };
367
383
  const selection = repeatEach(select(collection, config, inputs.filters, options.passWithNoTests !== undefined ? { passWithNoTests: options.passWithNoTests } : {}), options.repeatEach ?? 1);
368
384
  return { collection, selection };
369
385
  }));
@@ -450,15 +466,26 @@ export async function run(options = {}) {
450
466
  // Tests are about to start, and only now is the previous run's evidence
451
467
  // given up: the artifact tree is emptied, so what is there once this run
452
468
  // ends is its own and nothing a report no longer names, and this run's
453
- // report replaces the last. A run that stopped before here (no test
454
- // selected, a collection error, a target that cannot record what it asks,
455
- // an app that failed to start, an interrupt) leaves both, and
469
+ // report replaces the last. A `--last-failed` rerun keeps the evidence
470
+ // the report it reruns names instead, since that report's results fold
471
+ // into the rerun's and its carried tests do not run again, and files its
472
+ // own attempts in a fresh `rerun-<n>` directory beside it, so no attempt
473
+ // overwrites an earlier one's files. A run that stopped before here (no
474
+ // test selected, a collection error, a target that cannot record what it
475
+ // asks, an app that failed to start, an interrupt) leaves both, and
456
476
  // `--last-failed` still reads the run that executed. A wipe that fails
457
477
  // part way has already given up the old evidence, so this run's report
458
478
  // records the failure.
459
479
  testsStarted = true;
480
+ let rerunDir;
460
481
  try {
461
- await rm(layout.artifacts, { recursive: true, force: true });
482
+ if (lastRun === undefined) {
483
+ await rm(layout.artifacts, { recursive: true, force: true });
484
+ }
485
+ else {
486
+ await pruneArtifacts(layout.artifacts, reportArtifactPaths(lastRun));
487
+ rerunDir = await claimRerunDir(layout.artifacts);
488
+ }
462
489
  }
463
490
  catch (cause) {
464
491
  recordFailure(cause, 'launch');
@@ -482,6 +509,7 @@ export async function run(options = {}) {
482
509
  inMemory: options.tests,
483
510
  runId,
484
511
  artifactsRoot,
512
+ rerunDir,
485
513
  sessionStore: store,
486
514
  headed: options.headed ?? false,
487
515
  debug,
@@ -495,6 +523,7 @@ export async function run(options = {}) {
495
523
  ports: config.ports,
496
524
  runId,
497
525
  artifactsRoot,
526
+ rerunDir,
498
527
  headed: options.headed ?? false,
499
528
  sessionsRoot: layout.sessions,
500
529
  sessionKeyBase64: store.exportKeyForWorker(),
@@ -747,13 +776,21 @@ function resultExitCodes(results) {
747
776
  /** Why a reporter's `onRunFinished` was given up on: the message is the stderr line. */
748
777
  class ReporterAbandoned extends Error {
749
778
  }
779
+ /**
780
+ * The built-in reporters that write files beside `report.json`. A forced
781
+ * interrupt never abandons them: they are local writes, and giving one up
782
+ * would leave the previous run's `summary.md` or `junit.xml` beside this
783
+ * run's report, where CI reads it as this run's.
784
+ */
785
+ const FILE_REPORTERS = new Set([STATELESS_REPORTERS.junit, STATELESS_REPORTERS.markdown]);
750
786
  /**
751
787
  * Awaits every reporter's `onRunFinished` at once, each within `timeoutMs`
752
- * and until a forced interrupt, and collects the summary rows they resolve
753
- * with. Each reporter gets a signal that aborts on either, so a well-behaved
754
- * one cancels its own work and leaves no handle holding the process. A
755
- * reporter that throws, runs out of time, or returns something other than
756
- * rows is one line on stderr; it can never change the run's outcome.
788
+ * and, except the built-in file reporters, until a forced interrupt, and
789
+ * collects the summary rows they resolve with. Each reporter gets a signal
790
+ * that aborts on either, so a well-behaved one cancels its own work and
791
+ * leaves no handle holding the process. A reporter that throws, runs out of
792
+ * time, or returns something other than rows is one line on stderr; it can
793
+ * never change the run's outcome.
757
794
  */
758
795
  async function runReporters(reporters, finished, timeoutMs, force) {
759
796
  const warn = (reporter, detail) => {
@@ -767,10 +804,12 @@ async function runReporters(reporters, finished, timeoutMs, force) {
767
804
  const abandon = new AbortController();
768
805
  const timer = setTimeout(() => abandon.abort(new ReporterAbandoned(`did not finish within ${budget}`)), timeoutMs);
769
806
  const onForce = () => abandon.abort(new ReporterAbandoned('abandoned: the run was forced to stop'));
770
- if (force.aborted)
771
- onForce();
772
- else
773
- force.addEventListener('abort', onForce, { once: true });
807
+ if (!FILE_REPORTERS.has(reporter)) {
808
+ if (force.aborted)
809
+ onForce();
810
+ else
811
+ force.addEventListener('abort', onForce, { once: true });
812
+ }
774
813
  try {
775
814
  const result = await withAbort(() => Promise.resolve(onRunFinished.call(reporter, finished, abandon.signal)), abandon.signal, () => abandon.signal.reason);
776
815
  if (result === undefined)
@@ -37,6 +37,8 @@ export interface SharedSerialSession {
37
37
  export interface SerialHost {
38
38
  readonly target: ResolvedTarget;
39
39
  readonly artifactsRoot: string;
40
+ /** See `TargetExecutorOptions.rerunDir`. */
41
+ readonly rerunDir: string | undefined;
40
42
  readonly runId: string;
41
43
  /** The configured artifact store, so group-owned artifacts (the shared trace) upload like any other. */
42
44
  readonly artifactStore: ArtifactStore | undefined;
@@ -3,7 +3,7 @@ import { classifyError, ConfigurationError, serializeError, } from '../internal/
3
3
  import { canonicalDigest, timestamp, uuidv7 } from '../internal/ids.js';
4
4
  import { groupTitles } from '../collect/registry.js';
5
5
  import { pairRecordings } from '../collect/select.js';
6
- import { createAttemptArtifacts, sanitizePathSegment } from './artifacts.js';
6
+ import { attemptSegments, createAttemptArtifacts, sanitizePathSegment } from './artifacts.js';
7
7
  import { findRegistered, RealmManager } from './realm.js';
8
8
  import { isFailedStatus } from './records.js';
9
9
  import { runWithRetries } from './retry.js';
@@ -35,8 +35,11 @@ export async function runSerialUnit(host, members, file) {
35
35
  const finalStatus = await runWithRetries(first.options.retries + 1, host.interruptSignal, async (attemptIndex) => {
36
36
  const attempt = await runSerialAttempt(host, members, file, attemptIndex);
37
37
  group.attempts.push(attempt);
38
- for (const member of attempt.members)
39
- memberFinalStatus.set(member.testId, member);
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';
40
+ if (!cutRetry)
41
+ for (const member of attempt.members)
42
+ memberFinalStatus.set(member.testId, member);
40
43
  // A beforeAll failure is not retry-eligible: the
41
44
  // attempt stands as recorded and the retry loop stops here.
42
45
  if (attempt.error?.code === 'HOOK_FAILED')
@@ -88,13 +91,13 @@ async function runSerialAttempt(host, members, file, attemptIndex) {
88
91
  const memberRecords = [];
89
92
  const first = members[0];
90
93
  const recordings = pairRecordings(first, attemptIndex);
91
- const artifactSegments = [
94
+ const artifactSegments = attemptSegments(host.rerunDir, [
92
95
  host.target.name,
93
96
  sanitizePathSegment(first.test.serialId ?? first.test.id),
94
97
  first.agent,
95
98
  ...repeatSegment(first.repeat),
96
99
  `attempt-${attemptIndex}`,
97
- ];
100
+ ]);
98
101
  const artifacts = createAttemptArtifacts({
99
102
  artifactsRoot: host.artifactsRoot,
100
103
  segments: artifactSegments,
@@ -271,12 +271,6 @@ export interface StepRecorderOptions {
271
271
  /** Replaces secret values in a step's label, and in its error's message and details, before the record keeps them. */
272
272
  readonly redact?: (text: string) => string;
273
273
  }
274
- /**
275
- * Whether an unhandled rejection is an abandoned step's: already recorded as
276
- * `STEP_NOT_AWAITED`, reaching the process only because the promise the
277
- * test did not await rejected with it. Such a rejection is not a fault.
278
- */
279
- export declare function isAbandonedStepRejection(cause: unknown): boolean;
280
274
  export declare class StepRecorder {
281
275
  private readonly attemptId;
282
276
  private readonly attempt;
package/dist/run/steps.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Attempt-scoped step timeline. */
2
2
  import { AsyncLocalStorage } from 'node:async_hooks';
3
3
  import { isAgentError } from '../agent/error.js';
4
+ import { markAbandonedRejection, relocateStack } from '../internal/abandoned.js';
4
5
  import { withAiTraceStep } from '../internal/ai-trace.js';
5
6
  import { classifyError, serializeError, TestError, withHint } from '../internal/errors.js';
6
7
  import { timestamp } from '../internal/ids.js';
@@ -9,16 +10,6 @@ import { sourceLocation } from '../internal/source.js';
9
10
  export const STEP_KINDS = ['agent', 'locator', 'assertion', 'screen', 'app', 'session', 'resource'];
10
11
  /** Frames kept when a step captures where it was called from; the user's line is a few frames up. */
11
12
  const STEP_STACK_FRAMES = 20;
12
- /** What abandoned steps rejected with. The promise the test holds rejects with the same value, and nobody awaits it. */
13
- const ABANDONED_REJECTIONS = new WeakSet();
14
- /**
15
- * Whether an unhandled rejection is an abandoned step's: already recorded as
16
- * `STEP_NOT_AWAITED`, reaching the process only because the promise the
17
- * test did not await rejected with it. Such a rejection is not a fault.
18
- */
19
- export function isAbandonedStepRejection(cause) {
20
- return typeof cause === 'object' && cause !== null && ABANDONED_REJECTIONS.has(cause);
21
- }
22
13
  /**
23
14
  * The stack at a step's start, or nothing without a project root to read it
24
15
  * against. Every step pays the capture, since the failing one is not known
@@ -38,13 +29,6 @@ function stepStack(projectRoot) {
38
29
  Error.stackTraceLimit = limit;
39
30
  }
40
31
  }
41
- /** Points `error` at the frames of `stack`, keeping its own name and message as the first line. */
42
- function relocate(error, stack) {
43
- if (stack === undefined)
44
- return;
45
- const frames = stack.split('\n').slice(1).join('\n');
46
- error.stack = `${error.name}: ${error.message}\n${frames}`;
47
- }
48
32
  /** Characters of a step label an error message quotes before clipping it. */
49
33
  const MAX_QUOTED_LABEL_CHARS = 80;
50
34
  function quoted(label) {
@@ -137,8 +121,7 @@ export class StepRecorder {
137
121
  }
138
122
  catch (cause) {
139
123
  if (this.abandoned.has(record.id)) {
140
- if (typeof cause === 'object' && cause !== null)
141
- ABANDONED_REJECTIONS.add(cause);
124
+ markAbandonedRejection(cause);
142
125
  throw cause;
143
126
  }
144
127
  record.durationMs = Date.now() - startedMs;
@@ -177,7 +160,7 @@ export class StepRecorder {
177
160
  for (const step of running) {
178
161
  const more = first === undefined && running.length > 1 ? ` and ${running.length - 1} more` : '';
179
162
  const error = new TestError('STEP_NOT_AWAITED', withHint(`the test body returned before ${step.api} ${quoted(step.label)}${more} finished`, 'put `await` in front of every step call'));
180
- relocate(error, this.stacks.get(step.id));
163
+ relocateStack(error, this.stacks.get(step.id));
181
164
  first ??= error;
182
165
  this.abandoned.add(step.id);
183
166
  step.status = 'failed';