e2e 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/dist/.build.json +1 -1
  2. package/dist/agent/act.js +7 -4
  3. package/dist/agent/action-dispatcher.js +4 -5
  4. package/dist/agent/actions.d.ts +32 -23
  5. package/dist/agent/actions.js +38 -24
  6. package/dist/agent/model/sdk.js +2 -0
  7. package/dist/agent/observation-feed.d.ts +5 -6
  8. package/dist/agent/observation-feed.js +5 -7
  9. package/dist/agent/observation.d.ts +40 -11
  10. package/dist/agent/observation.js +109 -59
  11. package/dist/agent/point-tap.d.ts +8 -8
  12. package/dist/agent/point-tap.js +1 -1
  13. package/dist/agent/replay.d.ts +19 -8
  14. package/dist/agent/replay.js +11 -14
  15. package/dist/agent/scroll-target.d.ts +2 -3
  16. package/dist/agent/step-cache.d.ts +41 -14
  17. package/dist/agent/step-cache.js +147 -62
  18. package/dist/agent/tool-loop.js +2 -0
  19. package/dist/cache/anchors.d.ts +72 -23
  20. package/dist/cache/anchors.js +233 -60
  21. package/dist/cache/context.d.ts +25 -14
  22. package/dist/cache/context.js +50 -8
  23. package/dist/cache/decide.d.ts +2 -2
  24. package/dist/cache/decide.js +4 -6
  25. package/dist/cache/identity.d.ts +18 -4
  26. package/dist/cache/identity.js +9 -4
  27. package/dist/cache/recorder.d.ts +15 -1
  28. package/dist/cache/recorder.js +36 -23
  29. package/dist/cache/rekeyed.d.ts +46 -0
  30. package/dist/cache/rekeyed.js +89 -0
  31. package/dist/cache/relocate.d.ts +14 -14
  32. package/dist/cache/relocate.js +33 -25
  33. package/dist/cache/route.d.ts +32 -33
  34. package/dist/cache/route.js +94 -50
  35. package/dist/cache/store.d.ts +6 -0
  36. package/dist/cache/store.js +21 -1
  37. package/dist/cache/trace.d.ts +53 -14
  38. package/dist/cache/trace.js +70 -28
  39. package/dist/cli/index.js +8 -8
  40. package/dist/cli/init/engines.d.ts +1 -3
  41. package/dist/cli/init/engines.js +2 -2
  42. package/dist/cli/init/gateways.d.ts +1 -1
  43. package/dist/cli/init/gateways.js +11 -1
  44. package/dist/cli/init/sibling-versions.json +1 -1
  45. package/dist/cli/init/versions.d.ts +0 -8
  46. package/dist/cli/init/versions.js +1 -10
  47. package/dist/config/resolve.d.ts +1 -0
  48. package/dist/config/resolve.js +33 -1
  49. package/dist/engine/index.d.ts +2 -2
  50. package/dist/expect/async.js +2 -1
  51. package/dist/expect/poll-scope.d.ts +52 -0
  52. package/dist/expect/poll-scope.js +113 -0
  53. package/dist/expect/poll.js +31 -4
  54. package/dist/expect/values.js +11 -4
  55. package/dist/internal/abandoned.d.ts +11 -0
  56. package/dist/internal/abandoned.js +23 -0
  57. package/dist/internal/brands.d.ts +2 -0
  58. package/dist/internal/brands.js +2 -0
  59. package/dist/internal/client-identity.d.ts +9 -0
  60. package/dist/internal/client-identity.js +14 -0
  61. package/dist/internal/redact.d.ts +7 -4
  62. package/dist/internal/redact.js +116 -23
  63. package/dist/internal/time.d.ts +6 -0
  64. package/dist/internal/time.js +25 -13
  65. package/dist/internal/urls.d.ts +9 -5
  66. package/dist/internal/urls.js +19 -10
  67. package/dist/locator/engine.d.ts +12 -2
  68. package/dist/locator/engine.js +8 -3
  69. package/dist/locator/screen.js +28 -4
  70. package/dist/mcp/catalog.d.ts +3 -2
  71. package/dist/mcp/catalog.js +6 -6
  72. package/dist/mcp/session.js +1 -1
  73. package/dist/oauth/chatgpt.js +3 -23
  74. package/dist/oauth/cli.js +1 -0
  75. package/dist/oauth/copilot.d.ts +5 -4
  76. package/dist/oauth/copilot.js +80 -7
  77. package/dist/oauth/grok.js +2 -1
  78. package/dist/oauth/models.js +2 -1
  79. package/dist/oauth/opencode-console.d.ts +10 -0
  80. package/dist/oauth/opencode-console.js +133 -0
  81. package/dist/oauth/providers/github-copilot.d.ts +12 -0
  82. package/dist/oauth/providers/github-copilot.js +77 -23
  83. package/dist/oauth/providers/opencode-console.d.ts +31 -0
  84. package/dist/oauth/providers/opencode-console.js +148 -0
  85. package/dist/oauth/providers.d.ts +2 -6
  86. package/dist/oauth/providers.js +3 -7
  87. package/dist/oauth/responses.d.ts +9 -0
  88. package/dist/oauth/responses.js +23 -0
  89. package/dist/report/build.d.ts +26 -0
  90. package/dist/report/build.js +16 -8
  91. package/dist/report/failure-text.d.ts +6 -1
  92. package/dist/report/failure-text.js +14 -4
  93. package/dist/report/format.d.ts +10 -5
  94. package/dist/report/format.js +12 -5
  95. package/dist/report/junit.js +11 -12
  96. package/dist/report/list.d.ts +1 -0
  97. package/dist/report/list.js +38 -18
  98. package/dist/report/markdown.d.ts +1 -1
  99. package/dist/report/markdown.js +37 -14
  100. package/dist/report/repeats.d.ts +6 -1
  101. package/dist/report/repeats.js +12 -3
  102. package/dist/run/artifacts.d.ts +18 -0
  103. package/dist/run/artifacts.js +68 -1
  104. package/dist/run/execute.d.ts +9 -0
  105. package/dist/run/execute.js +49 -24
  106. package/dist/run/extended-fixtures.js +6 -2
  107. package/dist/run/failure-evidence.js +4 -2
  108. package/dist/run/fixtures.js +6 -8
  109. package/dist/run/in-process.d.ts +2 -0
  110. package/dist/run/in-process.js +1 -0
  111. package/dist/run/last-run.d.ts +42 -8
  112. package/dist/run/last-run.js +121 -14
  113. package/dist/run/realm.d.ts +4 -1
  114. package/dist/run/realm.js +20 -6
  115. package/dist/run/records.d.ts +16 -0
  116. package/dist/run/records.js +26 -0
  117. package/dist/run/retry.d.ts +3 -1
  118. package/dist/run/retry.js +4 -2
  119. package/dist/run/runner.d.ts +1 -1
  120. package/dist/run/runner.js +69 -30
  121. package/dist/run/serial.d.ts +2 -0
  122. package/dist/run/serial.js +8 -5
  123. package/dist/run/steps.d.ts +0 -6
  124. package/dist/run/steps.js +3 -20
  125. package/dist/run/worker/entry.js +5 -4
  126. package/dist/run/worker/protocol.d.ts +2 -0
  127. package/dist/run/worker/session.d.ts +2 -0
  128. package/dist/run/worker/session.js +1 -0
  129. package/dist/telemetry/events.js +1 -0
  130. package/dist/types.d.ts +9 -3
  131. package/docs/agents.mdx +6 -3
  132. package/docs/assertions.mdx +94 -0
  133. package/docs/authentication.mdx +3 -1
  134. package/docs/browser.mdx +42 -6
  135. package/docs/cache.mdx +64 -18
  136. package/docs/ci.mdx +11 -7
  137. package/docs/debugging.mdx +6 -3
  138. package/docs/executors.mdx +4 -2
  139. package/docs/github.mdx +12 -4
  140. package/docs/{writing-tests.mdx → goals.mdx} +21 -101
  141. package/docs/index.mdx +4 -4
  142. package/docs/integrations/kernel.mdx +1 -1
  143. package/docs/locators.mdx +64 -0
  144. package/docs/migrate/cypress.mdx +2 -2
  145. package/docs/migrate/maestro.mdx +2 -2
  146. package/docs/migrate/playwright.mdx +19 -16
  147. package/docs/migrate/selenium.mdx +2 -2
  148. package/docs/mobile.mdx +21 -4
  149. package/docs/models.mdx +53 -7
  150. package/docs/physical-devices.mdx +76 -0
  151. package/docs/quickstart.mdx +4 -3
  152. package/docs/reference/app.mdx +1 -1
  153. package/docs/reference/cli.mdx +30 -13
  154. package/docs/reference/config.mdx +25 -7
  155. package/docs/reference/engine.mdx +3 -3
  156. package/docs/reference/environment.mdx +8 -1
  157. package/docs/reference/errors.mdx +5 -5
  158. package/docs/reference/expect.mdx +34 -12
  159. package/docs/reference/mobile.mdx +23 -13
  160. package/docs/reference/reporters.mdx +8 -2
  161. package/docs/reference/screen.mdx +30 -7
  162. package/docs/reference/test.mdx +11 -4
  163. package/docs/reference/web.mdx +125 -21
  164. package/docs/security.mdx +26 -16
  165. package/docs/subscriptions.mdx +91 -12
  166. package/docs/telemetry.mdx +1 -1
  167. package/package.json +16 -3
  168. package/schema/report-v1.schema.json +14 -1
  169. package/skills/e2e/SKILL.md +2 -2
  170. package/skills/e2e/references/agent.md +22 -11
  171. package/skills/e2e/references/debugging.md +4 -4
  172. package/skills/e2e/references/running.md +13 -8
  173. package/skills/e2e/references/setup.md +36 -15
  174. package/skills/e2e/references/writing-tests.md +34 -17
@@ -10,7 +10,7 @@ export const setupPrompt = `Set up e2e end-to-end tests in this project. Docs: \
10
10
  1. Run \`npx e2e init --yes\` (or the pnpm or bun equivalent for this project). It writes a web config using Vercel AI Gateway, an example test, the e2e skill, and MCP config. It installs nothing yet.
11
11
  2. Read the e2e skill (\`npx e2e guide\` prints it, \`npx e2e guide <topic>\` prints a topic) and follow it for every step below.
12
12
  3. Point the target at this app. For a web app, set its URL and the command that starts its dev server. For an iOS or Android app, replace @e2e-dev/web and playwright with @e2e-dev/mobile and rewrite the example test for the mobile engine.
13
- 4. Ask me which model to use for agent steps: a ChatGPT, GitHub Copilot, or SuperGrok subscription, an API key, or a local model. Configure it with its provider package. If it needs a sign-in, give me the \`npx e2e login\` command and wait until I confirm.
13
+ 4. Ask me which model to use for agent steps: a ChatGPT, GitHub Copilot, OpenCode Console, or SuperGrok subscription, an API key, or a local model. Configure it with its provider package. If it needs a sign-in, give me the \`npx e2e login\` command and wait until I confirm.
14
14
  5. Install dependencies, run the example test, and fix setup errors until it passes.
15
15
  6. Write one test for the most important user flow in this app, run it, and iterate until it passes.`;
16
16
 
@@ -159,6 +159,7 @@ Connect the provider you chose in the wizard.
159
159
  | --- | --- |
160
160
  | ChatGPT Plus or Pro | `npx e2e login openai` |
161
161
  | GitHub Copilot | `npx e2e login github-copilot` |
162
+ | OpenCode Console (OpenCode Zen and OpenCode Go) | `npx e2e login opencode-console` |
162
163
  | SuperGrok or X Premium+ | `npx e2e login spacexai` |
163
164
 
164
165
  Copilot reuses your GitHub CLI login if available. See
@@ -245,8 +246,8 @@ to watch a web test, `--no-cache` to skip replaying cached actions.
245
246
  ## Next
246
247
 
247
248
  <CardGroup cols={2}>
248
- <Card title="Writing tests" icon="pen-to-square" href="/writing-tests">
249
- Write goals, check outcomes, and extract data.
249
+ <Card title="Goals" icon="pen-to-square" href="/goals">
250
+ Write test steps as goals in natural language.
250
251
  </Card>
251
252
  <Card title="Signing in" icon="key" href="/authentication">
252
253
  Reuse an app login and keep passwords out of model input.
@@ -43,7 +43,7 @@ timeout. They do not accept per-call timeout options.
43
43
 
44
44
  | Method | Timeout | Throws |
45
45
  | --- | --- | --- |
46
- | `open(path?)` | `config.timeout`, 120000 ms | `APP_URL_REQUIRED` for a path when the target declares no `app.url`, `POLICY_DENIED` for a `file:`, `data:`, or `javascript:` URL, `APP_UNREACHABLE`, `ACTION_FAILED`, `UNSUPPORTED_CAPABILITY` without `session.open` on a target with a URL or without `session.restart` on one without (a device launches its pinned app) |
46
+ | `open(path?)` | `config.timeout`, 120000 ms | `APP_URL_REQUIRED` for a path when the target declares no `app.url`, `POLICY_DENIED` for a URL whose scheme is not `http:` or `https:` (`about:blank` aside), `APP_UNREACHABLE`, `ACTION_FAILED`, `UNSUPPORTED_CAPABILITY` without `session.open` on a target with a URL or without `session.restart` on one without (a device launches its pinned app) |
47
47
  | `restart()` | `config.timeout`, 120000 ms | `ACTION_FAILED`, `ENGINE_FAILURE`, `UNSUPPORTED_CAPABILITY` without `session.restart` |
48
48
  | `clearState()` | `config.timeout`, 120000 ms | `ACTION_FAILED`, `ENGINE_FAILURE`, `UNSUPPORTED_CAPABILITY` without `session.reset` |
49
49
  | `back()` | `config.actionTimeout`, 30000 ms | `APP_NOT_OPEN`, `ACTION_FAILED`, `UNSUPPORTED_CAPABILITY` without `session.back` |
@@ -96,9 +96,9 @@ For a new project, the wizard asks you to choose:
96
96
  **Mobile (iOS/Android)** adds `@e2e-dev/mobile`, defaults to iOS on
97
97
  macOS and Android elsewhere, and opens Settings. **None** adds nothing.
98
98
  - A model provider or subscription for agent steps. Choose Vercel AI Gateway,
99
- OpenRouter, an OpenAI-compatible endpoint, ChatGPT, GitHub Copilot, or
100
- SuperGrok. Choose None for tests that only use locators. The wizard adds
101
- the required packages and model config. Subscription users then sign in
99
+ OpenRouter, an OpenAI-compatible endpoint, ChatGPT, GitHub Copilot,
100
+ OpenCode Console, or SuperGrok. Choose None for tests that only use
101
+ locators. The wizard adds the required packages and model config. Subscription users then sign in
102
102
  with `e2e login`. See [Models](/models) and [Subscriptions](/subscriptions).
103
103
  - Where to install the agent skill (see [e2e guide](#e2e-guide)):
104
104
  `.agents/skills/` for Codex, Cursor, Copilot, Gemini CLI, OpenCode, and
@@ -164,6 +164,7 @@ npx e2e login openai # ChatGPT, browser sign-in
164
164
  npx e2e login openai --device # sign in with a device code
165
165
  npx e2e login github-copilot --from-gh # use your GitHub CLI login
166
166
  npx e2e login github-copilot --client-id <id> [--enterprise-url <host>]
167
+ npx e2e login opencode-console # OpenCode Console, device code
167
168
  npx e2e login spacexai # SuperGrok, device code
168
169
  npx e2e login # choose a provider
169
170
  ```
@@ -173,6 +174,7 @@ pnpm exec e2e login openai # ChatGPT, browser sign-in
173
174
  pnpm exec e2e login openai --device # sign in with a device code
174
175
  pnpm exec e2e login github-copilot --from-gh # use your GitHub CLI login
175
176
  pnpm exec e2e login github-copilot --client-id <id> [--enterprise-url <host>]
177
+ pnpm exec e2e login opencode-console # OpenCode Console, device code
176
178
  pnpm exec e2e login spacexai # SuperGrok, device code
177
179
  pnpm exec e2e login # choose a provider
178
180
  ```
@@ -182,6 +184,7 @@ bunx e2e login openai # ChatGPT, browser sign-in
182
184
  bunx e2e login openai --device # sign in with a device code
183
185
  bunx e2e login github-copilot --from-gh # use your GitHub CLI login
184
186
  bunx e2e login github-copilot --client-id <id> [--enterprise-url <host>]
187
+ bunx e2e login opencode-console # OpenCode Console, device code
185
188
  bunx e2e login spacexai # SuperGrok, device code
186
189
  bunx e2e login # choose a provider
187
190
  ```
@@ -194,7 +197,7 @@ configuration.
194
197
 
195
198
  | Argument or flag | Type | Default | Behavior |
196
199
  | --- | --- | --- | --- |
197
- | `[provider]` | `openai`, `github-copilot`, or `spacexai` | a picker | The subscription to sign in to |
200
+ | `[provider]` | `openai`, `github-copilot`, `opencode-console`, or `spacexai` | a picker | The subscription to sign in to |
198
201
  | `--device` | boolean | `false` | ChatGPT: show a code to enter on another device instead of opening a browser |
199
202
  | `--from-gh` | boolean | `false` | GitHub Copilot: reuse the token of the signed-in GitHub CLI |
200
203
  | `--client-id <id>` | string | none | GitHub Copilot: the client id of your GitHub OAuth App with the device flow enabled |
@@ -239,10 +242,11 @@ bunx e2e models openai # one provider
239
242
  </CodeGroup>
240
243
 
241
244
  Asks the vendor which models the stored login serves and prints one line per
242
- model: the id `chatgpt()`, `copilot()`, or `grok()` takes, the vendor's name
243
- for it, and its notes (reasoning levels, vision, preview, hidden, and for
244
- Copilot `not enabled` or `no chat completions` when `copilot()` cannot call
245
- it). Without a
245
+ model: the id `chatgpt()`, `copilot()`, `opencodeConsole()`, or `grok()`
246
+ takes, the vendor's name for it, and its notes (reasoning levels, vision,
247
+ preview, hidden, for Copilot `not enabled` or `no chat or responses` when
248
+ `copilot()` cannot call it, and for OpenCode Console the plan, Zen or Go,
249
+ that serves it). Without a
246
250
  stored login, or when the named provider has none, exits 1 with the sign-in
247
251
  command; when one of several logins fails to list, the others still print and
248
252
  the command exits 1. An unknown provider exits 2. See
@@ -359,7 +363,7 @@ or every test was filtered out.
359
363
  | `--exclude-tag <tags>` | comma-separated or repeated | none | Leaves out every test carrying any of the tags, whatever `--tag` or a positional selected. A serial group still runs whole when any member is selected; tag the serial `describe` to exclude the flow. |
360
364
  | `--grep <pattern>` | regular expression, repeatable | none | Only tests whose title matches one of the patterns. The text matched is the describe titles and the test title joined by one space (`checkout pays`); the file and the tags are not part of it. A bare pattern is case-sensitive; `'/pattern/i'` adds flags. A pattern that does not compile is a usage error, exit 2. |
361
365
  | `--grep-invert <pattern>` | regular expression, repeatable | none | Leaves out every test whose title matches one of the patterns. Same text and pattern forms as `--grep`; both together keep the tests `--grep` matches minus the ones `--grep-invert` matches. |
362
- | `--last-failed` | boolean | `false` | Only the tests the previous run did not pass, read from the `report.json` this run overwrites, `<output>/report.json` (`--output`, else the config's [`output`](/reference/config#output)): failed, timed out, interrupted, or skipped because a setup, a serial predecessor, a hook, or the worker failed, and every test it selected in the describe or file, on that target, of a `beforeAll` or `afterAll` that failed (for a setup, the tests that consume its session). A rerun that runs the hook again reports it again if it still fails; another filter that leaves its tests out leaves the failure out of the next report too. A test is named per target and agent. No report is `NO_LAST_RUN`; a previous run with nothing failed selects nothing, `NO_TESTS` unless `--pass-with-no-tests`. |
366
+ | `--last-failed` | boolean | `false` | Only the tests the previous run did not pass, read from the `report.json` this run overwrites, `<output>/report.json` (`--output`, else the config's [`output`](/reference/config#output)): failed, timed out, interrupted, or skipped because a setup, a serial predecessor, a hook, or the worker failed or `--max-failures` stopped the run first, and every test it selected in the describe or file, on that target, of a `beforeAll` or `afterAll` that failed (for a setup, the tests that consume its session). A rerun that runs the hook again reports it again if it still fails, and a rerun that runs any test of its scope without it failing resolves it. A test another filter (`--grep`, `--tag`, `--target`, `--agent`, a positional) leaves out of the rerun stays owed: the rerun's report carries its last result, and the hook failure whose scope it is in, under `run.carried`, the next `--last-failed` selects it again, and the [`@e2e-dev/github`](/github) page stays red until it passes (⏹️ for a test the run before interrupted). The rerun's own exit code and summary count only what it ran. It keeps the artifacts the previous report names and writes its own under `artifacts/rerun-<n>/`, so no attempt overwrites an earlier one's files. A test is named per target and agent. No report is `NO_LAST_RUN`; a previous run with nothing failed selects nothing, `NO_TESTS` unless `--pass-with-no-tests`. |
363
367
  | `--shard <index/total>` | `2/3` | none | Runs one contiguous slice of the selected tests, cut once every other filter applied. Slices differ in size by at most one test, a serial group counts as one and is never split, and each shard brings only the setup tests its own tests need. Every shard runs the same command with a different index; the order is the report order, so the same suite shards the same way on every machine. A shard that gets no test is `NO_TESTS` unless `--pass-with-no-tests`. See [Sharding](/ci#sharding). |
364
368
  | `--headed` | boolean | `false` | Visible UI when the engine supports it. |
365
369
  | `--agent <names>` | comma-separated or repeated | `default` | Runs unpinned tests once per selected agent. For a test with `{ agent }`, selects the shared names when there is overlap; otherwise keeps the test's selection. An unknown name is `INVALID_CONFIG`. |
@@ -368,7 +372,7 @@ or every test was filtered out.
368
372
  | `--repeat-each <n>` | integer, at least 1 | `1` | Runs every selected test this many times. Each run is a result of its own, with `repeat` 0 through `n - 1` and its own `id`; the list reporter titles the later runs `(repeat #1)`, `(repeat #2)`, and their artifacts go under a `repeat-<n>` directory. Setup tests run once. With the replay cache on, the first run records and the later ones replay, so pair it with `--no-cache` to exercise the model each time. The `list` summary adds a `Repeats` row (`1 of 2 tests passed all 5 runs`) and one line per test that did not pass every run, naming each run that failed; the `markdown` page carries the same tally. |
369
373
  | `--max-failures <n>` | integer, at least 1 | none | Stops the run once this many tests have failed or timed out. Nothing more is dispatched, the tests running on other workers end as `interrupted`, and the tests not started, the rest of the file whose test reached the limit included, are skipped with cause `failure-limit` and the reason `run stopped after 3 failures (--max-failures 3)`. The exit code is the failures' own, 1, not 130. |
370
374
  | `--reporter <ids>` | comma-separated list | config | Replaces the built-in IDs in `config.reporters`: `list`, `json`, `junit`, `markdown`. `json` with `list` is `INVALID_CONFIG`. [Reporter objects](/reference/reporters) in the config keep running. |
371
- | `--output <dir>` | string | config, else `.e2e` | The results directory for this run, over the config's [`output`](/reference/config#output): the report, the reporter files, the AI trace, `artifacts/` (cleared once the run's tests start), and `sessions/`. Resolved from the project root; one the run cannot own is `INVALID_CONFIG`. The removed `--artifacts <dir>` fails with the `--output` it maps to: `--artifacts out/artifacts` is `--output out`, and a directory whose parent is the project root or outside it maps to `--output .e2e`. |
375
+ | `--output <dir>` | string | config, else `.e2e` | The results directory for this run, over the config's [`output`](/reference/config#output): the report, the reporter files, the AI trace, `artifacts/` (cleared once the run's tests start, down to what the previous report names on a `--last-failed` rerun), and `sessions/`. Resolved from the project root; one the run cannot own is `INVALID_CONFIG`. The removed `--artifacts <dir>` fails with the `--output` it maps to: `--artifacts out/artifacts` is `--output out`, and a directory whose parent is the project root or outside it maps to `--output .e2e`. |
372
376
  | `--no-cache` | boolean | `false` | Replay cache off for this run, whatever `config.cache` says. |
373
377
  | `--strict-cache` | boolean | `false` | Turns on `cache.strict` for this run: a step whose recording exists but no longer replays fails with `REPLAY_STALE` instead of handing off to the agent. See [Fail on a stale recording](/cache#fail-on-a-stale-recording). |
374
378
  | `--pass-with-no-tests` | boolean | `false` | Exit 0 instead of `NO_TESTS` when nothing is selected. |
@@ -479,8 +483,8 @@ bunx e2e run --reporter list,junit
479
483
  | --- | --- |
480
484
  | `list` | Terminal progress, failures with code frames, and a run summary. On a TTY, live progress includes the current agent step, model turns, tool calls, and reasoning excerpts. Untrusted fields are capped at 8 KiB and stripped of control characters. |
481
485
  | `json` | The report document to stdout and nothing else there, so `e2e run --reporter json \| jq` works. Same document as `<output>/report.json`, described by `schema/report-v1.schema.json` in the package. The shape changes only with the `schemaVersion` field (`report-1` today). Cannot be combined with `list`. |
482
- | `junit` | `junit.xml` beside the report. One `<testsuite>` per file, one `<testcase>` per test-target pair named `title > path [target]`. A test-class error is a `<failure>`, any other an `<error>`, a skip a `<skipped>` with its reason. Run-level errors form a `run` suite. |
483
- | `markdown` | `summary.md` beside the report with counts, cost, errors, and failure details. Failed and flaky tests also get a page under `failures/` there, with steps, model turns, and the screen at failure. Exploration runs show findings. [`@e2e-dev/github`](/github) uses this summary for pull request comments. |
486
+ | `junit` | `junit.xml` beside the report. One `<testsuite>` per file, one `<testcase>` per test-target pair named `title > path [target]`. A test-class error is a `<failure>`, any other an `<error>`, a skip a `<skipped>` with its reason. An interrupted test reached no verdict, so it is a `<skipped>` whose message starts `interrupted:`. Run-level errors form a `run` suite. |
487
+ | `markdown` | `summary.md` beside the report with counts, cost, errors, and failure details. Failed and flaky tests also get a page under `failures/` there, with steps, model turns, and the screen at failure. Interrupted tests are counted on their own and get no page. Exploration runs show findings. [`@e2e-dev/github`](/github) uses this summary for pull request comments. |
484
488
 
485
489
  Every run that gets to its tests writes `report.json` under the [output directory](/reference/config#output)
486
490
  (`.e2e` by default) whatever the reporters are, and the reporters' files
@@ -490,6 +494,16 @@ fails to start, an interrupt) writes none of them and leaves the previous
490
494
  run's files in place; its errors are on the terminal and in the exit code. A `junit.xml` or `summary.md` that could not be written is a
491
495
  line on stderr, never a run error.
492
496
 
497
+ `run.summary` in the report counts the results. `discovered` is every
498
+ test-target pair the runner collected and `selected` the ones this run
499
+ chose; the selection's left-out pairs are in `results` as `selected: false`
500
+ but in no other count. `passed`, `failed` (timed out included),
501
+ `interrupted`, `flaky`, and `skipped` count the selected results and add up
502
+ to `selected`. `interrupted` is a test the run stopped before its verdict
503
+ (a signal, `--max-failures`, or a run-level error such as
504
+ `MODEL_UNAVAILABLE`), never a failure. `executed` is the selected
505
+ results that ran at least one attempt.
506
+
493
507
  With `--ai-trace`, the run also writes `<output>/ai-trace.json`: one entry per
494
508
  model round trip with the exact prompt, tool definitions, response, usage,
495
509
  and provider metadata. Observations in it are already redacted. Inline image
@@ -510,7 +524,10 @@ Signals escalate.
510
524
  any test starts (during collection, an engine's `prepare`, or app
511
525
  startup) writes no report and leaves the previous one in place.
512
526
  2. A second signal forces. Workers dispose their engines at once and are
513
- killed when the cleanup budget is spent. The report is still written.
527
+ killed when the cleanup budget is spent. The report is still written,
528
+ and so are `junit.xml` and `summary.md` when the `junit` and
529
+ `markdown` reporters are on; a custom reporter's
530
+ `onRunFinished` is abandoned.
514
531
  3. A third signal exits on the spot, after killing the process groups of every
515
532
  app command the run started. A process attached through
516
533
  `reuseExisting` is left alone.
@@ -96,6 +96,8 @@ export interface E2EConfig {
96
96
  cleanupTimeout?: number;
97
97
  /** Retries per test, 0 through 10; default 1 in CI, else 0. */
98
98
  retries?: number;
99
+ /** Fail the run when a test skips itself after a soft failure or an earlier failed attempt; default false. */
100
+ failOnSkippedFailure?: boolean;
99
101
  /** Parallel workers, 1 through 1024; default 1 in CI, else half the cores. An engine may cap it lower. */
100
102
  workers?: number;
101
103
  /** `{ store }` hands every artifact to a host store as it is produced. */
@@ -158,6 +160,17 @@ Every numeric value must be a safe integer. `projectId` falls back to
158
160
  `package.json`. `workers` never exceeds what a target's engine declares. A
159
161
  targeted agent action is bounded by the smaller of `actionTimeout` and 15 s.
160
162
 
163
+ ## failOnSkippedFailure
164
+
165
+ Set `failOnSkippedFailure: true` to fail the run with exit code 1 when a test
166
+ skips after a soft assertion failure or skips a retry after an earlier failed
167
+ attempt. The default is `false`.
168
+
169
+ The test keeps its `skipped` status and reason. Its failures remain in the
170
+ report, and the CLI shows them under `Skipped After Failure` with either
171
+ setting. Clean skips, successful retries, and engine cleanup diagnostics do
172
+ not trigger this policy. A skip still stops the body and does not retry.
173
+
161
174
  ## targets
162
175
 
163
176
  ```ts
@@ -267,8 +280,9 @@ it (no `app.command`) is `INVALID_CONFIG`. An `app.command` with neither
267
280
 
268
281
  ### The app's site
269
282
 
270
- A test or the agent may open any http(s) URL; `file:`, `data:`, and
271
- `javascript:` are `POLICY_DENIED`. The site of `url`, its registrable domain
283
+ A test or the agent may open any http(s) URL, and `about:blank`; every
284
+ other scheme (`file:`, `data:`, `javascript:`, `view-source:`, `blob:`) is
285
+ `POLICY_DENIED`. The site of `url`, its registrable domain
272
286
  (`app.example.com` and `auth.example.com` are one site), scopes two things:
273
287
  the browser engine's [`headers`](/reference/web#options) reach the
274
288
  site and no other host, and child frames off the site stay out of
@@ -369,7 +383,7 @@ run.
369
383
  | `report.json` | The canonical report; `--last-failed` reads the one here |
370
384
  | `junit.xml`, `summary.md`, `failures/` | The `junit` and `markdown` reporters' files |
371
385
  | `ai-trace.json` | `--ai-trace` |
372
- | `artifacts/` | Screenshots, traces, videos, downloads, and logs, one directory per attempt. Cleared once a run's tests start, so it holds the latest run's evidence and nothing its report no longer names |
386
+ | `artifacts/` | Screenshots, traces, videos, downloads, and logs, one directory 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 `artifacts/rerun-<n>/` |
373
387
  | `sessions/` | The run's encrypted `test.setup` sessions, deleted when it ends |
374
388
  | `videos/<session>/` | [`e2e mcp`](/reference/mcp#recording) session recordings |
375
389
 
@@ -754,8 +768,9 @@ export interface ModelInstance {
754
768
  }
755
769
  ```
756
770
 
757
- Construct the model in your config with an AI SDK provider. For subscription
758
- sign-in, use the [`e2e/oauth/*` models](/subscriptions).
771
+ Construct the model in your config with an AI SDK provider. You can use all
772
+ providers that support tool calls, see [AI SDK providers](/models#ai-sdk-providers).
773
+ For subscription sign-in, use the [`e2e/oauth/*` models](/subscriptions).
759
774
 
760
775
  | Gateway | Constructor | Package | Reads |
761
776
  | --- | --- | --- | --- |
@@ -798,7 +813,9 @@ Credentials and secrets are separate namespaces: `secrets.get(name)` returns a
798
813
  reports know that handle as `<name>.password` (`<secret:admin.password>`), so
799
814
  `credentials.admin` and `secrets.admin` can both exist; a `secrets` entry
800
815
  named exactly `admin.password` beside an `admin` credential is
801
- `INVALID_CONFIG`.
816
+ `INVALID_CONFIG`. So are two credentials whose names map to one
817
+ `E2E_USER_<NAME>_*` override, like `svc-a` and `svc_a` (see
818
+ [Environment variables](/reference/environment)).
802
819
 
803
820
  ```ts
804
821
  /** Named test identity. The password remains an opaque Secret. */
@@ -824,7 +841,8 @@ export type SecretConfig = string | SecretProvider;
824
841
 
825
842
  An entry is a static value, or a provider called on every authorized fill (a
826
843
  vault lookup, a freshly computed TOTP). It is recorded in the config digest by
827
- name only, and an `E2E_SECRET_<NAME>` override wins over both. Every entry
844
+ name only, and an `E2E_SECRET_<NAME>` override wins over both. Two entries whose
845
+ names map to one variable, like `api-key` and `api_key`, are `INVALID_CONFIG`. Every entry
828
846
  must resolve to a string of at least 6 characters (code points) or a
829
847
  function; anything else is `INVALID_CONFIG`. The minimum exists for redaction: every occurrence
830
848
  of a value becomes `<secret:name>`, and a shorter value would take ordinary
@@ -960,7 +960,7 @@ Use these helpers to keep behavior consistent across engines:
960
960
  | --- | --- |
961
961
  | `matchesText(actual, pattern)`, `toTextPattern(match, { exact? })`, `describePattern(pattern)` | `TextPattern` matching for `locate` and text matchers; `toTextPattern` throws `INVALID_ARGUMENT` for a `match` that is neither a string nor a `RegExp` |
962
962
  | `resolveExpression(expression, nodes, { selector? })` | The reference `locate`. Resolves one `LocatorExpression` over a semantic tree in document order, with `text` and `label` answering the innermost match when a container echoes a descendant's text. An engine whose tree is the whole truth (a device) calls it; a browser engine reproduces it. A `selector` needs the hook, a `frame` is `FRAME_NOT_FOUND` |
963
- | `urlMatches(current, expected, baseHref)` | `toHaveURL`-style comparison; an `expected` that is neither a string nor a `RegExp` throws `INVALID_ARGUMENT`, as in `toTextPattern` |
963
+ | `urlMatches(current, expected, baseHref, ignoreCase?)` | `toHaveURL`-style comparison, `ignoreCase` as on the locator matchers; an `expected` that is neither a string nor a `RegExp` throws `INVALID_ARGUMENT`, as in `toTextPattern` |
964
964
  | `pollCondition({ deadline, signal, negated, evaluate, onTimeout })`, `Deadline` | Assertion polling and the negation grace window |
965
965
  | `validateJsonValue(value, label, rules?)` | Checks a value is plain JSON: a top-level `undefined` passes; a non-finite number, a cycle, an object that is not a plain object or array, or a function, symbol, bigint, or `undefined` inside is `INVALID_ARGUMENT` naming `label`. A `Secret` is `POLICY_DENIED` unless `rules.onSecret` throws its own; `rules.maxDepth` caps the nesting |
966
966
  | `rejectUnknownKeys(label, value, keys, code?)` | Throws `INVALID_CONFIG` for the first own key of `value` outside `keys`, naming the nearest known key when one is a plausible typo (`web() has unknown key "viewprt"; did you mean "viewport"?`) and the known keys otherwise: the check the runner runs on its own config objects, for an engine factory's options. Pass `'INVALID_ARGUMENT'` as `code` for an object a test passes to a fixture method |
@@ -1185,8 +1185,8 @@ export interface EngineFixtureContext {
1185
1185
  /**
1186
1186
  * Resolves a navigation target against the base URL. Throws
1187
1187
  * `APP_URL_REQUIRED` when the target declares no URL and `POLICY_DENIED`
1188
- * for a `file:`, `data:`, or `javascript:` scheme, so a fixture never
1189
- * re-implements the rule the harness owns.
1188
+ * for any scheme but `http:` and `https:` (`about:blank` aside), so a
1189
+ * fixture never re-implements the rule the harness owns.
1190
1190
  */
1191
1191
  resolveUrl(url: string): string;
1192
1192
  };
@@ -21,6 +21,7 @@ See [Models](/models) and [Subscriptions](/subscriptions).
21
21
  | Variable | Reading | Effect |
22
22
  | --- | --- | --- |
23
23
  | `E2E_OAUTH_CREDENTIALS` | value | The stored subscription logins as JSON, the contents of `oauth.json`, for a machine nobody can sign in on. The `e2e/oauth/*` models and `e2e models` read it in place of `$XDG_CONFIG_HOME/e2e/oauth.json`; `e2e login` and `e2e logout` refuse to change it, and refreshed tokens are not written back |
24
+ | `OPENCODE_API_KEY` | value | An OpenCode Console service account key; `opencodeConsole()` uses it in place of the stored `opencode-console` login; `e2e models` still reads the stored login. OpenCode reads the same variable |
24
25
  | `XDG_CONFIG_HOME` | value | Directory of the stored logins, `$XDG_CONFIG_HOME/e2e/oauth.json`; default `~/.config/e2e/oauth.json` |
25
26
 
26
27
  ## Credentials and secrets
@@ -32,7 +33,13 @@ See [Models](/models) and [Subscriptions](/subscriptions).
32
33
  | `E2E_SECRET_<NAME>` | set | Replaces the value of `secrets.<name>` |
33
34
 
34
35
  `<NAME>` is the name uppercased with every character outside `A-Z0-9`
35
- replaced by `_`, so `power-user` becomes `E2E_USER_POWER_USER_USERNAME`. A
36
+ replaced by `_`, so `power-user` becomes `E2E_USER_POWER_USER_USERNAME`. Two
37
+ credentials, or two secrets, whose names map to the same `<NAME>` (`api-key`
38
+ and `api_key` both read `E2E_SECRET_API_KEY`; `svc-a` and `svc_a` both read
39
+ `E2E_USER_SVC_A_USERNAME` and `_PASSWORD`) are `INVALID_CONFIG` at config
40
+ load, whether or not a variable is set, so one rotation never replaces two
41
+ entries; the message names the entries and the variables. A credential and a
42
+ secret may share a name, since their prefixes differ. A
36
43
  variable for a name absent from the config is ignored. An empty value is
37
44
  `INVALID_CONFIG`, and so is a password or secret shorter than 6 characters
38
45
  (code points): redaction rewrites every occurrence of a value, and one that
@@ -56,7 +56,7 @@ try {
56
56
  | `TEST_SETUP_FAILED` | Test prerequisites outside the product failed to arrange (blocked: test_setup). Also a `test.extend()` fixture that broke its contract: returned without calling `use()`, called it twice, or redefined a fixture the target's engine contributes | configuration | 2 |
57
57
  | `AUTOMATION_UNSUPPORTED` | The step needs an interaction the available toolset cannot perform, e.g. drag-and-drop (blocked: automation) | test | 1 |
58
58
  | `MODEL_UNAVAILABLE` | No model configured: the built-in agent's `agents.<name>.model` holds no AI SDK model instance; the message names the agent that was selected. Checked once per run when the `agent` fixture is first acquired; recorded as one run-level error that stops the run. `e2e explore` checks before anything starts, a custom executor's entry included, since explore runs the built-in agent. A custom executor without a model is not checked: its `act` and `assert` steps run, and `waitFor` or `extract` fails with this code on its first call. Evicts no [recording](/cache). | configuration | 2 |
59
- | `POLICY_DENIED` | A denied destination (`file:`, `data:`, `javascript:`, a malformed URL), a forbidden fill (an undeclared secret, a non-editable sink, a password into a field that is not a password field), or pixel evidence after a secret fill (`vision: 'only'` with pixels withheld). Also reading a value, text, or attribute from a secure field, the getters and the `toHaveValue`, `toHaveText`, `toContainText`, and `toHaveAttribute` matchers alike. An unknown tool or an undeclared field is refused to the model as the call's result and is not this code | configuration | 2 |
59
+ | `POLICY_DENIED` | A denied destination (a navigation scheme other than `http:` or `https:`, `about:blank` aside, a malformed URL), a forbidden fill (an undeclared secret, a non-editable sink, a password into a field that is not a password field), or pixel evidence after a secret fill (`vision: 'only'` with pixels withheld). Also reading a value, text, or attribute from a secure field, the getters and the `toHaveValue`, `toHaveText`, `toContainText`, and `toHaveAttribute` matchers alike. An unknown tool or an undeclared field is refused to the model as the call's result and is not this code | configuration | 2 |
60
60
  | `APP_UNREACHABLE` | The app URL could not be reached, or an app command exited early or exceeded `startupTimeout`. The startup error includes recent log output with `command.env` values redacted, and suggests adding a `log` if none is configured. Under the agent, also any engine fault with no more specific code: on a device, an iOS automation runner that is busy or wedged reports here with a message that names the runner, the session and device, and the recovery; see [Testing iOS and Android](/mobile#troubleshooting) | infrastructure | 3 |
61
61
  | `APP_ALREADY_RUNNING` | `readyUrl` already answered before the target's `app.command` was spawned and `reuseExisting` was not in effect; stop that process or set `reuseExisting: true` locally | infrastructure | 3 |
62
62
  | `MODEL_PROVIDER_FAILED` | Provider transport failure (network error, 5xx, rate limit, or a request with no response within 120 s) after the adapter's transport retries (up to 5 per model call, honoring `retry-after`, bounded by the remaining step timeout). A request that stalls is aborted at 120 s and sent again, and an `agent.act` step's `--debug` transcript notes it. The bound covers the whole request, so a model that needs longer than 120 s for one answer fails here too. Evicts no [recording](/cache) | infrastructure | 3 |
@@ -73,7 +73,7 @@ try {
73
73
  | `ASSERTION_INCONCLUSIVE` | `agent.assert` could not judge: the screen did not show enough to decide either way (the relevant part is not on screen, still loading, or unreadable). `agent.extract` raises it when the model answers that the screen does not show the requested data, instead of returning a placeholder. A failure, never a pass; the explanation says what was missing. Judged from the tree alone with `vision` off, the message adds `pass vision: true when the answer is in pixels`, as a condition (`if the engine captures pixels, ...`) until a step of the attempt has received pixels; no hint when the engine declares no screenshot capture, a secret was filled, or an earlier step's pixel request was degraded | test | 1 |
74
74
  | `AUTHENTICATION_FAILED` | The executor concluded that signing in failed inside the app: a product verdict. `AUTH_CREDENTIAL_INVALID` is the blocked counterpart for a configured credential the app rejects | test | 1 |
75
75
  | `STEP_NO_CONCLUSION` | The executor used every turn without calling `complete_step`; the step fails with the agent's last summary | test | 1 |
76
- | `REPLAY_STALE` | Under `cache.strict` or `--strict-cache`, the step's recording exists but no longer replays (`step.cache.reason` is one of `invalid-entry`, `wrong-context`, `target-not-found`, `target-ambiguous`, `viewport-changed`, `action-failed`, `action-uncertain`, `end-mismatch`), so the step fails with no model call instead of handing off to the agent. Not retried: a retry never replays and would pass live. Re-record with a read-write run without the flag, and with `cache.strict` off when the config set it, then commit the changed entry; the message names the knob that turned strict on and the configured cache directory (or `cache.store`) | configuration | 2 |
76
+ | `REPLAY_STALE` | Under `cache.strict` or `--strict-cache`, the step's recording exists but no longer replays (`step.cache.reason` is one of `invalid-entry`, `wrong-context`, `target-not-found`, `target-ambiguous`, `viewport-changed`, `action-failed`, `action-uncertain`, `end-mismatch`, or `no-entry` while the cache directory, not a custom `cache.store`, holds a recording of the step under another key, which the message names), so the step fails with no model call instead of handing off to the agent. Not retried: a retry never replays and would pass live. Re-record with a read-write run without the flag, and with `cache.strict` off when the config set it, then commit the changed entry; the message names the knob that turned strict on and the configured cache directory (or `cache.store`) | configuration | 2 |
77
77
 
78
78
  ## Runner codes
79
79
 
@@ -84,7 +84,7 @@ These appear in terminal output and in the report as plain string codes.
84
84
  | `CONFIG_NOT_FOUND` | `--config <path>` names a file that does not exist, or no `e2e.config.ts` or `e2e.config.mts` exists from the working directory up to the repository root | configuration | 2 |
85
85
  | `CONFIG_AMBIGUOUS` | Both `e2e.config.ts` and `e2e.config.mts` in one directory | configuration | 2 |
86
86
  | `CONFIG_LOAD_FAILED` | The config module failed to import: a package that is declared but not installed, an undeclared package, a wrong subpath, a removed export, or any other throw while it evaluates. The message names the fix. A config error raised while it evaluates keeps its own code: an option a factory the config calls (`web()`, `mobile()`, `defineEngine()`) refuses, or a `secrets.get()` handle turned into a string, is `INVALID_CONFIG` | configuration | 2 |
87
- | `INVALID_CONFIG` | A factory the config module calls (`web()`, `mobile()`, `defineEngine()`) refused its options, with the factory's own message; or an unknown key, out-of-range value, invalid enum, missing `targets`, a target key outside `{ name, platform, engine, app, trace, video }`, a target `app` key that is unknown or malformed, `app` on a target without an engine, a `web()` target without `app.url` or with a device field, a `mobile()` target without `app.bundleId` or `app.appPath`, with `app.url` (not supported yet), a link in `bundleId`, or an unknown permission name or state (the message names the nearest name), an unknown key in a `web()` or `mobile()` option, including `web({ connect })` and `web({ basicAuth })` (the message names the nearest one), a `trace` or `video` that is not a recording mode, the removed artifact kinds list (`artifacts: [...]` or `artifacts.kinds`) and `artifacts.trace` block (the message names the `trace` mode they meant), the removed `artifacts.video` block or `video` artifact kind (the message names the `video` option), the removed `specVersion` key, a `secrets.get()` in an engine option that names no `secrets` entry (a credential's password is not one), a `secrets.get()` reference turned into a string while the config evaluates (a template literal, `String()`) or given where a string is needed (an `app.command` `args` entry or `env` value; only an engine option that declares secrets takes one), an `app.command` `args` entry or `env` value that is not a string (an `undefined` `env` value is dropped instead), an unknown key in `app.command` (the message names the nearest one), an `engine` that is not a `defineEngine` handle, `json` reporter combined with `list`, duplicate or malformed target name, a secret or credential password shorter than 6 characters (code points), oversized `agents.<name>.context`, an `expect.poll` `timeout` or `interval` that is not a finite number of milliseconds, a `tests` value that is not a glob or a list of globs, a `tests` list of only `!` exclusions, the removed top-level `limits` key, an agents entry that is a bare `StepExecutor` (the message names `{ executor }`) or a model instance (the message names `{ model }`), an unknown agent key (the message names the nearest one), the removed agent keys `timeout` and `maxTurns` (the message names `judgmentTimeout` and `maxModelCalls`), `system` or `tools` beside `executor`, a `tools` entry `defineTool` did not build or that takes a built-in tool name (a grammar verb, `complete_step`, `locate`, `start_recording`, `stop_recording`, or `report_finding`), `createToolLoopExecutor({ maxTurns })`, `reuseExisting` on an app whose `app.url` asks for a free port, or a port-0 `app.url` with no `app.command` to serve it | configuration | 2 |
87
+ | `INVALID_CONFIG` | A factory the config module calls (`web()`, `mobile()`, `defineEngine()`) refused its options, with the factory's own message; or an unknown key, out-of-range value, invalid enum, missing `targets`, a target key outside `{ name, platform, engine, app, trace, video }`, a target `app` key that is unknown or malformed, `app` on a target without an engine, a `web()` target without `app.url` or with a device field, a `mobile()` target without `app.bundleId` or `app.appPath`, with `app.url` (not supported yet), a link in `bundleId`, or an unknown permission name or state (the message names the nearest name), an unknown key in a `web()` or `mobile()` option, including `web({ connect })` and `web({ basicAuth })` (the message names the nearest one), a `trace` or `video` that is not a recording mode, the removed artifact kinds list (`artifacts: [...]` or `artifacts.kinds`) and `artifacts.trace` block (the message names the `trace` mode they meant), the removed `artifacts.video` block or `video` artifact kind (the message names the `video` option), the removed `specVersion` key, a `secrets.get()` in an engine option that names no `secrets` entry (a credential's password is not one), a `secrets.get()` reference turned into a string while the config evaluates (a template literal, `String()`) or given where a string is needed (an `app.command` `args` entry or `env` value; only an engine option that declares secrets takes one), an `app.command` `args` entry or `env` value that is not a string (an `undefined` `env` value is dropped instead), an unknown key in `app.command` (the message names the nearest one), an `engine` that is not a `defineEngine` handle, `json` reporter combined with `list`, duplicate or malformed target name, a secret or credential password shorter than 6 characters (code points), two credentials or two secrets whose names map to one `E2E_USER_*` or `E2E_SECRET_*` override variable (`api-key` and `api_key`), oversized `agents.<name>.context`, an `expect.poll` `timeout` or `interval` that is not a finite number of milliseconds, a `tests` value that is not a glob or a list of globs, a `tests` list of only `!` exclusions, the removed top-level `limits` key, an agents entry that is a bare `StepExecutor` (the message names `{ executor }`) or a model instance (the message names `{ model }`), an unknown agent key (the message names the nearest one), the removed agent keys `timeout` and `maxTurns` (the message names `judgmentTimeout` and `maxModelCalls`), `system` or `tools` beside `executor`, a `tools` entry `defineTool` did not build or that takes a built-in tool name (a grammar verb, `complete_step`, `locate`, `start_recording`, `stop_recording`, or `report_finding`), `createToolLoopExecutor({ maxTurns })`, `reuseExisting` on an app whose `app.url` asks for a free port, or a port-0 `app.url` with no `app.command` to serve it | configuration | 2 |
88
88
  | `CONFIG_NOT_DETERMINISTIC` | A worker re-resolved the config module and got a different digest than the runner | configuration | 2 |
89
89
  | `APP_URL_REQUIRED` | A navigation or relative URL on a target that declares no `app.url`, or an `app.command` with no `app.url` or `app.readyUrl` to poll | configuration | 2 |
90
90
  | `INVALID_APP_URL` | Base URL with userinfo, a query, a fragment, a non-HTTP scheme, plain HTTP on a non-loopback host, port 0 on a host other than a literal loopback address (`127.0.0.1`, `[::1]`), a loopback address the run could not bind a free port on, or unparseable | configuration | 2 |
@@ -104,14 +104,14 @@ These appear in terminal output and in the report as plain string codes.
104
104
  | `NO_LAST_RUN` | `--last-failed` found no `report.json` where the run writes it, or a file there that is not a `report-1` document. Run once without the flag first. | configuration | 2 |
105
105
  | `ONLY_IN_CI` | A focused test reached a CI run | configuration | 2 |
106
106
  | `COLLECTION_ERROR` | Registration outside collection, an invalid title, a non-function body, an async `describe` body, nested serial groups, an option forbidden inside a serial group, a setup test inside `describe`, an invalid session name, a malformed test option such as `tags` that is not a list of distinct names, a test or `describe` option key the call does not take (the message names the nearest one), a test file that fails to import (in a run with no positionals, or a file the positionals selected), a session that a selected test consumes or `e2e explore --session` names but no collected setup test produces (the message lists the declared sessions), a consumed session on a target whose engine has no `state` capability to save it, or a positional path outside the project root | configuration | 2 |
107
- | `POLICY_DENIED` | A denied destination (`file:`, `data:`, `javascript:`, a malformed URL), a forbidden fill (an undeclared secret, a non-editable sink, a mismatched purpose, a `Secret` reaching a JSON sink), or tainted pixels (`app.screenshot()` after a secret fill, `vision: 'only'` with pixels withheld); see the [security model](/security). An unknown tool or an undeclared field goes back to the model as the call's result, never this code | configuration | 2 |
107
+ | `POLICY_DENIED` | A denied destination (a navigation scheme other than `http:` or `https:`, `about:blank` aside, a malformed URL), a forbidden fill (an undeclared secret, a non-editable sink, a mismatched purpose, a `Secret` reaching a JSON sink), or tainted pixels (`app.screenshot()` after a secret fill, `vision: 'only'` with pixels withheld); see the [security model](/security). An unknown tool or an undeclared field goes back to the model as the call's result, never this code | configuration | 2 |
108
108
  | `SESSION_MISMATCH` | A stored session does not match the current run, target, engine, platform, or app identity | configuration | 2 |
109
109
  | `SESSION_EXPIRED` | A stored session is past its expiry at restore time (configuration, exit 2), or a setup test saved state whose expiry is not after its creation (test, exit 1) | configuration or test | 2 or 1 |
110
110
  | `SESSION_INVALID` | A stored session failed authentication. Tampered or corrupt. | configuration | 2 |
111
111
  | `SESSION_UNAVAILABLE` | A test requires a session no selected setup test produced | configuration | 2 |
112
112
  | `SESSION_CONTRACT` | A setup test saved a session twice, saved an undeclared name, or finished without saving a declared one | test | 1 |
113
113
  | `TEST_TIMEOUT` | The attempt exceeded the resolved test `timeout`. The body is not stopped: the harness operation it is in is cancelled and its fixtures die with the attempt, so it cannot reach the next test | test | 1 |
114
- | `STEP_NOT_AWAITED` | The test body settled while a step it started was still running: a step call without `await`. The step is failed at the line of the call and cancelled; the attempt fails with this code when the body returned, or keeps the body's own error and records this one under `secondaryErrors` when the body threw | test | 1 |
114
+ | `STEP_NOT_AWAITED` | The test body settled while a step it started was still running: a step call without `await`. The step is failed at the line of the call and cancelled; the attempt fails with this code when the body returned, or keeps the body's own error and records this one under `secondaryErrors` when the body threw. An `expect.poll` matcher left running when the body, an `afterEach` hook, or a fixture teardown returned fails that phase the same way at the line of the call, and is cancelled; one left by a `beforeAll` or `afterAll` hook fails the hook with `HOOK_FAILED` | test | 1 |
115
115
  | `UNHANDLED_REJECTION` | A promise rejected with no test running. An unhandled rejection is charged to the test running when it surfaces, since Node cannot say which test created the promise; between tests it is this run error, naming the last test that finished in the worker as the likely source. Before any test has finished in the worker it ends the worker instead: the rejection is the run error and the test on its way fails with `WORKER_CRASH`. The body that left it keeps running like one past `TEST_TIMEOUT` | test | 1 |
116
116
  | `HOOK_FAILED` | `beforeAll` or `afterAll` threw. Skips the suite's tests. | test | 1 |
117
117
  | `LAUNCH_TIMEOUT` | Engine init or attempt start exceeded `config.launchTimeout` | infrastructure | 3 |
@@ -52,14 +52,17 @@ before passing its result to `expect`.
52
52
  | Default timeout | `config.assertionTimeout`, 5000 ms |
53
53
  | Override | `{ timeout }` in milliseconds, on every locator and `browser` matcher |
54
54
  | Unknown option | `INVALID_ARGUMENT` before the first read, from JavaScript too |
55
- | Negation grace window | A negated matcher passes after 1000 ms of continuous truth, or after the whole budget when `{ timeout }` is shorter than that |
55
+ | Negation grace window | A negated matcher passes after 1000 ms of continuous truth, or at the deadline when `{ timeout }` is shorter than that and the negation held for the whole budget. The window counts from the moment the first read that saw the negation started, so a slow read counts toward it |
56
56
  | Failure code | `ASSERTION_FAILED`, exit code 1 |
57
57
 
58
58
  Locator strictness: except for `toHaveCount` and the list form of
59
59
  `toHaveText` and `toContainText`, a positive matcher requires exactly one
60
60
  match; zero matches keep polling until the timeout, and multiple matches fail
61
- immediately with `LOCATOR_AMBIGUOUS`. `toBeHidden`, negated visibility, and
62
- negated `toBeAttached` accept zero matches.
61
+ immediately with `LOCATOR_AMBIGUOUS`. `toBeHidden`, `toBeVisible({ visible:
62
+ false })`, `toBeAttached({ attached: false })`, negated visibility, and
63
+ negated `toBeAttached` accept zero matches; for them, and for
64
+ `waitFor({ state: 'detached' })` and `'hidden'`, a locator under a frame that
65
+ is not in the document is zero matches rather than a wait for the frame.
63
66
 
64
67
  Text comparison is normalized: leading and trailing whitespace is trimmed and
65
68
  each run of whitespace collapses to one space. `toHaveText` and
@@ -201,8 +204,8 @@ Every matcher call is a recorded `expect.<matcher>` assertion step.
201
204
  export interface BrowserExpectation {
202
205
  /** Inverts the matcher. */
203
206
  readonly not: BrowserExpectation;
204
- /** Waits for the current URL to match. */
205
- toHaveURL(expected: string | RegExp, options?: { timeout?: number }): Promise<void>;
207
+ /** Waits for the current URL to match; `ignoreCase` compares a string case-insensitively and adds the `i` flag to a RegExp, `false` removes it. */
208
+ toHaveURL(expected: string | RegExp, options?: { ignoreCase?: boolean; timeout?: number }): Promise<void>;
206
209
  /** Waits for the current title to match. */
207
210
  toHaveTitle(expected: TextMatch, options?: { timeout?: number }): Promise<void>;
208
211
  /** Waits for the target's `class` attribute to match: a string is the whole normalized class list, a RegExp is tested against it. */
@@ -212,9 +215,10 @@ export interface BrowserExpectation {
212
215
 
213
216
  A relative `toHaveURL` string resolves against the base URL and matches
214
217
  exactly after normalization; a `RegExp` tests the full URL. Anything else,
215
- such as a predicate function, is `INVALID_ARGUMENT`. Each `browser` matcher takes
216
- `timeout` only; any other key, such as Playwright's `ignoreCase` on
217
- `toHaveURL`, is `INVALID_ARGUMENT`.
218
+ such as a predicate function, is `INVALID_ARGUMENT`. `toHaveURL` also takes
219
+ Playwright's `ignoreCase`, with the locator matchers' semantics. Otherwise each
220
+ `browser` matcher takes `timeout` only; any other key, such as `ignoreCase` on
221
+ `toHaveTitle`, which Playwright does not take either, is `INVALID_ARGUMENT`.
218
222
 
219
223
  `toHaveClass` polls for the node too: a target that matches nothing yet is one
220
224
  more poll, not a failure, until the assertion timeout runs out. An ambiguous
@@ -326,8 +330,11 @@ side is refused.
326
330
  `toHaveProperty` walks a dotted path or an array of keys. Use the array form
327
331
  when a key contains a dot, or to index an array by number
328
332
  (`['lines', 0, 'sku']`). Inherited properties count; a property holding
329
- `undefined` exists. With a second argument the value at the path must equal it
330
- by `toEqual` rules.
333
+ `undefined` exists. A string, number, or boolean on the path is read through
334
+ its wrapper object, as in Jest, so `toHaveProperty('label.length', 3)` and
335
+ `toHaveProperty(['label', 0], 'a')` reach into a string; `null` and
336
+ `undefined` have no properties. With a second argument the value at the path
337
+ must equal it by `toEqual` rules.
331
338
 
332
339
  ### Matching a schema
333
340
 
@@ -455,11 +462,15 @@ ASSERTION_FAILED: 2 soft assertions failed
455
462
  A soft locator or `browser` matcher is still a recorded `expect.<matcher>` step,
456
463
  marked failed, so the report shows where in the flow it landed. A soft value
457
464
  matcher is not a step, the same as a hard one; its failure appears in the
458
- final error only.
465
+ attempt's error records.
459
466
 
460
467
  When the body throws on its own, or times out, that error stays the
461
468
  attempt's verdict and the soft failures are recorded beside it under
462
- `secondaryErrors`. A body that skips itself with `test.skip(...)` drops them.
469
+ `secondaryErrors`. A body that skips itself with `test.skip(...)` keeps them
470
+ there too. The CLI shows `Skipped After Failure`; set
471
+ [`failOnSkippedFailure: true`](/reference/config) to make that run fail with
472
+ exit code 1. Without that option, the test stays skipped and does not affect
473
+ the exit code.
463
474
 
464
475
  A soft `toMatchSchema` returns the schema's output when the value matches
465
476
  and `undefined` after a kept failure, and its type says so.
@@ -544,3 +555,14 @@ await expect.poll(() => readStatus(), { message: 'the batch never finished' }).n
544
555
 
545
556
  `expect.poll` is not recorded as a report step; a timeout surfaces only as
546
557
  the test's failure.
558
+
559
+ Await every poll. A poll belongs to the phase that started it: the test body
560
+ (with its fixtures and `beforeEach` hooks), one `afterEach` hook or fixture
561
+ teardown, or one `beforeAll` or `afterAll` hook. When that phase returns while
562
+ the poll still runs, the poll is cancelled and the phase fails with
563
+ `STEP_NOT_AWAITED` at the line of the call, whether or not the poll would
564
+ have passed. It never fails a later test: a poll started by work its phase
565
+ left running, such as a body past its timeout, is cancelled before its first
566
+ read. A body that threw keeps its own
567
+ error and records this one under `secondaryErrors`, and a suite hook reports
568
+ it as `HOOK_FAILED`.
@@ -3,7 +3,8 @@ title: Mobile
3
3
  description: Mobile engine options and the device fixture for iOS and Android.
4
4
  ---
5
5
 
6
- `@e2e-dev/mobile` drives iOS simulators and Android emulators through
6
+ `@e2e-dev/mobile` drives iOS simulators, Android emulators, and connected
7
+ phones through
7
8
  [agent-device](https://github.com/callstack/agent-device). `agent`, `app`,
8
9
  `screen`, and `expect` work as they do everywhere; this page covers what is
9
10
  specific to a device target. Import `test` from the package to have `device`
@@ -79,8 +80,9 @@ export interface MobileOptions {
79
80
  /** Platform the target's device runs. */
80
81
  readonly platform: MobilePlatform;
81
82
  /**
82
- * Simulator or emulator to use, by name, simulator UDID, or emulator
83
- * serial (`emulator-5554`). A list is a pool: the
83
+ * Device to use: a simulator or emulator by name, simulator UDID, or
84
+ * emulator serial (`emulator-5554`), or a connected phone by name. A list
85
+ * is a pool: the
84
86
  * engine declares one worker per entry and worker slot `n` drives the
85
87
  * `n`th, so `workers` at or above the pool size runs the target's files
86
88
  * across every device at once. Omitted, the pool is every booted device of
@@ -278,8 +280,8 @@ export interface Device {
278
280
  * `relaunch` restarts it fresh. `launchArguments` and `permissions` apply
279
281
  * to this launch alone; the target's configured ones apply to a `relaunch`
280
282
  * of the pinned app, and to no foreground-only open. A link is
281
- * `INVALID_ARGUMENT` (`file:`, `data:`, and `javascript:` are
282
- * `POLICY_DENIED`): `openLink` opens one.
283
+ * `INVALID_ARGUMENT` (`file:`, `data:`, `javascript:`, `view-source:`,
284
+ * `blob:`, and `filesystem:` are `POLICY_DENIED`): `openLink` opens one.
283
285
  */
284
286
  openApp(app: string, options?: OpenAppOptions): Promise<void>;
285
287
  /**
@@ -289,8 +291,8 @@ export interface Device {
289
291
  * a web link the app does not claim goes to Safari, and the next
290
292
  * observation brings the app back. Android starts the link on that
291
293
  * package. Without an app, Android lets the OS route the link; iOS needs
292
- * one (`INVALID_ARGUMENT` without). `file:`, `data:`, and `javascript:`
293
- * links are `POLICY_DENIED`.
294
+ * one (`INVALID_ARGUMENT` without). `file:`, `data:`, `javascript:`,
295
+ * `view-source:`, `blob:`, and `filesystem:` links are `POLICY_DENIED`.
294
296
  */
295
297
  openLink(url: string, options?: { app?: string }): Promise<void>;
296
298
  /**
@@ -426,7 +428,10 @@ session opened. Android reads the device's foreground activity.
426
428
  `installApp` installs a build from inside a test or a fixture: the run's build once per device (no path installs `app.appPath`), or another build for an upgrade path. A plain
427
429
  install keeps the app's data; `reinstall: true` removes the app named by
428
430
  `app` (default: the pinned app) first. It resolves to the id to `openApp`
429
- the build by.
431
+ the build by: the bundle id or package agent-device read from the build, else
432
+ the `app` passed in. With no reported id and no `app`, the install is
433
+ `ENGINE_FAILURE` instead of a file path posing as an id; see
434
+ [Troubleshooting](/mobile#troubleshooting).
430
435
 
431
436
  ```ts
432
437
  const { app } = await device.installApp('./build/MyApp.app', { reinstall: true });
@@ -444,9 +449,9 @@ name (`Settings`); `relaunch: true` restarts it fresh, and `launchArguments`
444
449
  and `permissions` apply to that launch alone (the target's own apply to a
445
450
  `relaunch` of the pinned app). It takes no link:
446
451
  agent-device opens any `scheme:rest` string as a URL, so a link is refused
447
- before the device sees it. A `file:`, `data:`, or `javascript:` link is
448
- `POLICY_DENIED`, the rule every navigation follows; any other link is
449
- `INVALID_ARGUMENT` pointing at `openLink`.
452
+ before the device sees it. A `file:`, `data:`, `javascript:`,
453
+ `view-source:`, `blob:`, or `filesystem:` link is `POLICY_DENIED`; any other
454
+ link is `INVALID_ARGUMENT` pointing at `openLink`.
450
455
 
451
456
  `openLink` opens a deep link or a web link into `app` (default: the pinned
452
457
  app), for magic-link sign-in and deep-link routes, and the session observes
@@ -456,8 +461,13 @@ observation brings the app back. Android starts the link on that package.
456
461
  Without an app, Android lets the OS route the link, to a verified app link
457
462
  owner or the browser, and the session follows the package that took it. iOS
458
463
  needs an app: an open bound to none leaves no app session to observe, so it
459
- is `INVALID_ARGUMENT`. `file:`, `data:`, and `javascript:` links are
460
- `POLICY_DENIED`, as on the web. The report shows the link cut before its
464
+ is `INVALID_ARGUMENT`. `file:`, `data:`, `javascript:`, `view-source:`,
465
+ `blob:`, and `filesystem:` links are `POLICY_DENIED`. A device link may use
466
+ any app's custom scheme, so the rule refuses these schemes rather than
467
+ admitting http(s) alone as the web does. A string that does not parse as an
468
+ absolute URL is `INVALID_ARGUMENT` too, and its message names no part of the
469
+ input, which may carry a magic-link token in its query, path, or userinfo.
470
+ The report shows the link cut before its
461
471
  query and fragment, where a magic link carries its token. The replay cache
462
472
  anchors on the location the link lands on: a step recorded after `openLink`
463
473
  replays when the link lands on the same screen again.
@@ -171,7 +171,7 @@ prints. All reporters run concurrently, each with a one-minute deadline.
171
171
  A timeout, exception, or invalid return value is reported on stderr without
172
172
  changing the run's status, exit code, or report.
173
173
 
174
- One Ctrl-C still lets reporters finish. A second Ctrl-C stops waiting for them.
174
+ One Ctrl-C still lets reporters finish. A second Ctrl-C stops waiting for them, except the built-in `junit` and `markdown`, which still write their files so none of the previous run's is left beside the new report.
175
175
 
176
176
  The second argument is an `AbortSignal` that aborts when the budget runs out
177
177
  or the run is forced to stop. Hand it to every request and timer, or an
@@ -180,7 +180,13 @@ abandoned reporter keeps the CLI alive after the run has ended.
180
180
  With `--last-failed`, `lastRun` is the report the rerun selected from. The
181
181
  rerun's own report lists the tests it left out as unselected, so a reporter
182
182
  that keeps one place current, the way `@e2e-dev/github` keeps a pull request
183
- comment, folds the rerun into `lastRun` to show both runs as one.
183
+ comment, folds the rerun into `lastRun` to show both runs as one. A test the
184
+ rerun owed but another filter left out is in the rerun's `run.carried`: its
185
+ last result, the serial group its attempts live on, and the `beforeAll` or
186
+ `afterAll` failure whose scope holds it, from whichever earlier run reported
187
+ them. A fold shows the carried result in place of the unselected one and
188
+ counts a carried failure or hook error against the outcome. The artifacts
189
+ both reports name are on disk under `artifactsRoot`.
184
190
 
185
191
  The rows a reporter resolves with print under the `list` summary in the same
186
192
  layout as `Report`; the `json` reporter does not carry them: