e2e 0.21.1 → 0.21.2

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 (133) hide show
  1. package/README.md +2 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/act-validation.js +1 -0
  4. package/dist/agent/act.js +7 -3
  5. package/dist/agent/action-dispatcher.js +30 -8
  6. package/dist/agent/actions.d.ts +2 -0
  7. package/dist/agent/actions.js +5 -2
  8. package/dist/agent/error.js +1 -0
  9. package/dist/agent/executor.d.ts +14 -6
  10. package/dist/agent/index.js +5 -1
  11. package/dist/agent/invocation.d.ts +3 -0
  12. package/dist/agent/model/sdk.d.ts +5 -0
  13. package/dist/agent/model/sdk.js +18 -1
  14. package/dist/agent/observation.js +1 -0
  15. package/dist/agent/primitives.js +14 -3
  16. package/dist/agent/replay.js +9 -3
  17. package/dist/agent/step-cache.d.ts +3 -2
  18. package/dist/agent/step-cache.js +7 -4
  19. package/dist/agent/tool-loop.js +4 -1
  20. package/dist/cache/context.js +1 -1
  21. package/dist/cache/recorder.d.ts +2 -1
  22. package/dist/cache/recorder.js +11 -2
  23. package/dist/cache/trace-fields.d.ts +10 -0
  24. package/dist/cache/trace-fields.js +17 -0
  25. package/dist/cache/trace.d.ts +9 -1
  26. package/dist/cache/trace.js +26 -7
  27. package/dist/cli/feedback.js +2 -1
  28. package/dist/cli/init/sibling-versions.json +1 -1
  29. package/dist/collect/select.js +12 -2
  30. package/dist/config/agent.d.ts +1 -1
  31. package/dist/config/diagnose.js +9 -3
  32. package/dist/config/esm-hooks.js +3 -2
  33. package/dist/config/resolve.js +3 -2
  34. package/dist/config/typescript.js +52 -2
  35. package/dist/engine/contract.d.ts +3 -2
  36. package/dist/engine/manifest.js +2 -2
  37. package/dist/internal/errors.js +5 -2
  38. package/dist/internal/source.js +2 -0
  39. package/dist/internal/unserved-model.d.ts +5 -0
  40. package/dist/internal/unserved-model.js +22 -0
  41. package/dist/locator/screen.js +1 -2
  42. package/dist/oauth/copilot.js +23 -5
  43. package/dist/oauth/device-code.js +35 -19
  44. package/dist/oauth/errors.d.ts +3 -0
  45. package/dist/oauth/errors.js +26 -11
  46. package/dist/oauth/providers/github-copilot.d.ts +4 -0
  47. package/dist/oauth/providers/github-copilot.js +64 -16
  48. package/dist/oauth/providers/openai.d.ts +2 -2
  49. package/dist/oauth/providers/openai.js +47 -19
  50. package/dist/oauth/providers/opencode-console.js +36 -15
  51. package/dist/oauth/providers/xai.js +18 -9
  52. package/dist/oauth/token-endpoint.d.ts +15 -6
  53. package/dist/oauth/token-endpoint.js +18 -6
  54. package/dist/report/code-frame.js +9 -3
  55. package/dist/report/failure-text.d.ts +10 -2
  56. package/dist/report/failure-text.js +24 -8
  57. package/dist/report/list-model.d.ts +13 -0
  58. package/dist/report/list-steps.d.ts +6 -1
  59. package/dist/report/list-steps.js +29 -0
  60. package/dist/report/list.d.ts +1 -0
  61. package/dist/report/list.js +16 -7
  62. package/dist/report/live-window.js +2 -1
  63. package/dist/report/markdown.js +1 -1
  64. package/dist/run/fixtures.js +2 -0
  65. package/dist/run/host-trace.d.ts +3 -2
  66. package/dist/run/host-trace.js +2 -1
  67. package/dist/run/last-run.d.ts +9 -0
  68. package/dist/run/last-run.js +28 -0
  69. package/dist/run/open-session.d.ts +3 -1
  70. package/dist/run/open-session.js +1 -1
  71. package/dist/run/runner.js +6 -2
  72. package/dist/run/sessions.js +3 -3
  73. package/dist/run/standalone-attempt.d.ts +2 -1
  74. package/dist/run/standalone-attempt.js +3 -1
  75. package/dist/run/steps.d.ts +3 -1
  76. package/dist/run/units.d.ts +8 -0
  77. package/dist/run/units.js +14 -0
  78. package/dist/run/worker-models.d.ts +2 -0
  79. package/dist/run/worker-models.js +7 -0
  80. package/dist/types.d.ts +2 -2
  81. package/docs/authentication.mdx +26 -23
  82. package/docs/browser.mdx +7 -4
  83. package/docs/bug-bash.mdx +2 -2
  84. package/docs/cache.mdx +6 -1
  85. package/docs/ci/bitrise.mdx +1 -1
  86. package/docs/ci/codemagic.mdx +2 -2
  87. package/docs/ci/eas-workflows.mdx +1 -1
  88. package/docs/coding-agents.mdx +7 -35
  89. package/docs/debugging.mdx +24 -36
  90. package/docs/executors.mdx +1 -1
  91. package/docs/github.mdx +2 -1
  92. package/docs/integrations/eas.mdx +1 -1
  93. package/docs/integrations/index.mdx +6 -0
  94. package/docs/integrations/lightpanda.mdx +192 -0
  95. package/docs/integrations/testmu.mdx +152 -0
  96. package/docs/mcp.mdx +123 -0
  97. package/docs/migrate/detox.mdx +1 -1
  98. package/docs/migrate/maestro.mdx +101 -105
  99. package/docs/migrate/playwright.mdx +65 -70
  100. package/docs/mobile.mdx +18 -3
  101. package/docs/models.mdx +4 -2
  102. package/docs/quickstart.mdx +7 -0
  103. package/docs/reference/agent.mdx +369 -277
  104. package/docs/reference/app.mdx +141 -32
  105. package/docs/reference/cli.mdx +674 -558
  106. package/docs/reference/config.mdx +1406 -752
  107. package/docs/reference/engine.mdx +1062 -256
  108. package/docs/reference/environment.mdx +156 -84
  109. package/docs/reference/errors.mdx +106 -72
  110. package/docs/reference/expect.mdx +752 -372
  111. package/docs/reference/mcp.mdx +366 -141
  112. package/docs/reference/mobile.mdx +844 -403
  113. package/docs/reference/reporters.mdx +312 -106
  114. package/docs/reference/runner.mdx +373 -174
  115. package/docs/reference/screen.mdx +931 -398
  116. package/docs/reference/security.mdx +106 -40
  117. package/docs/reference/test.mdx +626 -273
  118. package/docs/reference/web.mdx +956 -533
  119. package/docs/security.mdx +6 -5
  120. package/docs/snippets/model-provider.jsx +763 -0
  121. package/docs/snippets/setup-prompt-card.jsx +22 -0
  122. package/docs/snippets/show-more.jsx +22 -0
  123. package/docs/snippets/video-card.jsx +34 -0
  124. package/docs/snippets/video.jsx +25 -0
  125. package/docs/subscriptions.mdx +10 -3
  126. package/docs/telemetry.mdx +39 -39
  127. package/package.json +3 -2
  128. package/schema/report-v1.schema.json +6 -0
  129. package/skills/e2e/references/agent.md +8 -3
  130. package/skills/e2e/references/debugging.md +1 -1
  131. package/skills/e2e/references/mcp.md +1 -1
  132. package/skills/e2e/references/setup.md +12 -3
  133. package/skills/e2e/references/writing-tests.md +1 -1
@@ -8,6 +8,7 @@
8
8
  * each block to its page. Both read the facts from here, so the two never disagree.
9
9
  */
10
10
  import path from 'node:path';
11
+ import { modelLabel } from '../config/agent.js';
11
12
  import { isLoopbackHost } from '../internal/urls.js';
12
13
  import { cell, code, formatDuration, link, MAX_CELL_CHARS, MAX_ID_CHARS, MAX_LABEL_CHARS, MAX_PATH_CHARS, MAX_TITLE_CHARS, plural } from './markdown-text.js';
13
14
  import { repeatSuffix } from './format.js';
@@ -86,11 +87,25 @@ export function toldAttempt(result, final) {
86
87
  }
87
88
  return final.final;
88
89
  }
89
- /** The step a failure happened at: the first that did not pass, with its position. */
90
- export function failedStepOf(steps) {
91
- const index = steps.findIndex((step) => step.status !== 'passed');
92
- const step = steps[index];
93
- return step === undefined || step.status === 'passed' ? undefined : { index, step: step };
90
+ /**
91
+ * The step whose error ended the attempt, with its position: the step in the
92
+ * error's phase whose own error carries its code and line, else the last with
93
+ * its code, since a caught failure comes earlier, else the step a timeout or
94
+ * interrupt cut.
95
+ */
96
+ export function failedStepOf(attempt) {
97
+ const error = attempt.error;
98
+ if (error === undefined)
99
+ return undefined;
100
+ const phase = error.phase;
101
+ const candidates = attempt.steps.flatMap((step, index) => step.status !== 'passed' && (phase === undefined || (step.phase ?? 'body') === phase) ? [{ index, step: step }] : []);
102
+ const alike = candidates.filter(({ step }) => step.error?.code === error.code);
103
+ const line = error.source;
104
+ if (line !== undefined && alike.length > 0) {
105
+ // The step's own error, not its source: that is the call site, a line below the `await` of a split call.
106
+ return alike.findLast(({ step }) => step.error?.source?.file === line.file && step.error.source.line === line.line);
107
+ }
108
+ return alike.at(-1) ?? candidates.findLast(({ step }) => step.status === 'cancelled');
94
109
  }
95
110
  /**
96
111
  * How the attempts compare, when there was more than one: the same code at
@@ -105,7 +120,7 @@ export function attemptsLine(result, final) {
105
120
  .filter((attempt) => attempt.status !== 'passed' && attempt.status !== 'skipped' && attempt.status !== 'interrupted' && (attempt.error !== undefined || attempt.steps.length > 0))
106
121
  .map((attempt) => ({
107
122
  code: cell(attempt.error?.code ?? attempt.status, 128),
108
- step: failedStepOf(attempt.steps)?.index,
123
+ step: failedStepOf(attempt)?.index,
109
124
  }));
110
125
  const [first] = failed;
111
126
  if (first === undefined || final.attempts.length < 2 || failed.length < 2)
@@ -139,7 +154,7 @@ export function failureSource(result, told) {
139
154
  const fromError = told.error?.source;
140
155
  if (fromError !== undefined)
141
156
  return fromError;
142
- const step = failedStepOf(told.steps)?.step;
157
+ const step = failedStepOf(told)?.step;
143
158
  if (step !== undefined && step.source.file !== 'unknown')
144
159
  return step.source;
145
160
  return result.source;
@@ -315,6 +330,7 @@ function cacheLine(cache, cacheDir) {
315
330
  : `no replay: ${CACHE_REASON_TEXT[cache.reason ?? 'no-entry']}`;
316
331
  const parts = [
317
332
  `cache: ${how}`,
333
+ ...(cache.recordedBy === undefined ? [] : [`recorded with ${code(modelLabel({ provider: cache.recordedBy.provider, id: cache.recordedBy.model }), MAX_DETAIL_CHARS)}`]),
318
334
  ...(cache.detail === undefined ? [] : [cell(cache.detail, MAX_DETAIL_CHARS)]),
319
335
  ...(cache.write === undefined ? [] : [CACHE_WRITE_TEXT[cache.write]]),
320
336
  ...(cache.notRecorded === 'param-collision' ? ['nothing recorded: a unique() value collides with another param'] : []),
@@ -450,7 +466,7 @@ export function renderTracePage(report, result, final, options = {}) {
450
466
  lines.push(`Passed after ${plural(final.failedAttempts, 'failed attempt')}; this page tells the last failure. `);
451
467
  if (lines.at(-1) !== '')
452
468
  lines.push('');
453
- const at = failedStepOf(told.steps);
469
+ const at = failedStepOf(told);
454
470
  if (told.steps.length > 0) {
455
471
  const videos = told.artifacts
456
472
  .filter((artifact) => artifact.kind === 'video' && artifact.startedAt !== undefined)
@@ -4,6 +4,7 @@
4
4
  * each consumer renders at its own indent and width when it prints.
5
5
  */
6
6
  import type { ResultStatus } from '../run/records.ts';
7
+ import type { RecordedModel } from '../cache/trace.ts';
7
8
  import type { StepActivity, StepEvent, StepKind, StepProgress } from '../run/steps.ts';
8
9
  import type { AiUsage } from './format.ts';
9
10
  /** Step events worth a glance in the live window; polls and policy decisions stay quiet. */
@@ -60,6 +61,17 @@ export interface RunningTest {
60
61
  */
61
62
  readonly steps: FinishedStep[];
62
63
  }
64
+ /** The model and agent named in the run header. */
65
+ export interface ConfiguredModel {
66
+ readonly label: string;
67
+ readonly agent: string;
68
+ }
69
+ /** One recording mismatch, rendered with its test's finished steps. */
70
+ export interface ReplayNotice {
71
+ readonly api: string;
72
+ readonly label: string;
73
+ readonly source: RecordedModel;
74
+ }
63
75
  /** One finished pair, held until its file's block prints. */
64
76
  export interface TestLine {
65
77
  readonly title: string;
@@ -71,6 +83,7 @@ export interface TestLine {
71
83
  readonly firstErrorLine: string | undefined;
72
84
  readonly skipReason: string | undefined;
73
85
  readonly steps: readonly FinishedStep[];
86
+ readonly replays: readonly ReplayNotice[];
74
87
  }
75
88
  /**
76
89
  * Every pair of one test file on one target: vitest's "test module". Its
@@ -3,8 +3,9 @@
3
3
  * finished step prints in a CI log, in a file block, and in the live window,
4
4
  * each at its own indent, so nothing here knows where the line lands.
5
5
  */
6
+ import type { StepRecord } from '../run/steps.ts';
6
7
  import { type Colors } from './format.ts';
7
- import type { FinishedStep, ShownEvent } from './list-model.ts';
8
+ import type { ConfiguredModel, FinishedStep, ReplayNotice, ShownEvent } from './list-model.ts';
8
9
  /** One-line, quoted step label; `maxColumns` clips it further to fit a row. */
9
10
  export declare function stepLabel(label: string, maxColumns?: number): string;
10
11
  export interface StepLineOptions {
@@ -18,6 +19,10 @@ export interface StepLineOptions {
18
19
  * model calls, and a non-passed status.
19
20
  */
20
21
  export declare function stepLine(pc: Colors, step: FinishedStep, options?: StepLineOptions): string;
22
+ /** Recording mismatches under one test, deduplicated across its attempts. */
23
+ export declare function replayNotices(attempts: readonly (readonly StepRecord[])[], configured: ConfiguredModel | undefined): ReplayNotice[];
24
+ /** A recording mismatch under its test in the file block. */
25
+ export declare function replayLine(pc: Colors, notice: ReplayNotice): string;
21
26
  /**
22
27
  * One model turn or tool call under the current step. A tool call reads as
23
28
  * the act it performed when the engine described it, else as the tool name
@@ -3,11 +3,14 @@
3
3
  * finished step prints in a CI log, in a file block, and in the live window,
4
4
  * each at its own indent, so nothing here knows where the line lands.
5
5
  */
6
+ import { modelLabel } from '../config/agent.js';
6
7
  import { sanitizeText, truncateUtf8 } from '../internal/errors.js';
7
8
  import { collapseText } from '../internal/text.js';
8
9
  import { F_CHECK, F_CROSS, fitColumns, formatTime, formatTokens, terminalColumns, visibleWidth, } from './format.js';
9
10
  /** Step labels stay one glanceable line; the report holds the full text. */
10
11
  const MAX_STEP_LABEL_COLUMNS = 72;
12
+ /** Recording model labels stay on one bounded terminal line. */
13
+ const MAX_RECORDED_MODEL_COLUMNS = 120;
11
14
  /** A clipped step line keeps at least this much of its label, however narrow the terminal. */
12
15
  const MIN_STEP_LABEL_COLUMNS = 24;
13
16
  /** Columns a step line spends around its label: the quotes and the space before the tail. */
@@ -42,6 +45,32 @@ export function stepLine(pc, step, options = {}) {
42
45
  : options.maxWidth - visibleWidth(head) - visibleWidth(tail) - LABEL_CHROME;
43
46
  return `${head}${stepLabel(step.label, room)} ${tail}`;
44
47
  }
48
+ /** Recording mismatches under one test, deduplicated across its attempts. */
49
+ export function replayNotices(attempts, configured) {
50
+ if (configured === undefined)
51
+ return [];
52
+ const notices = new Map();
53
+ for (const steps of attempts) {
54
+ for (const step of steps) {
55
+ if (step.agent !== undefined && step.agent !== configured.agent)
56
+ continue;
57
+ const source = step.cache?.recordedBy;
58
+ if (source === undefined || step.cache?.replayedActions === 0)
59
+ continue;
60
+ const label = modelLabel({ provider: source.provider, id: source.model });
61
+ if (label === configured.label)
62
+ continue;
63
+ const key = JSON.stringify([step.cache?.entry ?? [step.index, step.api, step.label], source.provider, source.model]);
64
+ notices.set(key, { api: step.api, label: step.label, source });
65
+ }
66
+ }
67
+ return [...notices.values()];
68
+ }
69
+ /** A recording mismatch under its test in the file block. */
70
+ export function replayLine(pc, notice) {
71
+ const label = modelLabel({ provider: notice.source.provider, id: notice.source.model });
72
+ return `${pc.dim(notice.api)} ${stepLabel(notice.label)} ${pc.dim(`replayed · recorded with ${fitColumns(collapseText(label), MAX_RECORDED_MODEL_COLUMNS)}`)}`;
73
+ }
45
74
  /**
46
75
  * `(↑in ↓out)` for one model call, the total when only that is known, or
47
76
  * nothing when the provider reported no usage.
@@ -88,6 +88,7 @@ export declare class ListReporter implements Reporter {
88
88
  * there only when no step named one.
89
89
  */
90
90
  private models;
91
+ private configuredModel;
91
92
  /**
92
93
  * The exploration an `e2e explore` run reports through `explore` events;
93
94
  * while present, its view stands in for the one synthetic test's file
@@ -14,7 +14,7 @@ import { codeFrame, userFrame } from './code-frame.js';
14
14
  import { addCacheTally, addModelTally, addUsage, aiSegment, bounded, cacheText, durationText, fitColumns, emptyCacheTally, emptyCounters, emptyUsage, F_CHECK, F_CROSS, F_POINTER, fileOutcome, formatClock, formatTime, modelsText, padTitle, rule, stateString, statusBucket, stepsCacheTally, repeatSuffix, stepsModelTally, stepsUsage, sumUsage, tally, terminalColumns, usageText, } from './format.js';
15
15
  import { ExploreView } from './list-explore.js';
16
16
  import { isShownEvent } from './list-model.js';
17
- import { stepLine } from './list-steps.js';
17
+ import { replayLine, replayNotices, stepLine } from './list-steps.js';
18
18
  import { LiveWindow } from './live-window.js';
19
19
  import { repeatGroups, repeatLine, repeatSummary } from './repeats.js';
20
20
  import { RunningTree } from './running-tree.js';
@@ -53,7 +53,7 @@ function appLogTally(appLog) {
53
53
  return parts.length === 0 ? undefined : parts.join(', ');
54
54
  }
55
55
  /** Details of an ordinary result: summed over its attempts, the error from the last. */
56
- function attemptDetails(attempts) {
56
+ function attemptDetails(attempts, configured) {
57
57
  const steps = attempts.map((attempt) => attempt.steps);
58
58
  // A retry the run interrupted tells nothing; the failure before it is the verdict.
59
59
  const told = attempts.findLast((attempt) => attempt.status !== 'interrupted') ?? attempts.at(-1);
@@ -67,6 +67,7 @@ function attemptDetails(attempts) {
67
67
  ...failureOf(told),
68
68
  appLog: appLogTally(told?.appLog ?? []),
69
69
  skipReason: told?.status === 'skipped' ? undefined : told?.skip?.reason,
70
+ replays: replayNotices(steps, configured),
70
71
  };
71
72
  }
72
73
  /** The failure evidence of one attempt, and the path of the screen text it points at. */
@@ -96,7 +97,7 @@ function videoPaths(attempts) {
96
97
  * that attempt never ran (skipped, or absent after a launch failure) inherits
97
98
  * the attempt's error, since that is what stopped it.
98
99
  */
99
- function serialMemberDetails(group, testId) {
100
+ function serialMemberDetails(group, testId, configured) {
100
101
  const runs = group.attempts.map((attempt) => ({
101
102
  attempt,
102
103
  member: attempt.members.find((member) => member.testId === testId),
@@ -116,6 +117,7 @@ function serialMemberDetails(group, testId) {
116
117
  ...failureOf(own === undefined ? undefined : { failure: own.failure, artifacts: last?.attempt.artifacts ?? [] }),
117
118
  appLog: appLogTally(own?.appLog ?? []),
118
119
  skipReason: neverRan ? undefined : own.skip?.reason,
120
+ replays: replayNotices(steps, configured),
119
121
  };
120
122
  }
121
123
  const DEFAULT_OUTPUT = {
@@ -192,6 +194,7 @@ export class ListReporter {
192
194
  * there only when no step named one.
193
195
  */
194
196
  models;
197
+ configuredModel;
195
198
  /**
196
199
  * The exploration an `e2e explore` run reports through `explore` events;
197
200
  * while present, its view stands in for the one synthetic test's file
@@ -378,6 +381,7 @@ export class ListReporter {
378
381
  }
379
382
  runStarted(event) {
380
383
  const { pc } = this;
384
+ this.configuredModel = event.model === undefined ? undefined : { label: event.model, agent: event.agents?.[0] ?? 'default' };
381
385
  this.projectRoot = event.projectRoot;
382
386
  this.artifactsRoot = event.artifactsRoot;
383
387
  this.targets = event.targets;
@@ -568,15 +572,16 @@ export class ListReporter {
568
572
  * rather than failing the run.
569
573
  */
570
574
  detailsOf(result) {
575
+ const configured = this.configuredModel?.agent === result.agent ? this.configuredModel : undefined;
571
576
  if (result.serialGroupId === undefined)
572
- return attemptDetails(result.attempts);
577
+ return attemptDetails(result.attempts, configured);
573
578
  const pending = this.pendingSerial.get(result.serialGroupId);
574
579
  if (pending === undefined)
575
- return attemptDetails([]);
580
+ return attemptDetails([], configured);
576
581
  pending.remaining -= 1;
577
582
  if (pending.remaining <= 0)
578
583
  this.pendingSerial.delete(result.serialGroupId);
579
- return serialMemberDetails(pending.group, result.test.id);
584
+ return serialMemberDetails(pending.group, result.test.id, configured);
580
585
  }
581
586
  testFinished(result) {
582
587
  // Unselected pairs are report-only: they never print and the plan never
@@ -591,7 +596,7 @@ export class ListReporter {
591
596
  // Read before `detailsOf` releases the result's serial group.
592
597
  const skippedAfter = failureBeforeSkip(result, (id) => this.pendingSerial.get(id)?.group);
593
598
  const id = resultId(result.test.id, result.target.name, result.agent, result.repeat);
594
- const { durationMs, usage, models, cache, error, videos, failure, screenPath, appLog, skipReason } = this.detailsOf(result);
599
+ const { durationMs, usage, models, cache, error, videos, failure, screenPath, appLog, skipReason, replays } = this.detailsOf(result);
595
600
  addUsage(this.runUsage, usage);
596
601
  addModelTally(this.runModels, models);
597
602
  addCacheTally(this.runCache, cache);
@@ -614,6 +619,7 @@ export class ListReporter {
614
619
  firstErrorLine: error === undefined ? undefined : bounded(error.message).split('\n')[0],
615
620
  skipReason: result.skip === undefined ? undefined : bounded(result.skip.reason),
616
621
  steps,
622
+ replays,
617
623
  };
618
624
  group.lines.push(line);
619
625
  this.runs.push({
@@ -697,6 +703,7 @@ export class ListReporter {
697
703
  counts.interrupted > 0 ||
698
704
  counts.flaky > 0 ||
699
705
  this.groups.size === 1 ||
706
+ group.lines.some((line) => line.replays.length > 0) ||
700
707
  (this.live && group.lines.some((line) => line.steps.length > 0));
701
708
  if (!verbose)
702
709
  return;
@@ -706,6 +713,8 @@ export class ListReporter {
706
713
  this.print(rendered);
707
714
  for (const step of line.steps)
708
715
  this.print(`${STEP_INDENT}${stepLine(pc, step)}`);
716
+ for (const replay of line.replays)
717
+ this.print(`${STEP_INDENT}${replayLine(pc, replay)}`);
709
718
  }
710
719
  }
711
720
  /** One test row under its file line, plus the first error line for a failure. */
@@ -25,9 +25,10 @@ const SYNC_END = `${ESC}[?2026l`;
25
25
  * when counting and never cutting inside a grapheme cluster: a glyph or emoji
26
26
  * sequence that would cross the limit is dropped whole. The erase sequence
27
27
  * counts physical rows, so a line that wraps would leave stale fragments
28
- * behind on every repaint.
28
+ * behind on every repaint. An entry with a line break still paints as one row.
29
29
  */
30
30
  function clampToWidth(text, columns) {
31
+ text = text.replace(/\r\n?|\n/g, ' ');
31
32
  const max = Math.max(4, columns - WIDTH_MARGIN);
32
33
  let width = 0;
33
34
  let out = '';
@@ -210,7 +210,7 @@ function runErrorLine(error) {
210
210
  function failureBlock({ result, final }, manyTargets, options) {
211
211
  const kind = statusBucket(result.status);
212
212
  const told = toldAttempt(result, final);
213
- const at = failedStepOf(told.steps);
213
+ const at = failedStepOf(told);
214
214
  const error = told.error;
215
215
  const name = error === undefined ? `**${cell(result.status, MAX_ID_CHARS)}**` : errorName(error);
216
216
  const lead = at === undefined ? name : `${name} ${stepClause(told.steps, at)}`;
@@ -102,11 +102,13 @@ export function createFixtures(environment) {
102
102
  engineCapabilities: environment.target.engine?.capabilities ?? new Set(),
103
103
  steps: environment.steps,
104
104
  select,
105
+ reportUnservedModel: environment.models.unservedModel,
105
106
  config: environment.config,
106
107
  target: {
107
108
  name: environment.target.name,
108
109
  platform: environment.target.platform,
109
110
  verbs: environment.session.verbs,
111
+ tapModifiers: environment.session.tapModifiers,
110
112
  },
111
113
  app: environment.target.app,
112
114
  attempt: environment.attempt,
@@ -1,5 +1,6 @@
1
1
  /** The per-session `trace.md` a hosted attempt writes at close, through the run report's renderer. */
2
2
  import type { ResolvedConfig, ResolvedTarget } from '../config/resolve.ts';
3
+ import type { Report } from '../types.ts';
3
4
  import type { AttemptRecord } from './records.ts';
4
5
  export interface HostTraceOptions {
5
6
  readonly config: ResolvedConfig;
@@ -17,5 +18,5 @@ export interface HostTraceOptions {
17
18
  readonly tracePath: string;
18
19
  readonly redact: (text: string) => string;
19
20
  }
20
- /** Writes one session's trace page as a single-result run, so it reads like `e2e run`'s. */
21
- export declare function writeHostTrace(options: HostTraceOptions): Promise<void>;
21
+ /** Writes one session's trace page as a single-result run and returns its Report. */
22
+ export declare function writeHostTrace(options: HostTraceOptions): Promise<Report>;
@@ -5,7 +5,7 @@ import { buildReport } from '../report/build.js';
5
5
  import { renderTracePage } from '../report/failure-text.js';
6
6
  import { outcome } from '../report/outcome.js';
7
7
  import { toPosixPath, writeTextReport } from '../report/write.js';
8
- /** Writes one session's trace page as a single-result run, so it reads like `e2e run`'s. */
8
+ /** Writes one session's trace page as a single-result run and returns its Report. */
9
9
  export async function writeHostTrace(options) {
10
10
  const { config, attempt, title } = options;
11
11
  const status = attempt.status;
@@ -34,4 +34,5 @@ export async function writeHostTrace(options) {
34
34
  },
35
35
  });
36
36
  await writeTextReport(options.tracePath, options.redact(page));
37
+ return report;
37
38
  }
@@ -52,4 +52,13 @@ export declare function carryForward(lastRun: Report1Document, current: Report1D
52
52
  * `--last-failed` rerun keeps of the tree when it starts.
53
53
  */
54
54
  export declare function reportArtifactPaths(document: Report1Document): ReadonlySet<string>;
55
+ /**
56
+ * How long each file took for each target in the previous run, keyed by
57
+ * `target::file`: every attempt of every result and serial group summed. A
58
+ * file with no attempt (skipped, or never reached) has no entry. No readable
59
+ * report is no history, never an error.
60
+ */
61
+ export declare function previousFileDurations(reportPath: string): Promise<ReadonlyMap<string, number>>;
62
+ /** The key `previousFileDurations` files one target's file under. */
63
+ export declare function fileDurationKey(targetName: string, file: string): string;
55
64
  export {};
@@ -232,3 +232,31 @@ function isError(value) {
232
232
  typeof scope.targetId === 'string' &&
233
233
  Array.isArray(scope.titlePath))));
234
234
  }
235
+ /**
236
+ * How long each file took for each target in the previous run, keyed by
237
+ * `target::file`: every attempt of every result and serial group summed. A
238
+ * file with no attempt (skipped, or never reached) has no entry. No readable
239
+ * report is no history, never an error.
240
+ */
241
+ export async function previousFileDurations(reportPath) {
242
+ const durations = new Map();
243
+ let report;
244
+ try {
245
+ report = await readLastRun(reportPath);
246
+ }
247
+ catch {
248
+ return durations;
249
+ }
250
+ for (const unit of [...report.run.results, ...report.run.serialGroups]) {
251
+ if (unit.attempts.length === 0)
252
+ continue;
253
+ const key = fileDurationKey(unit.targetId, unit.file);
254
+ const ms = unit.attempts.reduce((sum, attempt) => sum + attempt.durationMs, 0);
255
+ durations.set(key, (durations.get(key) ?? 0) + ms);
256
+ }
257
+ return durations;
258
+ }
259
+ /** The key `previousFileDurations` files one target's file under. */
260
+ export function fileDurationKey(targetName, file) {
261
+ return `${targetName}::${file}`;
262
+ }
@@ -1,6 +1,6 @@
1
1
  /** Hosted test runners keep a worker alive while opening isolated per-test attempts. */
2
2
  import { type SerializedError } from '../internal/errors.ts';
3
- import type { E2EConfig, TestFixtures } from '../types.ts';
3
+ import type { E2EConfig, Report, TestFixtures } from '../types.ts';
4
4
  import type { StepProgress, StepRecord } from './steps.ts';
5
5
  export interface OpenHostOptions {
6
6
  /** Config search directory, or project root of a config value; default `process.cwd()`. */
@@ -48,6 +48,8 @@ export interface E2ESession<Fixtures extends object = Record<string, unknown>> e
48
48
  /** Absolute per-session trace path. Written before close settles successfully. */
49
49
  readonly tracePath: string;
50
50
  steps(): readonly StepRecord[];
51
+ /** The session Report after close and a successful trace write; otherwise undefined. */
52
+ report(): Report | undefined;
51
53
  /** Idempotent. Returns soft assertion, abandoned-operation, and cleanup failures. Disposal throws them as AggregateError. */
52
54
  close(): Promise<readonly SerializedError[]>;
53
55
  }
@@ -50,7 +50,7 @@ async function openHostWorker(options) {
50
50
  runId: attempt.runId, attemptId: attempt.attemptId, target: target.name,
51
51
  fixtures: attempt.fixtures,
52
52
  artifactsDir: attempt.artifactsDir, tracePath: attempt.tracePath,
53
- steps: () => attempt.steps.all(), close: attempt.close,
53
+ steps: () => attempt.steps.all(), report: attempt.report, close: attempt.close,
54
54
  [Symbol.asyncDispose]: () => dispose(attempt.close, 'session'),
55
55
  };
56
56
  }),
@@ -21,12 +21,12 @@ import { allocateAppPorts } from './app-ports.js';
21
21
  import { inProcessSpawner } from './in-process.js';
22
22
  import { someSkippedAfterFailure, tracedResultIds } from './records.js';
23
23
  import { runUnits } from './scheduler.js';
24
- import { buildWorkPlans, plannedSlots } from './units.js';
24
+ import { buildWorkPlans, longestFirst, plannedSlots } from './units.js';
25
25
  import { SessionStore } from './sessions.js';
26
26
  import { outputLayout } from './output.js';
27
27
  import { registerStaticSecrets } from './secrecy.js';
28
28
  import { nextRerunDir, pruneArtifacts } from './artifacts.js';
29
- import { carryForward, lastFailedIds, readLastRun, reportArtifactPaths } from './last-run.js';
29
+ import { carryForward, fileDurationKey, lastFailedIds, previousFileDurations, readLastRun, reportArtifactPaths } from './last-run.js';
30
30
  import { childProcessSpawner } from './worker/handle.js';
31
31
  import { setSecretRegistry } from '../secrets.js';
32
32
  import { withAbort } from '../internal/time.js';
@@ -461,6 +461,10 @@ export async function run(options = {}) {
461
461
  const workerConfigPath = options.tests === undefined ? config.configPath : undefined;
462
462
  const inProcess = workerConfigPath === undefined;
463
463
  const runWorkers = inProcess ? 1 : config.workers;
464
+ if (runWorkers > 1) {
465
+ const previous = await previousFileDurations(outputLayout(config.output).report);
466
+ plans = longestFirst(plans, (targetName, file) => previous.get(fileDurationKey(targetName, file)));
467
+ }
464
468
  const timeLimit = options.maxDuration !== undefined
465
469
  ? { ms: options.maxDuration, source: '--max-duration' }
466
470
  : config.maxDuration === undefined
@@ -287,12 +287,12 @@ function parseEnvelope(rawText, name) {
287
287
  /** Parses an authenticated payload; one this runner did not write is SESSION_INVALID. */
288
288
  function parsePayload(text, name) {
289
289
  const parsed = JSON.parse(text);
290
- const secrets = parsed?.secrets;
291
290
  if (typeof parsed !== 'object' ||
292
291
  parsed === null ||
293
292
  !('data' in parsed) ||
294
- !Array.isArray(secrets) ||
295
- !secrets.every((entry) => Array.isArray(entry) && entry.length === 2 && entry.every((part) => typeof part === 'string'))) {
293
+ !('secrets' in parsed) ||
294
+ !Array.isArray(parsed.secrets) ||
295
+ !parsed.secrets.every((entry) => Array.isArray(entry) && entry.length === 2 && entry.every((part) => typeof part === 'string'))) {
296
296
  throw new ConfigurationError('SESSION_INVALID', `session "${name}" has an unexpected payload`);
297
297
  }
298
298
  return parsed;
@@ -6,7 +6,7 @@ import { PollScope } from '../expect/poll-scope.ts';
6
6
  import { SoftFailures } from '../expect/soft.ts';
7
7
  import type { DebugTrace } from '../internal/debug.ts';
8
8
  import { type SerializedError } from '../internal/errors.ts';
9
- import type { TestFixtures } from '../types.ts';
9
+ import type { Report, TestFixtures } from '../types.ts';
10
10
  import { AttemptBudget } from './budget.ts';
11
11
  import { type TargetExecutor } from './execute.ts';
12
12
  import type { AttemptRecord } from './records.ts';
@@ -37,6 +37,7 @@ export interface StandaloneAttempt {
37
37
  readonly budget: AttemptBudget;
38
38
  readonly artifactsDir: string;
39
39
  readonly tracePath: string;
40
+ report(): Report | undefined;
40
41
  close(): Promise<readonly SerializedError[]>;
41
42
  }
42
43
  /** The prepared worker an attempt runs on. */
@@ -124,6 +124,7 @@ export async function launchAttempt(worker, reservation, onClosed) {
124
124
  const startedMs = Date.now();
125
125
  const tracePath = path.join(artifacts.dir, 'trace.md');
126
126
  let ending;
127
+ let report;
127
128
  const close = () => {
128
129
  ending ??= (async () => {
129
130
  clearTimeout(expiry);
@@ -167,7 +168,7 @@ export async function launchAttempt(worker, reservation, onClosed) {
167
168
  if (worker.kind === 'hosted') {
168
169
  try {
169
170
  const { error, status } = verdict();
170
- await writeHostTrace({
171
+ report = await writeHostTrace({
171
172
  config, target, runId, testId: identity.testId, agent, tracePath, redact,
172
173
  title: processSecrets.redact(redact(options.name ?? target.name)),
173
174
  resultsDir: worker.resultsDir, segments: identity.segments,
@@ -195,6 +196,7 @@ export async function launchAttempt(worker, reservation, onClosed) {
195
196
  return {
196
197
  runId, attemptId, fixtures: acquired.fixtures, agentRuntime: acquired.agentRuntime, session: opened, steps, budget,
197
198
  artifactsDir: artifacts.dir, tracePath, close,
199
+ report: () => report && structuredClone(report),
198
200
  };
199
201
  }
200
202
  /**
@@ -2,7 +2,7 @@
2
2
  import type { ReplayHandOffReason } from '../agent/executor.ts';
3
3
  import type { CacheWrite } from '../cache/context.ts';
4
4
  import type { TraceReplayMissReason } from '../cache/decide.ts';
5
- import type { DerivedReason } from '../cache/trace.ts';
5
+ import type { RecordedModel, DerivedReason } from '../cache/trace.ts';
6
6
  import { TestError, type SerializedError } from '../internal/errors.ts';
7
7
  import { type SourceLocation } from '../internal/source.ts';
8
8
  import type { AppLogEntry, AppLogSource } from '../engine/index.ts';
@@ -123,6 +123,8 @@ export interface StepCacheInfo {
123
123
  * screen the recording started on against the one the step did.
124
124
  */
125
125
  detail?: string;
126
+ /** Configured model when the consulted entry was recorded; absent on older entries. */
127
+ recordedBy?: RecordedModel;
126
128
  }
127
129
  /** The cache detail as the report keeps it: what `agent.act()` returns, and what became of the recording once the attempt settled. */
128
130
  export type StepCacheRecord = StepCacheInfo & {
@@ -37,6 +37,14 @@ export interface TargetWorkPlan {
37
37
  }
38
38
  /** Builds per-target work plans from a selection, preserving report order. */
39
39
  export declare function buildWorkPlans(selection: Selection, collection: Collection, projectRoot: string): TargetWorkPlan[];
40
+ /**
41
+ * Orders each plan's file units longest first by the time the previous run
42
+ * spent in them, so a long file starts while short ones fill the other
43
+ * workers instead of starting last and running alone. Files without a
44
+ * timing go first, being of unknown length; ties keep collection order.
45
+ * Report order does not depend on dispatch order.
46
+ */
47
+ export declare function longestFirst(plans: readonly TargetWorkPlan[], durationOf: (targetName: string, file: string) => number | undefined): TargetWorkPlan[];
40
48
  /**
41
49
  * The worker slots the run will start for a plan, `0` to `slots - 1`: the
42
50
  * run's worker cap, the engine's declared `workers`, and the units to
package/dist/run/units.js CHANGED
@@ -55,6 +55,20 @@ export function buildWorkPlans(selection, collection, projectRoot) {
55
55
  return { target, setupUnits, fileUnits, immediate };
56
56
  });
57
57
  }
58
+ /**
59
+ * Orders each plan's file units longest first by the time the previous run
60
+ * spent in them, so a long file starts while short ones fill the other
61
+ * workers instead of starting last and running alone. Files without a
62
+ * timing go first, being of unknown length; ties keep collection order.
63
+ * Report order does not depend on dispatch order.
64
+ */
65
+ export function longestFirst(plans, durationOf) {
66
+ return plans.map((plan) => ({
67
+ ...plan,
68
+ fileUnits: plan.fileUnits.toSorted((a, b) => (durationOf(b.targetName, b.file) ?? Number.POSITIVE_INFINITY) -
69
+ (durationOf(a.targetName, a.file) ?? Number.POSITIVE_INFINITY) || 0),
70
+ }));
71
+ }
58
72
  /**
59
73
  * The worker slots the run will start for a plan, `0` to `slots - 1`: the
60
74
  * run's worker cap, the engine's declared `workers`, and the units to
@@ -22,6 +22,8 @@ export declare class WorkerModels {
22
22
  * need none, and judgment methods still fail on first use.
23
23
  */
24
24
  readonly preflight: (agent: ResolvedAgentConfig, name: string) => void;
25
+ /** A model the provider refused, met by a step's call: reported to the run once, and every later preflight rethrows it. */
26
+ readonly unservedModel: (error: E2EError) => void;
25
27
  /** Adapter for one resolved model; each distinct model is built once per worker. A missing one names the agent `name`. */
26
28
  readonly build: (model: ResolvedModel | undefined, name: string) => ModelAdapter;
27
29
  }
@@ -40,6 +40,13 @@ export class WorkerModels {
40
40
  if (this.failure !== undefined)
41
41
  throw this.failure;
42
42
  };
43
+ /** A model the provider refused, met by a step's call: reported to the run once, and every later preflight rethrows it. */
44
+ unservedModel = (error) => {
45
+ if (this.failure !== undefined)
46
+ return;
47
+ this.failure = error;
48
+ this.onFailure(error);
49
+ };
43
50
  /** Adapter for one resolved model; each distinct model is built once per worker. A missing one names the agent `name`. */
44
51
  build = (model, name) => {
45
52
  if (model === undefined)
package/dist/types.d.ts CHANGED
@@ -292,7 +292,7 @@ export interface Screen {
292
292
  getByText(text: TextMatch, options?: TextMatchOptions): Locator;
293
293
  /** Creates a lazy displayed-value query. */
294
294
  getByDisplayValue(value: TextMatch, options?: TextMatchOptions): Locator;
295
- /** Creates a lazy test-id query: a string matches the whole id, case-sensitive; a RegExp tests it. */
295
+ /** Creates a lazy test-id query: a string matches the whole id, case-sensitive; a RegExp tests it. An engine may also match an id without its platform's namespacing. */
296
296
  getByTestId(id: TextMatch, options?: {
297
297
  visible?: boolean;
298
298
  }): Locator;
@@ -1336,7 +1336,7 @@ export interface E2EConfig {
1336
1336
  /**
1337
1337
  * The directory a run writes its results to, relative to the project root;
1338
1338
  * default `.e2e`. It holds `report.json`, `junit.xml`, `summary.md`,
1339
- * `ai-trace.json`, `artifacts/` (cleared at the start of every run),
1339
+ * `ai-trace.json`, `results/` (cleared once the run's tests start, except on a `--last-failed` rerun),
1340
1340
  * `sessions/`, and the `e2e mcp` session videos under `videos/`.
1341
1341
  * `--output <dir>` overrides it for one run. It must be inside the project
1342
1342
  * root and not the root itself, may not hold a test glob's directory, and