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,14 +6,9 @@ icon: "globe"
6
6
  "twitter:image": "https://e2e.tester.army/docs/images/og/reference/web.png?v=66fee465a4"
7
7
  ---
8
8
 
9
- `@e2e-dev/web` drives Chromium, Firefox, and WebKit through Playwright.
10
- A web target names it as its engine, and it contributes the `browser` fixture for
11
- browser-only work. Import `test` from the package to have `browser` typed.
12
-
13
- The package depends on `playwright-core` pinned to an exact version, so the
14
- engine always runs the Playwright it was tested against. An app that depends
15
- on Playwright itself keeps its own copy; the two share a browser cache only
16
- when their versions match.
9
+ `@e2e-dev/web` is the browser engine. It drives Chromium, Firefox, and WebKit
10
+ through Playwright. A web target names it as its engine with `web()`, and it
11
+ contributes the `browser` fixture for browser-only work.
17
12
 
18
13
  ```ts title="e2e.config.ts"
19
14
  import type { E2EConfig } from 'e2e';
@@ -24,12 +19,885 @@ export default {
24
19
  } satisfies E2EConfig;
25
20
  ```
26
21
 
22
+ Import `test` from the package to have `browser` typed:
23
+
24
+ ```ts title="tests/orders.e2e.ts"
25
+ import { expect } from 'e2e';
26
+ import { test } from '@e2e-dev/web';
27
+
28
+ test('orders in dark mode', async ({ app, browser, screen }) => {
29
+ await browser.emulateMedia({ colorScheme: 'dark' });
30
+ await app.open('/orders');
31
+ await expect(browser).toHaveURL('/orders');
32
+ await expect(screen.getByRole('heading', { name: 'Orders' })).toBeVisible();
33
+ });
34
+ ```
35
+
36
+ ## Reference
37
+
38
+ ### Options
39
+
40
+ `web(options)` sets how the engine drives the app. The app itself (its
41
+ `url`, `environment`, `identity`, and the command that starts it) is the
42
+ target's `app`, documented under
43
+ [the app under test](/reference/config#the-app-under-test).
44
+
45
+ ```ts
46
+ web({ viewport: { width: 390, height: 844 }, locale: 'de-DE' });
47
+ ```
48
+
49
+ | Option | Type | Default |
50
+ | --- | --- | --- |
51
+ | [`browser`](#browser) | `BrowserName \| BrowserProvider` | `'chromium'` |
52
+ | [`viewport`](#viewport) | `ViewportSize \| null` | 1280 by 720 |
53
+ | [`screencast`](#screencast) | `WebScreencastOptions` | the viewport's size |
54
+ | [`connect`](#connect) | `WebConnectOptions` | launches locally |
55
+ | [`headers`](#headers) | `Record<string, string>` | - |
56
+ | [`basicAuth`](#basicauth) | `WebBasicAuth` | - |
57
+ | [`testIdAttribute`](#testidattribute) | `string` | `'data-testid'` |
58
+ | [`userAgent`](#useragent) | `string` | the browser's own |
59
+ | [`locale`](#locale) | `string` | the browser's own |
60
+ | [`timezoneId`](#timezoneid) | `string` | the machine's |
61
+ | [`colorScheme`](#media-emulation) | `'light' \| 'dark' \| 'no-preference'` | the browser's own |
62
+ | [`reducedMotion`](#media-emulation) | `'reduce' \| 'no-preference'` | the browser's own |
63
+ | [`initScripts`](#init-scripts) | `WebInitScript[]` | - |
64
+
65
+ - A `web()` target needs `app.url` and refuses the device keys (`bundleId`,
66
+ `appPath`, `launchArguments`, `permissions`).
67
+ - A target with `app.url` is a `browser` target, so `requires: ['browser']`
68
+ selects web targets.
69
+ - The full declaration with its JSDoc is [`WebOptions`](#weboptions) under
70
+ Types.
71
+
72
+ | Throws | When |
73
+ | --- | --- |
74
+ | `INVALID_CONFIG` | An option `web()` does not take (`url` or any other app key included), or an unknown key inside `connect` or `basicAuth`. The message names the nearest known key for a likely typo (`viewprt` is `viewport`), else every key. |
75
+ | `INVALID_CONFIG` | `headers`, `basicAuth`, `userAgent`, `locale`, or `timezoneId` on a [persistent context](#recovering-a-cdp-transport) (`connect.reconnectEndpoint`, or a provider with `scope: 'attempt'`). |
76
+
77
+ #### `browser`
78
+
79
+ The browser to launch, or a provider that leases hosted browsers.
80
+
81
+ ```ts
82
+ web({ browser: 'firefox' }); // default: 'chromium'
83
+ ```
84
+
85
+ - `'chromium'`, `'firefox'`, or `'webkit'`.
86
+ - A `BrowserProvider` leases hosted browsers instead: one per worker slot for
87
+ the run, or one per attempt ([`WebOptions`](#weboptions)). The engine
88
+ drives each one as chromium over CDP, whether it is Chromium or another CDP
89
+ server such as [Lightpanda](/integrations/lightpanda).
90
+ - A provider with `scope: 'attempt'` rides a persistent context, with the
91
+ limits under [Recovering a CDP transport](#recovering-a-cdp-transport).
92
+ - [Hosted browsers](/browser#hosted-browsers) has the `BrowserProvider`
93
+ contract and an example provider.
94
+
95
+ | Throws | When |
96
+ | --- | --- |
97
+ | `INVALID_CONFIG` | `browser: provider` together with `connect`. |
98
+
99
+ #### `viewport`
100
+
101
+ The initial viewport of every attempt's page.
102
+
103
+ ```ts
104
+ web({ viewport: { width: 390, height: 844 } }); // default: { width: 1280, height: 720 }
105
+ ```
106
+
107
+ `null` emulates no size, so the page fills the browser window
108
+ ([`WebOptions`](#weboptions)). [`browser.setViewport`](#setviewport) still
109
+ fixes one.
110
+
111
+ #### `screencast`
112
+
113
+ How the engine's own screencast records an attempt that records video.
114
+
115
+ ```ts
116
+ web({ screencast: { size: { width: 800, height: 450 }, quality: 60 } });
117
+ ```
118
+
119
+ - `size` takes whole pixels, each side at least 1. `quality` is an integer.
120
+ - Which attempts record is the config's [`video`](/reference/config#video)
121
+ mode.
122
+
123
+ ```ts
124
+ /** The engine's screencast of the page: the frame size and the JPEG quality of the frames it encodes. */
125
+ export interface WebScreencastOptions {
126
+ /** Frame size in pixels; default the viewport's, or the window's under `viewport: null`. A smaller size makes a smaller file. */
127
+ readonly size?: ViewportSize;
128
+ /** JPEG quality of each captured frame, 0 through 100; Playwright's default when unset. */
129
+ readonly quality?: number;
130
+ }
131
+ ```
132
+
133
+ | Throws | When |
134
+ | --- | --- |
135
+ | `INVALID_CONFIG` | Any other `size` or `quality` value, or another key, at config load. The old `web({ video })` spelling is also refused, naming `screencast`. |
136
+
137
+ #### `connect`
138
+
139
+ Attaches to a remote browser over CDP instead of launching one locally.
140
+
141
+ ```ts
142
+ web({ connect: { cdpEndpoint: async () => 'ws://127.0.0.1:9222' } });
143
+ ```
144
+
145
+ `reconnectEndpoint` opts into a persistent context; see
146
+ [Recovering a CDP transport](#recovering-a-cdp-transport).
147
+
148
+ ```ts
149
+ interface WebConnectOptions {
150
+ cdpEndpoint(signal: AbortSignal): string | Promise<string>;
151
+ reconnectEndpoint?(signal: AbortSignal): string | Promise<string>;
152
+ }
153
+ ```
154
+
155
+ | Throws | When |
156
+ | --- | --- |
157
+ | `INVALID_CONFIG` | A `connect` without `cdpEndpoint`, or with one given as a string, at config load. Also `connect` beside a `browser` provider. |
158
+
159
+ <a id="recovering-a-cdp-transport" />
160
+
161
+ ##### Recovering a CDP transport
162
+
163
+ `connect.reconnectEndpoint(signal)` opts into recovery of the same remote
164
+ browser after its CDP transport disconnects. Omit it to keep isolated
165
+ contexts.
166
+
167
+ The remote browser:
168
+
169
+ - Every attempt, retries included, needs its own fresh browser from
170
+ `cdpEndpoint`. One long-lived browser cannot serve a suite.
171
+ - The remote must have only its default context.
172
+ - The host must delete the browser when the attempt ends. Engine cleanup
173
+ detaches its connection and does not terminate the host's browser.
174
+ - The attempt's first page is the browser's own first tab, navigated to
175
+ `about:blank`, not a second tab beside it. A page opened later, such as
176
+ the one `app.restart` opens, is a new tab.
177
+
178
+ How recovery runs:
179
+
180
+ - After a disconnect, the next operation calls `reconnectEndpoint` once,
181
+ within that operation's timeout and abort signal. The resolver must return
182
+ the existing browser's endpoint.
183
+ - The engine checks the persistent default context ID and the original
184
+ page's target ID. It refuses a browser that does not expose the default
185
+ context ID. A missing page or a different context fails the attempt. A
186
+ matching URL does not select a replacement tab.
187
+ - The engine installs secure-field masking before it publishes each CDP
188
+ binding. An incompatible Playwright version fails the connection instead of
189
+ exposing pixels.
190
+ - The original page's frames must keep their closed shadow DOM tracking
191
+ hooks. Existing closed roots cannot recover without a reload of the
192
+ application. Mask queries reject any untracked document.
193
+ - Endpoint resolution, CDP attachment, setup, and dispatch share one timeout.
194
+ Cancelling a concurrent waiter does not cancel the operation that started
195
+ recovery. Cancelling that recovery owner fails the shared recovery.
196
+
197
+ | Throws | When |
198
+ | --- | --- |
199
+ | `ENGINE_FAILURE` | A document loaded without the closed shadow DOM hook while disconnected. |
200
+ | `OPERATION_TIMEOUT` | Endpoint resolution, attachment, setup, and dispatch exhausted their shared timeout. |
201
+ | `CANCELLED` | The caller or the attempt cancelled. |
202
+
203
+ What survives:
204
+
205
+ - Recovery keeps the page's unsaved state, storage, the attempt's secret
206
+ taint, and the agent ledger. Dialog and route handlers are reattached.
207
+ - Video recording resumes. A segment lost during the disconnect is gone.
208
+ - Operations already dispatched are never retried.
209
+ - Recovery clears old node references. Observation-derived `tapAt` calls,
210
+ observation-root swipes, and focused engine keyboard input need a fresh
211
+ engine observation before they resume.
212
+ - An observation or locator read started before recovery cannot publish
213
+ references after the connection changes. Each attempt owns its binding and
214
+ recordings, so late cleanup cannot change a later attempt.
215
+ - The deterministic `browser.mouse` and `browser.keyboard` fixtures stay
216
+ available without an agent observation. Test code owns its input and can
217
+ read current geometry and focus with `browser.evaluate` before acting.
218
+ - Code that uses `surfaceOf(engine)` must read its accessors again after
219
+ recovery. Playwright objects it kept belong to the disconnected transport.
220
+
221
+ A `browser` provider with `scope: 'attempt'` rides the same persistent
222
+ context, with the same limits. The lease's `cdpEndpoint` provisions the
223
+ attempt's browser, and its `reconnectEndpoint` (else `cdpEndpoint` again)
224
+ reconnects to it. A lease is fresh by the provider's contract, so the engine
225
+ does not compare its context with earlier attempts'. A hosted service may
226
+ restore browsers from one snapshot, which gives new browsers the same
227
+ context id.
228
+
229
+ To reuse one sign-in across files, keep isolated contexts (no
230
+ `reconnectEndpoint`, no provider `scope: 'attempt'`) and
231
+ [sign in once in a setup test](/authentication#sign-in-once-in-a-setup-test).
232
+ A saved session carries it, not a shared browser.
233
+
234
+ <Note>
235
+ A persistent context does not support `headers`, `basicAuth`, `userAgent`,
236
+ `locale`, `timezoneId` (see [Options](#options)), `app.clearState`, or
237
+ session state capture and restore. `app.clearState` fails with
238
+ `UNSUPPORTED_CAPABILITY`. `app.restart` remains available.
239
+ </Note>
240
+
241
+ #### `headers`
242
+
243
+ Headers added to requests within the app's site, including subdomains.
244
+
245
+ ```ts
246
+ web({ headers: { 'x-preview-token': process.env.PREVIEW_TOKEN ?? '' } });
247
+ ```
248
+
249
+ - A [continued](#route) request bound for the app's site still carries them.
250
+ - `headers` and `basicAuth` are how a test reaches a
251
+ [protected preview](/web#protected-previews).
252
+
253
+ | Throws | When |
254
+ | --- | --- |
255
+ | `INVALID_CONFIG` | A `user-agent` entry beside [`userAgent`](#useragent), or an `accept-language` entry beside [`locale`](#locale). |
256
+
257
+ <Note>
258
+ `headers` enables request routing, which disables the browser's HTTP cache and
259
+ blocks service workers.
260
+ </Note>
261
+
262
+ #### `basicAuth`
263
+
264
+ HTTP basic authentication. The browser answers a `401` challenge with these
265
+ credentials on any origin, matching Playwright's `httpCredentials`.
266
+
267
+ ```ts
268
+ import { secrets } from 'e2e';
269
+
270
+ web({ basicAuth: { username: 'staging', password: secrets.get('staging-password') } });
271
+ ```
272
+
273
+ A `secrets.get(name)` password resolves when each attempt starts and is
274
+ redacted like any configured secret. So is the base64 credential of the
275
+ `Authorization` header it becomes.
276
+
277
+ ```ts
278
+ /** HTTP basic authentication the browser answers a `401` challenge with. */
279
+ export interface WebBasicAuth {
280
+ /** The user name; `:` is not allowed in one (RFC 7617). */
281
+ readonly username: string;
282
+ /** The password, or `secrets.get(name)` for one in `config.secrets`. */
283
+ readonly password: string | Secret;
284
+ }
285
+ ```
286
+
287
+ | Throws | When |
288
+ | --- | --- |
289
+ | `INVALID_CONFIG` | A `:` in the user name, or an unknown key. |
290
+
291
+ <Warning>
292
+ The secret protection is text only. Unlike a fill, it withholds no screenshot
293
+ or model pixels, so a page that renders the password on screen is not masked
294
+ in them ([Security rules](/reference/security#secrets-in-engine-options)).
295
+ </Warning>
296
+
297
+ #### `testIdAttribute`
298
+
299
+ The attribute that carries an element's test id: what the `testId` query
300
+ (`screen.getByTestId`) resolves and what `SemanticNode.testId` reports.
301
+
302
+ ```ts
303
+ web({ testIdAttribute: 'data-test' }); // default: 'data-testid'
304
+ ```
305
+
306
+ #### `userAgent`
307
+
308
+ The `User-Agent` every attempt's context sends and `navigator.userAgent`
309
+ reports, for an app that switches into a test mode on a marker in the agent
310
+ string.
311
+
312
+ ```ts
313
+ web({ userAgent: 'Mozilla/5.0 e2e' }); // default: the browser's own
314
+ ```
315
+
316
+ #### `locale`
317
+
318
+ The locale every attempt's context runs in, a BCP 47 tag: what
319
+ `navigator.language`, `Intl` formatting, and the `Accept-Language` header
320
+ report.
321
+
322
+ ```ts
323
+ web({ locale: 'de-DE' }); // default: the browser's own
324
+ ```
325
+
326
+ #### `timezoneId`
327
+
328
+ The IANA time zone every attempt's context runs in: what `Date` and `Intl`
329
+ resolve local time against.
330
+
331
+ ```ts
332
+ web({ timezoneId: 'Europe/Berlin' }); // default: the machine's
333
+ ```
334
+
335
+ #### Media emulation
336
+
337
+ `web({ colorScheme, reducedMotion })` sets the media features every page of
338
+ every attempt reports, as Playwright's context options of the same names do.
339
+ `browser.emulateMedia(media)` changes them for one attempt.
340
+
341
+ ```ts
342
+ web({ colorScheme: 'dark', reducedMotion: 'reduce' }); // default: the browser's own
343
+ await browser.emulateMedia({ colorScheme: 'light' });
344
+ ```
345
+
346
+ - The page's `@media` rules and `matchMedia` read them from the first paint.
347
+ - A persistent context takes the options too: the engine sets them on each
348
+ page it opens.
349
+ - `emulateMedia` applies for the rest of the attempt, on every open tab and
350
+ every tab opened after them, through `app.restart()` and `app.clearState()`.
351
+ - It works before the first navigation, so the next `app.open` loads with
352
+ them.
353
+ - A feature it leaves out keeps its value.
354
+ - A tab the app opens itself gets a test's change as it appears, on a
355
+ persistent context too, so a script it runs at load may still read the
356
+ configured values.
357
+
358
+ ```ts
359
+ /** User preferences a page reads through `@media` rules and `matchMedia`. */
360
+ export interface WebMedia {
361
+ /** `prefers-color-scheme`. */
362
+ readonly colorScheme?: 'light' | 'dark' | 'no-preference';
363
+ /** `prefers-reduced-motion`. */
364
+ readonly reducedMotion?: 'reduce' | 'no-preference';
365
+ }
366
+ ```
367
+
368
+ | Throws | When |
369
+ | --- | --- |
370
+ | `INVALID_CONFIG` | A feature or value outside `WebMedia`, in `web()`. |
371
+ | `INVALID_ARGUMENT` | A feature or value outside `WebMedia`, in `emulateMedia`. |
372
+
373
+ #### Init scripts
374
+
375
+ An init script runs in every document before the page's own scripts, in every
376
+ tab and frame. `web({ initScripts })` applies to every test;
377
+ `browser.addInitScript` adds one for the rest of the test.
378
+
379
+ ```ts
380
+ await browser.addInitScript((value) => { Math.random = () => value; }, 0.5);
381
+ await app.open('/raffle');
382
+ ```
383
+
384
+ - A function cannot close over test variables. Pass a JSON `arg` instead.
385
+ - A script added after the page opened runs from the next navigation or
386
+ reload.
387
+ - `browser.addInitScript` takes the same three forms, or a function with its
388
+ JSON `arg`. Its overloads are on [`Browser`](#browser-interface).
389
+
390
+ ```ts
391
+ /** JavaScript source, a file relative to the project root, or a function. */
392
+ export type WebInitScript = string | { readonly path: string } | (() => unknown);
393
+ ```
394
+
395
+ ### The browser fixture
396
+
397
+ `browser` is the whole browser, showing one active tab.
398
+
399
+ - What reads or acts on a page acts on the active tab: `goto`, `reload`,
400
+ `back`, `forward`, `url`, `title`, `waitForURL`, `locator`, `frameLocator`,
401
+ `evaluate`, `waitForResponse`, `waitForDownload`, `keyboard`, `mouse`, the
402
+ `expect(browser)` matchers, and every `screen` query.
403
+ - What belongs to the browser covers every tab: `cookies`, `setCookies`,
404
+ `route`, `unroute`, `onDialog`, and `addInitScript`.
405
+ - `setViewport` sizes the active tab and every tab opened after it, and
406
+ `emulateMedia` sets its media the same way.
407
+ - A tab the app opens itself, from a `target="_blank"` link or `window.open`,
408
+ is not followed. The active tab stays the one the attempt opened. Assert on
409
+ what that tab shows, or open the link's URL with `browser.goto`.
410
+ - Every `browser` call is a recorded `browser.<method>` step, except
411
+ `locator` and `frameLocator`. They build a locator without touching the
412
+ page, and the action or assertion on it is the step.
413
+ - A relative URL resolves against the target's `app.url`, else
414
+ `APP_URL_REQUIRED`.
415
+ - Declare `requires: ['browser']` in a suite that also runs on other
416
+ platforms.
417
+ - `expect(browser)` returns the `BrowserExpectation` the engine attaches:
418
+ `toHaveURL`, `toHaveTitle`, and `toHaveClass`. See
419
+ [browser matchers](/reference/expect#browser-matchers).
420
+
421
+ | Member | Returns |
422
+ | --- | --- |
423
+ | [`goto`](#goto) | `Promise<void>` |
424
+ | [`reload`](#reload-back-forward) | `Promise<void>` |
425
+ | [`back`](#reload-back-forward) | `Promise<void>` |
426
+ | [`forward`](#reload-back-forward) | `Promise<void>` |
427
+ | [`url`](#url-title) | `Promise<string>` |
428
+ | [`title`](#url-title) | `Promise<string>` |
429
+ | [`waitForURL`](#waitforurl) | `Promise<void>` |
430
+ | [`locator`](#locator) | `Locator` |
431
+ | [`frameLocator`](#framelocator) | `FrameScreen` |
432
+ | [`evaluate`](#evaluate) | `Promise<T>` |
433
+ | [`addInitScript`](#init-scripts) | `Promise<void>` |
434
+ | [`route`](#route) | `Promise<void>` |
435
+ | [`unroute`](#unroute) | `Promise<void>` |
436
+ | [`waitForResponse`](#waitforresponse) | `Promise<WebResponse>` |
437
+ | [`cookies`](#cookies) | `Promise<Cookie[]>` |
438
+ | [`setCookies`](#setcookies) | `Promise<void>` |
439
+ | [`setViewport`](#setviewport) | `Promise<void>` |
440
+ | [`emulateMedia`](#media-emulation) | `Promise<void>` |
441
+ | [`onDialog`](#ondialog) | `Promise<() => Promise<void>>` |
442
+ | [`waitForDownload`](#waitfordownload) | `Promise<{ path, absolutePath, … }>` |
443
+ | [`keyboard`](#keyboard) | `{ press, down, up, type }` |
444
+ | [`mouse`](#mouse) | `{ move, wheel, down, up }` |
445
+
446
+ The [config](/reference/config) sets each member's budget:
447
+
448
+ - `goto`, `reload`, `back`, and `forward`: [`timeout`](/reference/config#timeout),
449
+ the same budget `app.open()` uses.
450
+ - `waitForURL`: [`assertionTimeout`](/reference/config#assertiontimeout).
451
+ - Every other member: [`actionTimeout`](/reference/config#actiontimeout).
452
+ `locator` and `frameLocator` touch no page and take none.
453
+
454
+ A `{ timeout }` option overrides the budget on `goto`, `reload`, `back`,
455
+ `forward`, `waitForResponse`, and `waitForDownload`, capped by the test
456
+ deadline. `url`, `title`, `evaluate`, `addInitScript`, `route`, `unroute`,
457
+ `cookies`, `setCookies`, `setViewport`, `emulateMedia`, `onDialog`,
458
+ `keyboard.*`, and `mouse.*` take no override.
459
+
460
+ #### `goto`
461
+
462
+ Navigates the active tab to a URL.
463
+
464
+ ```ts
465
+ await browser.goto('/dashboard', { waitUntil: 'networkidle' }); // default: 'load'
466
+ ```
467
+
468
+ `waitUntil` is `'load'`, `'domcontentloaded'`, or `'networkidle'`.
469
+
470
+ | Throws | When |
471
+ | --- | --- |
472
+ | `POLICY_DENIED` | A URL whose scheme is not `http:` or `https:`. `about:blank` is admitted. |
473
+ | `APP_URL_REQUIRED` | A relative URL, on a target that declares no `app.url`. |
474
+ | `ACTION_FAILED` | The navigation failed. |
475
+
476
+ #### `reload`, `back`, `forward`
477
+
478
+ `reload` reloads the current document. `back` and `forward` navigate browser
479
+ history once.
480
+
481
+ ```ts
482
+ await browser.reload();
483
+ await browser.back({ timeout: 10_000 });
484
+ ```
485
+
486
+ | Throws | When |
487
+ | --- | --- |
488
+ | `ACTION_FAILED` | The navigation failed. |
489
+
490
+ #### `url`, `title`
491
+
492
+ Return the active tab's current URL and title.
493
+
494
+ ```ts
495
+ const url = await browser.url();
496
+ const title = await browser.title();
497
+ ```
498
+
499
+ #### `waitForURL`
500
+
501
+ Waits for the current URL to match, polling every 100 ms. It is the same poll
502
+ as `expect(browser).toHaveURL`.
503
+
504
+ ```ts
505
+ await browser.waitForURL(/\/orders\/\d+$/, { timeout: 15_000 });
506
+ ```
507
+
508
+ - A relative string resolves against the base URL and matches exactly after
509
+ normalization.
510
+ - A `RegExp` tests the full URL.
511
+
512
+ | Throws | When |
513
+ | --- | --- |
514
+ | `INVALID_ARGUMENT` | A pattern that is not a string or `RegExp` (a predicate function, say), or an option other than `timeout`, such as Playwright's `waitUntil`. |
515
+ | `ASSERTION_FAILED` | The URL did not match before the timeout. |
516
+
517
+ #### `locator`
518
+
519
+ Creates a web-only locator from a Playwright selector.
520
+
521
+ ```ts
522
+ const conditions = browser.locator(
523
+ '[data-testid="row"] >> nth=2 >> [data-testid="conditions"] > div',
524
+ );
525
+ ```
526
+
527
+ - The selector is CSS by default, XPath with `xpath=` or a leading `//`, and
528
+ takes `>>` chains, so the example scopes CSS to one row.
529
+ - Use `filter({ has })` for an element that contains another.
530
+
531
+ | Throws | When |
532
+ | --- | --- |
533
+ | `INVALID_LOCATOR` | An empty selector, or a `*` capture before the last part (`*css=article >> text=Hello`). |
534
+
535
+ <Note>
536
+ Every `screen` query searches its scope and each closed shadow root the page
537
+ attached under it, with Playwright's own role, name, text, label, and
538
+ attribute matching and each node found once. A control the observed tree
539
+ lists inside a closed root resolves and takes actions and reads. `locator`,
540
+ `frameLocator`, and `filter({ hasText })` keep Playwright's reach and stop at
541
+ a closed root; the [browser guide](/browser#shadow-dom) has the details.
542
+ </Note>
543
+
544
+ #### `frameLocator`
545
+
546
+ Creates a screen query scope inside one iframe.
547
+
548
+ ```ts
549
+ const payment = browser.frameLocator('#payment-frame');
550
+ await payment.getByLabel('Card number').fill('4242424242424242');
551
+ ```
552
+
553
+ It returns a `FrameScreen`, a `Screen` whose queries answer from inside the
554
+ frame. Use the frame's `locator` only when the control has no accessible
555
+ name, label, placeholder, or test id. The
556
+ [browser guide](/browser#reach-into-an-iframe) shows when that applies.
557
+
558
+ ```ts
559
+ export interface FrameScreen extends Screen {
560
+ /** Creates a web-only CSS or XPath locator inside this frame. */
561
+ locator(selector: string): Locator;
562
+ /** Creates a screen query scope inside an iframe nested in this frame. */
563
+ frameLocator(selector: string): FrameScreen;
564
+ }
565
+ ```
566
+
567
+ The errors are the same at the page and inside a frame:
568
+
569
+ | Throws | When |
570
+ | --- | --- |
571
+ | `INVALID_LOCATOR` | An empty selector, here or in the frame's `locator`. |
572
+ | `LOCATOR_NOT_FOUND` | No frame matches. |
573
+ | `LOCATOR_AMBIGUOUS` | Several frames match. |
574
+
575
+ #### `evaluate`
576
+
577
+ Evaluates trusted test code in the page and returns its result.
578
+
579
+ ```ts
580
+ const count = await browser.evaluate(() => document.querySelectorAll('li').length);
581
+ const flag = await browser.evaluate(
582
+ (name: string) => window.localStorage.getItem(name),
583
+ 'featureFlags',
584
+ );
585
+ const title = await browser.evaluate<string>('document.title');
586
+ ```
587
+
588
+ - The function runs in the page and cannot access variables from the test.
589
+ e2e serializes it by its source and inlines it into the page, so it must
590
+ not close over anything outside its own body. Helpers declared inside the
591
+ body work. Pass outside values through `arg`.
592
+ - A string is evaluated as a single expression, not as statements. If it
593
+ evaluates to a function, that function is called with `arg`. Else its
594
+ value is the result.
595
+ - A promise, from either, is awaited.
596
+
597
+ | Throws | When |
598
+ | --- | --- |
599
+ | `INVALID_ARGUMENT` | `arg` or the return value is not a [`JsonValue`](#jsonvalue). |
600
+ | `EVALUATE_FAILED` | The evaluated code threw. The error carries its message. |
601
+ | `ACTION_FAILED` | A Playwright timeout. |
602
+ | `ENGINE_FAILURE` | The page closed or the context was lost. |
603
+
604
+ #### `route`
605
+
606
+ Adds an attempt-scoped network route.
607
+
608
+ ```ts
609
+ await browser.route('**/api/quote', async (route) => {
610
+ await route.fulfill({ json: { cents: 4200 } });
611
+ });
612
+ ```
613
+
614
+ - The pattern is a string or a `RegExp`, both tested against the full URL. In
615
+ a string, `*` matches within a path segment, `**` across `/`, `?` any one
616
+ character, and `\` escapes the next. `unroute` and `waitForResponse` take
617
+ the same patterns.
618
+ - A route survives `app.restart()`, `app.clearState()`, and session restore.
619
+ - Routes run newest first.
620
+ - The handler must decide exactly once, with `fulfill`, `continue`,
621
+ `fallback`, or `abort`, each described on `WebRoute` below.
622
+ - A `continue` URL goes through the same rule as `goto`.
623
+ - A handler that throws (a failed `expect` on the request, say) fails the
624
+ next step with that error, or the attempt at its end when no step follows.
625
+ A handler still running then gets the cleanup budget to finish. Whatever it
626
+ throws after the budget is dropped.
627
+
628
+ | Throws | When |
629
+ | --- | --- |
630
+ | `INVALID_ARGUMENT` | A predicate function as the pattern. Also an option a decision does not take (Playwright's `abort(errorCode)`, a misspelled key): the request is aborted and the next step fails. |
631
+ | `ACTION_FAILED` | The handler decided twice or not at all. |
632
+ | `POLICY_DENIED` | A `continue` URL with a forbidden scheme. |
633
+ | `CLEANUP_TIMEOUT` | A handler outlived the cleanup budget. It fails the attempt. |
634
+
635
+ ```ts
636
+ /**
637
+ * One of `json`, `body`, or `path`, or none for an empty body. `path` names a
638
+ * file relative to the project root; its `Content-Type` follows its
639
+ * extension unless `contentType` or a `content-type` header sets one.
640
+ */
641
+ export type RouteFulfillResponse = {
642
+ status?: number;
643
+ headers?: Record<string, string>;
644
+ /** Sets the `Content-Type` response header. */
645
+ contentType?: string;
646
+ } & (
647
+ | { json: JsonValue; body?: never; path?: never }
648
+ | { body: string; json?: never; path?: never }
649
+ | { path: string; json?: never; body?: never }
650
+ | { body?: never; json?: never; path?: never }
651
+ );
652
+
653
+ /** What `continue` changes about the request before it goes to the network. */
654
+ export interface RouteContinueOverrides {
655
+ /** Resolved against the base URL like `goto`; must keep the request's scheme. */
656
+ url?: string;
657
+ /** Request method. */
658
+ method?: string;
659
+ /** Replaces every request header; the configured `headers` for the app's site are added on top. */
660
+ headers?: Record<string, string>;
661
+ /** Request body. */
662
+ postData?: string;
663
+ }
664
+
665
+ export interface WebRoute {
666
+ /** The intercepted request. */
667
+ readonly request: {
668
+ readonly url: string;
669
+ readonly method: string;
670
+ readonly headers: Readonly<Record<string, string>>;
671
+ readonly postData?: string;
672
+ };
673
+ /** Fulfills the intercepted request once. */
674
+ fulfill(response: RouteFulfillResponse): Promise<void>;
675
+ /** Sends the intercepted request to the network once, skipping every other route. */
676
+ continue(overrides?: RouteContinueOverrides): Promise<void>;
677
+ /** Hands the intercepted request to the route registered before this one, or the network when none matches. */
678
+ fallback(): Promise<void>;
679
+ /** Aborts the intercepted request once. */
680
+ abort(): Promise<void>;
681
+ }
682
+ ```
683
+
684
+ #### `unroute`
685
+
686
+ Removes the attempt-scoped routes that match a pattern, in the
687
+ [`route`](#route) pattern syntax.
688
+
689
+ ```ts
690
+ await browser.unroute('**/api/quote');
691
+ ```
692
+
693
+ | Throws | When |
694
+ | --- | --- |
695
+ | `INVALID_ARGUMENT` | A predicate function as the pattern. |
696
+
697
+ #### `waitForResponse`
698
+
699
+ Waits for a response that matches a pattern, in the [`route`](#route) pattern
700
+ syntax.
701
+
702
+ ```ts
703
+ const response = await browser.waitForResponse('**/api/orders');
704
+ expect(response.status).toBe(201);
705
+ ```
706
+
707
+ - The deadline bounds the match: the response resolves as soon as its headers
708
+ arrive.
709
+ - `text()` and `json()` wait for the body on a budget of their own,
710
+ `config.actionTimeout` capped by the test deadline.
711
+ - `url`, `status`, and `headers` are always readable.
712
+ - A genuinely empty body reads as `''`.
713
+
714
+ | Throws | When |
715
+ | --- | --- |
716
+ | `INVALID_ARGUMENT` | A predicate function as the pattern. |
717
+ | `ACTION_FAILED` | Nothing matches inside the deadline. From `text()` and `json()`: a body still streaming when their budget runs out, or a body the browser could not read (a connection reset short of the declared `Content-Length`, a request the page aborted, or a redirect, whose body the browser never keeps), instead of an empty string. |
718
+
719
+ ```ts
720
+ export interface WebResponse {
721
+ /** Response URL. */
722
+ readonly url: string;
723
+ /** HTTP status. */
724
+ readonly status: number;
725
+ /** Response headers, lower-cased names. */
726
+ readonly headers: Readonly<Record<string, string>>;
727
+ /** Waits for the body and parses it as JSON. */
728
+ json<T = unknown>(): Promise<T>;
729
+ /** Waits for the body and reads it as text. */
730
+ text(): Promise<string>;
731
+ }
732
+ ```
733
+
734
+ #### `cookies`
735
+
736
+ Returns the cookies visible to the current context.
737
+
738
+ ```ts
739
+ const cookies = await browser.cookies();
740
+ ```
741
+
742
+ #### `setCookies`
743
+
744
+ Validates every cookie, then sets them.
745
+
746
+ ```ts
747
+ await browser.setCookies([{ url: 'http://localhost:3000', name: 'tz', value: 'UTC' }]);
748
+ ```
749
+
750
+ - A relative `url` resolves against the base URL, as `goto` resolves it.
751
+ - For the `domain` form the scheme follows the base URL, and a leading `.` is
752
+ stripped.
753
+
754
+ | Throws | When |
755
+ | --- | --- |
756
+ | `POLICY_DENIED` | An unparseable target or a forbidden scheme. Nothing is set. |
757
+
758
+ ```ts
759
+ export interface CookieFields {
760
+ /** Cookie name. */
761
+ name: string;
762
+ /** Cookie value. */
763
+ value: string;
764
+ /** Unix timestamp in whole seconds. */
765
+ expires?: number;
766
+ /** HttpOnly flag. */
767
+ httpOnly?: boolean;
768
+ /** Secure flag. */
769
+ secure?: boolean;
770
+ /** SameSite attribute. */
771
+ sameSite?: 'Strict' | 'Lax' | 'None';
772
+ }
773
+
774
+ /** `url`, or `domain` with an optional `path`, never both. */
775
+ export type Cookie = CookieFields &
776
+ (
777
+ | { url: string; domain?: never; path?: never }
778
+ | { url?: never; domain: string; path?: string }
779
+ );
780
+ ```
781
+
782
+ #### `setViewport`
783
+
784
+ Sets the viewport size and records the new size on the report.
785
+
786
+ ```ts
787
+ await browser.setViewport({ width: 390, height: 844 });
788
+ await app.open('/');
789
+ ```
790
+
791
+ - It works before the first navigation too, as Playwright's
792
+ `setViewportSize` does. The next `app.open` loads at that size, so a
793
+ phone-sized test never renders the desktop layout first.
794
+ - The size holds for the rest of the attempt, through `app.restart()` and
795
+ `app.clearState()`.
796
+
797
+ #### `onDialog`
798
+
799
+ Registers an attempt-scoped handler for native dialogs and returns an
800
+ unsubscribe function.
801
+
802
+ ```ts
803
+ const off = await browser.onDialog('accept');
804
+ await screen.getByRole('button', 'Delete').tap();
805
+ await off();
806
+ ```
807
+
808
+ - The handler is `'accept'`, `'dismiss'`, or a function that receives the
809
+ `Dialog`.
810
+ - A function handler must call `accept` or `dismiss`. One that does neither
811
+ has the dialog dismissed, as an unhandled dialog does.
812
+ - A handler that throws fails the next step with its error, or the attempt
813
+ at its end when no step follows. One that throws before answering has its
814
+ dialog dismissed.
815
+
816
+ The errors surface on the next step, not on `onDialog`:
817
+
818
+ | Throws | When |
819
+ | --- | --- |
820
+ | `APP_NOT_OPEN` | A function handler answered neither way, or no handler took the dialog. |
821
+ | `ASSERTION_FAILED` | The handler's `expect` failed. |
822
+ | `ENGINE_FAILURE` | The handler threw an unclassified error. |
823
+
824
+ ```ts
825
+ /** A native dialog as a test's handler sees it. */
826
+ export interface Dialog {
827
+ /** The dialog's text. */
828
+ readonly message: string;
829
+ /** Accepts the dialog once. */
830
+ accept(text?: string): Promise<void>;
831
+ /** Dismisses the dialog once. */
832
+ dismiss(): Promise<void>;
833
+ }
834
+
835
+ export type DialogHandler = 'accept' | 'dismiss' | ((dialog: Dialog) => void | Promise<void>);
836
+ ```
837
+
838
+ #### `waitForDownload`
839
+
840
+ Runs a trigger and waits for the download it starts.
841
+
842
+ ```ts
843
+ const file = await browser.waitForDownload(() =>
844
+ screen.getByRole('link', 'Export CSV').tap(),
845
+ );
846
+ ```
847
+
848
+ - It registers the file as a `download` artifact and returns its relative
849
+ `path`, the runner-side `absolutePath`, and `suggestedFilename`.
850
+ - Use `absolutePath` with file APIs to inspect the contents, including on a
851
+ hosted browser.
852
+ - On a hosted browser the file comes back through the provider's
853
+ [`downloads`](/browser#downloads-on-the-provider).
854
+ - The wait includes the trigger's own time.
855
+
856
+ | Throws | When |
857
+ | --- | --- |
858
+ | `ACTION_FAILED` | The download did not complete in time. A trigger that resolved in time but started no download fails with `no download started within <n>ms; the trigger resolved after <m>ms`. |
859
+
860
+ A trigger still running at the timeout fails with the step timeout. One that
861
+ throws fails with its own error.
862
+
863
+ #### `keyboard`
864
+
865
+ Viewport-level keyboard input, for whatever has focus.
866
+
867
+ ```ts
868
+ await browser.keyboard.press('Escape');
869
+ await browser.keyboard.type('hello');
870
+ ```
871
+
872
+ - `type(text)` types plain text only, never a `Secret`.
873
+ - Prefer `locator.press` and `locator.fill` when a target exists.
874
+ - [`Browser`](#browser-interface) describes each method.
875
+
876
+ #### `mouse`
877
+
878
+ Viewport-level pointer input from test code, independent of agent
879
+ observations.
880
+
881
+ ```ts
882
+ await browser.mouse.move(120, 240);
883
+ await browser.mouse.down();
884
+ await browser.mouse.move(320, 240);
885
+ await browser.mouse.up();
886
+ ```
887
+
888
+ [`Browser`](#browser-interface) describes each method, `wheel` included.
889
+
27
890
  ## Installing browsers
28
891
 
892
+ The package pins `playwright-core` to an exact version, so the engine always
893
+ runs the Playwright it was tested against. An app that depends on Playwright
894
+ itself keeps its own copy. The two share a browser cache only when their
895
+ versions match.
896
+
29
897
  A missing browser downloads once per run, before the run's clock starts. To
30
898
  install browsers as their own step, in CI or a container image, run the
31
- package's command. It installs the browsers for the Playwright version the
32
- engine pins:
899
+ package's command. It installs the browsers for the engine's pinned
900
+ Playwright:
33
901
 
34
902
  <CodeGroup>
35
903
  ```bash npm
@@ -45,11 +913,79 @@ bunx @e2e-dev/web install chromium --with-deps
45
913
  ```
46
914
  </CodeGroup>
47
915
 
48
- Name any of `chromium`, `firefox`, and `webkit`; with none named it installs
49
- `chromium`. `--with-deps` also installs the system libraries the browsers need
50
- on Linux. The command exits with the installer's exit code.
916
+ - Name any of `chromium`, `firefox`, and `webkit`. With none named it
917
+ installs `chromium`.
918
+ - `--with-deps` also installs the system libraries the browsers need on
919
+ Linux.
920
+ - The command exits with the installer's exit code.
921
+
922
+ ## Semantic capture recovery
923
+
924
+ When the semantic reader times out, agent observations can recover with a
925
+ fresh viewport screenshot.
926
+
927
+ - Playwright runs a new secure-field mask sweep across every reachable frame,
928
+ including open and closed shadow roots. Every mask probe must succeed.
929
+ - The observation reports unavailable semantics and clears earlier node
930
+ references.
931
+ - Recovery requires time left in the operation budget and permission from the
932
+ runner. `act` permits it before a secret fill; judgments also require
933
+ `vision: true` or `'only'`.
934
+ - Failed masking, cancellation, and hard engine errors still stop the
935
+ observation.
936
+
937
+ When fallback is permitted, the reader reserves one quarter of the remaining
938
+ observation budget, capped at two seconds, for the screenshot. The deadline
939
+ covers the page walk, metadata and element readback, and nested frames.
940
+
941
+ Semantic metadata crosses the Playwright connection as one JSON string, to
942
+ cut serialization overhead. Actions still use the live element handles
943
+ captured with that metadata. A page that replaces `JSON.stringify`, or adds
944
+ `toJSON` methods to object or array prototypes, gets structured transfer.
945
+ Cleanup does not wait for stalled protocol replies. An abandoned reader
946
+ cannot reuse node IDs or publish handles over a newer observation.
947
+
948
+ ## surfaceOf
949
+
950
+ Returns the live Playwright surface of a `web()` handle, for a
951
+ [custom executor](/executors#driving-the-page-yourself) that drives the page
952
+ itself.
953
+
954
+ ```ts
955
+ import { web, surfaceOf } from '@e2e-dev/web';
956
+
957
+ const engine = web();
958
+ const live = surfaceOf(engine); // PlaywrightLiveSurface | undefined
959
+ live!.page(); // the Page of the current attempt
960
+ live!.context(); // its BrowserContext
961
+ ```
962
+
963
+ - It returns `undefined` for a handle from another package.
964
+ - Direct Playwright calls are not recorded as steps.
965
+ - After [CDP recovery](#recovering-a-cdp-transport), read the accessors
966
+ again.
967
+
968
+ | Throws | When |
969
+ | --- | --- |
970
+ | `INVALID_STATE` | An accessor called outside an active attempt. |
971
+
972
+ ## Types
973
+
974
+ The package exports these types for typing config helpers and fixtures:
975
+ `WebOptions`, `WebMedia`, `WebScreencastOptions`, `WebConnectOptions`,
976
+ `WebBasicAuth`, `WebInitScript`, `BrowserName`, `BrowserProvider`,
977
+ `BrowserProviderScope`, `BrowserRequest`, `BrowserLease`,
978
+ `BrowserReleaseContext`, `BrowserProviderDownloads`,
979
+ `BrowserDownloadContext`, `PlaywrightLiveSurface`, `Browser`, `FrameScreen`,
980
+ `BrowserExpectation`, `WebRoute`, `RouteFulfillResponse`,
981
+ `RouteContinueOverrides`, `WebResponse`, `Cookie`, `CookieFields`, `Dialog`,
982
+ and `DialogHandler`.
51
983
 
52
- ## Options
984
+ The runtime exports are `web`, [`surfaceOf`](#surfaceof), and `test`,
985
+ `describe`, `beforeEach`, `afterEach`, `beforeAll`, and `afterAll`, typed
986
+ with the `browser` fixture.
987
+
988
+ ### `WebOptions`
53
989
 
54
990
  ```ts
55
991
  /**
@@ -132,137 +1068,9 @@ export interface WebOptions {
132
1068
  /** Scripts every document runs before the page's own. See [Init scripts](#init-scripts). */
133
1069
  readonly initScripts?: readonly WebInitScript[];
134
1070
  }
135
-
136
- /** JavaScript source, a file relative to the project root, or a function. */
137
- export type WebInitScript = string | { readonly path: string } | (() => unknown);
138
-
139
- /** The engine's screencast of the page: the frame size and the JPEG quality of the frames it encodes. */
140
- export interface WebScreencastOptions {
141
- /** Frame size in pixels; default the viewport's, or the window's under `viewport: null`. A smaller size makes a smaller file. */
142
- readonly size?: ViewportSize;
143
- /** JPEG quality of each captured frame, 0 through 100; Playwright's default when unset. */
144
- readonly quality?: number;
145
- }
146
- ```
147
-
148
- The app a web target opens is the target's `app`, not an engine option:
149
- `targets: [{ engine: web(), app: { url } }]`, documented under
150
- [the app under test](/reference/config#the-app-under-test). A `web()` target
151
- needs `app.url` and refuses the device keys (`bundleId`, `appPath`,
152
- `launchArguments`, `permissions`); `web({ url })` or any other app key on
153
- `web()` is an unknown key, `INVALID_CONFIG`.
154
- A target with `app.url` is a `browser` target, so `requires: ['browser']`
155
- selects web targets.
156
-
157
- `connect` needs chromium; `cdpEndpoint(signal)` is called once per worker and
158
- again at an attempt start after a disconnect. A `browser` provider object leases hosted
159
- browsers and releases them when the run or the attempt ends; see
160
- [Hosted browsers](/browser#hosted-browsers) for the `BrowserProvider`
161
- contract and an example provider. `browser: provider` together with
162
- `connect` is `INVALID_CONFIG`, and so is an option `web()` does not take, or
163
- an unknown key inside `connect` or `basicAuth`, naming the nearest known key
164
- when it is a likely typo (`viewprt` is `viewport`) and every key otherwise. `:` is not allowed in the `basicAuth` user name.
165
- The `basicAuth` password is a string or `secrets.get(name)`: a secret the
166
- engine resolves when each attempt starts, redacted like any configured one,
167
- and so is the base64 credential of the `Authorization` header it becomes.
168
- That protection is text only: unlike a fill, it withholds no screenshot or
169
- model pixels, so a page that renders the password on screen is not masked in
170
- them ([Security rules](/reference/security#secrets-in-engine-options)).
171
-
172
- `headers` and `basicAuth` are how a test reaches a [protected preview](/web#protected-previews).
173
-
174
- `screencast` shapes the recording, not which attempts record: that is the
175
- config's [`video`](/reference/config#video) mode. `size` takes whole pixels,
176
- each side at least 1, and `quality` an integer from 0 through 100; anything
177
- else, or another key, is `INVALID_CONFIG` at config load. The old
178
- `web({ video })` spelling is `INVALID_CONFIG` naming `screencast`.
179
-
180
- ### Recovering a CDP transport
181
-
182
- `connect.reconnectEndpoint(signal)` opts into recovery of the same remote
183
- browser after its CDP transport disconnects. This mode uses the browser's
184
- persistent default context because Chrome deletes ordinary Playwright contexts
185
- when their transport detaches.
186
-
187
- ```ts
188
- interface WebConnectOptions {
189
- cdpEndpoint(signal: AbortSignal): string | Promise<string>;
190
- reconnectEndpoint?(signal: AbortSignal): string | Promise<string>;
191
- }
192
1071
  ```
193
1072
 
194
- In this mode, `cdpEndpoint` provisions a fresh, dedicated browser at every
195
- attempt start, including retries. The engine rejects a browser reused by a
196
- previous attempt. The remote must have only its default context, and the host
197
- must delete the browser when the attempt ends. Engine cleanup detaches its
198
- connection; it does not terminate the host's browser.
199
-
200
- After a disconnect, the next operation calls `reconnectEndpoint` once, bounded
201
- by that operation's timeout and abort signal. This resolver must return the
202
- existing browser's endpoint. The engine verifies the persistent default context
203
- ID and the original page's target ID. It refuses browsers that do not expose
204
- the default context ID. A missing page or a different context fails the attempt.
205
- A URL match is insufficient to select a replacement tab.
206
- The engine installs secure-field masking before publishing each CDP binding;
207
- an incompatible Playwright version fails the connection instead of exposing pixels.
208
- Recovery also requires the original page's frames to retain their closed shadow
209
- DOM tracking hooks. If a document loaded without that hook while disconnected,
210
- recovery fails with `ENGINE_FAILURE`: existing closed roots cannot be recovered
211
- without reloading the application. Mask queries reject any untracked document.
212
-
213
- Endpoint resolution, CDP attachment, setup, and dispatch share one timeout.
214
- Exhausting it raises `OPERATION_TIMEOUT`; caller or attempt cancellation raises
215
- `CANCELLED`. Cancelling a concurrent waiter does not cancel the operation that
216
- started recovery. Cancelling that recovery owner fails the shared recovery.
217
-
218
- Recovery clears old node references. Observation-derived `tapAt` calls,
219
- observation-root swipes, and focused engine keyboard input require a fresh
220
- engine observation before they resume.
221
- An observation or locator read started before recovery cannot publish references
222
- after the connection changes. Each attempt owns its binding and recordings, so
223
- late cleanup cannot change a later attempt.
224
- The deterministic `browser.mouse` and `browser.keyboard` fixtures remain available
225
- without an agent observation. Test code owns its input and can read current
226
- geometry and focus with `browser.evaluate` before acting.
227
-
228
- Recovery preserves the page's unsaved state, storage, the attempt's secret
229
- taint, and the agent ledger. Operations already dispatched
230
- are never retried. Dialog and route handlers are reattached. Video recording
231
- resumes, but a segment lost during the disconnect cannot be recovered.
232
- Code using `surfaceOf(engine)` must read its accessors again after recovery;
233
- previously retained Playwright objects belong to the disconnected transport.
234
-
235
- Persistent recovery does not support `headers`, `basicAuth`, `userAgent`, `locale`, `timezoneId`, `app.clearState`,
236
- or session state capture and restore. `app.restart` remains available. Omit
237
- `reconnectEndpoint` to keep ordinary isolated contexts and the existing behavior.
238
- A `browser` provider with `scope: 'attempt'` rides the same persistent
239
- context, with the same limits: the lease's `cdpEndpoint` provisions the
240
- attempt's browser and its `reconnectEndpoint` (else `cdpEndpoint` again)
241
- reconnects to it. A lease is fresh by the provider's contract, so the engine
242
- does not compare its context with earlier attempts': a hosted service may
243
- restore browsers from one snapshot, which gives new browsers the same
244
- context id.
245
-
246
- The attempt's first page is the browser's own first tab, navigated to
247
- `about:blank`, rather than a second tab beside it; a page opened after that,
248
- such as the one `app.restart` opens, is a new tab.
249
-
250
- ## The active tab
251
-
252
- `browser` is the whole browser, showing one active tab. What reads or acts on
253
- a page acts on that tab: `goto`, `reload`, `back`, `forward`, `url`, `title`,
254
- `waitForURL`, `locator`, `frameLocator`, `evaluate`, `waitForResponse`,
255
- `waitForDownload`, `keyboard`, `mouse`, the `expect(browser)` matchers, and
256
- every `screen` query. What belongs to the browser covers every tab:
257
- `cookies`, `setCookies`, `route`, `unroute`, `onDialog`, and `addInitScript`. `setViewport`
258
- sizes the active tab and every tab opened after it, and `emulateMedia` sets
259
- its media the same way.
260
-
261
- A tab the app opens itself, from a `target="_blank"` link or `window.open`,
262
- is not followed: the active tab stays the one the attempt opened, so assert
263
- on what that tab shows, or open the link's URL with `browser.goto`.
264
-
265
- ## The browser fixture
1073
+ ### `Browser` interface
266
1074
 
267
1075
  ```ts
268
1076
  /**
@@ -341,7 +1149,7 @@ export interface Browser extends Expectable<BrowserExpectation> {
341
1149
  readonly keyboard: {
342
1150
  /** Sends one key. */
343
1151
  press(key: string): Promise<void>;
344
- /** Holds a key or modifier until up, with repeat on another down. */
1152
+ /** Holds one key until up; repeated down calls send a repeat event. */
345
1153
  down(key: string): Promise<void>;
346
1154
  /** Releases a held key. */
347
1155
  up(key: string): Promise<void>;
@@ -366,340 +1174,6 @@ export interface Browser extends Expectable<BrowserExpectation> {
366
1174
  }
367
1175
  ```
368
1176
 
369
- | Member | Default timeout |
370
- | --- | --- |
371
- | `goto`, `reload`, `back`, `forward` | `config.timeout`, 120000 ms, the same budget `app.open()` uses; `{ timeout }` overrides, capped by the test deadline |
372
- | `waitForResponse`, `waitForDownload` | `config.actionTimeout`, 30000 ms; `{ timeout }` overrides, capped by the test deadline |
373
- | `waitForURL` | `config.assertionTimeout`, 5000 ms |
374
- | `url`, `title`, `evaluate`, `addInitScript`, `route`, `unroute`, `cookies`, `setCookies`, `setViewport`, `emulateMedia`, `onDialog`, `keyboard.*`, `mouse.*` | `config.actionTimeout`, 30000 ms, not overridable |
375
-
376
- Every `browser` call is a recorded `browser.<method>` step, except `locator` and
377
- `frameLocator`: they build a locator without touching the page, and the
378
- action or assertion on it is the step. A relative URL resolves against the
379
- target's `app.url`, else `APP_URL_REQUIRED`. Declare `requires: ['browser']`
380
- in a suite that also runs on other platforms.
381
-
382
- ### Navigation
383
-
384
- `goto` defaults `waitUntil` to `'load'` and throws `POLICY_DENIED` for a
385
- URL whose scheme is not `http:` or `https:` (`about:blank` is admitted). `waitForURL` polls every 100 ms; a
386
- relative string resolves against the base URL and matches exactly after
387
- normalization, a `RegExp` tests the full URL, anything else (a predicate
388
- function, say) is `INVALID_ARGUMENT`, and a timeout is `ASSERTION_FAILED`. It
389
- takes `timeout` only: another key, such as Playwright's `waitUntil`, is
390
- `INVALID_ARGUMENT`. The other navigations throw `ACTION_FAILED`.
391
-
392
- ```ts
393
- await browser.goto('/dashboard', { waitUntil: 'networkidle' });
394
- await browser.waitForURL(/\/orders\/\d+$/, { timeout: 15_000 });
395
- ```
396
-
397
- ### Locators and frames
398
-
399
- `locator` and `frameLocator`, at the page or inside a frame, throw
400
- `INVALID_LOCATOR` for an empty selector. `frameLocator` throws
401
- `LOCATOR_NOT_FOUND` for a missing frame and `LOCATOR_AMBIGUOUS` for several.
402
- The selector is a Playwright selector: CSS by default, XPath with `xpath=` or
403
- a leading `//`, and `>>` chains, so
404
- `browser.locator('[data-testid="row"] >> nth=2 >> [data-testid="conditions"] > div')`
405
- scopes CSS to one row. A `*` capture before the last part
406
- (`*css=article >> text=Hello`) is `INVALID_LOCATOR`: use
407
- `filter({ has })` for an element that contains another.
408
-
409
- ```ts
410
- const payment = browser.frameLocator('#payment-frame');
411
- await payment.getByLabel('Card number').fill('4242424242424242');
412
- ```
413
-
414
- `frameLocator` returns a `FrameScreen`: a `Screen` whose queries answer from
415
- inside the frame, plus the two web-only escape hatches scoped to it.
416
-
417
- ```ts
418
- export interface FrameScreen extends Screen {
419
- /** Creates a web-only CSS or XPath locator inside this frame. */
420
- locator(selector: string): Locator;
421
- /** Creates a screen query scope inside an iframe nested in this frame. */
422
- frameLocator(selector: string): FrameScreen;
423
- }
424
- ```
425
-
426
- Reach for the frame's `locator` only when the control has no accessible
427
- name, label, placeholder, or test id; the [browser guide](/browser#reach-into-an-iframe)
428
- shows when that applies.
429
-
430
- Every `screen` query searches its scope and each closed shadow root the page
431
- attached under it, with Playwright's own role, name, text, label, and
432
- attribute matching and each node found once, so a control the observed tree
433
- lists inside a closed root resolves and takes actions and reads. `locator`,
434
- `frameLocator`, and `filter({ hasText })` keep Playwright's reach and stop at
435
- a closed root; the [browser guide](/browser#shadow-dom) has the details.
436
-
437
- ### evaluate
438
-
439
- The function runs in the page and cannot access variables from the test.
440
- It is serialized by its source and inlined into the page, so it must not
441
- close over anything outside its own body; helpers declared inside the body
442
- work. Pass outside values through `arg`. Both `arg` and the return value
443
- must be `JsonValue`, otherwise the call fails with `INVALID_ARGUMENT`.
444
-
445
- A string is evaluated as a single expression, not as statements. If it
446
- evaluates to a function, that function is called with `arg`; otherwise its
447
- value is the result. A promise, from either, is awaited.
448
-
449
- An exception in the evaluated code is `EVALUATE_FAILED` with its message. A
450
- Playwright timeout is `ACTION_FAILED`; a closed page or lost context is
451
- `ENGINE_FAILURE`.
452
-
453
- ```ts
454
- const count = await browser.evaluate(() => document.querySelectorAll('li').length);
455
- const flag = await browser.evaluate(
456
- (name: string) => window.localStorage.getItem(name),
457
- 'featureFlags',
458
- );
459
- const title = await browser.evaluate<string>('document.title');
460
- ```
461
-
462
- ### Init scripts
463
-
464
- An init script runs in every document before the page's own scripts, in every
465
- tab and frame. `web({ initScripts })` applies to every test;
466
- `browser.addInitScript` adds one for the rest of the test.
467
-
468
- A script is JavaScript source, a `{ path }` relative to the project root, or a
469
- function. A function cannot close over test variables; pass a JSON `arg`
470
- instead.
471
-
472
- ```ts
473
- await browser.addInitScript((value) => { Math.random = () => value; }, 0.5);
474
- await app.open('/raffle');
475
- ```
476
-
477
- A script added after the page opened runs from the next navigation or reload.
478
-
479
- ### Routing
480
-
481
- `route`, `unroute`, and `waitForResponse` take a string pattern (`*` within a
482
- path segment, `**` across `/`, `?` for any one character, `\` to escape the
483
- next) or a `RegExp`, both tested against the full URL; a predicate function
484
- is `INVALID_ARGUMENT`. A route survives `app.restart()`, `app.clearState()`,
485
- and session restore.
486
- The handler must decide exactly once with `fulfill`, `continue`, `fallback`,
487
- or `abort`; deciding twice or not at all is `ACTION_FAILED`. Routes run newest
488
- first. `continue` sends the request to the network and no other route sees
489
- it; its `url` resolves against the base URL and goes through the same rule as
490
- `goto`, so a forbidden scheme is `POLICY_DENIED`, and it must keep the
491
- request's scheme. `headers` replaces every request header. The configured
492
- `headers` still ride a continued request bound for the app's site, and only
493
- that site. `fallback` hands the request to the route registered before this
494
- one, or the network when none matches. `fulfill` takes one of `json`, `body`,
495
- or `path`, a file relative to the project root whose extension sets the
496
- `Content-Type` unless `contentType` or a `content-type` header does. An option
497
- a decision does not take (Playwright's `abort(errorCode)`, a misspelled key) is
498
- `INVALID_ARGUMENT`: the request is aborted and the next step fails.
499
-
500
- A `waitForResponse` that nothing matches inside the deadline is
501
- `ACTION_FAILED`. The deadline bounds the match: the response resolves as soon
502
- as its headers arrive, and `text()` and `json()` wait for the body on a budget
503
- of their own, `config.actionTimeout` capped by the test deadline; a body still
504
- streaming when it runs out is `ACTION_FAILED`. `url`, `status`, and `headers`
505
- are always readable, and a body the
506
- browser could not read (a connection reset short of the declared
507
- `Content-Length`, a request the page aborted, or a redirect, whose body the
508
- browser never keeps) rejects `text()` and `json()` with `ACTION_FAILED`
509
- instead of an empty string. A genuinely empty body reads as `''`. A handler
510
- that throws (a failed `expect` on the request, say) fails the next step with
511
- that error, or the attempt at its end when no step follows; a handler still
512
- running then gets the cleanup budget to finish. One that outlives the budget
513
- fails the attempt with `CLEANUP_TIMEOUT`, and whatever it throws afterwards is
514
- dropped.
515
-
516
- ```ts
517
- await browser.route('**/api/quote', async (route) => {
518
- await route.fulfill({ json: { cents: 4200 } });
519
- });
520
-
521
- const response = await browser.waitForResponse('**/api/orders');
522
- expect(response.status).toBe(201);
523
- ```
524
-
525
- ```ts
526
- /**
527
- * One of `json`, `body`, or `path`, or none for an empty body. `path` names a
528
- * file relative to the project root; its `Content-Type` follows its
529
- * extension unless `contentType` or a `content-type` header sets one.
530
- */
531
- export type RouteFulfillResponse = {
532
- status?: number;
533
- headers?: Record<string, string>;
534
- /** Sets the `Content-Type` response header. */
535
- contentType?: string;
536
- } & (
537
- | { json: JsonValue; body?: never; path?: never }
538
- | { body: string; json?: never; path?: never }
539
- | { path: string; json?: never; body?: never }
540
- | { body?: never; json?: never; path?: never }
541
- );
542
-
543
- /** What `continue` changes about the request before it goes to the network. */
544
- export interface RouteContinueOverrides {
545
- /** Resolved against the base URL like `goto`; must keep the request's scheme. */
546
- url?: string;
547
- /** Request method. */
548
- method?: string;
549
- /** Replaces every request header; the configured `headers` for the app's site are added on top. */
550
- headers?: Record<string, string>;
551
- /** Request body. */
552
- postData?: string;
553
- }
554
-
555
- export interface WebRoute {
556
- /** The intercepted request. */
557
- readonly request: {
558
- readonly url: string;
559
- readonly method: string;
560
- readonly headers: Readonly<Record<string, string>>;
561
- readonly postData?: string;
562
- };
563
- /** Fulfills the intercepted request once. */
564
- fulfill(response: RouteFulfillResponse): Promise<void>;
565
- /** Sends the intercepted request to the network once, skipping every other route. */
566
- continue(overrides?: RouteContinueOverrides): Promise<void>;
567
- /** Hands the intercepted request to the route registered before this one, or the network when none matches. */
568
- fallback(): Promise<void>;
569
- /** Aborts the intercepted request once. */
570
- abort(): Promise<void>;
571
- }
572
-
573
- export interface WebResponse {
574
- /** Response URL. */
575
- readonly url: string;
576
- /** HTTP status. */
577
- readonly status: number;
578
- /** Response headers, lower-cased names. */
579
- readonly headers: Readonly<Record<string, string>>;
580
- /** Waits for the body and parses it as JSON. */
581
- json<T = unknown>(): Promise<T>;
582
- /** Waits for the body and reads it as text. */
583
- text(): Promise<string>;
584
- }
585
- ```
586
-
587
- ### Cookies and viewport
588
-
589
- `setCookies` validates every cookie before setting any; an unparseable
590
- target or a forbidden scheme is `POLICY_DENIED` and nothing is set. A
591
- relative `url` resolves against the base URL, as `goto` resolves it. For the
592
- `domain` form the scheme follows the base URL and a leading `.` is stripped.
593
- `setViewport` records the new size on the report. It works before the first
594
- navigation too, as Playwright's `setViewportSize` does: the next `app.open`
595
- loads at that size, so a phone-sized test never renders the desktop layout first.
596
- The size holds for the rest of the attempt, through `app.restart()` and
597
- `app.clearState()`.
598
-
599
- ```ts
600
- export interface CookieFields {
601
- /** Cookie name. */
602
- name: string;
603
- /** Cookie value. */
604
- value: string;
605
- /** Unix timestamp in whole seconds. */
606
- expires?: number;
607
- /** HttpOnly flag. */
608
- httpOnly?: boolean;
609
- /** Secure flag. */
610
- secure?: boolean;
611
- /** SameSite attribute. */
612
- sameSite?: 'Strict' | 'Lax' | 'None';
613
- }
614
-
615
- /** `url`, or `domain` with an optional `path`, never both. */
616
- export type Cookie = CookieFields &
617
- (
618
- | { url: string; domain?: never; path?: never }
619
- | { url?: never; domain: string; path?: string }
620
- );
621
- ```
622
-
623
- ```ts
624
- await browser.setCookies([{ url: 'http://localhost:3000', name: 'tz', value: 'UTC' }]);
625
- ```
626
-
627
- ### Media emulation
628
-
629
- `web({ colorScheme, reducedMotion })` sets the media features every page
630
- of every attempt reports, as Playwright's context options of the same names
631
- do. The page's `@media` rules and `matchMedia` read them from the first
632
- paint. A persistent context takes them too: the engine sets them on each
633
- page it opens. Each defaults to the browser's own.
634
-
635
- `browser.emulateMedia(media)` changes them for the rest of the attempt, on
636
- every open tab and every tab opened after them, through `app.restart()` and
637
- `app.clearState()`. It works before the first navigation, so the next
638
- `app.open` loads with them. A feature it leaves out keeps its value. A tab
639
- the app opens itself gets a test's change as it appears, on a persistent
640
- context too, so a script it runs at load may still read the configured
641
- values. A feature or value outside `WebMedia` is `INVALID_CONFIG` in `web()` and
642
- `INVALID_ARGUMENT` in `emulateMedia`.
643
-
644
- ```ts
645
- /** User preferences a page reads through `@media` rules and `matchMedia`. */
646
- export interface WebMedia {
647
- /** `prefers-color-scheme`. */
648
- readonly colorScheme?: 'light' | 'dark' | 'no-preference';
649
- /** `prefers-reduced-motion`. */
650
- readonly reducedMotion?: 'reduce' | 'no-preference';
651
- }
652
- ```
653
-
654
- ### Dialogs and downloads
655
-
656
- A function handler must call `accept` or `dismiss`; one that does neither
657
- has the dialog dismissed and fails the next step with `APP_NOT_OPEN`, as an
658
- unhandled dialog does. One that throws fails the next step, or the attempt at
659
- its end when no step follows, with its error: a failed `expect` stays
660
- `ASSERTION_FAILED`, an unclassified throw is `ENGINE_FAILURE`.
661
- `waitForDownload` registers the file as a `download`
662
- artifact and returns its relative `path`, readable runner-side `absolutePath`,
663
- and `suggestedFilename`. Use `absolutePath` with file APIs to inspect its
664
- contents, including on a hosted browser. It throws `ACTION_FAILED` when it
665
- does not complete in time. A
666
- trigger that resolves in time but starts no download fails with
667
- `no download started within <n>ms; the trigger resolved after <m>ms`, where
668
- the wait includes the trigger's own time. A trigger still running at the
669
- timeout fails with the step timeout, and one that throws with its own error.
670
- On a hosted browser the file comes back through the provider's
671
- [`downloads`](/browser#downloads-on-the-provider).
672
-
673
- ```ts
674
- /** A native dialog as a test's handler sees it. */
675
- export interface Dialog {
676
- /** The dialog's text. */
677
- readonly message: string;
678
- /** Accepts the dialog once. */
679
- accept(text?: string): Promise<void>;
680
- /** Dismisses the dialog once. */
681
- dismiss(): Promise<void>;
682
- }
683
- ```
684
-
685
- ```ts
686
- const off = await browser.onDialog('accept');
687
- await screen.getByRole('button', 'Delete').tap();
688
- await off();
689
-
690
- const file = await browser.waitForDownload(() =>
691
- screen.getByRole('link', 'Export CSV').tap(),
692
- );
693
- ```
694
-
695
- A handler that throws before answering has its dialog dismissed, and its error fails the next step.
696
-
697
- ### keyboard and mouse
698
-
699
- Both namespaces are viewport-level. Prefer `locator.press` and
700
- `locator.fill` when a target exists. `keyboard.type` takes plain text only,
701
- never a `Secret`.
702
-
703
1177
  ### JsonValue
704
1178
 
705
1179
  ```ts
@@ -713,59 +1187,8 @@ export type JsonValue =
713
1187
  | readonly JsonValue[];
714
1188
  ```
715
1189
 
716
- ## Semantic capture recovery
717
-
718
- When the semantic reader times out, agent observations can recover using a
719
- fresh viewport screenshot. Playwright runs a new secure-field mask sweep
720
- across every reachable frame, including open and closed shadow roots. Every
721
- mask probe must succeed. The observation explicitly reports unavailable
722
- semantics and clears earlier node references.
723
-
724
- Recovery requires time left in the operation budget and permission from the
725
- runner. `act` permits it before a secret fill; judgments also require
726
- `vision: true` or `'only'`. Failed masking, cancellation, and hard engine
727
- errors still stop the observation.
728
-
729
- When fallback is permitted, the reader reserves one quarter of the remaining
730
- observation budget, capped at two seconds, for the screenshot. The deadline
731
- covers the page walk, metadata and element readback, and nested frames.
732
- Semantic metadata crosses the Playwright connection as one JSON string to
733
- reduce serialization overhead. Actions still use the live element handles
734
- captured with that metadata. Pages that replace `JSON.stringify` or add
735
- `toJSON` methods to object or array prototypes use structured transfer.
736
- Cleanup does not wait for stalled protocol replies. An abandoned reader
737
- cannot reuse node IDs or publish handles over a newer observation.
738
-
739
- ## Exported types
740
-
741
- `WebOptions`, `WebMedia`, `WebScreencastOptions`, `WebConnectOptions`, `WebBasicAuth`, `BrowserName`,
742
- `BrowserProvider`, `BrowserProviderScope`, `BrowserRequest`, `BrowserLease`,
743
- `BrowserReleaseContext`, `BrowserProviderDownloads`, `BrowserDownloadContext`,
744
- `PlaywrightLiveSurface`, `Browser`, `FrameScreen`,
745
- `BrowserExpectation`, `WebRoute`, `RouteFulfillResponse`, `RouteContinueOverrides`, `WebResponse`,
746
- `Cookie`, `CookieFields`, `Dialog`, and `DialogHandler` are exported from the
747
- package for typing config helpers and fixtures. The runtime exports are
748
- `web`, [`surfaceOf`](#surfaceof), and `test`, `describe`, `beforeEach`,
749
- `afterEach`, `beforeAll`, and `afterAll`, typed with the `browser` fixture.
750
-
751
- ## expect(browser)
752
-
753
- `expect(browser)` returns the `BrowserExpectation` the engine attaches: `toHaveURL`,
754
- `toHaveTitle`, and `toHaveClass`. See [browser matchers](/reference/expect#browser-matchers).
755
-
756
- ## surfaceOf
757
-
758
- ```ts
759
- import { web, surfaceOf } from '@e2e-dev/web';
760
-
761
- const engine = web();
762
- const live = surfaceOf(engine); // PlaywrightLiveSurface | undefined
763
- live!.page(); // the Page of the current attempt
764
- live!.context(); // its BrowserContext
765
- ```
1190
+ ## Related
766
1191
 
767
- Use `surfaceOf(handle)` in a [custom executor](/executors#driving-the-page-yourself)
768
- to access the current Playwright page and context directly. It returns
769
- `undefined` for a handle from another package. The accessors throw
770
- `INVALID_STATE` outside an active attempt. Direct Playwright calls are not
771
- recorded as steps.
1192
+ The [browser guide](/browser) shows these members in recipes and covers
1193
+ [hosted browsers](/browser#hosted-browsers). The portable operations live on
1194
+ the [`app`](/reference/app) fixture.