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
@@ -6,9 +6,11 @@ icon: "sliders"
6
6
  "twitter:image": "https://e2e.tester.army/docs/images/og/reference/config.png?v=6c4dead1fc"
7
7
  ---
8
8
 
9
- Export an object from `e2e.config.ts` and use `satisfies E2EConfig` to check
10
- its shape without widening its inferred types. The runner also validates the
11
- config when it loads, including ranges and rules TypeScript cannot check.
9
+ `e2e.config.ts` configures the runner: the targets tests run on, the app each
10
+ one tests, the agents, and the replay cache. Export an object with
11
+ `satisfies E2EConfig`, which checks its shape and keeps its inferred types.
12
+ The runner validates the config again at load, including ranges and rules
13
+ TypeScript cannot check.
12
14
 
13
15
  ```ts title="e2e.config.ts"
14
16
  import type { E2EConfig } from 'e2e';
@@ -25,13 +27,46 @@ export default {
25
27
  } satisfies E2EConfig;
26
28
  ```
27
29
 
28
- Unknown keys are rejected with `INVALID_CONFIG` at every level. There is no
29
- top-level `app` key: each target declares the app it tests (see
30
- [the app under test](#the-app-under-test)).
30
+ ## Reference
31
31
 
32
- An optional key takes a value or is left out; `undefined` is not a value, so
33
- `store: process.env.CACHE_URL && createStore(...)` fails to compile. Spread a
34
- key that depends on the environment in only when it has a value:
32
+ ### Top level
33
+
34
+ | Option | Type | Default |
35
+ | --- | --- | --- |
36
+ | [`projectId`](#projectid) | `string` | `package.json` name |
37
+ | [`targets`](#targets) | `Target[]` | required |
38
+ | [`tests`](#tests) | `string \| string[]` | `'tests/**/*.e2e.ts'` |
39
+ | [`timeout`](#timeout) | `number` | `120000` |
40
+ | [`launchTimeout`](#launchtimeout) | `number` | `60000` |
41
+ | [`actionTimeout`](#actiontimeout) | `number` | `30000` |
42
+ | [`assertionTimeout`](#assertiontimeout) | `number` | `5000` |
43
+ | [`cleanupTimeout`](#cleanuptimeout) | `number` | `30000` |
44
+ | [`retries`](#retries) | `number` | `0`, `1` in CI |
45
+ | [`failOnSkippedFailure`](#failonskippedfailure) | `boolean` | `false` |
46
+ | [`maxDuration`](#maxduration) | `number` | - |
47
+ | [`workers`](#workers) | `number` | half the cores, `1` in CI |
48
+ | [`reporters`](#reporters) | `(BuiltinReporter \| Reporter)[]` | `['list']` |
49
+ | [`output`](#output) | `string` | `'.e2e'` |
50
+ | [`artifacts`](#artifacts) | `ArtifactsConfig` | local only |
51
+ | [`trace`](#trace) | `RecordingMode` | `'retain-on-failure'` |
52
+ | [`video`](#video) | `RecordingMode` | `'off'` |
53
+ | [`agents`](#agents) | `Record<string, AgentConfig>` | `default`, no model |
54
+ | [`cache`](#cache) | `CacheMode \| CacheConfig` | `'read-write'`, `'read-only'` in CI |
55
+ | [`credentials`](#credentials) | `Record<string, CredentialConfig>` | - |
56
+ | [`secrets`](#secrets) | `Record<string, SecretConfig>` | - |
57
+
58
+ The full declaration is [`E2EConfig`](#e2econfig).
59
+
60
+ - An unknown key at any level is `INVALID_CONFIG`. There is no top-level
61
+ `app` key: each target declares [the app it tests](#the-app-under-test).
62
+ - Every numeric value must be a safe integer.
63
+ - An optional key takes a value or is left out. Under
64
+ `exactOptionalPropertyTypes`, `undefined` is a type error, so
65
+ `store: process.env.CACHE_URL && createStore(...)` fails to compile. At
66
+ runtime, `undefined` counts as unset.
67
+ - The target's `app` fields also accept `undefined` in their types, so they
68
+ can read `process.env` directly. Spread any other key that depends on the
69
+ environment in only when it has a value:
35
70
 
36
71
  ```ts
37
72
  export default {
@@ -40,244 +75,231 @@ export default {
40
75
  } satisfies E2EConfig;
41
76
  ```
42
77
 
43
- ## Loading
78
+ #### `projectId`
44
79
 
45
- `--config <path>` selects an explicit file. Otherwise the runner searches the
46
- working directory and each parent for `e2e.config.ts` or `e2e.config.mts`,
47
- stopping at the repository root. Both names in one directory is
48
- `CONFIG_AMBIGUOUS`. The selected file's directory is the project root.
49
-
50
- The `tests` globs select files relative to the project root. An entry that
51
- starts with `!` excludes: `tests: ['tests/**/*.e2e.ts', '!tests/wip/**']`
52
- selects every test file except those under `tests/wip/`. A file is selected
53
- when an including glob matches it and no exclusion does, whatever the order of
54
- the list, and a list of only exclusions is `INVALID_CONFIG`. A glob names
55
- files, so an entry with no wildcard that names a directory (`'!tests/wip'`)
56
- is `INVALID_GLOB` naming the glob to write (`'!tests/wip/**'`). A file
57
- passed to `e2e run` that an exclusion takes out is `NO_TESTS` naming that
58
- entry. Discovery visits
59
- only directories that could match, never enters one an exclusion takes whole,
60
- skips `node_modules`, and ignores symlinks. A dot directory is included only
61
- when a glob segment explicitly starts with a dot.
62
- `?` matches one Unicode code point, including an emoji.
80
+ A stable id for the project.
63
81
 
64
- e2e loads no `.env` file itself. Load one at the top of `e2e.config.ts` with
65
- Node's `process.loadEnvFile()`; every worker imports the config again, so the
66
- variables reach the workers too:
82
+ ```ts
83
+ projectId: 'acme-web', // default: the root package.json name
84
+ ```
67
85
 
68
- ```ts title="e2e.config.ts"
69
- process.loadEnvFile('.env');
86
+ - 1 through 256 characters.
87
+ - Without a `name` in the root `package.json`, it falls back to
88
+ `unportable-<sha256 of the project root, 32 hex>`.
89
+
90
+ #### `tests`
91
+
92
+ The test file globs, relative to the project root.
93
+
94
+ ```ts
95
+ tests: ['tests/**/*.e2e.ts', '!tests/wip/**'], // default: 'tests/**/*.e2e.ts'
70
96
  ```
71
97
 
72
- It throws when the file is missing; guard it with `existsSync` from
73
- `node:fs` when only some machines have one.
98
+ - A glob takes `*`, `?`, and a whole `**` segment. `?` matches one Unicode
99
+ code point, including an emoji.
100
+ - An entry that starts with `!` excludes. The example selects every test file
101
+ except those under `tests/wip/`.
102
+ - A file is selected when an including glob matches it and no exclusion does,
103
+ whatever the order of the list.
104
+ - Discovery visits only directories that could match. It never enters a
105
+ directory an exclusion takes whole, skips `node_modules`, and ignores
106
+ symlinks.
107
+ - A dot directory is included only when a glob segment starts with a dot.
108
+
109
+ | Throws | When |
110
+ | --- | --- |
111
+ | `INVALID_CONFIG` | The list holds only exclusions. |
112
+ | `INVALID_GLOB` | An entry with no wildcard names a directory (`'!tests/wip'`). A glob names files; the error names the glob to write (`'!tests/wip/**'`). |
113
+ | `NO_TESTS` | A file passed to `e2e run` is taken out by an exclusion. The error names that entry. |
74
114
 
75
- Config and test files run as ESM whatever the nearest `package.json` `type`
76
- says, and so does every `.ts`, `.mts`, `.tsx`, and `.jsx` file they reach: a
77
- helper by path, a package that exports its `.ts` source by name, or a `.ts`
78
- file a CommonJS file requires. A `.cts` file is CommonJS, written with
79
- `require`, `import x = require()`, and `export =`; its `import` statements are
80
- not turned into `require` calls. A `.ts` file written with `require` or
81
- `module.exports` fails to load: name it `.cts`. JavaScript keeps the format
82
- its package declares.
83
-
84
- e2e compiles TypeScript and JSX itself, and an import written in such a file
85
- resolves the way TypeScript resolves it: `./helper.js` and `./helper` load
86
- `helper.ts`, `./view.jsx` loads `view.tsx`, `./lib` loads the file
87
- `lib/package.json` names as `main`, else `lib/index.ts`, a bare import that
88
- `tsconfig.json` maps through `paths` or `baseUrl` loads the mapped file, and
89
- a `#` import or package export that names a missing `./x.js`, `./x.jsx`, or
90
- `./x` loads its `x.ts` or `x.tsx` source when imported. One that names a
91
- directory, or a subpath of a package without `exports` that is a directory
92
- (`@acme/db/enums`), loads the directory the way `./lib` does: the file its
93
- `package.json` names as `main`, else its `index.ts`. Under
94
- Yarn PnP that package subpath still fails. A `require()` of either fails as
95
- Node.js reports it. An import written in a JavaScript file resolves
96
- as Node.js resolves it.
97
-
98
- The `tsconfig.json` is the nearest one above the file, or, when that one has
99
- `references` and does not include the file itself (as Vite writes it), the
100
- transitively referenced one that does. References are visited in declaration order; cycles are skipped and, if none owns the file, the nearest config remains the fallback. e2e reads its `paths`, `baseUrl`, `jsx`, `jsxFactory`,
101
- `jsxFragmentFactory`, `jsxImportSource`, `experimentalDecorators`,
102
- `emitDecoratorMetadata`, `useDefineForClassFields` (and `target`, which sets
103
- its default), and `verbatimModuleSyntax`, once per process and again for each
104
- session `e2e mcp` opens. A `tsconfig.json` e2e cannot read, such as one that
105
- `extends` a package that is not installed, is skipped with a warning.
106
- TypeScript inside an installed package compiles without the project's
107
- `tsconfig.json`.
108
-
109
- e2e refuses, naming the file and line, two things it cannot compile:
110
- auto-accessors (`accessor x = 1`) and decorators without
111
- `experimentalDecorators`. Each file compiles on its own, without type
112
- information, so a type imported without `import type` fails to load when the
113
- import stays in the output (a decorated member's type under
114
- `emitDecoratorMetadata`, a re-exported type); the error names the import to
115
- change. A JSON file imported without `with { type: 'json' }` loads with its
116
- parsed contents as the default export. Stack traces and failure locations
117
- point at the TypeScript source, through another loader's source map too
118
- (`NODE_OPTIONS=--import tsx`).
119
-
120
- ## Top level
115
+ #### `timeout`
116
+
117
+ The deadline of one test attempt, in milliseconds.
121
118
 
122
119
  ```ts
123
- export interface E2EConfig {
124
- /** Stable project id, 1 through 256 characters; defaults to the root `package.json` name. */
125
- projectId?: string;
126
- /** The surfaces tests run on; required, at least one, each naming its engine. */
127
- targets: readonly Target[];
128
- /** Test file globs relative to the project root (`*`, `?`, and a whole `**` segment; a leading `!` excludes); default every `*.e2e.ts` under `tests/`. */
129
- tests?: string | readonly string[];
130
- /** Test attempt deadline in milliseconds; default 120000. */
131
- timeout?: number;
132
- /** Engine init and attempt start budget in milliseconds; default 60000. */
133
- launchTimeout?: number;
134
- /** Budget of every engine operation in milliseconds; default 30000. */
135
- actionTimeout?: number;
136
- /** Default `expect` deadline in milliseconds; default 5000. */
137
- assertionTimeout?: number;
138
- /** Budget of each `afterEach` hook, fixture teardown, and engine cleanup in milliseconds; default 30000. */
139
- cleanupTimeout?: number;
140
- /** Retries per test, 0 through 10; default 1 in CI, else 0. */
141
- retries?: number;
142
- /** Fail the run when a test skips a retry after an earlier failed attempt; default false. */
143
- failOnSkippedFailure?: boolean;
144
- /** Wall-clock budget for running the tests, in milliseconds; default none. */
145
- maxDuration?: number;
146
- /** Parallel workers, 1 through 1024; default 1 in CI, else half the cores. An engine may cap it lower. */
147
- workers?: number;
148
- /** `{ store }` hands every artifact to a host store as it is produced. */
149
- artifacts?: ArtifactsConfig;
150
- /**
151
- * Which tests keep a trace page, `trace.md` in the test's directory under
152
- * `<output>/results/`; default
153
- * `retain-on-failure`. A target's `trace` wins over it, `--trace [mode]`
154
- * over both, and a test's own `trace` over all.
155
- */
156
- trace?: RecordingMode;
157
- /**
158
- * Which attempts record a video; default `off`. A target's `video` wins
159
- * over it, `--video [mode]` over both, and a test's own `video` over all.
160
- * Applies to the targets whose engine can record.
161
- */
162
- video?: RecordingMode;
163
- /**
164
- * The results directory, relative to the project root; default `.e2e`.
165
- * `--output <dir>` overrides it for one run.
166
- */
167
- output?: string;
168
- /**
169
- * Output renderers and reporter objects. `junit` writes `<output>/junit.xml`,
170
- * `markdown` writes `<output>/summary.md`, `json` prints the report and
171
- * excludes `list`; a `Reporter` object runs
172
- * beside them and `--reporter` never removes it.
173
- */
174
- reporters?: readonly (BuiltinReporter | Reporter)[];
175
- /**
176
- * Named agents, each one plain object: the built-in agent's options, or
177
- * `{ executor }` for a custom brain. Tests use `default` unless selected
178
- * otherwise. No agent inherits another's values. Each worker constructs
179
- * its own agents from this config module.
180
- */
181
- agents?: Readonly<Record<string, AgentConfig>>;
182
- /**
183
- * Replay cache mode or options. Defaults to `read-write` locally and
184
- * `read-only` in CI. An explicit mode overrides the default;
185
- * `--no-cache` disables caching for the run.
186
- */
187
- cache?: CacheMode | CacheConfig;
188
- /**
189
- * Named accounts. `credentials.user(name).password` is the handle of a
190
- * credential's password, a secret named `<name>.password`; `secrets.get()`
191
- * never returns it, so a credential and a secret may share a name.
192
- */
193
- credentials?: Readonly<Record<string, CredentialConfig>>;
194
- /**
195
- * Named values the model must never see: API keys, tokens, anything sourced
196
- * from the environment. `secrets.get(name)` hands a test the opaque handle;
197
- * the value is filled by the runner, masked in every observation, and
198
- * redacted from logs, traces, and the report.
199
- */
200
- secrets?: Readonly<Record<string, SecretConfig>>;
201
- }
120
+ timeout: 180_000, // default: 120000
121
+ ```
122
+
123
+ #### `launchTimeout`
124
+
125
+ The budget for engine init and attempt start, in milliseconds.
126
+
127
+ ```ts
128
+ launchTimeout: 90_000, // default: 60000
202
129
  ```
203
130
 
204
- Every numeric value must be a safe integer. `projectId` falls back to
205
- `unportable-<sha256 of the project root, 32 hex>` when there is no
206
- `package.json`. `workers` never exceeds what a target's engine declares. A
207
- targeted agent action is bounded by the smaller of `actionTimeout` and 15 s.
131
+ #### `actionTimeout`
208
132
 
209
- ## failOnSkippedFailure
133
+ The budget of every engine operation, in milliseconds.
210
134
 
211
- Set `failOnSkippedFailure: true` to fail the run with exit code 1 when a test
212
- skips a retry after an earlier failed attempt. The default is `false`.
135
+ ```ts
136
+ actionTimeout: 10_000, // default: 30000
137
+ ```
213
138
 
214
- The test keeps its `skipped` status and reason. Its failures remain in the
215
- report, and the CLI shows them under `Skipped After Failure` with either
216
- setting. Clean skips, successful retries, and engine cleanup diagnostics do
217
- not trigger this policy. A skip after a failed `expect.soft` needs no option:
218
- it [fails the attempt](/reference/test#members).
139
+ A targeted agent action is bounded by the smaller of `actionTimeout` and 15 s.
219
140
 
220
- ## maxDuration
141
+ #### `assertionTimeout`
221
142
 
222
- Set `maxDuration` to stop a run that takes too long, in milliseconds, at most 2147483647 (about 24 days):
143
+ The default `expect` deadline, in milliseconds.
223
144
 
224
145
  ```ts
225
- export default { targets, maxDuration: 900_000 } satisfies E2EConfig;
146
+ assertionTimeout: 10_000, // default: 5000
226
147
  ```
227
148
 
228
- The clock starts when the tests start; app startup is bounded by the app
229
- command's [`startupTimeout`](#command) instead. Once the time is spent, nothing more is
230
- dispatched, the running tests end as `interrupted`, and the tests not
231
- started are skipped with cause `time-limit`. The run records a
232
- `RUN_TIMEOUT` error and exits 1. `--last-failed` runs the interrupted and
233
- skipped tests next. `--max-duration <ms>` overrides it for one run.
234
- `e2e explore` ignores it and keeps its own `--timeout`.
149
+ #### `cleanupTimeout`
235
150
 
236
- ## targets
151
+ The budget of each `afterEach` hook, fixture teardown, and engine cleanup, in
152
+ milliseconds.
237
153
 
238
154
  ```ts
239
- /**
240
- * One named test target: the app under test and the engine that drives it.
241
- * Without an engine, the target supports agent tools only.
242
- */
243
- export interface Target {
244
- /** Label in reports and for `--target`; defaults to the platform. */
245
- name?: string;
246
- /**
247
- * Platform label, inherited from the engine when omitted. Required for a
248
- * target without an engine; when both name one, they must agree.
249
- */
250
- platform?: string;
251
- /** The engine driving the surface: `web(...)`, `mobile(...)`, or any `defineEngine` handle. */
252
- engine?: EngineHandle;
253
- /** The app under test: what it is, where it is served, and the command that starts it. */
254
- app?: TargetApp;
255
- /**
256
- * Which tests on this target keep a trace page, in place of the config's
257
- * `trace`; `--trace` and a test's own `trace` win over it.
258
- */
259
- trace?: RecordingMode;
260
- /**
261
- * Which attempts on this target record a video, in place of the config's
262
- * `video`; `--video` and a test's own `video` win over it. A mode set here
263
- * is required of the engine: one that cannot record fails the run with
264
- * `UNSUPPORTED_ARTIFACT`.
265
- */
266
- video?: RecordingMode;
267
- }
155
+ cleanupTimeout: 60_000, // default: 30000
156
+ ```
157
+
158
+ #### `retries`
159
+
160
+ Retries per test.
161
+
162
+ ```ts
163
+ retries: 2, // default: 1 in CI, else 0
164
+ ```
165
+
166
+ - 0 through 10.
167
+
168
+ #### `failOnSkippedFailure`
169
+
170
+ Fails the run with exit code 1 when a test skips a retry after an earlier
171
+ failed attempt.
172
+
173
+ ```ts
174
+ failOnSkippedFailure: true, // default: false
175
+ ```
176
+
177
+ - The test keeps its `skipped` status and reason.
178
+ - Its failures remain in the report, and the CLI shows them under
179
+ `Skipped After Failure` with either setting.
180
+ - Clean skips, successful retries, and engine cleanup diagnostics do not
181
+ trigger it.
182
+
183
+ <Note>
184
+ A skip after a failed `expect.soft` needs no option: it
185
+ [fails the attempt](/reference/test#members).
186
+ </Note>
187
+
188
+ #### `maxDuration`
189
+
190
+ The wall-clock budget for running the tests, in milliseconds. It stops a run
191
+ that takes too long.
192
+
193
+ ```ts
194
+ export default { targets, maxDuration: 900_000 } satisfies E2EConfig; // default: none
195
+ ```
196
+
197
+ - At most 2147483647 (about 24 days).
198
+ - The clock starts when the tests start. The app command's
199
+ [`startupTimeout`](#command) bounds app startup instead.
200
+ - Once the time is spent, the runner dispatches nothing more. The running
201
+ tests end as `interrupted`, and the tests not started are skipped with cause
202
+ `time-limit`. The run records a `RUN_TIMEOUT` error and exits 1.
203
+ - `--last-failed` runs the interrupted and skipped tests next.
204
+ - `--max-duration <ms>` overrides it for one run.
205
+ - `e2e explore` ignores it and keeps its own `--timeout`.
206
+
207
+ #### `workers`
208
+
209
+ Parallel workers.
210
+
211
+ ```ts
212
+ workers: 4, // default: 1 in CI, else half the cores
213
+ ```
214
+
215
+ - 1 through 1024.
216
+ - Locally the default is `max(1, floor(availableParallelism() / 2))`.
217
+ - An engine may cap it lower: `workers` never exceeds what a target's engine
218
+ declares.
219
+
220
+ #### `reporters`
221
+
222
+ Output renderers and reporter objects.
223
+
224
+ ```ts
225
+ reporters: ['list', 'junit'], // default: ['list']
268
226
  ```
269
227
 
270
- `name` is the id `--target`, reports, sessions, and artifact paths use:
271
- ASCII letters, digits, `_`, `-`, `.`, not only dots (`.` and `..` are path
272
- segments), unique across targets.
228
+ | Value | Does |
229
+ | --- | --- |
230
+ | `'list'` | Terminal progress, failures, and a run summary. |
231
+ | `'junit'` | Writes `<output>/junit.xml`. |
232
+ | `'markdown'` | Writes `<output>/summary.md`. |
233
+ | `'json'` | Prints the report. Excludes `list`: the two together are `INVALID_CONFIG`. |
234
+ | a `Reporter` object | Runs beside the built-in ones. `--reporter` never removes it. |
235
+
236
+ An unknown id is `INVALID_CONFIG`. See [Reporters](/reference/reporters).
237
+
238
+ ### targets
239
+
240
+ A target is one named surface tests run on: the app under test and the
241
+ [engine](#engine) that drives it. `targets` is required and holds at least
242
+ one.
243
+
244
+ ```ts title="e2e.config.ts"
245
+ targets: [
246
+ { name: 'chromium', engine: web(), app: { url: 'http://localhost:3000' } },
247
+ { name: 'firefox', engine: web({ browser: 'firefox' }), app: { url: 'http://localhost:3000' } },
248
+ ],
249
+ ```
250
+
251
+ | Option | Type | Default |
252
+ | --- | --- | --- |
253
+ | [`name`](#name) | `string` | the platform |
254
+ | [`platform`](#platform) | `string` | the engine's platform |
255
+ | [`engine`](#engine) | `EngineHandle` | - |
256
+ | [`app`](#the-app-under-test) | `TargetApp` | - |
257
+ | [`trace`](#trace) | `RecordingMode` | the config's `trace` |
258
+ | [`video`](#video) | `RecordingMode` | the config's `video` |
259
+
260
+ - `{ engine: web(), app: { url } }` is a complete target.
261
+ - A test whose `requires` names a capability the target lacks is skipped for
262
+ it. A `test.setup` that a selected test depends on must be runnable there,
263
+ or selection fails with `COLLECTION_ERROR`.
264
+
265
+ The full declaration is [`Target`](#target).
266
+
267
+ #### `name`
268
+
269
+ The id `--target`, reports, sessions, and artifact paths use.
273
270
 
274
- `{ engine: web(), app: { url } }` is a complete target. Two targets on one
275
- platform need names. A target without an engine runs through agent tools
276
- only. Tests whose `requires` names a capability the target lacks are skipped
277
- for it; a `test.setup` a selected test depends on must be runnable there or
278
- selection fails with `COLLECTION_ERROR`.
271
+ ```ts
272
+ name: 'chromium', // default: the platform
273
+ ```
274
+
275
+ - ASCII letters, digits, `_`, `-`, and `.`, not only dots (`.` and `..` are
276
+ path segments).
277
+ - Unique across targets. Two targets on one platform need names.
278
+
279
+ #### `platform`
280
+
281
+ The platform label.
282
+
283
+ ```ts
284
+ platform: 'web', // default: inherited from the engine
285
+ ```
279
286
 
280
- ## The app under test
287
+ - Required for a target without an engine.
288
+ - When the target and its engine both name one, they must agree.
289
+
290
+ #### `engine`
291
+
292
+ The engine driving the surface: `web(...)`, `mobile(...)`, or any
293
+ `defineEngine` handle.
294
+
295
+ ```ts
296
+ engine: web({ browser: 'firefox' }),
297
+ ```
298
+
299
+ A target without an engine runs through agent tools only. A target with `app`
300
+ and no engine is `INVALID_CONFIG`.
301
+
302
+ ### The app under test
281
303
 
282
304
  A target declares the app it tests in `app`; the engine only drives it. A
283
305
  browser target names the URL it opens, a device target the installed app or
@@ -307,52 +329,223 @@ targets: [
307
329
  ],
308
330
  ```
309
331
 
310
- | Key | Targets | Meaning |
332
+ | Option | Type | Default |
311
333
  | --- | --- | --- |
312
- | `url` | web | Base URL: `app.open()` opens it and relative navigation resolves against it. WHATWG-normalized; no userinfo, query, or fragment; a missing scheme becomes `https://`, or `http://` for a loopback host. Plain HTTP is accepted for loopback hosts only. Port `0` on `127.0.0.1` or `[::1]` asks for a [free port](#free-ports) |
313
- | `bundleId` | mobile | The installed app a device launches: a bundle id, an Android package name, or a display name the device resolves (`Settings`). A link is `INVALID_CONFIG`: a test opens one with `device.openLink` |
314
- | `appPath` | mobile | The build the suite runs against, an iOS `.app` bundle or an Android `.apk`, resolved against the project root. Without `bundleId`, the app it installs is the one launched. See [the device engine](/reference/mobile#engine) for when it is installed |
315
- | `identity` | any | Stable identity keying replay cache and session entries. Defaults to the URL's origin and base path, else `bundleId`, else `appPath`. Set a stable value to reuse entries across preview URLs or versioned build paths. Different apps must have different identities to prevent replay across them |
316
- | `environment` | any | `test`, `staging`, or `production`. Labels the target in the report and joins the cache and session identity digest; never gates a run. Defaults to `test` for loopback, `.localhost`, and `.test` hosts and for an app without a URL, `production` otherwise |
317
- | `launchArguments` | mobile | Arguments every fresh launch passes the app: `app.open()`, `app.restart()`, and `app.clearState()`. Arguments that select a build mode change what the app is: give each mode its own `identity` |
318
- | `permissions` | mobile | Permissions the app holds on every fresh launch, by name, each `grant`, `deny`, or `reset`; see [permissions](/reference/mobile#engine) |
319
- | `command` | any | Process the runner starts before the first test and stops on every exit path (a dev server, Metro for a debug build); see [command](#command). Targets declaring the same command share one process, probed at the first declaring target's `readyUrl` |
320
- | `readyUrl` | any | URL polled until `command` is ready (a 200-499 status); defaults to `url`, and is required without one. `{port}` in it is the port of `url` |
321
-
322
- Each engine checks the keys at config load, with `INVALID_CONFIG` naming
323
- the target. A `web()` target needs `app.url` and refuses `bundleId`,
324
- `appPath`, `launchArguments`, and `permissions`. A `mobile()` target needs
325
- `app.bundleId` or `app.appPath`; `app.url` on a mobile target is not
326
- supported yet. An unknown key in `app` or `app.command` fails with a
327
- did-you-mean. A target with `app` and no engine is
328
- `INVALID_CONFIG`.
334
+ | [`url`](#url) | `string` | - |
335
+ | [`bundleId`](#bundleid) | `string` | - |
336
+ | [`appPath`](#apppath) | `string` | - |
337
+ | [`identity`](#identity) | `string` | derived from the app |
338
+ | [`environment`](#environment) | `'test' \| 'staging' \| 'production'` | `'test'` or `'production'` |
339
+ | [`launchArguments`](#launcharguments) | `string[]` | - |
340
+ | [`permissions`](#permissions) | `Record<string, AppPermissionState>` | - |
341
+ | [`command`](#command) | `CommandConfig` | - |
342
+ | [`readyUrl`](#readyurl) | `string` | `url` |
343
+
344
+ Each engine checks the keys at config load, with `INVALID_CONFIG` naming the
345
+ target. A target with `app` needs an [engine](#engine).
346
+
347
+ - A `web()` target needs `app.url` and refuses `bundleId`, `appPath`,
348
+ `launchArguments`, and `permissions`.
349
+ - A `mobile()` target needs `app.bundleId` or `app.appPath`. `app.url` on a
350
+ mobile target is not supported yet.
351
+ - An unknown key in `app` or `app.command` fails with a did-you-mean.
329
352
 
353
+ <Note>
330
354
  The old spellings are unknown keys and fail at config load with
331
355
  `INVALID_CONFIG`: `web({ url })` and the other app options of `web()` and
332
356
  `mobile()`, `mobile({ app })` (now `app.bundleId`), an app key directly on the
333
- target, a top-level `app` or `url`, and `services` anywhere, which this
334
- version does not have.
357
+ target, a top-level `app` or `url`, and [`services`](#services) anywhere.
358
+ </Note>
359
+
360
+ #### `url`
361
+
362
+ The base URL: `app.open()` opens it and relative navigation resolves against
363
+ it.
364
+
365
+ ```ts
366
+ url: 'http://localhost:3000',
367
+ ```
368
+
369
+ - WHATWG-normalized; no userinfo, query, or fragment.
370
+ - A missing scheme becomes `https://`, or `http://` for a loopback host.
371
+ - Plain HTTP is accepted for loopback hosts only.
372
+ - Port `0` on `127.0.0.1` or `[::1]` asks for a [free port](#free-ports).
373
+ - Its site scopes engine headers and observed frames: see
374
+ [the app's site](#the-apps-site).
375
+
376
+ | Throws | When |
377
+ | --- | --- |
378
+ | `INVALID_APP_URL` | Port `0` on any host other than `127.0.0.1` or `[::1]`, `localhost` included. |
379
+ | `INVALID_CONFIG` | A port-0 `url` with nothing to start on it (no `app.command`). |
380
+
381
+ #### `bundleId`
382
+
383
+ The installed app a device launches: a bundle id, an Android package name, or
384
+ a display name the device resolves (`Settings`).
385
+
386
+ ```ts
387
+ bundleId: 'com.example.app',
388
+ ```
389
+
390
+ A link is `INVALID_CONFIG`: a test opens one with `device.openLink`.
391
+
392
+ #### `appPath`
393
+
394
+ The build the suite runs against, an iOS `.app` bundle or an Android `.apk`,
395
+ resolved against the project root.
396
+
397
+ ```ts
398
+ appPath: './build/App.app',
399
+ ```
400
+
401
+ Without `bundleId`, the app it installs is the one launched. See
402
+ [the device engine](/reference/mobile#engine) for when it is installed.
403
+
404
+ #### `identity`
405
+
406
+ The stable identity keying replay cache and session entries.
407
+
408
+ ```ts
409
+ identity: 'checkout-web', // default: the URL's origin and base path, else bundleId, else appPath
410
+ ```
411
+
412
+ Set a stable value to reuse entries across preview URLs or versioned build
413
+ paths. With a [free port](#free-ports), the default derives from the declared
414
+ `:0`, so cache and session entries do not churn with the port.
415
+
416
+ <Note>
417
+ Different apps must have different identities. A shared identity replays
418
+ recordings across them.
419
+ </Note>
420
+
421
+ #### `environment`
422
+
423
+ Labels the target in the report and joins the cache and session identity
424
+ digest. It never gates a run.
335
425
 
336
- Port `0` on any host other than `127.0.0.1` or `[::1]`, `localhost`
337
- included, is `INVALID_APP_URL`, and a port-0 `url` with nothing to start on
338
- it (no `app.command`) is `INVALID_CONFIG`. An `app.command` with neither
339
- `app.url` nor `app.readyUrl` is `APP_URL_REQUIRED` at config load.
426
+ ```ts
427
+ environment: 'staging', // default: 'test' for a local host or no URL, else 'production'
428
+ ```
429
+
430
+ - `'test'`, `'staging'`, or `'production'`.
431
+ - A local host is a loopback, `.localhost`, or `.test` host.
432
+
433
+ #### `launchArguments`
434
+
435
+ Arguments every fresh launch passes the app: `app.open()`, `app.restart()`,
436
+ and `app.clearState()`.
437
+
438
+ ```ts
439
+ launchArguments: ['-e2e', 'YES'],
440
+ ```
441
+
442
+ Arguments that select a build mode change what the app is: give each mode its
443
+ own `identity`.
444
+
445
+ #### `permissions`
446
+
447
+ Permissions the app holds on every fresh launch, by name.
448
+
449
+ ```ts
450
+ permissions: { camera: 'grant' },
451
+ ```
452
+
453
+ Each is `'grant'`, `'deny'`, or `'reset'`. See
454
+ [permissions](/reference/mobile#engine).
455
+
456
+ #### `command`
457
+
458
+ The process the runner starts before the first test and stops on every exit
459
+ path: a dev server, or Metro for a debug build.
460
+
461
+ ```ts
462
+ command: {
463
+ executable: 'pnpm',
464
+ args: ['dev', '--port', '{port}'],
465
+ inheritEnv: ['STRIPE_*'],
466
+ startupTimeout: 120_000, // default: 60000
467
+ },
468
+ ```
469
+
470
+ | Field | Example | Default |
471
+ | --- | --- | --- |
472
+ | `executable` | `'npm'` | required |
473
+ | `args` | `['run', 'dev']` | none |
474
+ | `cwd` | `'apps/web'` | the project root |
475
+ | `env` | `{ PORT: '{port}' }` | none |
476
+ | `inheritEnv` | `['STRIPE_*']` | none |
477
+ | `startupTimeout` | `120_000` | `60000` |
478
+ | `shutdownTimeout` | `5_000` | `10000` |
479
+ | `log` | `'.e2e/logs/app.log'` | output discarded |
480
+ | `reuseExisting` | `true` | `false` |
481
+
482
+ - `executable` is resolved with `PATH` and never shell-interpreted.
483
+ - `args` pass verbatim. `{port}` in `args` and `env` values expands to the
484
+ port of `url`.
485
+ - `cwd` resolves from the project root.
486
+ - The child inherits only `PATH`, `HOME`, the temp-directory variables, the
487
+ variables `inheritEnv` matches, and `command.env`. Pass what the app needs
488
+ explicitly.
489
+ - `inheritEnv` takes exact names or prefixes ending in `*` (`'STRIPE_*'`). It
490
+ copies a variable only when it is set. `env` wins over an inherited value.
491
+ - Every `args` entry and `env` value is a string. An `undefined` `env` value
492
+ is dropped.
493
+ - `startupTimeout` is the budget of the [`readyUrl`](#readyurl) probe.
494
+ - `shutdownTimeout` is the grace period before force-kill.
495
+ - `log` receives the process's stdout and stderr, appended, resolved from the
496
+ project root. It must stay inside the project root.
497
+ - A startup failure quotes the last 20 lines appended since the command
498
+ started, with `command.env` and `inheritEnv` values replaced by
499
+ `<secret:NAME>` markers.
500
+ - `reuseExisting`: when the readiness URL already answers before the command
501
+ starts, the runner uses that process. It starts nothing and stops nothing.
502
+ CI ignores it, with a notice, and always starts the command.
503
+ - Targets declaring the same command share one process, probed at the first
504
+ declaring target's `readyUrl`.
505
+ - The runner never terminates a process it did not start.
506
+
507
+ | Throws | When |
508
+ | --- | --- |
509
+ | `INVALID_CONFIG` | A bare `'*'` in `inheritEnv`. |
510
+ | `INVALID_CONFIG` | An `args` entry or `env` value that is not a string, a number or a `secrets.get()` handle included. |
511
+ | `INVALID_CONFIG` | `reuseExisting` on an app whose `url` asks for port 0: a process already running cannot serve the port the run assigns. |
512
+ | `APP_URL_REQUIRED` | An `app.command` with neither `app.url` nor `app.readyUrl`, at config load. |
513
+ | `APP_ALREADY_RUNNING` | Without `reuseExisting`, a `readyUrl` that already answers. |
514
+ | `APP_UNREACHABLE` | `startupTimeout` expires. |
515
+
516
+ <Warning>
517
+ `log` holds the raw, unredacted output. Keep it in an ignored directory.
518
+ </Warning>
519
+
520
+ The full declaration is [`CommandConfig`](#commandconfig).
521
+
522
+ #### `readyUrl`
523
+
524
+ The URL polled until `command` is ready, at a 200-499 status.
525
+
526
+ ```ts
527
+ readyUrl: 'http://localhost:3000/health', // default: url
528
+ ```
529
+
530
+ - Required without a `url`.
531
+ - `{port}` in it is the port of `url`.
340
532
 
341
- ### The app's site
533
+ #### The app's site
342
534
 
343
- A test or the agent may open any http(s) URL, and `about:blank`; every
344
- other scheme (`file:`, `data:`, `javascript:`, `view-source:`, `blob:`) is
345
- `POLICY_DENIED`. The site of `url`, its registrable domain
346
- (`app.example.com` and `auth.example.com` are one site), scopes two things:
347
- the browser engine's [`headers`](/reference/web#options) reach the
348
- site and no other host, and child frames off the site stay out of
349
- observations.
535
+ A test or the agent may open any http(s) URL, and `about:blank`. Every other
536
+ scheme (`file:`, `data:`, `javascript:`, `view-source:`, `blob:`) is
537
+ `POLICY_DENIED`. The site of `url` is its registrable domain:
538
+ `app.example.com` and `auth.example.com` are one site. It scopes two things:
350
539
 
351
- ### Free ports
540
+ - The browser engine's [`headers`](/reference/web#options) reach the site and
541
+ no other host.
542
+ - Child frames off the site stay out of observations.
543
+
544
+ #### Free ports
352
545
 
353
546
  A `url` on a literal loopback address with port `0` (`http://127.0.0.1:0`,
354
547
  `http://[::1]:0`) asks the run for a free TCP port. The runner picks one when
355
- the config loads and substitutes it wherever the declaration used the port:
548
+ the config loads. It substitutes the port wherever the declaration used it:
356
549
  the base URL, the default `readyUrl`, and the report. The command must take
357
550
  the port through `{port}` in `args` or `env`.
358
551
 
@@ -364,17 +557,947 @@ app: {
364
557
  },
365
558
  ```
366
559
 
367
- `localhost:0` is `INVALID_APP_URL`: it may resolve to an address other than
368
- the one the command binds. Write the address the command will bind.
369
-
370
- Each target asking for port `0` gets its own port and its own process; two
371
- targets that declare the same command on a fixed port share one. `{port}` in
372
- `app.command` and `app.readyUrl` is the port of `url`. The default `identity`
373
- derives from the declared `:0`, so cache and session entries do not churn
374
- with the port. Tests read the allocated URL from
375
- [`app.baseUrl`](/reference/app).
376
-
377
- ### command
560
+ - `localhost:0` is [`INVALID_APP_URL`](#url): it may resolve to an address
561
+ other than the one the command binds. Write the address the command will
562
+ bind.
563
+ - Each target asking for port `0` gets its own port and its own process. Two
564
+ targets that declare the same command on a fixed port share one.
565
+ - The default [`identity`](#identity) derives from the declared `:0`.
566
+ - Tests read the allocated URL from [`app.baseUrl`](/reference/app).
567
+
568
+ #### services
569
+
570
+ <Note>
571
+ This version starts no dependency processes (a database, an API mock, a dev
572
+ server several targets share), and `services` is an unknown key wherever it
573
+ appears. Start them before the run, or have `app.command` start a script that
574
+ brings them up and serves the app. A services API returns in a later release.
575
+ </Note>
576
+
577
+ ### output
578
+
579
+ The directory everything a run writes about itself goes under, resolved from
580
+ the project root.
581
+
582
+ ```ts
583
+ output: 'test-results/e2e', // default: '.e2e'
584
+ ```
585
+
586
+ `--output <dir>` on [`e2e run`](/reference/cli#e2e-run) and `e2e explore`
587
+ overrides it for one run.
588
+
589
+ | Path under the output | What |
590
+ | --- | --- |
591
+ | `report.json` | The canonical report; `--last-failed` reads the one here |
592
+ | `junit.xml`, `summary.md` | The `junit` and `markdown` reporters' files |
593
+ | `ai-trace.json` | `--ai-trace` |
594
+ | `results/` | One directory per test: its [trace page](/debugging#traces), `trace.md`, when the [`trace`](#trace) mode keeps one, and its screenshots, videos, downloads, and logs, one `attempt-<n>/` per attempt. Cleared once a run's tests start, so it holds the latest run's evidence and nothing its report no longer names. A `--last-failed` rerun keeps the files the report it reruns names and writes its own attempts under each test's `rerun-<n>/` |
595
+ | `sessions/` | The run's encrypted `test.setup` sessions, deleted when it ends |
596
+ | `videos/<session>/` | [`e2e mcp`](/reference/mcp#recording) session recordings |
597
+
598
+ - A run that stops before its tests start writes nothing here. Such stops are
599
+ a selection that matches no test (`NO_TESTS`, a mistyped `--grep`), a
600
+ collection error, a target that cannot record what it asks for
601
+ (`UNSUPPORTED_ARTIFACT`), `NO_LAST_RUN`, an app command that fails to start,
602
+ or an interrupt. The previous run's `report.json`, `ai-trace.json`, and
603
+ `results/` stay as they were, so `--last-failed` still reads the last run
604
+ that executed.
605
+ - An `e2e mcp` session writes its attempt's artifacts under `results/mcp/` and
606
+ never clears it.
607
+ - The replay cache stays in [`cache.dir`](#dir) whatever `output` says, so
608
+ moving the results never moves the recordings.
609
+ - `e2e init` writes `.gitignore` entries for the default `.e2e/` paths only.
610
+ With `output` set elsewhere, add that directory to `.gitignore` yourself.
611
+
612
+ A run clears and owns part of the output directory. So the config load fails
613
+ with `INVALID_CONFIG`, naming the reason, when the directory:
614
+
615
+ - is the project root, or outside it;
616
+ - is a file, or a path under one (a missing directory is created);
617
+ - is the cache directory (`cache.dir`, `--cache-dir`, or `E2E_CACHE_DIR`) or
618
+ inside it, or would hold it under `results/`, `sessions/`, or `videos/`;
619
+ - holds a directory a `tests` glob scans (`output: 'tests'` with the default
620
+ `tests/**/*.e2e.ts`).
621
+
622
+ Paths are compared with symlinks resolved. An absolute path spelled through a
623
+ symlink (`/tmp` for `/private/tmp` on macOS) names the same directory as its
624
+ target. A symlink whose target does not exist yet is followed too, so an
625
+ output through one that points outside the project root is refused.
626
+
627
+ <Note>
628
+ The removed `--artifacts <dir>` flag fails with the `--output` it maps to. The
629
+ report used to go to the directory's parent, so `--artifacts out/artifacts` is
630
+ `--output out`. A parent an output cannot be (the project root, or outside it)
631
+ maps to `--output .e2e`.
632
+ </Note>
633
+
634
+ ### artifacts
635
+
636
+ `artifacts` says where artifacts go, not which are made. `{ store }` hands
637
+ every artifact to a host store as it is produced; without one, files stay
638
+ local only.
639
+
640
+ ```ts
641
+ artifacts: {
642
+ store: {
643
+ async put(artifact) {
644
+ const key = await bucket.upload(artifact.path, artifact.bytes);
645
+ return { ref: key };
646
+ },
647
+ },
648
+ },
649
+ ```
650
+
651
+ | Member | Returns |
652
+ | --- | --- |
653
+ | [`store.put`](#put) | `Promise<{ ref }>` |
654
+ | [`store.putLink`](#putlink) | `Promise<{ ref }>` |
655
+
656
+ - [`trace` and `video`](#recording) choose the recordings. The runner
657
+ captures a failure's screenshot and screen text whenever the engine can
658
+ (never after a secret fill, see
659
+ [Security rules](/reference/security#screenshots-after-a-secret-fill)).
660
+ - Each worker constructs its own store from the config module.
661
+ - A failed `put` or `putLink` never fails the run.
662
+
663
+ The removed forms fail with `INVALID_CONFIG` naming what replaced them:
664
+
665
+ | Written | Now |
666
+ | --- | --- |
667
+ | a kinds list (`artifacts: ['screenshot', 'trace']` or `artifacts.kinds`) | the `trace` mode it meant |
668
+ | `artifacts.trace` with `record: 'retries'` | `trace: 'on-all-retries'`, read together with a kinds list that holds `trace`, so the pair still means retries only |
669
+ | `artifacts.video` | the `video` option |
670
+
671
+ The declarations are under [`ArtifactsConfig`](#artifactsconfig).
672
+
673
+ #### `put`
674
+
675
+ Receives every artifact once it is complete on disk and returns a reference
676
+ the report records as `ref` beside the local path.
677
+
678
+ ```ts
679
+ async put(artifact) {
680
+ return { ref: await bucket.upload(artifact.path, artifact.bytes) };
681
+ },
682
+ ```
683
+
684
+ - Required on every store.
685
+ - `artifact.redaction` is the report's label. A store that exports only what
686
+ the runner vouches for keeps back an `incomplete` artifact: a video or a
687
+ download the runner did not rewrite.
688
+
689
+ #### `putLink`
690
+
691
+ Receives a video a hosted service keeps (a
692
+ [provider's own recording](#what-a-video-writes)). Such a video is a link with
693
+ no bytes, so `put` never receives it.
694
+
695
+ ```ts
696
+ async putLink(link) {
697
+ return { ref: await index.record(link.url, link.attemptId) };
698
+ },
699
+ ```
700
+
701
+ - Optional. Called for each link once the attempt stopped the recording.
702
+ - Its `ref` lands on the report's artifact beside `url`.
703
+ - A passed attempt's link under `retain-on-failure` is never handed over,
704
+ since the report leaves it out too.
705
+
706
+ ### Recording
707
+
708
+ `trace` and `video` choose what is recorded, with one set of modes.
709
+
710
+ ```ts
711
+ trace: 'on',
712
+ video: 'retain-on-failure',
713
+ targets: [
714
+ { name: 'chromium', engine: web(), app: { url } },
715
+ { name: 'firefox', engine: web({ browser: 'firefox' }), app: { url }, video: 'on-first-retry' },
716
+ ],
717
+ ```
718
+
719
+ | Option | Type | Default |
720
+ | --- | --- | --- |
721
+ | [`trace`](#trace) | `RecordingMode` | `'retain-on-failure'` |
722
+ | [`video`](#video) | `RecordingMode` | `'off'` |
723
+
724
+ ```ts
725
+ /** Which tests keep a trace page, or which attempts record a video. */
726
+ export type RecordingMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries';
727
+ ```
728
+
729
+ | Mode | Trace page | Video |
730
+ | --- | --- | --- |
731
+ | `off` | none | none |
732
+ | `on` | every test, passing ones included | every attempt |
733
+ | `retain-on-failure` | each failed, timed-out, interrupted, or flaky test | every attempt, keeping those that did not pass |
734
+ | `on-first-retry` | each test whose first retry ran | the first retry only, the second attempt |
735
+ | `on-all-retries` | each test that was retried | every attempt after the first |
736
+
737
+ The names are Playwright's `use.trace` and `use.video` modes. Under a retry
738
+ mode, a test with `retries: 0` records nothing, and neither does a failure
739
+ the runner does not retry, such as `LAUNCH_TIMEOUT`. When such a test runs,
740
+ the run prints one notice per kind at plan time: no traces (or videos) will
741
+ be recorded for it.
742
+
743
+ Four places set a mode, lowest first. Each replaces the one before it, for
744
+ `trace` and `video` alike:
745
+
746
+ 1. `trace` or `video` at the top of the config.
747
+ 2. A target's `trace` or `video`.
748
+ 3. `--trace [mode]` or `--video [mode]` on [`e2e run`](/reference/cli#e2e-run),
749
+ over the config and every target. A bare flag is `on`.
750
+ 4. A test's or describe's own [`trace` or `video` option](/reference/test#options),
751
+ innermost wins: `test('checkout', { video: 'on' }, ...)`.
752
+
753
+ - A serial group records as one unit, so its members cannot set `trace` or
754
+ `video` (a `COLLECTION_ERROR`, like `retries`). The value on the serial
755
+ `describe`, or on a group around it, applies to every member.
756
+ - `e2e explore` is one attempt and uses the target's mode, `--trace` and
757
+ `--video` included, so a retry mode records nothing there.
758
+ - An `e2e mcp` session writes no trace and never records video from the
759
+ configured mode. Its recordings are the ones `start_recording` and
760
+ `stop_recording` make.
761
+ - `trace` and `video`, at the top or on a target, enter neither the config
762
+ digest nor a replay's [cache key](/cache#what-must-match). Turning one on or
763
+ off never invalidates cached replays.
764
+
765
+ <Note>
766
+ The old trace spellings fail with `INVALID_CONFIG` naming the mode they
767
+ meant, wherever a mode goes (the config, a target, a test, `--trace`):
768
+ `'all'` and `{ record: 'all' }` are `'on'`, and `'retries'` and `{ record:
769
+ 'retries' }` are `'on-all-retries'`.
770
+ </Note>
771
+
772
+ #### `trace`
773
+
774
+ Which tests keep a trace page.
775
+
776
+ ```ts
777
+ trace: 'on', // default: 'retain-on-failure'
778
+ ```
779
+
780
+ - The default is the same locally and in CI.
781
+ - A trace page is markdown, `trace.md` in the test's directory under
782
+ `<output>/results/`, such as
783
+ `.e2e/results/checkout-applies-the-coupon-1a2b3c4d5e6f7a8b/trace.md`: every
784
+ step with what it did, the cache's decisions, what the app logged, and the
785
+ screen at failure.
786
+ - It works on every engine. [Debugging](/debugging#traces) shows one.
787
+
788
+ #### `video`
789
+
790
+ Which attempts record a video.
791
+
792
+ ```ts
793
+ video: 'retain-on-failure', // default: 'off'
794
+ ```
795
+
796
+ - Where the mode is set decides what an engine that cannot record does. See
797
+ [Engines that cannot record](#engines-that-cannot-record).
798
+ - A video costs CPU and holds what the screen showed, secrets included, so it
799
+ is always asked for.
800
+
801
+ #### Engines that cannot record
802
+
803
+ Before any test starts, the run grades every target. Where a `video` mode
804
+ was set decides what an engine that cannot record video does:
805
+
806
+ - Set at the top of the config or with `--video`: the mode applies to the
807
+ targets whose engine can record it. The run prints one notice at plan time
808
+ naming the targets that cannot, and runs them without video.
809
+ - Set on a target, or on a test that will run on that target: the mode is
810
+ required. A target whose engine cannot record it fails the run with
811
+ `UNSUPPORTED_ARTIFACT`, naming the target and, for a test's, the test.
812
+
813
+ Only a mode that records on some attempt counts: a run whose tests all
814
+ resolve to `off`, or to a retry mode with `retries: 0`, asks nothing of the
815
+ engine.
816
+
817
+ #### What a video writes
818
+
819
+ | Recorder | Files under the attempt's artifact directory |
820
+ | --- | --- |
821
+ | Browser engine, Playwright screencast | `video/video.webm`, one segment per page the attempt showed, later ones as `video/video-part<n>.webm`. [`web({ screencast })`](/reference/web#options) sets the frame size and quality |
822
+ | Mobile engine, agent-device | `video/video.mp4` |
823
+ | A provider's own recording ([`BrowserProvider.record`](/browser#recording-on-the-provider), [`DeviceProvider.record`](/mobile#hosted-devices)) | the file the provider wrote, as `video/<file>`: Kernel's is `video/replay.mp4`. Or a link to a recording the service keeps, recorded with `url` in place of `path`; the runner never downloads it |
824
+
825
+ - Each segment carries `startedAt`, so `step.startedAt - artifact.startedAt`
826
+ is a step's offset into it.
827
+ - A recording masks nothing and is recorded with `redaction: 'incomplete'`.
828
+
829
+ <Note>
830
+ Under `retain-on-failure` a link is left out of the report of a passed
831
+ attempt, but the service still holds the recording.
832
+ </Note>
833
+
834
+ ### agents
835
+
836
+ Named agents, each one plain object: the built-in agent's options, or
837
+ `{ executor }` for a custom brain.
838
+
839
+ ```ts title="e2e.config.ts"
840
+ import type { E2EConfig } from 'e2e';
841
+ import { web } from '@e2e-dev/web';
842
+ import { gateway } from 'ai';
843
+ import { houseBrain } from './house-brain.ts';
844
+
845
+ export default {
846
+ targets: [{ engine: web(), app: { url: 'http://localhost:3000' } }],
847
+ agents: {
848
+ default: { model: gateway('openai/gpt-6-luna-fast') },
849
+ ux: { model: gateway('openai/gpt-6-luna-fast'), system: 'Check labels, contrast, and keyboard access.' },
850
+ thorough: { model: gateway('anthropic/claude-opus-5'), context: 'Verify every claim on screen.' },
851
+ buyer: {
852
+ model: gateway('openai/gpt-6-luna-fast'),
853
+ system: 'You are a first-time buyer.',
854
+ context: 'Plans are called tiers. Billing lives under Settings.',
855
+ maxSteps: 40,
856
+ },
857
+ house: { executor: houseBrain, model: gateway('openai/gpt-6-luna-fast'), maxModelCalls: 10 },
858
+ },
859
+ } satisfies E2EConfig;
860
+ ```
861
+
862
+ | Option | Type | Default |
863
+ | --- | --- | --- |
864
+ | [`model`](#model) | `ModelInstance` | - |
865
+ | [`judge`](#judge) | `ModelInstance` | `model` |
866
+ | [`context`](#context) | `string` | - |
867
+ | [`system`](#system) | `string` | - |
868
+ | [`tools`](#tools) | `Record<string, AgentTool>` | - |
869
+ | [`executor`](#executor) | `StepExecutor` | the built-in agent |
870
+ | [`maxSteps`](#maxsteps) | `number` | `25` |
871
+ | [`maxModelCalls`](#maxmodelcalls) | `number` | `25` |
872
+ | [`judgmentTimeout`](#judgmenttimeout) | `number` | `30000` |
873
+ | [`maxObservationBytes`](#maxobservationbytes) | `number` | `262144` |
874
+ | [`maxInputTokens`](#maxinputtokens) | `number` | `64000` |
875
+ | [`providerOptions`](#provideroptions) | `ProviderOptions` | - |
876
+
877
+ - Tests use the `default` agent unless you select another: with
878
+ `e2e run --agent <name>`, a test or group option, or an `agent.*` call. A
879
+ test with `{ agent: ['buyer', 'admin'] }` runs once for each agent.
880
+ - `default` exists even when the config declares no agents, but has no model.
881
+ - Every entry starts from the built-in defaults. Agents do not inherit from
882
+ each other: `ux` above gets no `context` from `default`. A key left out of
883
+ an entry is the default value, never another entry's.
884
+ - Each worker constructs its own agents from this config module.
885
+ - Names may contain ASCII letters, numbers, `_`, `-`, or `.`, and are never
886
+ only dots: a name is an artifact path segment.
887
+
888
+ | Throws | When |
889
+ | --- | --- |
890
+ | `INVALID_CONFIG` | An unknown agent name on the `--agent` flag. |
891
+ | `COLLECTION_ERROR` | An unknown agent name on a test or group. |
892
+ | `INVALID_ARGUMENT` | An unknown agent name on a call. |
893
+
894
+ See [Agents and personas](/agents). The declarations are under
895
+ [`AgentConfig`](#agentconfig).
896
+
897
+ #### `model`
898
+
899
+ An AI SDK model instance. There is no implicit default.
900
+
901
+ ```ts
902
+ model: gateway('openai/gpt-6-luna-fast'),
903
+ ```
904
+
905
+ Construct the model in your config with an AI SDK provider. Every provider
906
+ that supports tool calls works, see
907
+ [AI SDK providers](/models#ai-sdk-providers). For subscription sign-in, use
908
+ the [`e2e/oauth/*` models](/subscriptions).
909
+
910
+ | Gateway | Constructor | Package | Reads |
911
+ | --- | --- | --- | --- |
912
+ | [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) | `gateway('openai/gpt-6-luna-fast')` | `ai` | `AI_GATEWAY_API_KEY`, or a Vercel OIDC token without it |
913
+ | [OpenRouter](https://openrouter.ai/models) | `openrouter('openai/gpt-6-luna-fast')` | `@openrouter/ai-sdk-provider` | `OPENROUTER_API_KEY` |
914
+ | Any OpenAI-compatible endpoint | `createOpenAICompatible({ name, baseURL, apiKey? }).chatModel('llama3.2')` | `@ai-sdk/openai-compatible` | `apiKey`, when the endpoint needs one |
915
+ | A provider directly | `openai('gpt-6-luna')`, `anthropic(...)`, ... | `@ai-sdk/openai`, `@ai-sdk/anthropic`, ... | the provider's own variable |
916
+
917
+ - Reports record `provider` and `modelId` per step.
918
+ - Each worker constructs its own instance from the config module. A digest
919
+ that differs between the runner and a worker is
920
+ `CONFIG_NOT_DETERMINISTIC`.
921
+ - A string is `INVALID_CONFIG` naming the constructor to write.
922
+ - An agent never falls back to `default`'s model. See
923
+ [Resolution order](#resolution-order).
924
+
925
+ See [Models](/models) and [`ModelInstance`](#modelinstance).
926
+
927
+ #### `judge`
928
+
929
+ The model for `assert`, `waitFor`, and `extract`.
930
+
931
+ ```ts
932
+ judge: gateway('anthropic/claude-opus-5'), // default: model
933
+ ```
934
+
935
+ - Judgments allow 8192 output tokens per call.
936
+ - A judgment sees the instruction and the current screen only.
937
+
938
+ See [A judge of its own](/models#a-judge-of-its-own).
939
+
940
+ #### `context`
941
+
942
+ What the app calls things, told to every model call this agent makes, act
943
+ turns and judgments alike.
944
+
945
+ ```ts
946
+ context: 'Plans are called tiers. Billing lives under Settings.',
947
+ ```
948
+
949
+ At most 16384 bytes.
950
+
951
+ #### `system`
952
+
953
+ How the acting agent should work, appended to the built-in rules. Only the
954
+ act loop reads it.
955
+
956
+ ```ts
957
+ system: 'You are a first-time buyer.',
958
+ ```
959
+
960
+ With `executor` it is `INVALID_CONFIG`: the executor brings its own prompt.
961
+
962
+ #### `tools`
963
+
964
+ Project tools from `defineTool` (`e2e/agent`), offered beside the built-in
965
+ toolset.
966
+
967
+ ```ts
968
+ tools: { lookupOrder },
969
+ ```
970
+
971
+ With `executor` it is `INVALID_CONFIG`: the executor brings its own tools.
972
+
973
+ #### `executor`
974
+
975
+ A custom brain `agent.act()` dispatches to instead of the built-in agent.
976
+
977
+ ```ts
978
+ house: { executor: houseBrain, model: gateway('openai/gpt-6-luna-fast'), maxModelCalls: 10 },
979
+ ```
980
+
981
+ - `model`, `judge`, `context`, and the budgets still apply; judgments need a
982
+ model. [`system`](#system) and [`tools`](#tools) beside it are
983
+ `INVALID_CONFIG`.
984
+ - `maxModelCalls` bounds the model turns of one step.
985
+ - A `model` or `judge` that differs from the one the executor brought is
986
+ `INVALID_CONFIG`.
987
+
988
+ See [Custom executors](/executors).
989
+
990
+ #### `maxSteps`
991
+
992
+ Committed actions per agent call.
993
+
994
+ ```ts
995
+ maxSteps: 40, // default: 25
996
+ ```
997
+
998
+ 1 through 100.
999
+
1000
+ #### `maxModelCalls`
1001
+
1002
+ Model requests per agent call.
1003
+
1004
+ ```ts
1005
+ maxModelCalls: 10, // default: 25
1006
+ ```
1007
+
1008
+ 1 through 100.
1009
+
1010
+ #### `judgmentTimeout`
1011
+
1012
+ The deadline of one `assert`, `waitFor`, or `extract` call, in milliseconds.
1013
+
1014
+ ```ts
1015
+ judgmentTimeout: 60_000, // default: 30000
1016
+ ```
1017
+
1018
+ Raise it for a slow judge. Engine operations keep `actionTimeout`.
1019
+
1020
+ #### `maxObservationBytes`
1021
+
1022
+ The observation payload ceiling for act turns and judgments.
1023
+
1024
+ ```ts
1025
+ maxObservationBytes: 524_288, // default: 262144
1026
+ ```
1027
+
1028
+ 1024 through 16777216.
1029
+
1030
+ #### `maxInputTokens`
1031
+
1032
+ Input tokens per model request.
1033
+
1034
+ ```ts
1035
+ maxInputTokens: 128_000, // default: 64000
1036
+ ```
1037
+
1038
+ - 1 through 1000000.
1039
+ - A dense screen is cut to fit under it.
1040
+
1041
+ #### `providerOptions`
1042
+
1043
+ Provider options every model call carries, such as a reasoning effort.
1044
+
1045
+ ```ts
1046
+ providerOptions: { openai: { reasoningEffort: 'low' } },
1047
+ ```
1048
+
1049
+ OpenAI and Azure OpenAI Responses calls also carry `store: false` and a prompt
1050
+ cache key unless set here.
1051
+
1052
+ #### Removed options
1053
+
1054
+ An unknown key (the error names the nearest known one), a removed key, or a
1055
+ value in an old shape fails the config load with `INVALID_CONFIG`:
1056
+
1057
+ | Written | Now |
1058
+ | --- | --- |
1059
+ | a bare `StepExecutor` as the entry | `{ executor }` |
1060
+ | `timeout` | `judgmentTimeout` |
1061
+ | `maxTurns` (on the entry or `createToolLoopExecutor`) | `maxModelCalls` |
1062
+ | top-level `limits.maxModelTokensPerCall` | `maxInputTokens` on each agent |
1063
+ | top-level `limits.maxAgentContextBytes`, `maxLedgerBytes`, `maxEventsPerStep` | removed; the runner fixes them at 16384 bytes, 8192 bytes, and 1000 events |
1064
+
1065
+ ### credentials
1066
+
1067
+ Named accounts. Each entry is a username and a password the model never sees.
1068
+
1069
+ ```ts
1070
+ credentials: {
1071
+ admin: { username: 'admin@example.com', password: () => readFile('.secrets/admin', 'utf8') },
1072
+ },
1073
+ ```
1074
+
1075
+ | Field | Type |
1076
+ | --- | --- |
1077
+ | [`username`](#username) | `string` |
1078
+ | [`password`](#password) | `string \| SecretProvider` |
1079
+
1080
+ - Credentials and secrets are separate namespaces. `secrets.get(name)`
1081
+ returns a `secrets` entry only. A credential's password is reachable only
1082
+ as `credentials.user(name).password`.
1083
+ - The agent, the replay cache, and the reports know that password as a secret
1084
+ named `<name>.password` (`<secret:admin.password>`). So `credentials.admin`
1085
+ and `secrets.admin` can both exist.
1086
+
1087
+ | Throws | When |
1088
+ | --- | --- |
1089
+ | `INVALID_CONFIG` | A `secrets` entry named exactly `admin.password` beside an `admin` credential. |
1090
+ | `INVALID_CONFIG` | Two credentials whose names map to one `E2E_USER_<NAME>_*` override, like `svc-a` and `svc_a` (see [Environment variables](/reference/environment)). |
1091
+
1092
+ See [Signing in](/authentication). The declarations are under
1093
+ [`CredentialConfig`](#credentialconfig).
1094
+
1095
+ #### `username`
1096
+
1097
+ The account's plain username.
1098
+
1099
+ ```ts
1100
+ username: 'admin@example.com',
1101
+ ```
1102
+
1103
+ `E2E_USER_<NAME>_USERNAME` wins over the config value. With neither, the
1104
+ entry is `INVALID_CONFIG`.
1105
+
1106
+ #### `password`
1107
+
1108
+ The account's password: a static value, or a `SecretProvider` function.
1109
+
1110
+ ```ts
1111
+ password: () => readFile('.secrets/admin', 'utf8'),
1112
+ ```
1113
+
1114
+ - A static password is at least 6 characters (code points), like a
1115
+ [secret](#secrets), or `INVALID_CONFIG`.
1116
+ - A `SecretProvider` runs on every authorized fill (a vault lookup, a freshly
1117
+ computed TOTP). The config digest records it by name only. A provider
1118
+ returning fewer than 6 characters fails the fill with
1119
+ `AUTH_CREDENTIAL_UNAVAILABLE`.
1120
+ - `E2E_USER_<NAME>_PASSWORD` wins over both.
1121
+
1122
+ #### `credentials.user()`
1123
+
1124
+ Resolves a configured entry into a `Credential` in a test.
1125
+
1126
+ ```ts
1127
+ const admin = credentials.user('admin');
1128
+ ```
1129
+
1130
+ - The username is a plain string. The password is an opaque `Secret` that
1131
+ test code cannot read, accepted only by `locator.fill` and `agent.act`
1132
+ params.
1133
+ - An unconfigured name throws `AUTH_CREDENTIAL_UNAVAILABLE`.
1134
+
1135
+ ### secrets
1136
+
1137
+ Named values the model must never see: API keys, tokens, anything sourced
1138
+ from the environment.
1139
+
1140
+ ```ts
1141
+ secrets: {
1142
+ apiKey: () => readFile('.secrets/api-key', 'utf8'),
1143
+ },
1144
+ ```
1145
+
1146
+ | Entry | Example | Checked |
1147
+ | --- | --- | --- |
1148
+ | a static value | `'sk_test_51Hx…'` | at config load |
1149
+ | a provider | `() => readFile('.secrets/api-key', 'utf8')` | on every authorized fill |
1150
+
1151
+ - A provider runs on every authorized fill (a vault lookup, a freshly
1152
+ computed TOTP). The config digest records it by name only.
1153
+ - An `E2E_SECRET_<NAME>` override wins over both.
1154
+ - Every entry must resolve to a string of at least 6 characters (code points)
1155
+ or a function; anything else is `INVALID_CONFIG`. A provider's value is held
1156
+ to the same minimum at fill time, with `SECRET_UNAVAILABLE`.
1157
+ - The minimum exists for redaction. Every occurrence of a value becomes
1158
+ `<secret:name>`, so a shorter value would take ordinary text in reports and
1159
+ output with it.
1160
+ - Two entries whose names map to one variable, like `api-key` and `api_key`,
1161
+ are `INVALID_CONFIG`.
1162
+
1163
+ The declarations are under [`SecretConfig`](#secretconfig). See
1164
+ [Secrets that are not passwords](/authentication#secrets-that-are-not-passwords).
1165
+
1166
+ #### `secrets.get()`
1167
+
1168
+ Returns the opaque `Secret` for a configured entry.
1169
+
1170
+ ```ts
1171
+ await screen.getByLabel('API key').fill(secrets.get('apiKey'));
1172
+ ```
1173
+
1174
+ - It has no plaintext accessor and is accepted only by `locator.fill` and
1175
+ `agent.act` params.
1176
+ - The runner fills the field, masks the value in every observation, and
1177
+ redacts it from logs, traces, and the report.
1178
+ - An unconfigured name throws `SECRET_UNAVAILABLE`.
1179
+
1180
+ #### Secrets in engine options
1181
+
1182
+ While `e2e.config.ts` evaluates, before any run exists, `secrets.get(name)`
1183
+ returns a reference by name that an engine option can hold.
1184
+
1185
+ ```ts
1186
+ engine: web({ basicAuth: { username, password: secrets.get('previewPassword') } }),
1187
+ ```
1188
+
1189
+ - The engine declares it. When no `secrets` entry has the name, the config
1190
+ load fails with `INVALID_CONFIG`. A credential's password is not one:
1191
+ declare a value an engine option needs under `secrets`.
1192
+ - The engine resolves the value when each attempt starts, so a provider runs
1193
+ again every time. The value then joins the attempt's redaction. It goes to
1194
+ the engine, not onto the page, so it brings none of a fill's screenshot
1195
+ restrictions.
1196
+ - The digest records the name only.
1197
+ - The reference is not the value. Turned into a string (a template literal,
1198
+ `String()`, `+`, `JSON.stringify`), it throws `INVALID_CONFIG`. So does a
1199
+ reference in an `args` entry or `env` value of `app.command`, which takes
1200
+ strings and placeholders only. Read a value a string needs, such as an
1201
+ app's API key, from `process.env` yourself.
1202
+
1203
+ ### cache
1204
+
1205
+ Replay cache mode or options. In `read-write` mode the replay cache records an
1206
+ `agent.act()` only after a later verification passes, such as a locator
1207
+ assertion or `agent.assert()`.
1208
+
1209
+ ```ts
1210
+ cache: { mode: 'read-write', dir: '.e2e/cache', strict: true },
1211
+ ```
1212
+
1213
+ | Option | Type | Default |
1214
+ | --- | --- | --- |
1215
+ | [`mode`](#mode) | `CacheMode` | `'read-write'`, `'read-only'` in CI |
1216
+ | [`store`](#store) | `CacheStore` | the file store at `dir` |
1217
+ | [`dir`](#dir) | `string` | `'.e2e/cache'` |
1218
+ | [`strict`](#strict) | `boolean` | `false` |
1219
+
1220
+ A bare string is shorthand for `{ mode }`: `cache: 'read-only'`. See
1221
+ [Caching](/cache). The declarations are under [`CacheConfig`](#cacheconfig).
1222
+
1223
+ #### `mode`
1224
+
1225
+ What the replay cache reads and writes.
1226
+
1227
+ ```ts
1228
+ cache: 'read-write', // default: 'read-write' locally, 'read-only' in CI
1229
+ ```
1230
+
1231
+ | Mode | Does |
1232
+ | --- | --- |
1233
+ | `'read-write'` | Replays entries and records new ones. |
1234
+ | `'read-only'` | Replays existing entries and writes none. |
1235
+ | `'off'` | Disables both. |
1236
+
1237
+ - An explicit mode overrides the default. Set `'read-write'` explicitly when
1238
+ CI restores the cache from its own cache service.
1239
+ - `--no-cache` wins over both and disables caching for the run.
1240
+
1241
+ #### `store`
1242
+
1243
+ A custom entry store that replaces the file store.
1244
+
1245
+ ```ts
1246
+ cache: { store: createStore(cacheUrl) },
1247
+ ```
1248
+
1249
+ - Each worker constructs its own.
1250
+ - Exempt from the CI demotion: it states its own trust through `writable`.
1251
+ - `--cache-dir` beside it is `INVALID_CONFIG`, and `E2E_CACHE_DIR` is ignored.
1252
+
1253
+ `CacheStore`, `CacheReadResult`, `TraceEntry`, and `ActionTrace` are exported
1254
+ from `e2e`.
1255
+
1256
+ #### `dir`
1257
+
1258
+ The file store directory, resolved against the project root.
1259
+
1260
+ ```ts
1261
+ cache: { dir: '.cache/e2e' }, // default: '.e2e/cache'
1262
+ ```
1263
+
1264
+ - It stays where it is whatever `output` says.
1265
+ - `--cache-dir` and `E2E_CACHE_DIR` replace it.
1266
+
1267
+ #### `strict`
1268
+
1269
+ Fails a step whose recording exists but no longer replays with
1270
+ `REPLAY_STALE`, instead of handing it to the agent.
1271
+
1272
+ ```ts
1273
+ cache: { strict: true }, // default: false
1274
+ ```
1275
+
1276
+ - `--strict-cache` turns it on for one run.
1277
+ - The run then never writes the cache.
1278
+
1279
+ See [Fail on a stale recording](/cache#fail-on-a-stale-recording).
1280
+
1281
+ #### Commit the replay cache
1282
+
1283
+ `e2e init` adds `.e2e/cache/` to `.gitignore`, so committing entries is
1284
+ opt-in. Remove that line to share replays with CI and teammates. CI stays
1285
+ `read-only` unless `cache: 'read-write'` is set. See
1286
+ [Caching](/cache#commit-the-replay-cache).
1287
+
1288
+ ## Run limits
1289
+
1290
+ The report's `run.limits` block records the ceilings a run enforced. There is
1291
+ no `limits` key: two fields come from the agents, and the runner fixes the
1292
+ rest.
1293
+
1294
+ | Report field | Value |
1295
+ | --- | --- |
1296
+ | `maxAgentContextBytes` | 16384: an agent's `context` and a test's `agentContext` |
1297
+ | `maxLedgerBytes` | 8192: the prior-step ledger an act step reads |
1298
+ | `maxEventsPerStep` | 1000: events recorded per step |
1299
+ | `maxObservationBytes` | the largest `agents.<name>.maxObservationBytes` |
1300
+ | `maxModelTokensPerCall` | the largest `agents.<name>.maxInputTokens` |
1301
+
1302
+ Per-call options may only lower an agent's budgets.
1303
+
1304
+ ## Environment variables
1305
+
1306
+ The runner reads a closed set, listed on the
1307
+ [Environment variables](/reference/environment) page. `E2E_USER_<NAME>_USERNAME`
1308
+ and `E2E_USER_<NAME>_PASSWORD` override a credential, `E2E_SECRET_<NAME>` a
1309
+ secret, and `CI` switches the defaults below.
1310
+
1311
+ ### Resolution order
1312
+
1313
+ | Setting | Order |
1314
+ | --- | --- |
1315
+ | Base URL | the target's `app.url`, else `APP_URL_REQUIRED` at the first navigation. The runner reads no `APP_URL`; the scaffolded config reads it itself |
1316
+ | Built-in agent model | `agents.<name>.model`, then `MODEL_UNAVAILABLE` at `agent` acquisition; an agent never falls back to `default`'s model |
1317
+ | Custom executor model | `agents.<name>.model`, else the model the executor brought; two different values is `INVALID_CONFIG` |
1318
+ | Model credential | the provider package's own variable or the key passed at construction, read by the provider on the first model call |
1319
+ | Credential username and password | the `E2E_USER_*` variable, then the config value |
1320
+
1321
+ The built-in agent checks for a missing model once per run, when the `agent`
1322
+ fixture is first acquired, and stops the run with exit 2. A custom
1323
+ [executor](#executor) can implement `act` and `assert` without a model.
1324
+ `waitFor` and `extract` still require one.
1325
+
1326
+ ### CI
1327
+
1328
+ CI mode is on unless `CI` is unset, blank, `0`, or `false`.
1329
+
1330
+ | Setting | Local | CI |
1331
+ | --- | --- | --- |
1332
+ | `retries` | `0` | `1` |
1333
+ | `workers` | `max(1, floor(availableParallelism() / 2))` | `1` |
1334
+ | `cache` | `read-write` | `read-only` when unset |
1335
+ | `test.only` | allowed | `ONLY_IN_CI`, exit code 2 |
1336
+ | `command.reuseExisting` | honored | ignored, with a notice |
1337
+
1338
+ ## Loading
1339
+
1340
+ How the runner finds the config and loads it and the files it imports.
1341
+
1342
+ ### Finding the config file
1343
+
1344
+ - `--config <path>` selects an explicit file.
1345
+ - Otherwise the runner searches the working directory and each parent for
1346
+ `e2e.config.ts` or `e2e.config.mts`, stopping at the repository root.
1347
+ - Both names in one directory is `CONFIG_AMBIGUOUS`.
1348
+ - The selected file's directory is the project root.
1349
+
1350
+ ### `.env` files
1351
+
1352
+ e2e loads no `.env` file itself. Load one at the top of `e2e.config.ts` with
1353
+ Node's `process.loadEnvFile()`. Every worker imports the config again, so the
1354
+ variables reach the workers too:
1355
+
1356
+ ```ts title="e2e.config.ts"
1357
+ process.loadEnvFile('.env');
1358
+ ```
1359
+
1360
+ It throws when the file is missing; guard it with `existsSync` from
1361
+ `node:fs` when only some machines have one.
1362
+
1363
+ ### Module format
1364
+
1365
+ - Config and test files run as ESM whatever the nearest `package.json`
1366
+ `type` says. So does every `.ts`, `.mts`, `.tsx`, and `.jsx` file they
1367
+ reach: a helper by path, a package that exports its `.ts` source by name, or
1368
+ a `.ts` file a CommonJS file requires.
1369
+ - A `.cts` file is CommonJS, written with `require`, `import x = require()`,
1370
+ and `export =`; its `import` statements are not turned into `require`
1371
+ calls.
1372
+ - A `.ts` file written with `require` or `module.exports` fails to load: name
1373
+ it `.cts`.
1374
+ - JavaScript keeps the format its package declares.
1375
+
1376
+ ### Import resolution
1377
+
1378
+ e2e compiles TypeScript and JSX itself. An import in such a file resolves the
1379
+ way TypeScript resolves it:
1380
+
1381
+ - `./helper.js` and `./helper` load `helper.ts`, and `./view.jsx` loads
1382
+ `view.tsx`.
1383
+ - `./lib` loads the file `lib/package.json` names as `main`, else
1384
+ `lib/index.ts`.
1385
+ - A bare import that `tsconfig.json` maps through `paths` or `baseUrl` loads
1386
+ the mapped file.
1387
+ - A `#` import or package export that names a missing `./x.js`, `./x.jsx`, or
1388
+ `./x` loads its `x.ts` or `x.tsx` source when imported.
1389
+ - A `#` import or package export that names a directory, or a subpath of a
1390
+ package without `exports` that is a directory (`@acme/db/enums`), loads it
1391
+ the way `./lib` loads. Under Yarn PnP that package subpath still fails.
1392
+ - A `require()` of any of the last two fails as Node.js reports it.
1393
+ - An import written in a JavaScript file resolves as Node.js resolves it.
1394
+
1395
+ ### `tsconfig.json`
1396
+
1397
+ - The `tsconfig.json` is the nearest one above the file. When that one has
1398
+ `references` and does not include the file itself (as Vite writes it), it
1399
+ is the transitively referenced one that does.
1400
+ - References are visited in declaration order, and cycles are skipped. If
1401
+ none owns the file, the nearest config remains the fallback.
1402
+ - e2e reads its `paths`, `baseUrl`, `jsx`, `jsxFactory`,
1403
+ `jsxFragmentFactory`, `jsxImportSource`, `experimentalDecorators`,
1404
+ `emitDecoratorMetadata`, `useDefineForClassFields` (and `target`, which
1405
+ sets its default), and `verbatimModuleSyntax`, once per process and again
1406
+ for each session `e2e mcp` opens.
1407
+ - A `tsconfig.json` e2e cannot read, such as one that `extends` a package that
1408
+ is not installed, is skipped with a warning.
1409
+ - TypeScript inside an installed package compiles without the project's
1410
+ `tsconfig.json`.
1411
+
1412
+ ### What fails to compile
1413
+
1414
+ - e2e refuses, naming the file and line, two things it cannot compile:
1415
+ auto-accessors (`accessor x = 1`) and decorators without
1416
+ `experimentalDecorators`.
1417
+ - Each file compiles on its own, without type information. So a type
1418
+ imported without `import type` fails to load when the import stays in the
1419
+ output (a decorated member's type under `emitDecoratorMetadata`, a
1420
+ re-exported type). The error names the import to change.
1421
+ - A JSON file imported without `with { type: 'json' }` loads with its parsed
1422
+ contents as the default export.
1423
+ - Stack traces and failure locations point at the TypeScript source, through
1424
+ another loader's source map too (`NODE_OPTIONS=--import tsx`).
1425
+
1426
+ ## Types
1427
+
1428
+ ### `E2EConfig`
1429
+
1430
+ ```ts
1431
+ export interface E2EConfig {
1432
+ /** Stable project id, 1 through 256 characters; defaults to the root `package.json` name. */
1433
+ projectId?: string;
1434
+ /** The surfaces tests run on; required, at least one, each naming its engine. */
1435
+ targets: readonly Target[];
1436
+ /** Test file globs relative to the project root (`*`, `?`, and a whole `**` segment; a leading `!` excludes); default every `*.e2e.ts` under `tests/`. */
1437
+ tests?: string | readonly string[];
1438
+ /** Test attempt deadline in milliseconds; default 120000. */
1439
+ timeout?: number;
1440
+ /** Engine init and attempt start budget in milliseconds; default 60000. */
1441
+ launchTimeout?: number;
1442
+ /** Budget of every engine operation in milliseconds; default 30000. */
1443
+ actionTimeout?: number;
1444
+ /** Default `expect` deadline in milliseconds; default 5000. */
1445
+ assertionTimeout?: number;
1446
+ /** Budget of each `afterEach` hook, fixture teardown, and engine cleanup in milliseconds; default 30000. */
1447
+ cleanupTimeout?: number;
1448
+ /** Retries per test, 0 through 10; default 1 in CI, else 0. */
1449
+ retries?: number;
1450
+ /** Fail the run when a test skips a retry after an earlier failed attempt; default false. */
1451
+ failOnSkippedFailure?: boolean;
1452
+ /** Wall-clock budget for running the tests, in milliseconds; default none. */
1453
+ maxDuration?: number;
1454
+ /** Parallel workers, 1 through 1024; default 1 in CI, else half the cores. An engine may cap it lower. */
1455
+ workers?: number;
1456
+ /** `{ store }` hands every artifact to a host store as it is produced. */
1457
+ artifacts?: ArtifactsConfig;
1458
+ /** Which tests keep a trace page; default `retain-on-failure`. */
1459
+ trace?: RecordingMode;
1460
+ /** Which attempts record a video; default `off`. */
1461
+ video?: RecordingMode;
1462
+ /** The results directory, relative to the project root; default `.e2e`. */
1463
+ output?: string;
1464
+ /** Output renderers and reporter objects; default `['list']`. */
1465
+ reporters?: readonly (BuiltinReporter | Reporter)[];
1466
+ /** Named agents: the built-in agent's options, or `{ executor }` for a custom brain. */
1467
+ agents?: Readonly<Record<string, AgentConfig>>;
1468
+ /** Replay cache mode or options; default `read-write`, `read-only` in CI. */
1469
+ cache?: CacheMode | CacheConfig;
1470
+ /** Named accounts: a username and a password the model never sees. */
1471
+ credentials?: Readonly<Record<string, CredentialConfig>>;
1472
+ /** Named values the model must never see: API keys, tokens, anything sourced from the environment. */
1473
+ secrets?: Readonly<Record<string, SecretConfig>>;
1474
+ }
1475
+ ```
1476
+
1477
+ ### `Target`
1478
+
1479
+ ```ts
1480
+ /**
1481
+ * One named test target: the app under test and the engine that drives it.
1482
+ * Without an engine, the target supports agent tools only.
1483
+ */
1484
+ export interface Target {
1485
+ /** Label in reports and for `--target`; defaults to the platform. */
1486
+ name?: string;
1487
+ /** Platform label; inherited from the engine when omitted. */
1488
+ platform?: string;
1489
+ /** The engine driving the surface. */
1490
+ engine?: EngineHandle;
1491
+ /** The app under test: what it is, where it is served, and the command that starts it. */
1492
+ app?: TargetApp;
1493
+ /** Which tests on this target keep a trace page, in place of the config's `trace`. */
1494
+ trace?: RecordingMode;
1495
+ /** Which attempts on this target record a video, in place of the config's `video`. */
1496
+ video?: RecordingMode;
1497
+ }
1498
+ ```
1499
+
1500
+ ### `CommandConfig`
378
1501
 
379
1502
  ```ts
380
1503
  export interface CommandConfig {
@@ -386,163 +1509,20 @@ export interface CommandConfig {
386
1509
  cwd?: string;
387
1510
  /** Added to the inherited set; `{port}` expands in values. */
388
1511
  env?: Readonly<Record<string, string>>;
389
- /**
390
- * Variables copied from the runner's environment when set: exact names, or
391
- * prefixes ending in `*` (`'STRIPE_*'`). `env` wins over an inherited value.
392
- * A bare `'*'` is `INVALID_CONFIG`.
393
- */
1512
+ /** Variables copied from the runner's environment when set: exact names, or prefixes ending in `*`. */
394
1513
  inheritEnv?: readonly string[];
395
- /** Ready-probe budget in milliseconds; default 60000. Expiry is `APP_UNREACHABLE`. */
1514
+ /** Ready-probe budget in milliseconds; default 60000. */
396
1515
  startupTimeout?: number;
397
1516
  /** Grace period before force-kill in milliseconds; default 10000. */
398
1517
  shutdownTimeout?: number;
399
- /** File that receives the process's stdout and stderr, appended, resolved from the project root. Omitted discards output. */
1518
+ /** File that receives the process's stdout and stderr; omitted discards output. */
400
1519
  log?: string;
401
- /**
402
- * When the readiness URL already answers before the command starts, use that
403
- * process instead of spawning: nothing is started and nothing is stopped.
404
- * Off by default; CI ignores it and always starts the command. A URL on a
405
- * free port (port 0) can never already answer, so the two together are
406
- * `INVALID_CONFIG`.
407
- */
1520
+ /** Use a process already answering the readiness URL instead of spawning; default `false`. */
408
1521
  reuseExisting?: boolean;
409
1522
  }
410
1523
  ```
411
1524
 
412
- `log` must stay inside the project root and holds the raw, unredacted
413
- output, so keep it in an ignored directory. A startup failure quotes the last
414
- 20 lines appended since the command started, with `command.env` and
415
- `inheritEnv` values replaced by `<secret:NAME>` markers. Without
416
- `reuseExisting`, a `readyUrl` that already answers is `APP_ALREADY_RUNNING`.
417
- `reuseExisting` on an app whose `app.url` asks for port 0 is
418
- `INVALID_CONFIG`: a process already running cannot serve the port the run
419
- assigns.
420
-
421
- The child inherits only `PATH`, `HOME`, the temp-directory variables, the
422
- variables `inheritEnv` matches, and `command.env`, so pass what the app needs
423
- explicitly. `inheritEnv` takes names or trailing-`*` prefixes and copies a
424
- variable only when it is set; `env` wins. A bare `*` is `INVALID_CONFIG`.
425
- Every `args` entry and `env` value is a string; anything else, a number or a
426
- `secrets.get()` handle included, is `INVALID_CONFIG`, except an `undefined`
427
- `env` value, which is dropped. The runner never terminates a process it did
428
- not start.
429
-
430
- ### services
431
-
432
- <Note>
433
- Dependency processes (a database, an API mock, a dev server several targets
434
- share) are not started by this version, and `services` is an unknown key
435
- wherever it appears. Start them before the run, or have `app.command` start
436
- a script that brings them up and serves the app. A services API returns in a
437
- later release.
438
- </Note>
439
-
440
- ## output
441
-
442
- ```ts
443
- output: 'test-results/e2e',
444
- ```
445
-
446
- Everything a run writes about itself goes under one directory, `.e2e` by
447
- default, resolved from the project root. `--output <dir>` on
448
- [`e2e run`](/reference/cli#e2e-run) and `e2e explore` overrides it for one
449
- run.
450
-
451
- | Path under the output | What |
452
- | --- | --- |
453
- | `report.json` | The canonical report; `--last-failed` reads the one here |
454
- | `junit.xml`, `summary.md` | The `junit` and `markdown` reporters' files |
455
- | `ai-trace.json` | `--ai-trace` |
456
- | `results/` | One directory per test: its [trace page](/debugging#traces), `trace.md`, when the [`trace`](#trace) mode keeps one, and its screenshots, videos, downloads, and logs, one `attempt-<n>/` per attempt. Cleared once a run's tests start, so it holds the latest run's evidence and nothing its report no longer names. A `--last-failed` rerun keeps the files the report it reruns names and writes its own attempts under each test's `rerun-<n>/` |
457
- | `sessions/` | The run's encrypted `test.setup` sessions, deleted when it ends |
458
- | `videos/<session>/` | [`e2e mcp`](/reference/mcp#recording) session recordings |
459
-
460
- A run that stops before its tests start writes nothing here: a selection
461
- that matches no test (`NO_TESTS`, a mistyped `--grep`), a collection error,
462
- a target that cannot record what it asks for (`UNSUPPORTED_ARTIFACT`),
463
- `NO_LAST_RUN`, an app or service that fails to start, or an interrupt
464
- leaves the previous run's `report.json`, `ai-trace.json`, and `results/`
465
- as they were, so `--last-failed` still reads the last run that executed. An `e2e mcp` session writes its attempt's artifacts under
466
- `results/mcp/` and never clears it.
467
-
468
- The replay cache is independent: [`cache.dir`](#cache) defaults to
469
- `.e2e/cache` and resolves from the project root whatever `output` says, so
470
- moving the results never moves the recordings. Because a run clears and
471
- owns part of it, the output directory fails the config load with
472
- `INVALID_CONFIG`, naming the reason, when it:
473
-
474
- - is the project root, or outside it;
475
- - is a file, or a path under one (a missing directory is created);
476
- - is the cache directory (`cache.dir`, `--cache-dir`, or `E2E_CACHE_DIR`) or
477
- inside it, or would hold it under `results/`, `sessions/`, or `videos/`;
478
- - holds a directory a `tests` glob scans (`output: 'tests'` with the default
479
- `tests/**/*.e2e.ts`).
480
-
481
- Paths are compared with symlinks resolved, so an absolute path spelled
482
- through a symlink (`/tmp` for `/private/tmp` on macOS) names the same
483
- directory as its target. A symlink whose target does not exist yet is
484
- followed too: an output through one that points outside the project root is
485
- refused.
486
-
487
- `e2e init` writes `.gitignore` entries for the default `.e2e/` paths only.
488
- With `output` set elsewhere, add that directory to `.gitignore` yourself.
489
-
490
- The removed `--artifacts <dir>` flag fails with the `--output` it maps to:
491
- the report used to go to the directory's parent, so `--artifacts
492
- out/artifacts` is `--output out`. A parent an output cannot be (the project
493
- root, or outside it) maps to `--output .e2e`.
494
-
495
- ## artifacts
496
-
497
- ```ts
498
- artifacts: {
499
- store: {
500
- async put(artifact) {
501
- const key = await bucket.upload(artifact.path, artifact.bytes);
502
- return { ref: key };
503
- },
504
- },
505
- },
506
- ```
507
-
508
- `artifacts` says where artifacts go, not which are made: [`trace` and
509
- `video`](#recording) choose the recordings, and a failure's screenshot and
510
- screen text are captured whenever the engine can (never after a secret
511
- fill, see
512
- [Security rules](/reference/security#screenshots-after-a-secret-fill)). The
513
- removed forms fail with
514
- `INVALID_CONFIG` naming what replaced them: a kinds list (`artifacts:
515
- ['screenshot', 'trace']` or `artifacts.kinds`) names the `trace` mode it
516
- meant, `artifacts.trace` names `trace: 'on-all-retries'` for `record:
517
- 'retries'` (read together with a kinds list that holds `trace`, so the pair
518
- still means retries only), and `artifacts.video` names the `video` option.
519
-
520
- `store` receives every artifact once it is complete on disk and returns a
521
- reference the report records as `ref` beside the local path. A failed `put`
522
- never fails the run. Each worker constructs its own store from the config
523
- module. `redaction` is the report's label: a store that exports only what
524
- the runner vouches for keeps back an `incomplete` artifact, a video or a
525
- download the runner did not rewrite.
526
-
527
- A video a hosted service keeps (a [provider's own recording](#what-a-video-writes))
528
- is a link with no bytes, so `put` never receives it. A store that implements
529
- the optional `putLink` gets each one instead, once the attempt stopped the
530
- recording, and its `ref` lands on the report's artifact beside `url`. A
531
- passed attempt's link under `retain-on-failure` is never handed over, since
532
- the report leaves it out too, and a failed `putLink` never fails the run.
533
-
534
- ```ts
535
- artifacts: {
536
- store: {
537
- async put(artifact) {
538
- return { ref: await bucket.upload(artifact.path, artifact.bytes) };
539
- },
540
- async putLink(link) {
541
- return { ref: await index.record(link.url, link.attemptId) };
542
- },
543
- },
544
- },
545
- ```
1525
+ ### `ArtifactsConfig`
546
1526
 
547
1527
  ```ts
548
1528
  export interface ArtifactsConfig {
@@ -594,113 +1574,7 @@ export interface StoredArtifactLink {
594
1574
  }
595
1575
  ```
596
1576
 
597
- ## Recording
598
-
599
- ```ts
600
- trace: 'on',
601
- video: 'retain-on-failure',
602
- targets: [
603
- { name: 'chromium', engine: web(), app: { url } },
604
- { name: 'firefox', engine: web({ browser: 'firefox' }), app: { url }, video: 'on-first-retry' },
605
- ],
606
- ```
607
-
608
- `trace` and `video` choose what is recorded, with one set of modes:
609
-
610
- ```ts
611
- /** Which tests keep a trace page, or which attempts record a video. */
612
- export type RecordingMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries';
613
- ```
614
-
615
- | Mode | Trace page | Video |
616
- | --- | --- | --- |
617
- | `off` | none | none |
618
- | `on` | every test, passing ones included | every attempt |
619
- | `retain-on-failure` | each failed, timed-out, interrupted, or flaky test | every attempt, keeping those that did not pass |
620
- | `on-first-retry` | each test whose first retry ran | the first retry only, the second attempt |
621
- | `on-all-retries` | each test that was retried | every attempt after the first |
622
-
623
- The names are Playwright's `use.trace` and `use.video` modes. Under a retry
624
- mode a test with `retries: 0` records nothing, and neither does a failure the
625
- runner does not retry, such as `LAUNCH_TIMEOUT`. When such a test runs, the
626
- run prints one notice per kind at plan time saying no traces (or videos) will
627
- be recorded for it.
628
-
629
- The old trace spellings fail with `INVALID_CONFIG` naming the mode they
630
- meant, wherever a mode goes (the config, a target, a test, `--trace`):
631
- `'all'` and `{ record: 'all' }` are `'on'`, and `'retries'` and `{ record:
632
- 'retries' }` are `'on-all-retries'`.
633
-
634
- Four places set a mode, lowest first, and each replaces the one before it.
635
- The same rule holds for `trace` and `video`:
636
-
637
- 1. `trace` or `video` at the top of the config.
638
- 2. A target's `trace` or `video`.
639
- 3. `--trace [mode]` or `--video [mode]` on [`e2e run`](/reference/cli#e2e-run),
640
- over the config and every target. A bare flag is `on`.
641
- 4. A test's or describe's own [`trace` or `video` option](/reference/test#options),
642
- innermost wins: `test('checkout', { video: 'on' }, ...)`.
643
-
644
- A serial group records as one unit, so its members cannot set `trace` or
645
- `video` (a `COLLECTION_ERROR`, like `retries`). The value on the serial
646
- `describe`, or on a group around it, applies to every member.
647
-
648
- `e2e explore` is one attempt and uses the target's mode, `--trace` and
649
- `--video` included, so a retry mode records nothing there. An `e2e mcp`
650
- session writes no trace, and never records video from the configured mode:
651
- its recordings are the ones `start_recording` and `stop_recording` make.
652
-
653
- Neither `trace` nor `video`, at the top or on a target, enters the config
654
- digest, and neither is part of a replay's
655
- [cache key](/cache#what-must-match): turning one on or off never
656
- invalidates cached replays.
657
-
658
- ### trace
659
-
660
- The default is `retain-on-failure`, locally and in CI. A trace page is
661
- markdown, `trace.md` in the test's directory under `<output>/results/`, such as
662
- `.e2e/results/checkout-applies-the-coupon-1a2b3c4d5e6f7a8b/trace.md`: every step
663
- with what it did, the cache's decisions, what the app logged, and the screen
664
- at failure. It works on every engine. [Debugging](/debugging#traces) shows
665
- one.
666
-
667
- ### video
668
-
669
- The default is `off`. A video costs CPU and holds what the screen showed,
670
- secrets included, so it is always asked for.
671
-
672
- ### Engines that cannot record
673
-
674
- Before any test starts, the run grades every target. Where a `video` mode
675
- was set decides what an engine that cannot record video does:
676
-
677
- - Set at the top of the config or with `--video`: the mode applies
678
- to the targets whose engine can record it. The run prints one notice at
679
- plan time naming the targets that cannot, and runs them without.
680
- - Set on a target, or on a test that will run on that target: the mode is
681
- required. A target whose engine cannot record it fails the run with
682
- `UNSUPPORTED_ARTIFACT`, naming the target and, for a test's, the test.
683
-
684
- Only a mode that records on some attempt counts: a run whose tests all
685
- resolve to `off`, or to a retry mode with `retries: 0`, asks nothing of the
686
- engine.
687
-
688
- ### What a video writes
689
-
690
- | Recorder | Files under the attempt's artifact directory |
691
- | --- | --- |
692
- | Browser engine, Playwright screencast | `video/video.webm`, one segment per page the attempt showed, later ones as `video/video-part<n>.webm`. [`web({ screencast })`](/reference/web#options) sets the frame size and quality |
693
- | Mobile engine, agent-device | `video/video.mp4` |
694
- | A provider's own recording ([`BrowserProvider.record`](/browser#recording-on-the-provider), [`DeviceProvider.record`](/mobile#hosted-devices)) | the file the provider wrote, as `video/<file>`: Kernel's is `video/replay.mp4`. Or a link to a recording the service keeps, recorded with `url` in place of `path`; the runner never downloads it |
695
-
696
- Each segment carries `startedAt`, so `step.startedAt - artifact.startedAt` is
697
- a step's offset into it.
698
-
699
- A recording masks nothing and is recorded with `redaction: 'incomplete'`.
700
- Under `retain-on-failure` a link is left out of the report of a passed
701
- attempt, but the service still holds the recording.
702
-
703
- ## agents
1577
+ ### `AgentConfig`
704
1578
 
705
1579
  ```ts
706
1580
  /** A project tool as `defineTool` from `e2e/agent` returns it. */
@@ -715,26 +1589,19 @@ export interface AgentOptions {
715
1589
  model?: ModelInstance;
716
1590
  /** Model for `assert`, `waitFor`, and `extract`; defaults to `model`. */
717
1591
  judge?: ModelInstance;
718
- /**
719
- * What the app calls things, told to every model call this agent makes,
720
- * act turns and judgments alike. At most 16384 bytes.
721
- */
1592
+ /** What the app calls things, told to every model call; at most 16384 bytes. */
722
1593
  context?: string;
723
1594
  /** Committed actions per agent call, 1 through 100; default 25. */
724
1595
  maxSteps?: number;
725
1596
  /** Model requests per agent call, 1 through 100; default 25. */
726
1597
  maxModelCalls?: number;
727
- /** Deadline of one `assert`, `waitFor`, or `extract` call in milliseconds; default 30000. Raise it for a slow judge; engine operations keep `actionTimeout`. */
1598
+ /** Deadline of one `assert`, `waitFor`, or `extract` call in milliseconds; default 30000. */
728
1599
  judgmentTimeout?: number;
729
1600
  /** Observation payload ceiling for act turns and judgments, 1024 through 16777216; default 262144. */
730
1601
  maxObservationBytes?: number;
731
- /** Input tokens per model request, 1 through 1000000; default 64000. A dense screen is cut to fit under it. */
1602
+ /** Input tokens per model request, 1 through 1000000; default 64000. */
732
1603
  maxInputTokens?: number;
733
- /**
734
- * Provider options every model call carries, e.g. a reasoning effort.
735
- * OpenAI and Azure OpenAI Responses calls also carry `store: false` and a
736
- * prompt cache key unless set here.
737
- */
1604
+ /** Provider options every model call carries, such as a reasoning effort. */
738
1605
  providerOptions?: ProviderOptions;
739
1606
  }
740
1607
 
@@ -742,7 +1609,7 @@ export interface AgentOptions {
742
1609
  export type AgentConfig = AgentOptions &
743
1610
  (
744
1611
  | {
745
- /** How the acting agent should work, appended to the built-in rules; only the act loop reads it. */
1612
+ /** How the acting agent should work, appended to the built-in rules. */
746
1613
  system?: string;
747
1614
  /** Project tools from `defineTool`, offered beside the built-in toolset. */
748
1615
  tools?: Readonly<Record<string, AgentTool>>;
@@ -754,66 +1621,7 @@ export type AgentConfig = AgentOptions &
754
1621
  );
755
1622
  ```
756
1623
 
757
- Tests use the `default` agent unless you select another with
758
- `e2e run --agent <name>`, a test or group option, or an individual `agent.*`
759
- call. A test with `{ agent: ['buyer', 'admin'] }` runs once for each agent.
760
- `default` exists even when the config declares no agents, but has no model.
761
-
762
- Names may contain ASCII letters, numbers, `_`, `-`, or `.`, and are never
763
- only dots: a name is an artifact path segment. An unknown name
764
- is `INVALID_CONFIG` on the CLI flag, `COLLECTION_ERROR` on a test or group,
765
- and `INVALID_ARGUMENT` on a call. See [Agents and personas](/agents).
766
-
767
- ```ts title="e2e.config.ts"
768
- import type { E2EConfig } from 'e2e';
769
- import { web } from '@e2e-dev/web';
770
- import { gateway } from 'ai';
771
- import { houseBrain } from './house-brain.ts';
772
-
773
- export default {
774
- targets: [{ engine: web(), app: { url: 'http://localhost:3000' } }],
775
- agents: {
776
- default: { model: gateway('openai/gpt-6-luna-fast') },
777
- ux: { model: gateway('openai/gpt-6-luna-fast'), system: 'Check labels, contrast, and keyboard access.' },
778
- thorough: { model: gateway('anthropic/claude-opus-5'), context: 'Verify every claim on screen.' },
779
- buyer: {
780
- model: gateway('openai/gpt-6-luna-fast'),
781
- system: 'You are a first-time buyer.',
782
- context: 'Plans are called tiers. Billing lives under Settings.',
783
- maxSteps: 40,
784
- },
785
- house: { executor: houseBrain, model: gateway('openai/gpt-6-luna-fast'), maxModelCalls: 10 },
786
- },
787
- } satisfies E2EConfig;
788
- ```
789
-
790
- Every entry starts from the built-in defaults. Agents do not inherit from
791
- each other: `ux` above gets no `context` from `default`, and a key left out of
792
- an entry is the default value, never another entry's.
793
-
794
- With `executor`, `model`, `judge`, `context`, and the budgets still apply
795
- (judgments need a model), while `system` and `tools` are `INVALID_CONFIG`: the
796
- executor brings its own prompt and tools. `maxModelCalls` bounds the model
797
- turns of one step. A string in `model` is `INVALID_CONFIG` naming the
798
- constructor to write, and so is a `model` or `judge` that differs from the one
799
- the executor brought. See [Custom executors](/executors) and
800
- [A judge of its own](/models#a-judge-of-its-own).
801
-
802
- Loading the config fails with `INVALID_CONFIG` for an unknown key (naming the
803
- nearest known one), a removed key, and a value in the old shapes:
804
-
805
- | Written | Now |
806
- | --- | --- |
807
- | a bare `StepExecutor` as the entry | `{ executor }` |
808
- | `timeout` | `judgmentTimeout` |
809
- | `maxTurns` (on the entry or `createToolLoopExecutor`) | `maxModelCalls` |
810
- | top-level `limits.maxModelTokensPerCall` | `maxInputTokens` on each agent |
811
- | top-level `limits.maxAgentContextBytes`, `maxLedgerBytes`, `maxEventsPerStep` | removed; the runner fixes them at 16384 bytes, 8192 bytes, and 1000 events |
812
-
813
- Judgments (`assert`, `waitFor`, `extract`) allow 8192 output tokens per call
814
- and see the instruction and the current screen only.
815
-
816
- ### model
1624
+ ### `ModelInstance`
817
1625
 
818
1626
  ```ts
819
1627
  /**
@@ -832,23 +1640,7 @@ export interface ModelInstance {
832
1640
  }
833
1641
  ```
834
1642
 
835
- Construct the model in your config with an AI SDK provider. You can use all
836
- providers that support tool calls, see [AI SDK providers](/models#ai-sdk-providers).
837
- For subscription sign-in, use the [`e2e/oauth/*` models](/subscriptions).
838
-
839
- | Gateway | Constructor | Package | Reads |
840
- | --- | --- | --- | --- |
841
- | [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) | `gateway('openai/gpt-6-luna-fast')` | `ai` | `AI_GATEWAY_API_KEY`, or a Vercel OIDC token without it |
842
- | [OpenRouter](https://openrouter.ai/models) | `openrouter('openai/gpt-6-luna-fast')` | `@openrouter/ai-sdk-provider` | `OPENROUTER_API_KEY` |
843
- | Any OpenAI-compatible endpoint | `createOpenAICompatible({ name, baseURL, apiKey? }).chatModel('llama3.2')` | `@ai-sdk/openai-compatible` | `apiKey`, when the endpoint needs one |
844
- | A provider directly | `openai('gpt-6-luna')`, `anthropic(...)`, ... | `@ai-sdk/openai`, `@ai-sdk/anthropic`, ... | the provider's own variable |
845
-
846
- Reports record `provider` and `modelId` per step. Each worker constructs its
847
- own instance from the config module; a digest that differs between the
848
- runner and a worker is `CONFIG_NOT_DETERMINISTIC`. See
849
- [Models](/models).
850
-
851
- ## credentials
1643
+ ### `CredentialConfig`
852
1644
 
853
1645
  ```ts
854
1646
  /** One `config.credentials` entry: an account whose password is a secret named `<entry>.password`. */
@@ -858,30 +1650,7 @@ export interface CredentialConfig {
858
1650
  }
859
1651
 
860
1652
  type SecretProvider = () => string | Promise<string>;
861
- ```
862
1653
 
863
- A `SecretProvider` is called on every authorized fill (a vault lookup, a
864
- freshly computed TOTP) and recorded in the config digest by name only. An
865
- `E2E_USER_*` override wins over it. A static password is at least 6
866
- characters (code points), like a secret, or `INVALID_CONFIG`; a provider
867
- returning fewer fails the fill with `AUTH_CREDENTIAL_UNAVAILABLE`.
868
-
869
- `credentials.user(name)` resolves a configured entry into a `Credential`.
870
- The username is a plain string; the password is an opaque `Secret` that test
871
- code cannot read, accepted only by `locator.fill` and `agent.act` params. An
872
- unconfigured name throws `AUTH_CREDENTIAL_UNAVAILABLE`.
873
-
874
- Credentials and secrets are separate namespaces: `secrets.get(name)` returns a
875
- `secrets` entry only, and a credential's password is reachable only as
876
- `credentials.user(name).password`. The agent, the replay cache, and the
877
- reports know that handle as `<name>.password` (`<secret:admin.password>`), so
878
- `credentials.admin` and `secrets.admin` can both exist; a `secrets` entry
879
- named exactly `admin.password` beside an `admin` credential is
880
- `INVALID_CONFIG`. So are two credentials whose names map to one
881
- `E2E_USER_<NAME>_*` override, like `svc-a` and `svc_a` (see
882
- [Environment variables](/reference/environment)).
883
-
884
- ```ts
885
1654
  /** Named test identity. The password remains an opaque Secret. */
886
1655
  export interface Credential {
887
1656
  /** The entry's name in `credentials`. */
@@ -894,47 +1663,12 @@ export interface Credential {
894
1663
  }
895
1664
  ```
896
1665
 
897
- See [Signing in](/authentication).
898
-
899
- ## secrets
1666
+ ### `SecretConfig`
900
1667
 
901
1668
  ```ts
902
1669
  /** One `config.secrets` entry: the value itself, or a provider computing it at fill time. */
903
1670
  export type SecretConfig = string | SecretProvider;
904
- ```
905
1671
 
906
- An entry is a static value, or a provider called on every authorized fill (a
907
- vault lookup, a freshly computed TOTP). It is recorded in the config digest by
908
- name only, and an `E2E_SECRET_<NAME>` override wins over both. Two entries whose
909
- names map to one variable, like `api-key` and `api_key`, are `INVALID_CONFIG`. Every entry
910
- must resolve to a string of at least 6 characters (code points) or a
911
- function; anything else is `INVALID_CONFIG`. The minimum exists for redaction: every occurrence
912
- of a value becomes `<secret:name>`, and a shorter value would take ordinary
913
- text in reports and output with it. A provider's value is held to the same
914
- minimum at fill time, with `SECRET_UNAVAILABLE`.
915
-
916
- `secrets.get(name)` returns the opaque `Secret` for a configured entry. It has
917
- no plaintext accessor and is accepted only by `locator.fill` and `agent.act`
918
- params; the runner fills the field, masks the value in every observation, and
919
- redacts it from logs, traces, and the report. An unconfigured name throws
920
- `SECRET_UNAVAILABLE`.
921
-
922
- Called while `e2e.config.ts` evaluates, before any run exists, `secrets.get(name)`
923
- returns a reference by name that an engine option can hold, such as
924
- `web({ basicAuth: { username, password: secrets.get('previewPassword') } })`.
925
- The engine declares it, the config load fails with `INVALID_CONFIG` when no
926
- `secrets` entry has the name (a credential's password is not one: declare a
927
- value an engine option needs under `secrets`), and the engine resolves the value
928
- when each attempt starts (a provider runs again every time). The value joins
929
- the attempt's redaction then, without the screenshot restrictions a fill
930
- brings, since it goes to the engine rather than onto the page. The digest
931
- records the name only. The reference is not the value: turned into a string
932
- (a template literal, `String()`, `+`, `JSON.stringify`) it throws
933
- `INVALID_CONFIG`, and so does one placed in an `args` entry or `env` value of
934
- `app.command`, which takes strings and placeholders only. Read a value a string needs, such as an app's API
935
- key, from `process.env` yourself.
936
-
937
- ```ts
938
1672
  /**
939
1673
  * What a secret is for, which decides where it may be filled: a `password`
940
1674
  * goes only into a password field; a `generic-secret` (an API key, a token,
@@ -952,26 +1686,16 @@ export interface Secret {
952
1686
  }
953
1687
  ```
954
1688
 
955
- See [Secrets that are not passwords](/authentication#secrets-that-are-not-passwords).
956
-
957
- ## cache
958
-
959
- In `read-write` mode the replay cache records an `agent.act()` only after a
960
- later verification passes, such as a locator assertion or `agent.assert()`.
961
- `read-only` replays existing entries and writes none; `off` disables both.
962
- See [Caching](/cache).
1689
+ ### `CacheConfig`
963
1690
 
964
1691
  ```ts
965
- /**
966
- * Replay cache options. Defaults to files under `.e2e/cache/`.
967
- * A custom store replaces file storage; each worker constructs its own.
968
- */
1692
+ /** Replay cache options. */
969
1693
  export interface CacheConfig {
970
1694
  /** Default `read-write`; `read-only` in CI when unset. */
971
1695
  mode?: CacheMode;
972
1696
  /** Custom entry store; omit it to use the file store at `dir`. */
973
1697
  store?: CacheStore;
974
- /** File store directory, resolved against the project root; default `.e2e/cache`, whatever `output` says. `--cache-dir` and `E2E_CACHE_DIR` replace it. */
1698
+ /** File store directory, resolved against the project root; default `.e2e/cache`. */
975
1699
  dir?: string;
976
1700
  /** Fail a step whose recording no longer replays with `REPLAY_STALE`. Default `false`. */
977
1701
  strict?: boolean;
@@ -997,73 +1721,3 @@ export type CacheReadResult =
997
1721
  | { readonly status: 'miss' }
998
1722
  | { readonly status: 'invalid'; readonly reason: string; readonly bytes?: number };
999
1723
  ```
1000
-
1001
- `CacheStore`, `CacheReadResult`, `TraceEntry`, and `ActionTrace` are
1002
- exported from `e2e`.
1003
-
1004
- A bare string is shorthand for `{ mode }`. Set `'read-write'` explicitly when
1005
- CI restores the cache from its own cache service; `--no-cache` wins over
1006
- both. A custom `store` is exempt from the CI demotion and states its own
1007
- trust through `writable`. `strict` (or `--strict-cache` for one run) turns a
1008
- recording that exists but no longer replays into a `REPLAY_STALE` failure
1009
- instead of a hand-off to the agent, and the run then never writes the
1010
- cache; see
1011
- [Fail on a stale recording](/cache#fail-on-a-stale-recording).
1012
-
1013
- ### Commit the replay cache
1014
-
1015
- `e2e init` adds `.e2e/cache/` to `.gitignore`, so committing entries is
1016
- opt-in. Remove that line to share replays with CI and teammates; CI stays
1017
- `read-only` unless `cache: 'read-write'` is set. See
1018
- [Caching](/cache#commit-the-replay-cache).
1019
-
1020
- ## Run limits
1021
-
1022
- The report's `run.limits` block records the ceilings a run enforced. There is
1023
- no `limits` key: `maxObservationBytes` and `maxModelTokensPerCall` are the
1024
- largest `maxObservationBytes` and `maxInputTokens` any configured agent
1025
- sets, and the rest are fixed by the runner.
1026
-
1027
- | Report field | Value |
1028
- | --- | --- |
1029
- | `maxAgentContextBytes` | 16384: an agent's `context` and a test's `agentContext` |
1030
- | `maxLedgerBytes` | 8192: the prior-step ledger an act step reads |
1031
- | `maxEventsPerStep` | 1000: events recorded per step |
1032
- | `maxObservationBytes` | the largest `agents.<name>.maxObservationBytes` |
1033
- | `maxModelTokensPerCall` | the largest `agents.<name>.maxInputTokens` |
1034
-
1035
- Per-call options may only lower an agent's budgets.
1036
-
1037
- ## Environment variables
1038
-
1039
- The runner reads a closed set, listed on the
1040
- [Environment variables](/reference/environment) page. `E2E_USER_<NAME>_USERNAME`
1041
- and `E2E_USER_<NAME>_PASSWORD` override a credential, `E2E_SECRET_<NAME>` a
1042
- secret, and `CI` switches the defaults below.
1043
-
1044
- ### Resolution order
1045
-
1046
- | Setting | Order |
1047
- | --- | --- |
1048
- | Base URL | the target's `app.url`, else `APP_URL_REQUIRED` at the first navigation. The runner reads no `APP_URL`; the scaffolded config reads it itself |
1049
- | Built-in agent model | `agents.<name>.model`, then `MODEL_UNAVAILABLE` at `agent` acquisition; an agent never falls back to `default`'s model |
1050
- | Custom executor model | `agents.<name>.model`, else the model the executor brought; two different values is `INVALID_CONFIG` |
1051
- | Model credential | the provider package's own variable or the key passed at construction, read by the provider on the first model call |
1052
- | Credential username and password | the `E2E_USER_*` variable, then the config value |
1053
-
1054
- The built-in agent checks for a missing model once per run, when the `agent`
1055
- fixture is first acquired, and stops the run with exit 2. A custom executor
1056
- can implement `act` and `assert` without a model. `waitFor` and `extract`
1057
- still require one.
1058
-
1059
- ### CI
1060
-
1061
- CI mode is on unless `CI` is unset, blank, `0`, or `false`.
1062
-
1063
- | Setting | Local | CI |
1064
- | --- | --- | --- |
1065
- | `retries` | `0` | `1` |
1066
- | `workers` | `max(1, floor(availableParallelism() / 2))` | `1` |
1067
- | `cache` | `read-write` | `read-only` when unset |
1068
- | `test.only` | allowed | `ONLY_IN_CI`, exit code 2 |
1069
- | `command.reuseExisting` | honored | ignored, with a notice |