@e2e-dev/web 0.11.2 → 0.12.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 (50) hide show
  1. package/README.md +15 -9
  2. package/dist/.build.json +1 -1
  3. package/dist/actions.d.ts +1 -1
  4. package/dist/actions.js +54 -5
  5. package/dist/attempt-session.d.ts +1 -1
  6. package/dist/bin.d.ts +2 -0
  7. package/dist/bin.js +3 -0
  8. package/dist/browser-connection.d.ts +7 -3
  9. package/dist/browser-connection.js +7 -1
  10. package/dist/browser.d.ts +43 -6
  11. package/dist/browser.js +87 -36
  12. package/dist/capture-scope.d.ts +1 -1
  13. package/dist/cdp-recovery.d.ts +1 -1
  14. package/dist/cdp-selectors.d.ts +1 -1
  15. package/dist/cli.d.ts +9 -0
  16. package/dist/cli.js +42 -0
  17. package/dist/dialogs.d.ts +1 -1
  18. package/dist/downloads.d.ts +1 -1
  19. package/dist/engine.d.ts +1 -1
  20. package/dist/engine.js +74 -16
  21. package/dist/evaluation.d.ts +2 -0
  22. package/dist/evaluation.js +3 -1
  23. package/dist/in-page/read-semantics.d.ts +1 -0
  24. package/dist/in-page/read-semantics.js +151 -39
  25. package/dist/index.d.ts +2 -1
  26. package/dist/init-scripts.d.ts +30 -0
  27. package/dist/init-scripts.js +105 -0
  28. package/dist/install.d.ts +8 -1
  29. package/dist/install.js +35 -8
  30. package/dist/locators.d.ts +1 -1
  31. package/dist/locators.js +30 -18
  32. package/dist/observation-capture.d.ts +1 -1
  33. package/dist/observation.d.ts +1 -1
  34. package/dist/observation.js +3 -3
  35. package/dist/observe.d.ts +1 -1
  36. package/dist/protected-app.d.ts +20 -4
  37. package/dist/protected-app.js +35 -4
  38. package/dist/provider-video.d.ts +1 -1
  39. package/dist/read-node.d.ts +9 -2
  40. package/dist/route-options.d.ts +44 -0
  41. package/dist/route-options.js +125 -0
  42. package/dist/selector-engines.js +2 -0
  43. package/dist/shown-selector.d.ts +20 -0
  44. package/dist/shown-selector.js +27 -0
  45. package/dist/support.d.ts +4 -2
  46. package/dist/support.js +1 -1
  47. package/dist/surface.d.ts +40 -3
  48. package/dist/surface.js +44 -5
  49. package/dist/video.d.ts +1 -1
  50. package/package.json +9 -5
package/README.md CHANGED
@@ -11,14 +11,13 @@ seam with no privilege either way.
11
11
  ## Install
12
12
 
13
13
  ```bash
14
- npm install --save-dev e2e @e2e-dev/web playwright
14
+ npm install --save-dev e2e @e2e-dev/web
15
15
  ```
16
16
 
17
- Bring your own Playwright: `playwright` is a peer dependency (`>=1.63.0 <2`),
18
- not something this package installs. An app that already depends on Playwright
19
- keeps its version, one copy in `node_modules`, and one browser cache. A version
20
- outside the range may be rejected by your package manager as an unmet peer
21
- (npm's `ERESOLVE`), so upgrade `playwright` within the range.
17
+ This package depends on `playwright-core` pinned to an exact version, so the
18
+ engine always runs the Playwright it was tested against. An app that depends
19
+ on Playwright itself keeps its own copy; the two share a browser cache only
20
+ when their versions match.
22
21
 
23
22
  ```ts title="e2e.config.ts"
24
23
  import type { E2EConfig } from 'e2e';
@@ -41,7 +40,10 @@ target, so a test can `requires: ['browser']`. Browser options:
41
40
  `BrowserProvider` that leases hosted browsers, see below), `viewport`
42
41
  (`{ width, height }`; default 1280x720), `testIdAttribute` (the attribute
43
42
  `getByTestId` and a node's `testId` read; default `data-testid`), `userAgent`
44
- (the `User-Agent` every attempt sends and `navigator.userAgent` reports), and
43
+ (the `User-Agent` every attempt sends and `navigator.userAgent` reports),
44
+ `locale` and `timezoneId` (the language and time zone every attempt runs in,
45
+ such as `de-DE` and `Europe/Berlin`), `initScripts` (scripts every document
46
+ runs before the page's own), and
45
47
  `connect` — attach to a remote browser over CDP instead of launching a local
46
48
  one.
47
49
 
@@ -71,7 +73,7 @@ engine verifies the default context ID and active page target ID, clears stale
71
73
  references, and never repeats a dispatched operation. A missing target fails
72
74
  the attempt. The host owns deleting the remote browser after cleanup.
73
75
 
74
- This mode does not support `headers`, `basicAuth`, `userAgent`, context reset, or session
76
+ This mode does not support `headers`, `basicAuth`, `userAgent`, `locale`, `timezoneId`, context reset, or session
75
77
  state capture and restore. Recording resumes after reconnect, but a segment
76
78
  lost during the disconnect remains unavailable. See the
77
79
  [Playwright reference](../../docs/reference/web.mdx#recovering-a-cdp-transport)
@@ -131,9 +133,13 @@ In CI, install browsers as their own step instead, so the cost is visible and
131
133
  cacheable:
132
134
 
133
135
  ```bash
134
- npx playwright install chromium --with-deps
136
+ npx @e2e-dev/web install chromium --with-deps
135
137
  ```
136
138
 
139
+ It downloads the browsers for the Playwright version this package pins, the
140
+ ones the engine launches. Under pnpm, run the bin by name:
141
+ `pnpm exec e2e-web install chromium --with-deps`.
142
+
137
143
  `--with-deps` also installs the system libraries a slim container image lacks.
138
144
 
139
145
  ## Documentation
package/dist/.build.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.11.2"}
1
+ {"version":"0.12.0"}
package/dist/actions.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Locator action dispatch for the Playwright engine. */
2
- import type { Page } from 'playwright';
2
+ import type { Page } from 'playwright-core';
3
3
  import { type LocatorAction, type NodeRef, type PointerAction, type ViewportPoint } from 'e2e/engine';
4
4
  import { type ActionTarget } from './support.ts';
5
5
  /**
package/dist/actions.js CHANGED
@@ -1,12 +1,63 @@
1
1
  /** Locator action dispatch for the Playwright engine. */
2
2
  import { EngineError } from 'e2e/engine';
3
- import { asActionable, isClassified, isPwTimeout, message, nearestPixel, performElementSwipe, performPointDrag, performPointerDrag, performViewportSwipe, POST_DISPATCH_PATTERN, } from './support.js';
3
+ import { asActionable, isClassified, isNavigationRace, isPwTimeout, message, nearestPixel, performElementSwipe, performPointDrag, performPointerDrag, performViewportSwipe, POST_DISPATCH_PATTERN, } from './support.js';
4
4
  /** How long a long press holds the button when the action names no duration. */
5
5
  const DEFAULT_LONG_PRESS_MS = 500;
6
6
  /** The keys a click holds, as Playwright's `modifiers` takes them; the contract spells them the same. */
7
7
  function heldKeys(action) {
8
8
  return action.modifiers === undefined ? {} : { modifiers: [...action.modifiers] };
9
9
  }
10
+ /** Playwright's words for an element handle whose element left the DOM. */
11
+ const DETACHED_PATTERN = /element (is |was )?(detached|not attached)/i;
12
+ /**
13
+ * Sets a checkbox, switch, or radio to `checked` with one click, as
14
+ * Playwright's `check` does, apart from the read after the click: a control
15
+ * that is gone by then (an app that swaps a picked radio for its selected
16
+ * view, or navigates on change) took the click, so the action is done, where
17
+ * Playwright reports it detached as if the click never happened. Whatever
18
+ * took its place is not read: the next observation or assertion shows it,
19
+ * as it does after a tap. The reads and the click share one element, so the state before and after the click
20
+ * is one control's.
21
+ */
22
+ async function setChecked(target, checked, timeout) {
23
+ const deadline = Date.now() + timeout;
24
+ // Playwright reads a timeout of 0 as no timeout at all.
25
+ const remaining = () => Math.max(1, deadline - Date.now());
26
+ const element = target.kind === 'element' ? target.element : await target.locator.elementHandle({ timeout });
27
+ const verb = checked ? 'check' : 'uncheck';
28
+ try {
29
+ if ((await element.isChecked()) === checked)
30
+ return;
31
+ if (!checked && (await isRadio(element))) {
32
+ throw new EngineError('NOT_ACTIONABLE', 'uncheck cannot clear a radio button; select another radio in its group', {
33
+ retryable: false,
34
+ });
35
+ }
36
+ await element.click({ timeout: remaining() });
37
+ let after;
38
+ try {
39
+ after = await element.isChecked();
40
+ }
41
+ catch (cause) {
42
+ if (DETACHED_PATTERN.test(message(cause)) || isNavigationRace(cause))
43
+ return;
44
+ throw cause;
45
+ }
46
+ if (after !== checked) {
47
+ throw new EngineError('NOT_ACTIONABLE', `${verb} clicked the control but its checked state did not change`, {
48
+ retryable: false,
49
+ });
50
+ }
51
+ }
52
+ finally {
53
+ if (target.kind === 'locator')
54
+ void element.dispose().catch(() => undefined);
55
+ }
56
+ }
57
+ /** Whether a checkable element is a radio, which a click can select but never clear. */
58
+ function isRadio(element) {
59
+ return element.evaluate((node) => (node instanceof HTMLInputElement && node.type === 'radio') || node.getAttribute('role') === 'radio');
60
+ }
10
61
  /**
11
62
  * Dispatches one deterministic locator action onto a Playwright target with the
12
63
  * platform's own actionability checks. `lookup` resolves the second ref of a
@@ -39,10 +90,8 @@ export async function dispatchLocatorAction(target, action, timeout, lookup) {
39
90
  await locator.press(action.key, { timeout });
40
91
  return;
41
92
  case 'check':
42
- await locator.check({ timeout });
43
- return;
44
93
  case 'uncheck':
45
- await locator.uncheck({ timeout });
94
+ await setChecked(target, action.kind === 'check', timeout);
46
95
  return;
47
96
  case 'focus':
48
97
  // ElementHandle.focus takes no timeout: the element is already resolved.
@@ -177,7 +226,7 @@ export function classifyActionError(rawCause, action) {
177
226
  if (/strict mode violation/i.test(text)) {
178
227
  return new EngineError('ENGINE_FAILURE', text, { retryable: false, cause });
179
228
  }
180
- if (/element (is |was )?(detached|not attached)/i.test(text)) {
229
+ if (DETACHED_PATTERN.test(text)) {
181
230
  return new EngineError('NODE_STALE', text, { retryable: true, cause });
182
231
  }
183
232
  if (/Timeout .*exceeded/i.test(text) || isPwTimeout(rawCause)) {
@@ -1,4 +1,4 @@
1
- import type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';
1
+ import type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright-core';
2
2
  import { type EngineCleanupContext, type OperationContext, type VideoSegment, type ViewportSize } from 'e2e/engine';
3
3
  import { type CdpEndpointResolver, type SessionBinding } from './cdp-recovery.ts';
4
4
  import { RefRegistry } from './refs.ts';
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './cli.js';
3
+ process.exitCode = await main(process.argv.slice(2));
@@ -1,7 +1,11 @@
1
1
  /** Shares one browser process connection across a worker's attempts. */
2
- import type { Browser } from 'playwright';
3
- import { chromium } from 'playwright';
4
- export type BrowserName = 'chromium' | 'firefox' | 'webkit';
2
+ import type { Browser } from 'playwright-core';
3
+ import { chromium } from 'playwright-core';
4
+ /** Every browser the engine launches, in the order the CLI lists them. */
5
+ export declare const BROWSER_NAMES: readonly ['chromium', 'firefox', 'webkit'];
6
+ export type BrowserName = (typeof BROWSER_NAMES)[number];
7
+ /** Whether `name` is a browser the engine launches. */
8
+ export declare function isBrowserName(name: string): name is BrowserName;
5
9
  /**
6
10
  * Launching a browser process costs hundreds of milliseconds; per-attempt
7
11
  * isolation lives in browser contexts, so every attempt a worker runs can
@@ -1,6 +1,12 @@
1
1
  /** Shares one browser process connection across a worker's attempts. */
2
- import { chromium, firefox, selectors, webkit } from 'playwright';
2
+ import { chromium, firefox, selectors, webkit } from 'playwright-core';
3
3
  import { SELECTOR_ENGINES } from './selector-engines.js';
4
+ /** Every browser the engine launches, in the order the CLI lists them. */
5
+ export const BROWSER_NAMES = ['chromium', 'firefox', 'webkit'];
6
+ /** Whether `name` is a browser the engine launches. */
7
+ export function isBrowserName(name) {
8
+ return BROWSER_NAMES.includes(name);
9
+ }
4
10
  /**
5
11
  * Playwright's default SIGINT handler closes the browser and exits the
6
12
  * process, and its SIGTERM handler closes the browser. A terminal Ctrl-C
package/dist/browser.d.ts CHANGED
@@ -15,20 +15,44 @@ import type { ActionOptions, Expectable, JsonValue, Locator, Screen, TextMatch }
15
15
  import { type EngineFixtureContext } from 'e2e/engine';
16
16
  import type { DialogHandler } from './dialogs.ts';
17
17
  import type { PlaywrightSurface } from './surface.ts';
18
- /** `json` or `body`, never both; neither fulfills with an empty body. */
18
+ /**
19
+ * One of `json`, `body`, or `path`, or none for an empty body. `path` names a
20
+ * file relative to the project root; its `Content-Type` follows its
21
+ * extension unless `contentType` or a `content-type` header sets one.
22
+ */
19
23
  export type RouteFulfillResponse = {
20
24
  status?: number;
21
25
  headers?: Record<string, string>;
26
+ /** Sets the `Content-Type` response header. */
27
+ contentType?: string;
22
28
  } & ({
23
29
  json: JsonValue;
24
30
  body?: never;
31
+ path?: never;
25
32
  } | {
26
33
  body: string;
27
34
  json?: never;
35
+ path?: never;
36
+ } | {
37
+ path: string;
38
+ json?: never;
39
+ body?: never;
28
40
  } | {
29
41
  body?: never;
30
42
  json?: never;
43
+ path?: never;
31
44
  });
45
+ /** What `continue` changes about the request before it goes to the network. */
46
+ export interface RouteContinueOverrides {
47
+ /** Resolved against the base URL like `goto`; must keep the request's scheme. */
48
+ url?: string;
49
+ /** Request method. */
50
+ method?: string;
51
+ /** Replaces every request header; the configured `headers` for the app's site are added on top. */
52
+ headers?: Record<string, string>;
53
+ /** Request body. */
54
+ postData?: string;
55
+ }
32
56
  export interface WebRoute {
33
57
  /** The intercepted request. */
34
58
  readonly request: {
@@ -39,8 +63,10 @@ export interface WebRoute {
39
63
  };
40
64
  /** Fulfills the intercepted request once. */
41
65
  fulfill(response: RouteFulfillResponse): Promise<void>;
42
- /** Continues the intercepted request once. */
43
- continue(): Promise<void>;
66
+ /** Sends the intercepted request to the network once, skipping every other route. */
67
+ continue(overrides?: RouteContinueOverrides): Promise<void>;
68
+ /** Hands the intercepted request to the route registered before this one, or the network when none matches. */
69
+ fallback(): Promise<void>;
44
70
  /** Aborts the intercepted request once. */
45
71
  abort(): Promise<void>;
46
72
  }
@@ -51,9 +77,9 @@ export interface WebResponse {
51
77
  readonly status: number;
52
78
  /** Response headers, lower-cased names. */
53
79
  readonly headers: Readonly<Record<string, string>>;
54
- /** Parses the response body as JSON; rejects with `ACTION_FAILED` when the body could not be read. */
80
+ /** Waits for the body and parses it as JSON; rejects with `ACTION_FAILED` when the body could not be read. */
55
81
  json<T = unknown>(): Promise<T>;
56
- /** Reads the response body as text; rejects with `ACTION_FAILED` when the body could not be read. */
82
+ /** Waits for the body and reads it as text; rejects with `ACTION_FAILED` when the body could not be read. */
57
83
  text(): Promise<string>;
58
84
  }
59
85
  export interface CookieFields {
@@ -83,8 +109,9 @@ export type Cookie = CookieFields & ({
83
109
  export interface BrowserExpectation {
84
110
  /** Inverts the matcher. */
85
111
  readonly not: BrowserExpectation;
86
- /** Waits for the current URL to match. */
112
+ /** Waits for the current URL to match; `ignoreCase` compares a string case-insensitively and adds the `i` flag to a RegExp, `false` removes it. */
87
113
  toHaveURL(expected: string | RegExp, options?: {
114
+ ignoreCase?: boolean;
88
115
  timeout?: number;
89
116
  }): Promise<void>;
90
117
  /** Waits for the current title to match. */
@@ -146,6 +173,16 @@ export interface Browser extends Expectable<BrowserExpectation> {
146
173
  evaluate<T extends JsonValue>(fn: string | (() => T | Promise<T>)): Promise<T>;
147
174
  /** Evaluates trusted test code with one required JSON-safe argument. */
148
175
  evaluate<T extends JsonValue, Arg extends JsonValue>(fn: string | ((arg: Arg) => T | Promise<T>), arg: Arg): Promise<T>;
176
+ /**
177
+ * Adds a script every document runs before the page's own, from the next
178
+ * navigation on: JavaScript source, a `{ path }` relative to the project
179
+ * root, or a function.
180
+ */
181
+ addInitScript(script: string | {
182
+ path: string;
183
+ } | (() => unknown)): Promise<void>;
184
+ /** Adds an init script function called with one JSON argument. */
185
+ addInitScript<Arg extends JsonValue>(script: (arg: Arg) => unknown, arg: Arg): Promise<void>;
149
186
  /** Adds an attempt-scoped network route. */
150
187
  route(pattern: string | RegExp, handler: (route: WebRoute) => void | Promise<void>): Promise<void>;
151
188
  /** Removes matching attempt-scoped routes. */
package/dist/browser.js CHANGED
@@ -11,10 +11,13 @@
11
11
  * never re-implemented here: `context.app.resolveUrl` is the one place that
12
12
  * says which URLs a test may open.
13
13
  */
14
- import { Deadline, describePattern, EngineError, matchesText, pollCondition, rejectUnknownOptions, TestError, toTextPattern, urlMatches, validateJsonValue, } from 'e2e/engine';
14
+ import { ConfigurationError, Deadline, describePattern, EngineError, matchesText, pollCondition, raceAbort, rejectUnknownOptions, TestError, toTextPattern, urlMatches, validateJsonValue, withTimeout, } from 'e2e/engine';
15
15
  import { saveDownloadsTo, saveFromBrowser, saveLocally } from './downloads.js';
16
16
  import { isTestErrorCode, message as causeMessage, translatePwError } from './support.js';
17
17
  import { compileEvaluation } from './evaluation.js';
18
+ import { initScriptLabel, testInitScriptSource } from './init-scripts.js';
19
+ import { lowercaseNames } from './protected-app.js';
20
+ import { parseContinue, parseFulfill, requireNoArguments } from './route-options.js';
18
21
  import { routePatternMatches, routePatternsEqual } from './route-pattern.js';
19
22
  /**
20
23
  * Reads the body once the response is known and keeps the outcome: a body
@@ -103,6 +106,10 @@ export function createBrowserFixture(surface, context) {
103
106
  validateJsonValue(result.value, 'evaluate result');
104
107
  return result.value;
105
108
  },
109
+ async addInitScript(script, ...rest) {
110
+ const source = await testInitScriptSource(script, rest.length === 0 ? undefined : { arg: rest[0] }, (file) => surface.projectPath(file));
111
+ await surface.guard(context.operation(), 'addInitScript', () => surface.addInitScript(source));
112
+ },
106
113
  route(pattern, handler) {
107
114
  const wirePattern = toTextPattern(pattern);
108
115
  const predicate = (url) => routePatternMatches(wirePattern, url.href);
@@ -117,39 +124,62 @@ export function createBrowserFixture(surface, context) {
117
124
  }
118
125
  decided = true;
119
126
  };
120
- const postData = route.request().postData();
127
+ // Validation runs before `decide`, so a rejected option leaves the
128
+ // request to the abort below. A Playwright call that fails after the
129
+ // decision aborts too, rather than leaving the page's request pending.
130
+ const decideWith = async (name, act) => {
131
+ decide(name);
132
+ try {
133
+ await act();
134
+ }
135
+ catch (cause) {
136
+ await route.abort().catch(() => undefined);
137
+ throw cause;
138
+ }
139
+ };
140
+ const request = route.request();
141
+ const postData = request.postData();
121
142
  const publicRoute = {
122
143
  request: {
123
- url: route.request().url(),
124
- method: route.request().method(),
125
- headers: route.request().headers(),
144
+ url: request.url(),
145
+ method: request.method(),
146
+ headers: request.headers(),
126
147
  ...(postData === null ? {} : { postData }),
127
148
  },
128
149
  fulfill: async (response) => {
129
- decide('fulfill');
130
- await route.fulfill({
131
- status: response.status ?? 200,
132
- headers: response.headers ?? {},
133
- ...('json' in response && response.json !== undefined
134
- ? { json: response.json }
135
- : 'body' in response && response.body !== undefined
136
- ? { body: response.body }
137
- : { body: '' }),
138
- });
150
+ const decision = parseFulfill(response, (file) => surface.projectPath(file));
151
+ await decideWith('fulfill', () => route.fulfill({
152
+ ...decision,
153
+ ...(decision.json === undefined && decision.path === undefined && decision.body === undefined
154
+ ? { body: '' }
155
+ : {}),
156
+ }));
157
+ },
158
+ continue: async (overrides) => {
159
+ const { headers, ...decision } = parseContinue(overrides, request.url(), context.app.resolveUrl);
160
+ // `continue` skips every route registered before this one, the
161
+ // site-header route included, so it merges those headers itself.
162
+ const site = surface.siteHeaders(decision.url ?? request.url());
163
+ await decideWith('continue', () => route.continue({
164
+ ...decision,
165
+ ...(headers === undefined && site === undefined
166
+ ? {}
167
+ : { headers: { ...lowercaseNames(headers ?? request.headers()), ...site } }),
168
+ }));
139
169
  },
140
- continue: async () => {
141
- decide('continue');
142
- await route.fallback();
170
+ fallback: async (...args) => {
171
+ requireNoArguments('route.fallback', args);
172
+ await decideWith('fallback', () => route.fallback());
143
173
  },
144
- abort: async () => {
145
- decide('abort');
146
- await route.abort();
174
+ abort: async (...args) => {
175
+ requireNoArguments('route.abort', args);
176
+ await decideWith('abort', () => route.abort());
147
177
  },
148
178
  };
149
179
  try {
150
180
  await handler(publicRoute);
151
181
  if (!decided) {
152
- throw new TestError('ACTION_FAILED', 'route handler returned without calling fulfill, continue, or abort');
182
+ throw new TestError('ACTION_FAILED', 'route handler returned without calling fulfill, continue, fallback, or abort');
153
183
  }
154
184
  }
155
185
  catch (cause) {
@@ -189,13 +219,22 @@ export function createBrowserFixture(surface, context) {
189
219
  const operation = context.operation(options?.timeout);
190
220
  return surface.guard(operation, 'waitForResponse', async (currentOperation) => {
191
221
  const response = await surface.requirePage().waitForResponse((candidate) => routePatternMatches(wirePattern, candidate.url()), { timeout: currentOperation.timeoutMs });
192
- const body = await readResponseBody(response);
222
+ // Started now, while the browser still holds the body, and awaited
223
+ // only by `text` and `json` on a budget of their own: the timeout
224
+ // bounds the match, never a body still streaming in behind headers
225
+ // that already arrived.
226
+ const body = readResponseBody(response);
227
+ const text = async () => {
228
+ const { signal, timeoutMs } = context.operation();
229
+ const read = await raceAbort(withTimeout(body, timeoutMs, () => new TestError('ACTION_FAILED', `waitForResponse: response body did not finish within ${timeoutMs}ms`)), signal, 'waitForResponse body');
230
+ return read();
231
+ };
193
232
  return {
194
233
  url: response.url(),
195
234
  status: response.status(),
196
235
  headers: response.headers(),
197
- json: async () => JSON.parse(await body()),
198
- text: body,
236
+ json: async () => JSON.parse(await text()),
237
+ text,
199
238
  };
200
239
  });
201
240
  },
@@ -215,17 +254,24 @@ export function createBrowserFixture(surface, context) {
215
254
  setCookies(cookies) {
216
255
  const scheme = new URL(baseHref()).protocol === 'https:' ? 'https' : 'http';
217
256
  // The harness's URL rule decides which cookie targets are admitted; a
218
- // domain cookie is checked as the origin it would be sent to.
219
- for (const cookie of cookies) {
220
- context.app.resolveUrl(cookie.url === undefined ? `${scheme}://${cookie.domain.replace(/^\./, '')}` : cookie.url);
221
- }
257
+ // domain cookie is checked as the origin it would be sent to. The rule
258
+ // admits `about:blank` for navigation, which holds no cookie. A `url`
259
+ // cookie is set on the URL the rule resolved, so a relative one lands
260
+ // on the base URL the way `goto` would.
261
+ const targets = cookies.map((cookie) => {
262
+ const target = context.app.resolveUrl(cookie.url === undefined ? `${scheme}://${cookie.domain.replace(/^\./, '')}` : cookie.url);
263
+ if (!/^https?:/.test(target)) {
264
+ throw new ConfigurationError('POLICY_DENIED', `cookie URL must be http(s): ${target}`);
265
+ }
266
+ return cookie.url === undefined
267
+ ? { domain: cookie.domain, path: cookie.path ?? '/' }
268
+ : { url: target };
269
+ });
222
270
  return surface.guard(context.operation(), 'setCookies', async () => {
223
- await surface.requireContext().addCookies(cookies.map((cookie) => ({
271
+ await surface.requireContext().addCookies(cookies.map((cookie, index) => ({
224
272
  name: cookie.name,
225
273
  value: cookie.value,
226
- ...(cookie.url === undefined
227
- ? { domain: cookie.domain, path: cookie.path ?? '/' }
228
- : { url: cookie.url }),
274
+ ...targets[index],
229
275
  ...(cookie.expires !== undefined ? { expires: cookie.expires } : {}),
230
276
  ...(cookie.httpOnly !== undefined ? { httpOnly: cookie.httpOnly } : {}),
231
277
  ...(cookie.secure !== undefined ? { secure: cookie.secure } : {}),
@@ -313,6 +359,7 @@ export function createBrowserFixture(surface, context) {
313
359
  title: action,
314
360
  waitForURL: { ...navigationCall, verifies: true, label: (url) => String(url) },
315
361
  evaluate: action,
362
+ addInitScript: { ...action, label: (script) => initScriptLabel(script) },
316
363
  route: { ...action, label: (pattern) => String(pattern) },
317
364
  unroute: { ...action, label: (pattern) => String(pattern) },
318
365
  waitForResponse: { ...action, timeout: (_pattern, options) => options?.timeout, label: (pattern) => String(pattern) },
@@ -336,16 +383,20 @@ function createBrowserExpectation(deps, negated = false) {
336
383
  onTimeout: async () => new TestError('ASSERTION_FAILED', `expect.${negated ? 'not.' : ''}${api} failed\nexpected: ${negated ? 'not ' : ''}${label}\nobserved: ${await observed()}`),
337
384
  });
338
385
  };
339
- const validate = (api, options) => rejectUnknownOptions(`expect.${negated ? 'not.' : ''}${api}`, options, ['timeout']);
386
+ const validate = (api, options, keys = ['timeout']) => rejectUnknownOptions(`expect.${negated ? 'not.' : ''}${api}`, options, keys);
340
387
  return {
341
388
  get not() {
342
389
  return createBrowserExpectation(deps, !negated);
343
390
  },
344
391
  toHaveURL(expected, options) {
345
- validate('toHaveURL', options);
392
+ validate('toHaveURL', options, ['ignoreCase', 'timeout']);
393
+ const ignoreCase = options?.ignoreCase;
394
+ if (ignoreCase !== undefined && typeof ignoreCase !== 'boolean') {
395
+ throw new TestError('INVALID_ARGUMENT', `expect.${negated ? 'not.' : ''}toHaveURL option ignoreCase must be a boolean`);
396
+ }
346
397
  const label = typeof expected === 'string' ? expected : String(expected);
347
398
  const target = deps.baseHref();
348
- return poll('toHaveURL', `URL ${label}`, async () => urlMatches(await deps.currentUrl(), expected, target), async () => `URL ${await deps.currentUrl()}`, options?.timeout);
399
+ return poll('toHaveURL', `URL ${label}${ignoreCase === true ? ' (ignoring case)' : ''}`, async () => urlMatches(await deps.currentUrl(), expected, target, ignoreCase), async () => `URL ${await deps.currentUrl()}`, options?.timeout);
349
400
  },
350
401
  toHaveTitle(expected, options) {
351
402
  validate('toHaveTitle', options);
@@ -1,5 +1,5 @@
1
1
  /** Deadline and handle ownership for one observation capture operation. */
2
- import type { JSHandle } from 'playwright';
2
+ import type { JSHandle } from 'playwright-core';
3
3
  /** Releases acquired and late-arriving handles without waiting on the transport. */
4
4
  export declare class CaptureScope {
5
5
  readonly deadline: number;
@@ -1,5 +1,5 @@
1
1
  /** Validates a persistent CDP binding without publishing or retaining it. */
2
- import type { Browser, BrowserContext, Page } from 'playwright';
2
+ import type { Browser, BrowserContext, Page } from 'playwright-core';
3
3
  import { EngineError } from 'e2e/engine';
4
4
  import { type ConnectionBudget } from './operation-budget.ts';
5
5
  export type CdpEndpointResolver = (signal: AbortSignal) => string | Promise<string>;
@@ -1,5 +1,5 @@
1
1
  /** Registers the engine's selector engines in a CDP default context; every locator and secure-field mask needs them. */
2
- import type { BrowserContext, Page } from 'playwright';
2
+ import type { BrowserContext, Page } from 'playwright-core';
3
3
  import { type ConnectionBudget } from './operation-budget.ts';
4
4
  /**
5
5
  * Playwright 1.63 seeds custom selectors only in newly created contexts, not
package/dist/cli.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** The `e2e-web` command: browser installs with the Playwright version this engine pins. */
2
+ /** What `main` writes to and spawns; tests replace them. */
3
+ export interface CliIo {
4
+ readonly stdout: (text: string) => void;
5
+ readonly stderr: (text: string) => void;
6
+ readonly run: (args: readonly string[]) => Promise<number>;
7
+ }
8
+ /** Runs `e2e-web` with the arguments after the command name and resolves to the exit code. */
9
+ export declare function main(args: readonly string[], io?: CliIo): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,42 @@
1
+ /** The `e2e-web` command: browser installs with the Playwright version this engine pins. */
2
+ import { BROWSER_NAMES, isBrowserName } from './browser-connection.js';
3
+ import { runPlaywrightCli } from './install.js';
4
+ const BROWSER_ARGS = `[${BROWSER_NAMES.join('|')} ...] [--with-deps]`;
5
+ const USAGE = `Usage: npx @e2e-dev/web install ${BROWSER_ARGS}
6
+ bunx @e2e-dev/web install ${BROWSER_ARGS}
7
+ pnpm exec e2e-web install ${BROWSER_ARGS}
8
+
9
+ Installs browsers for the Playwright version @e2e-dev/web runs. Installs
10
+ chromium when no browser is named. --with-deps also installs the system
11
+ libraries the browsers need on Linux.
12
+ `;
13
+ const PROCESS_IO = {
14
+ stdout: (text) => process.stdout.write(text),
15
+ stderr: (text) => process.stderr.write(text),
16
+ run: runPlaywrightCli,
17
+ };
18
+ /** Runs `e2e-web` with the arguments after the command name and resolves to the exit code. */
19
+ export async function main(args, io = PROCESS_IO) {
20
+ const [command, ...rest] = args;
21
+ if (command === 'help' || args.some((arg) => arg === '--help' || arg === '-h')) {
22
+ io.stdout(USAGE);
23
+ return 0;
24
+ }
25
+ if (command !== 'install') {
26
+ io.stderr(`${command === undefined ? 'missing command' : `unknown command "${command}"`}\n\n${USAGE}`);
27
+ return 2;
28
+ }
29
+ const options = rest.filter((arg) => arg.startsWith('-'));
30
+ const names = rest.filter((arg) => !arg.startsWith('-'));
31
+ const unknownOption = options.find((option) => option !== '--with-deps');
32
+ if (unknownOption !== undefined) {
33
+ io.stderr(`unknown option "${unknownOption}"\n\n${USAGE}`);
34
+ return 2;
35
+ }
36
+ const unknownBrowser = names.find((name) => !isBrowserName(name));
37
+ if (unknownBrowser !== undefined) {
38
+ io.stderr(`unknown browser "${unknownBrowser}"; expected one of ${BROWSER_NAMES.join(', ')}\n`);
39
+ return 2;
40
+ }
41
+ return io.run(['install', ...options, ...(names.length > 0 ? names : ['chromium'])]);
42
+ }
package/dist/dialogs.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * fixture; the surface owns the router so a dialog that fires before a test
4
4
  * ever touches `web` is still accounted for instead of silently dismissed.
5
5
  */
6
- import type { Dialog as PwDialog } from 'playwright';
6
+ import type { Dialog as PwDialog } from 'playwright-core';
7
7
  import { ErrorLatch } from './support.ts';
8
8
  /** A native dialog as a test's handler sees it. */
9
9
  export interface Dialog {
@@ -5,7 +5,7 @@
5
5
  * that serves downloads names a directory on the browser's own disk; the
6
6
  * browser saves there, and the provider reads each finished file back.
7
7
  */
8
- import type { Download, Page } from 'playwright';
8
+ import type { Download, Page } from 'playwright-core';
9
9
  import type { LeaseDownloads } from './provider.ts';
10
10
  /**
11
11
  * Has the browser behind `page` save the downloads of the page's context
package/dist/engine.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * package imports the contract from `e2e/engine` and contributes the `browser`
6
6
  * fixture the way a device engine contributes `device`.
7
7
  */
8
- import type { BrowserContext, Page } from 'playwright';
8
+ import type { BrowserContext, Page } from 'playwright-core';
9
9
  import { type EngineHandle } from 'e2e/engine';
10
10
  import { type WebOptions } from './surface.ts';
11
11
  /**