@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
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Argument validation for a route handler's decision. Every key is checked
3
+ * before the decision is taken: an option this engine does not implement
4
+ * (Playwright's `abort(errorCode)`, `fulfill({ response })`, a misspelled
5
+ * key) is `INVALID_ARGUMENT` rather than a request answered as if the option
6
+ * were absent. A continue URL goes through the harness's navigation rule,
7
+ * the same `resolveUrl` `goto` uses.
8
+ */
9
+ import { statSync } from 'node:fs';
10
+ import { rejectUnknownOptions, TestError, validateJsonValue } from 'e2e/engine';
11
+ import { headerProblem } from './protected-app.js';
12
+ const FULFILL_KEYS = ['status', 'headers', 'contentType', 'json', 'body', 'path'];
13
+ const CONTINUE_KEYS = ['url', 'method', 'headers', 'postData'];
14
+ /**
15
+ * Validates `route.fulfill(response)`, synchronously so the decision is
16
+ * taken the moment `fulfill` is called. `path` resolves with `resolvePath`
17
+ * (the project root, never `process.cwd()`) and must name a file now, so a
18
+ * missing fixture fails the step with its own message, and `json` follows
19
+ * the JSON-value rules `evaluate` does. A `content-type` header becomes
20
+ * `contentType` when none is given, since Playwright overwrites the header
21
+ * with the type it derives for `json` and `path`.
22
+ */
23
+ export function parseFulfill(response, resolvePath) {
24
+ const fields = requireOptions('route.fulfill', response, FULFILL_KEYS, false);
25
+ const sources = ['json', 'body', 'path'].filter((key) => fields[key] !== undefined);
26
+ if (sources.length > 1) {
27
+ throw new TestError('INVALID_ARGUMENT', `route.fulfill takes one of json, body, or path; got ${sources.join(' and ')}`);
28
+ }
29
+ const { status, headers, contentType, json, body, path } = fields;
30
+ if (status !== undefined && !(Number.isInteger(status) && status >= 100 && status <= 599)) {
31
+ throw new TestError('INVALID_ARGUMENT', 'route.fulfill status must be an integer from 100 to 599');
32
+ }
33
+ if (json !== undefined)
34
+ validateJsonValue(json, 'route.fulfill json');
35
+ const file = path === undefined ? undefined : resolvePath(requireString('route.fulfill', 'path', path, true));
36
+ if (file !== undefined)
37
+ requireFile(file);
38
+ const replied = requireHeaders('route.fulfill', headers) ?? {};
39
+ const type = contentType === undefined
40
+ ? Object.entries(replied).find(([name]) => name.toLowerCase() === 'content-type')?.[1]
41
+ : requireString('route.fulfill', 'contentType', contentType, true);
42
+ return {
43
+ status: status ?? 200,
44
+ headers: replied,
45
+ ...(type === undefined ? {} : { contentType: type }),
46
+ ...(json === undefined ? {} : { json: json }),
47
+ ...(body === undefined ? {} : { body: requireString('route.fulfill', 'body', body, false) }),
48
+ ...(file === undefined ? {} : { path: file }),
49
+ };
50
+ }
51
+ /**
52
+ * Validates `route.continue(overrides)`. A `url` resolves against the base
53
+ * URL through `resolveUrl`, which denies what navigation denies, and keeps
54
+ * the request's scheme, as the browser requires of a rewritten request.
55
+ */
56
+ export function parseContinue(overrides, requestUrl, resolveUrl) {
57
+ const fields = requireOptions('route.continue', overrides, CONTINUE_KEYS, true);
58
+ const { url, method, headers, postData } = fields;
59
+ let target;
60
+ if (url !== undefined) {
61
+ target = resolveUrl(requireString('route.continue', 'url', url, true));
62
+ const from = new URL(requestUrl).protocol;
63
+ const to = new URL(target).protocol;
64
+ if (from !== to) {
65
+ throw new TestError('INVALID_ARGUMENT', `route.continue url must keep the request's ${from} scheme; got ${to}`);
66
+ }
67
+ }
68
+ const replaced = requireHeaders('route.continue', headers);
69
+ return {
70
+ ...(target === undefined ? {} : { url: target }),
71
+ ...(method === undefined ? {} : { method: requireString('route.continue', 'method', method, true) }),
72
+ ...(replaced === undefined ? {} : { headers: replaced }),
73
+ ...(postData === undefined ? {} : { postData: requireString('route.continue', 'postData', postData, false) }),
74
+ };
75
+ }
76
+ /** Rejects any argument to a decision that takes none (`abort`, `fallback`). */
77
+ export function requireNoArguments(api, args) {
78
+ if (args.some((arg) => arg !== undefined)) {
79
+ throw new TestError('INVALID_ARGUMENT', `${api}() takes no arguments`);
80
+ }
81
+ }
82
+ /** The options object with every key checked against `allowed`; `undefined` is `{}` when `optional`. */
83
+ function requireOptions(api, value, allowed, optional) {
84
+ if (value === undefined) {
85
+ if (optional)
86
+ return {};
87
+ throw new TestError('INVALID_ARGUMENT', `${api} takes an options object`);
88
+ }
89
+ rejectUnknownOptions(api, value, allowed);
90
+ return value;
91
+ }
92
+ /** `value` as a string, else `INVALID_ARGUMENT` naming the option. */
93
+ function requireString(api, key, value, nonempty) {
94
+ if (typeof value !== 'string' || (nonempty && value.length === 0)) {
95
+ throw new TestError('INVALID_ARGUMENT', `${api} ${key} must be a ${nonempty ? 'nonempty ' : ''}string`);
96
+ }
97
+ return value;
98
+ }
99
+ /** A header map the browser can send, or `undefined` when none was given. */
100
+ function requireHeaders(api, value) {
101
+ if (value === undefined)
102
+ return undefined;
103
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
104
+ throw new TestError('INVALID_ARGUMENT', `${api} headers must map names to string values`);
105
+ }
106
+ for (const [name, entry] of Object.entries(value)) {
107
+ const problem = headerProblem(name, entry);
108
+ if (problem !== undefined)
109
+ throw new TestError('INVALID_ARGUMENT', `${api} ${problem}`);
110
+ }
111
+ return value;
112
+ }
113
+ /** Rejects a path that does not name a regular file; synchronous so the check lands before the decision. */
114
+ function requireFile(file) {
115
+ let isFile = false;
116
+ try {
117
+ isFile = statSync(file, { throwIfNoEntry: false })?.isFile() === true;
118
+ }
119
+ catch {
120
+ // A directory on the path the runner may not search (`EACCES`): not a file the route can serve.
121
+ }
122
+ if (!isFile) {
123
+ throw new TestError('INVALID_ARGUMENT', `route.fulfill path is not a readable file: ${file}`);
124
+ }
125
+ }
@@ -2,8 +2,10 @@
2
2
  import { CLOSED_SHADOW_SELECTOR_ENGINES } from './closed-shadow.js';
3
3
  import { EXACT_LABEL_SELECTOR_ENGINE, EXACT_LABEL_SELECTOR_ENGINE_SOURCE } from './label-selector.js';
4
4
  import { READ_SELECTOR_ENGINE, READ_SELECTOR_ENGINE_SOURCE } from './read-selector.js';
5
+ import { SHOWN_SELECTOR_ENGINE, SHOWN_SELECTOR_ENGINE_SOURCE } from './shown-selector.js';
5
6
  export const SELECTOR_ENGINES = [
6
7
  ...CLOSED_SHADOW_SELECTOR_ENGINES,
7
8
  { name: EXACT_LABEL_SELECTOR_ENGINE, source: EXACT_LABEL_SELECTOR_ENGINE_SOURCE },
8
9
  { name: READ_SELECTOR_ENGINE, source: READ_SELECTOR_ENGINE_SOURCE },
10
+ { name: SHOWN_SELECTOR_ENGINE, source: SHOWN_SELECTOR_ENGINE_SOURCE },
9
11
  ];
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The selector engine a `visible` query narrows through inside Playwright's
3
+ * own chain, before an index, a filter, or an enclosing scope runs. It keeps
4
+ * the query root when the reader's `hidden` state is false, so a node the
5
+ * chain retains is one `toBeVisible()` accepts by construction. Playwright's
6
+ * `filter({ visible: true })` could not stand in for it: it reads text under
7
+ * a `display: contents` element with `visibility: hidden` as visible, which
8
+ * paints nothing.
9
+ */
10
+ /** Playwright selector engine name; `e2e-shown=` resolves to the query root when it reads as shown. */
11
+ export declare const SHOWN_SELECTOR_ENGINE = "e2e-shown";
12
+ /** The selector step that keeps a match only when it reads as shown. */
13
+ export declare const SHOWN_SELECTOR = "e2e-shown=";
14
+ /**
15
+ * The engine behind `e2e-shown=`: the query root itself when the reader's
16
+ * `hidden` mode answers false, else nothing. The reader is embedded as source
17
+ * because a selector engine, like the reader, is one self-contained page
18
+ * function.
19
+ */
20
+ export declare const SHOWN_SELECTOR_ENGINE_SOURCE: string;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The selector engine a `visible` query narrows through inside Playwright's
3
+ * own chain, before an index, a filter, or an enclosing scope runs. It keeps
4
+ * the query root when the reader's `hidden` state is false, so a node the
5
+ * chain retains is one `toBeVisible()` accepts by construction. Playwright's
6
+ * `filter({ visible: true })` could not stand in for it: it reads text under
7
+ * a `display: contents` element with `visibility: hidden` as visible, which
8
+ * paints nothing.
9
+ */
10
+ import { readSemanticsFunction } from './in-page/read-semantics.js';
11
+ import { SECURE_FIELD_SELECTOR } from './read-node.js';
12
+ /** Playwright selector engine name; `e2e-shown=` resolves to the query root when it reads as shown. */
13
+ export const SHOWN_SELECTOR_ENGINE = 'e2e-shown';
14
+ /** The selector step that keeps a match only when it reads as shown. */
15
+ export const SHOWN_SELECTOR = `${SHOWN_SELECTOR_ENGINE}=`;
16
+ /**
17
+ * The engine behind `e2e-shown=`: the query root itself when the reader's
18
+ * `hidden` mode answers false, else nothing. The reader is embedded as source
19
+ * because a selector engine, like the reader, is one self-contained page
20
+ * function.
21
+ */
22
+ export const SHOWN_SELECTOR_ENGINE_SOURCE = `() => {
23
+ const read = (${readSemanticsFunction.toString()});
24
+ const options = { testIdAttribute: '', secureFieldSelector: ${JSON.stringify(SECURE_FIELD_SELECTOR)}, mode: { kind: 'hidden' } };
25
+ const queryAll = (root) => (read(root, options) ? [] : [root]);
26
+ return { queryAll, query: (root) => queryAll(root)[0] ?? null };
27
+ }`;
package/dist/support.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Shared error translation, filename, and swipe helpers for the Playwright engine. */
2
- import type { ElementHandle, Locator as PwLocator, Mouse, Page } from 'playwright';
2
+ import type { ElementHandle, Locator as PwLocator, Mouse, Page } from 'playwright-core';
3
3
  import { EngineError, type EngineCleanupContext, type Momentum, type ScrollDirection, type ViewportPoint, type ViewportSize } from 'e2e/engine';
4
4
  export declare const DEFAULT_VIEWPORT: {
5
5
  readonly width: 1280;
@@ -29,7 +29,7 @@ export type ActionTarget = {
29
29
  * evaluate, page ownership, drag) have explicit helpers below so every fork is
30
30
  * visible at its call site instead of hidden behind a uniform interface.
31
31
  */
32
- type Actionable = Pick<PwLocator, 'click' | 'dblclick' | 'fill' | 'press' | 'check' | 'uncheck' | 'hover' | 'scrollIntoViewIfNeeded' | 'selectOption' | 'setInputFiles'>;
32
+ type Actionable = Pick<PwLocator, 'click' | 'dblclick' | 'fill' | 'press' | 'hover' | 'scrollIntoViewIfNeeded' | 'selectOption' | 'setInputFiles'>;
33
33
  /** Narrows one action target to the shared Playwright action surface. */
34
34
  export declare function asActionable(target: ActionTarget): Actionable;
35
35
  /**
@@ -119,6 +119,8 @@ export declare function isTestErrorCode(cause: unknown, code: string): cause is
119
119
  export declare const POST_DISPATCH_PATTERN: RegExp;
120
120
  /** Translates an unexpected Playwright error at the contract boundary. */
121
121
  export declare function translatePwError(cause: unknown, operation: string): Error;
122
+ /** Whether a Playwright failure describes a read that lost its document to a navigation. */
123
+ export declare function isNavigationRace(cause: unknown): boolean;
122
124
  /**
123
125
  * Like translatePwError, but a read that lost its document to a navigation
124
126
  * becomes retryable NODE_STALE so the runner re-reads the new document instead
package/dist/support.js CHANGED
@@ -274,7 +274,7 @@ const STALE_PATTERN = /detached|not attached|resolved to hidden|no element|not f
274
274
  */
275
275
  const NAVIGATION_RACE_PATTERN = /execution context was destroyed|because of a navigation|navigating and changing the content|frame was detached|frame got detached|node is detached from document/i;
276
276
  /** Whether a Playwright failure describes a read that lost its document to a navigation. */
277
- function isNavigationRace(cause) {
277
+ export function isNavigationRace(cause) {
278
278
  return NAVIGATION_RACE_PATTERN.test(message(cause));
279
279
  }
280
280
  /**
package/dist/surface.d.ts CHANGED
@@ -6,11 +6,12 @@
6
6
  * `actions.ts` and tree capture in `observation.ts`. `AttemptSession` owns
7
7
  * the attempt's live binding, recovery, references, and recordings.
8
8
  */
9
- import type { BrowserContext, Page, Route } from 'playwright';
9
+ import type { BrowserContext, Page, Route } from 'playwright-core';
10
10
  import { type EngineAttemptContext, type EngineCleanupContext, type EngineInitInfo, type EngineObserveOptions, type EnginePrepareInfo, type EnginePrepareResult, type EngineFinishInfo, type EngineSnapshot, type EngineState, type LocatorAction, type LocatorExpression, type NodeRef, type OperationContext, type PointerAction, type Secret, type SemanticNode, type VideoSegment, type ViewportPoint, type ViewportSize } from 'e2e/engine';
11
11
  import { type BrowserName } from './browser-connection.ts';
12
12
  import { type BrowserProvider, type LeaseDownloads } from './provider.ts';
13
13
  import { DialogRouter } from './dialogs.ts';
14
+ import { type WebInitScript } from './init-scripts.ts';
14
15
  import { ErrorLatch } from './support.ts';
15
16
  type RoutePredicate = (url: URL) => boolean;
16
17
  type RouteHandler = (route: Route) => Promise<void>;
@@ -41,7 +42,7 @@ export interface WebConnectOptions {
41
42
  * same browser after a transport drop. Called once before the next
42
43
  * operation, within its budget. The original browser and page must survive.
43
44
  * Dispatched operations are never retried. The host owns browser cleanup.
44
- * Context replacement, headers, basicAuth, and userAgent are unavailable in this mode.
45
+ * Context replacement, headers, basicAuth, userAgent, locale, and timezoneId are unavailable in this mode.
45
46
  */
46
47
  readonly reconnectEndpoint?: (signal: AbortSignal) => string | Promise<string>;
47
48
  }
@@ -131,6 +132,29 @@ export interface WebOptions {
131
132
  * (`connect.reconnectEndpoint`, or a provider with `scope: 'attempt'`).
132
133
  */
133
134
  readonly userAgent?: string;
135
+ /**
136
+ * The locale every attempt's context runs in, a BCP 47 tag such as
137
+ * `de-DE`, as Playwright's own `locale` context option sets it: what
138
+ * `navigator.language`, `Intl` formatting, and the `Accept-Language` header
139
+ * report. Defaults to the browser's own. An `accept-language` entry in
140
+ * `headers` beside it is `INVALID_CONFIG`, and so is a persistent context
141
+ * (`connect.reconnectEndpoint`, or a provider with `scope: 'attempt'`).
142
+ */
143
+ readonly locale?: string;
144
+ /**
145
+ * The IANA time zone every attempt's context runs in, such as
146
+ * `Europe/Berlin`, as Playwright's own `timezoneId` context option sets it:
147
+ * what `Date` and `Intl` resolve local time against. Defaults to the
148
+ * machine's. A persistent context (`connect.reconnectEndpoint`, or a
149
+ * provider with `scope: 'attempt'`) is `INVALID_CONFIG`.
150
+ */
151
+ readonly timezoneId?: string;
152
+ /**
153
+ * Scripts every document runs before the page's own, in every tab and
154
+ * frame: JavaScript source, a `{ path }` relative to the project root, or a
155
+ * function, which cannot close over test variables.
156
+ */
157
+ readonly initScripts?: readonly WebInitScript[];
134
158
  }
135
159
  export declare class PlaywrightSurface {
136
160
  /**
@@ -153,7 +177,13 @@ export declare class PlaywrightSurface {
153
177
  private readonly basicAuth;
154
178
  private readonly testIdAttribute;
155
179
  private readonly userAgent;
180
+ private readonly locale;
181
+ private readonly timezoneId;
182
+ private readonly configuredInitScripts;
183
+ /** The attempt's init scripts, configured then added, applied to each context the attempt opens. */
184
+ private initScripts;
156
185
  private app;
186
+ private projectRoot;
157
187
  private headed;
158
188
  private artifactsDir;
159
189
  private artifactCounter;
@@ -170,7 +200,8 @@ export declare class PlaywrightSurface {
170
200
  * leases the run's browsers from a provider. A CDP attach uses the
171
201
  * remote's browser, so only a local launch needs the browser here. The
172
202
  * download narrates through `info.log` and is bounded by the run's
173
- * interrupt alone, never by a launch budget.
203
+ * interrupt alone, never by a launch budget. Init scripts are read first
204
+ * so a missing file fails the run before any worker starts.
174
205
  */
175
206
  prepare(info: EnginePrepareInfo): Promise<EnginePrepareResult | void>;
176
207
  /** Releases the browsers `prepare` leased; a local launch or a `connect` has nothing to release. */
@@ -235,8 +266,14 @@ export declare class PlaywrightSurface {
235
266
  dispose(context: EngineCleanupContext): Promise<void>;
236
267
  /** Returns the active attempt, including its connection generation and references. */
237
268
  private requireSession;
269
+ /** Adds one attempt-scoped init script to the current context, for every document it creates from now on. */
270
+ addInitScript(source: string): Promise<void>;
238
271
  /** Registers one attempt-scoped route on the current context. */
239
272
  route(predicate: RoutePredicate, handler: RouteHandler): Promise<void>;
273
+ /** The configured headers a request to `url` carries, lowercased; `undefined` off the app's site. */
274
+ siteHeaders(url: string): Readonly<Record<string, string>> | undefined;
275
+ /** Resolves a file a test names against the project root, as config paths do, never `process.cwd()`. */
276
+ projectPath(file: string): string;
240
277
  /** Removes one registered route from the attempt and the current context. */
241
278
  unroute(predicate: RoutePredicate, handler: RouteHandler): Promise<void>;
242
279
  requirePage(): Page;
package/dist/surface.js CHANGED
@@ -22,9 +22,10 @@ import { captureObservation } from './observation-capture.js';
22
22
  import { maskOptions, secureFieldMasks } from './observe.js';
23
23
  import { connectionAbort } from './operation-budget.js';
24
24
  import { CLOSED_SHADOW_ROOTS_INIT_SCRIPT } from './closed-shadow.js';
25
+ import { ConfiguredInitScripts } from './init-scripts.js';
25
26
  import { SECURE_FIELD_SELECTOR } from './read-node.js';
26
27
  import { readSelector, takeReadsFunction } from './read-selector.js';
27
- import { httpCredentials, installSiteHeaders, lowercaseNames } from './protected-app.js';
28
+ import { httpCredentials, installSiteHeaders, lowercaseNames, siteHeadersFor } from './protected-app.js';
28
29
  import { RefRegistry } from './refs.js';
29
30
  import { cancelled, DEFAULT_VIEWPORT, ErrorLatch, invalidState, message, navigationStaleOr, performViewportSwipe, sanitizeFilename, staleOr, translatePwError, } from './support.js';
30
31
  /** Browser launch budget when the harness init budget is not otherwise expressed. */
@@ -62,7 +63,13 @@ export class PlaywrightSurface {
62
63
  basicAuth;
63
64
  testIdAttribute;
64
65
  userAgent;
66
+ locale;
67
+ timezoneId;
68
+ configuredInitScripts;
69
+ /** The attempt's init scripts, configured then added, applied to each context the attempt opens. */
70
+ initScripts = [];
65
71
  app = {};
72
+ projectRoot = '';
66
73
  headed = false;
67
74
  artifactsDir = '';
68
75
  artifactCounter = 0;
@@ -83,6 +90,9 @@ export class PlaywrightSurface {
83
90
  this.basicAuth = options.basicAuth;
84
91
  this.testIdAttribute = options.testIdAttribute ?? DEFAULT_TEST_ID_ATTRIBUTE;
85
92
  this.userAgent = options.userAgent;
93
+ this.locale = options.locale;
94
+ this.timezoneId = options.timezoneId;
95
+ this.configuredInitScripts = new ConfiguredInitScripts(options.initScripts ?? []);
86
96
  }
87
97
  // --- lifecycle ---
88
98
  /**
@@ -90,9 +100,11 @@ export class PlaywrightSurface {
90
100
  * leases the run's browsers from a provider. A CDP attach uses the
91
101
  * remote's browser, so only a local launch needs the browser here. The
92
102
  * download narrates through `info.log` and is bounded by the run's
93
- * interrupt alone, never by a launch budget.
103
+ * interrupt alone, never by a launch budget. Init scripts are read first
104
+ * so a missing file fails the run before any worker starts.
94
105
  */
95
106
  async prepare(info) {
107
+ await this.configuredInitScripts.load(info.projectRoot);
96
108
  if (this.leases !== undefined)
97
109
  return this.leases.prepare(info);
98
110
  if (this.connect !== undefined)
@@ -106,13 +118,20 @@ export class PlaywrightSurface {
106
118
  /** Provisions the shared browser once per worker: a local launch, or a CDP attach. */
107
119
  async init(info) {
108
120
  this.app = info.app;
121
+ this.projectRoot = info.projectRoot;
109
122
  this.headed = info.headed;
110
123
  this.leases?.init(info);
111
124
  // The browser was installed in `prepare`; a launch or attach is the one
112
125
  // boot step left that can outlive a launch budget, and it honours the
113
- // init signal.
114
- if (!this.persistent)
115
- await this.acquireBrowser(info.signal);
126
+ // init signal. A failed init-script read throws only after the launch
127
+ // settles, so `dispose` owns the browser.
128
+ const settled = await Promise.allSettled([
129
+ this.configuredInitScripts.load(info.projectRoot),
130
+ this.persistent ? undefined : this.acquireBrowser(info.signal),
131
+ ]);
132
+ for (const outcome of settled)
133
+ if (outcome.status === 'rejected')
134
+ throw outcome.reason;
116
135
  }
117
136
  /** Whether attempts ride a persistent remote context, provisioned per attempt, instead of contexts on one shared browser. */
118
137
  get persistent() {
@@ -217,9 +236,11 @@ export class PlaywrightSurface {
217
236
  this.artifactsDir = context.artifactsDir;
218
237
  this.artifactCounter = 0;
219
238
  const routes = [];
239
+ const initScripts = this.configuredInitScripts.forAttempt();
220
240
  this.latch = new ErrorLatch();
221
241
  const dialogs = new DialogRouter(this.latch);
222
242
  this.routes = routes;
243
+ this.initScripts = initScripts;
223
244
  this.dialogs = dialogs;
224
245
  const session = new AttemptSession({
225
246
  artifactsDir: context.artifactsDir,
@@ -231,10 +252,14 @@ export class PlaywrightSurface {
231
252
  acceptDownloads: true,
232
253
  ...(credentials === undefined ? {} : { httpCredentials: credentials }),
233
254
  ...(this.userAgent === undefined ? {} : { userAgent: this.userAgent }),
255
+ ...(this.locale === undefined ? {} : { locale: this.locale }),
256
+ ...(this.timezoneId === undefined ? {} : { timezoneId: this.timezoneId }),
234
257
  ...(this.headers === undefined ? {} : { serviceWorkers: 'block' }),
235
258
  },
236
259
  configure: async (target) => {
237
260
  await target.addInitScript(CLOSED_SHADOW_ROOTS_INIT_SCRIPT);
261
+ for (const script of initScripts)
262
+ await target.addInitScript(script);
238
263
  target.setDefaultTimeout(CONTEXT_DEFAULT_TIMEOUT_MS);
239
264
  target.on('dialog', (dialog) => { void dialogs.dispatch(dialog); });
240
265
  await installSiteHeaders(target, this.app.site, this.headers);
@@ -303,6 +328,12 @@ export class PlaywrightSurface {
303
328
  throw invalidState('no attempt is running');
304
329
  return this.session;
305
330
  }
331
+ /** Adds one attempt-scoped init script to the current context, for every document it creates from now on. */
332
+ async addInitScript(source) {
333
+ const context = this.requireContext();
334
+ this.initScripts.push(source);
335
+ await context.addInitScript(source);
336
+ }
306
337
  // --- network routes shared with the browser fixture ---
307
338
  /** Registers one attempt-scoped route on the current context. */
308
339
  async route(predicate, handler) {
@@ -310,6 +341,14 @@ export class PlaywrightSurface {
310
341
  this.routes.push({ predicate, handler });
311
342
  await context.route(predicate, handler);
312
343
  }
344
+ /** The configured headers a request to `url` carries, lowercased; `undefined` off the app's site. */
345
+ siteHeaders(url) {
346
+ return siteHeadersFor(url, this.app.site, this.headers);
347
+ }
348
+ /** Resolves a file a test names against the project root, as config paths do, never `process.cwd()`. */
349
+ projectPath(file) {
350
+ return path.resolve(this.projectRoot, file);
351
+ }
313
352
  /** Removes one registered route from the attempt and the current context. */
314
353
  async unroute(predicate, handler) {
315
354
  const context = this.requireContext();
package/dist/video.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * timestamps on it. The first segment is `video/video.webm`; later ones are
14
14
  * `video/video-part<n>.webm`. Without a recording, every hook here is a no-op.
15
15
  */
16
- import type { Page } from 'playwright';
16
+ import type { Page } from 'playwright-core';
17
17
  import { type VideoFile, type VideoSegment } from 'e2e/engine';
18
18
  import type { WebScreencastOptions } from './surface.ts';
19
19
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e2e-dev/web",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "Web engine for e2e agentic end-to-end testing, built on Playwright",
5
5
  "keywords": [
6
6
  "e2e",
@@ -39,20 +39,24 @@
39
39
  "default": "./dist/index.js"
40
40
  }
41
41
  },
42
+ "bin": {
43
+ "e2e-web": "./dist/bin.js"
44
+ },
42
45
  "files": [
43
46
  "dist",
44
47
  "NOTICE"
45
48
  ],
49
+ "dependencies": {
50
+ "playwright-core": "1.63.0"
51
+ },
46
52
  "peerDependencies": {
47
- "e2e": ">=0.15.0 <1",
48
- "playwright": ">=1.63.0 <2"
53
+ "e2e": ">=0.15.0 <1"
49
54
  },
50
55
  "devDependencies": {
51
56
  "@types/node": "26.6.2",
52
- "e2e": "0.16.0",
57
+ "e2e": "0.17.0",
53
58
  "@types/pngjs": "6.0.5",
54
59
  "pngjs": "7.0.0",
55
- "playwright": "1.63.0",
56
60
  "typescript": "7.0.2",
57
61
  "vitest": "5.0.1"
58
62
  },