@e2e-dev/web 0.11.2 → 0.13.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 (53) hide show
  1. package/README.md +15 -9
  2. package/dist/.build.json +1 -1
  3. package/dist/actions.d.ts +9 -3
  4. package/dist/actions.js +87 -9
  5. package/dist/attempt-session.d.ts +4 -3
  6. package/dist/attempt-session.js +17 -14
  7. package/dist/bin.d.ts +2 -0
  8. package/dist/bin.js +3 -0
  9. package/dist/browser-connection.d.ts +7 -3
  10. package/dist/browser-connection.js +7 -1
  11. package/dist/browser.d.ts +44 -7
  12. package/dist/browser.js +135 -72
  13. package/dist/capture-scope.d.ts +1 -1
  14. package/dist/cdp-recovery.d.ts +1 -1
  15. package/dist/cdp-selectors.d.ts +1 -1
  16. package/dist/cli.d.ts +9 -0
  17. package/dist/cli.js +42 -0
  18. package/dist/dialogs.d.ts +1 -1
  19. package/dist/downloads.d.ts +1 -1
  20. package/dist/engine.d.ts +1 -1
  21. package/dist/engine.js +74 -16
  22. package/dist/evaluation.d.ts +8 -3
  23. package/dist/evaluation.js +10 -5
  24. package/dist/in-page/read-semantics.d.ts +1 -0
  25. package/dist/in-page/read-semantics.js +166 -40
  26. package/dist/index.d.ts +2 -1
  27. package/dist/init-scripts.d.ts +30 -0
  28. package/dist/init-scripts.js +105 -0
  29. package/dist/install.d.ts +14 -1
  30. package/dist/install.js +77 -20
  31. package/dist/locators.d.ts +1 -1
  32. package/dist/locators.js +30 -18
  33. package/dist/observation-capture.d.ts +1 -1
  34. package/dist/observation.d.ts +1 -1
  35. package/dist/observation.js +3 -3
  36. package/dist/observe.d.ts +1 -1
  37. package/dist/operation-budget.d.ts +14 -0
  38. package/dist/operation-budget.js +31 -0
  39. package/dist/protected-app.d.ts +20 -4
  40. package/dist/protected-app.js +35 -4
  41. package/dist/provider-video.d.ts +1 -1
  42. package/dist/read-node.d.ts +9 -2
  43. package/dist/route-options.d.ts +44 -0
  44. package/dist/route-options.js +125 -0
  45. package/dist/selector-engines.js +2 -0
  46. package/dist/shown-selector.d.ts +20 -0
  47. package/dist/shown-selector.js +27 -0
  48. package/dist/support.d.ts +18 -12
  49. package/dist/support.js +41 -20
  50. package/dist/surface.d.ts +46 -6
  51. package/dist/surface.js +60 -19
  52. package/dist/video.d.ts +1 -1
  53. package/package.json +10 -6
@@ -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
  /**
@@ -102,23 +102,29 @@ export declare function isTestErrorCode(cause: unknown, code: string): cause is
102
102
  * | `TimeoutError` on a read, navigation, or artifact call | OPERATION_TIMEOUT |
103
103
  * | `TimeoutError` on an action, log ends before the input dispatch | NOT_ACTIONABLE |
104
104
  * | `TimeoutError` on an action, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
105
+ * | operation deadline cut off an action, pointer, or keyboard call | ACTION_MAY_HAVE_COMMITTED |
105
106
  * | element detached / not attached / no element / resolved hidden | NODE_STALE (retryable) |
106
107
  * | execution context destroyed / frame detached by a navigation | NODE_STALE (retryable) |
108
+ * | strict mode violation, log ends before the input dispatch | NODE_STALE (retryable) |
109
+ * | strict mode violation, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
107
110
  * | not an input / not editable / not checkable | NOT_ACTIONABLE |
108
- * | strict mode violation, anything else | ENGINE_FAILURE |
111
+ * | anything else | ENGINE_FAILURE |
109
112
  *
110
113
  * The action split is read from the call log Playwright appends to a timeout
111
- * message: `performing <x> action`, `<x> action done`, and `waiting for
112
- * scheduled navigations to finish` are only logged once the input is being (or
113
- * has been) dispatched, so a timeout whose log reaches them is uncertain and
114
- * the harness must not blindly repeat it. Every earlier line (`waiting for
115
- * element to be visible, enabled and stable`, `scrolling into view if
116
- * needed`, `retrying <x> action`) precedes dispatch and is a plain
117
- * actionability miss.
114
+ * or strict mode message: `performing <x> action`, `<x> action done`, and
115
+ * `waiting for scheduled navigations to finish` are only logged once the input
116
+ * is being (or has been) dispatched, so a failure whose log reaches them is
117
+ * uncertain and the harness must not blindly repeat it. Every earlier line
118
+ * (`waiting for element to be visible, enabled and stable`, `scrolling into
119
+ * view if needed`, `retrying <x> action`) precedes dispatch and is a plain
120
+ * actionability miss. Only whole `- ` log lines count, so a locator or element
121
+ * text quoting those words never does.
118
122
  */
119
123
  export declare const POST_DISPATCH_PATTERN: RegExp;
120
124
  /** Translates an unexpected Playwright error at the contract boundary. */
121
125
  export declare function translatePwError(cause: unknown, operation: string): Error;
126
+ /** Whether a Playwright failure describes a read that lost its document to a navigation. */
127
+ export declare function isNavigationRace(cause: unknown): boolean;
122
128
  /**
123
129
  * Like translatePwError, but a read that lost its document to a navigation
124
130
  * becomes retryable NODE_STALE so the runner re-reads the new document instead
@@ -140,7 +146,7 @@ export declare function performViewportSwipe(page: Page, direction: ScrollDirect
140
146
  * CSS pixel instead.
141
147
  */
142
148
  export declare function nearestPixel(point: ViewportPoint): ViewportPoint;
143
- /** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion. */
144
- export declare function performPointDrag(mouse: Mouse, start: ViewportPoint, end: ViewportPoint): Promise<void>;
149
+ /** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion; paced over `durationMs` when given. */
150
+ export declare function performPointDrag(mouse: Mouse, start: ViewportPoint, end: ViewportPoint, durationMs?: number, signal?: AbortSignal): Promise<void>;
145
151
  export declare function performElementSwipe(target: ActionTarget, direction: ScrollDirection, momentum: Momentum, timeout: number): Promise<void>;
146
152
  export {};
package/dist/support.js CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Shared error translation, filename, and swipe helpers for the Playwright engine. */
2
+ import { setTimeout } from 'node:timers/promises';
2
3
  import { EngineError, withinCleanupBudget } from 'e2e/engine';
3
4
  import { ConfigurationError, InfrastructureError, TestError } from 'e2e/engine';
4
5
  export const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
@@ -193,11 +194,12 @@ export class ErrorLatch {
193
194
  }
194
195
  }
195
196
  /**
196
- * The runner error classes as their `name` reads. A project loads this engine
197
- * from its config file in one module realm (tsx) while the runner's core runs
198
- * in another, so a runner error reaching the engine is often a `TestError`
199
- * from a copy of `e2e` this module never imported: `instanceof` says no while
200
- * `name` and `code` still tell the truth.
197
+ * The runner error classes as their `name` reads. A project can load this
198
+ * engine against a copy of `e2e` other than the one the runner's core runs
199
+ * from (and e2e before 0.17 always loaded config in a module realm of its
200
+ * own), so a runner error reaching the engine can be a `TestError` this
201
+ * module never imported: `instanceof` says no while `name` and `code` still
202
+ * tell the truth.
201
203
  */
202
204
  const CLASSIFIED_NAMES = new Set([
203
205
  'EngineError',
@@ -236,21 +238,25 @@ export function isTestErrorCode(cause, code) {
236
238
  * | `TimeoutError` on a read, navigation, or artifact call | OPERATION_TIMEOUT |
237
239
  * | `TimeoutError` on an action, log ends before the input dispatch | NOT_ACTIONABLE |
238
240
  * | `TimeoutError` on an action, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
241
+ * | operation deadline cut off an action, pointer, or keyboard call | ACTION_MAY_HAVE_COMMITTED |
239
242
  * | element detached / not attached / no element / resolved hidden | NODE_STALE (retryable) |
240
243
  * | execution context destroyed / frame detached by a navigation | NODE_STALE (retryable) |
244
+ * | strict mode violation, log ends before the input dispatch | NODE_STALE (retryable) |
245
+ * | strict mode violation, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
241
246
  * | not an input / not editable / not checkable | NOT_ACTIONABLE |
242
- * | strict mode violation, anything else | ENGINE_FAILURE |
247
+ * | anything else | ENGINE_FAILURE |
243
248
  *
244
249
  * The action split is read from the call log Playwright appends to a timeout
245
- * message: `performing <x> action`, `<x> action done`, and `waiting for
246
- * scheduled navigations to finish` are only logged once the input is being (or
247
- * has been) dispatched, so a timeout whose log reaches them is uncertain and
248
- * the harness must not blindly repeat it. Every earlier line (`waiting for
249
- * element to be visible, enabled and stable`, `scrolling into view if
250
- * needed`, `retrying <x> action`) precedes dispatch and is a plain
251
- * actionability miss.
250
+ * or strict mode message: `performing <x> action`, `<x> action done`, and
251
+ * `waiting for scheduled navigations to finish` are only logged once the input
252
+ * is being (or has been) dispatched, so a failure whose log reaches them is
253
+ * uncertain and the harness must not blindly repeat it. Every earlier line
254
+ * (`waiting for element to be visible, enabled and stable`, `scrolling into
255
+ * view if needed`, `retrying <x> action`) precedes dispatch and is a plain
256
+ * actionability miss. Only whole `- ` log lines count, so a locator or element
257
+ * text quoting those words never does.
252
258
  */
253
- export const POST_DISPATCH_PATTERN = /performing \w+ action|\w+ action done|waiting for scheduled navigations to finish/i;
259
+ export const POST_DISPATCH_PATTERN = /^\s*- (performing \w+ action|[\w ]+ action done|waiting for scheduled navigations to finish)\s*$/im;
254
260
  /** Translates an unexpected Playwright error at the contract boundary. */
255
261
  export function translatePwError(cause, operation) {
256
262
  if (isClassified(cause))
@@ -274,7 +280,7 @@ const STALE_PATTERN = /detached|not attached|resolved to hidden|no element|not f
274
280
  */
275
281
  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
282
  /** Whether a Playwright failure describes a read that lost its document to a navigation. */
277
- function isNavigationRace(cause) {
283
+ export function isNavigationRace(cause) {
278
284
  return NAVIGATION_RACE_PATTERN.test(message(cause));
279
285
  }
280
286
  /**
@@ -321,16 +327,31 @@ export async function performViewportSwipe(page, direction, momentum) {
321
327
  export function nearestPixel(point) {
322
328
  return { x: Math.round(point.x), y: Math.round(point.y) };
323
329
  }
324
- /** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion. */
325
- export async function performPointDrag(mouse, start, end) {
330
+ /** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion; paced over `durationMs` when given. */
331
+ export async function performPointDrag(mouse, start, end, durationMs, signal) {
326
332
  const from = nearestPixel(start);
327
333
  const to = nearestPixel(end);
328
334
  const middle = nearestPixel({ x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 });
329
335
  await mouse.move(from.x, from.y);
330
336
  await mouse.down();
331
- await mouse.move(middle.x, middle.y);
332
- await mouse.move(to.x, to.y);
333
- await mouse.up();
337
+ try {
338
+ if (durationMs !== undefined) {
339
+ const steps = Math.max(2, Math.round(durationMs / 16));
340
+ const startedAt = performance.now();
341
+ for (let step = 1; step <= steps; step += 1) {
342
+ const due = startedAt + (durationMs * step) / steps;
343
+ await setTimeout(Math.max(0, due - performance.now()), undefined, { signal });
344
+ await mouse.move(from.x + ((to.x - from.x) * step) / steps, from.y + ((to.y - from.y) * step) / steps);
345
+ }
346
+ }
347
+ else {
348
+ await mouse.move(middle.x, middle.y);
349
+ await mouse.move(to.x, to.y);
350
+ }
351
+ }
352
+ finally {
353
+ await mouse.up();
354
+ }
334
355
  }
335
356
  export async function performElementSwipe(target, direction, momentum, timeout) {
336
357
  // Hover first: it auto-waits for visibility on both target kinds, so the
package/dist/surface.d.ts CHANGED
@@ -6,11 +6,13 @@
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 OperationBound } from './operation-budget.ts';
15
+ import { type WebInitScript } from './init-scripts.ts';
14
16
  import { ErrorLatch } from './support.ts';
15
17
  type RoutePredicate = (url: URL) => boolean;
16
18
  type RouteHandler = (route: Route) => Promise<void>;
@@ -41,7 +43,7 @@ export interface WebConnectOptions {
41
43
  * same browser after a transport drop. Called once before the next
42
44
  * operation, within its budget. The original browser and page must survive.
43
45
  * Dispatched operations are never retried. The host owns browser cleanup.
44
- * Context replacement, headers, basicAuth, and userAgent are unavailable in this mode.
46
+ * Context replacement, headers, basicAuth, userAgent, locale, and timezoneId are unavailable in this mode.
45
47
  */
46
48
  readonly reconnectEndpoint?: (signal: AbortSignal) => string | Promise<string>;
47
49
  }
@@ -131,6 +133,29 @@ export interface WebOptions {
131
133
  * (`connect.reconnectEndpoint`, or a provider with `scope: 'attempt'`).
132
134
  */
133
135
  readonly userAgent?: string;
136
+ /**
137
+ * The locale every attempt's context runs in, a BCP 47 tag such as
138
+ * `de-DE`, as Playwright's own `locale` context option sets it: what
139
+ * `navigator.language`, `Intl` formatting, and the `Accept-Language` header
140
+ * report. Defaults to the browser's own. An `accept-language` entry in
141
+ * `headers` beside it is `INVALID_CONFIG`, and so is a persistent context
142
+ * (`connect.reconnectEndpoint`, or a provider with `scope: 'attempt'`).
143
+ */
144
+ readonly locale?: string;
145
+ /**
146
+ * The IANA time zone every attempt's context runs in, such as
147
+ * `Europe/Berlin`, as Playwright's own `timezoneId` context option sets it:
148
+ * what `Date` and `Intl` resolve local time against. Defaults to the
149
+ * machine's. A persistent context (`connect.reconnectEndpoint`, or a
150
+ * provider with `scope: 'attempt'`) is `INVALID_CONFIG`.
151
+ */
152
+ readonly timezoneId?: string;
153
+ /**
154
+ * Scripts every document runs before the page's own, in every tab and
155
+ * frame: JavaScript source, a `{ path }` relative to the project root, or a
156
+ * function, which cannot close over test variables.
157
+ */
158
+ readonly initScripts?: readonly WebInitScript[];
134
159
  }
135
160
  export declare class PlaywrightSurface {
136
161
  /**
@@ -153,7 +178,13 @@ export declare class PlaywrightSurface {
153
178
  private readonly basicAuth;
154
179
  private readonly testIdAttribute;
155
180
  private readonly userAgent;
181
+ private readonly locale;
182
+ private readonly timezoneId;
183
+ private readonly configuredInitScripts;
184
+ /** The attempt's init scripts, configured then added, applied to each context the attempt opens. */
185
+ private initScripts;
156
186
  private app;
187
+ private projectRoot;
157
188
  private headed;
158
189
  private artifactsDir;
159
190
  private artifactCounter;
@@ -170,7 +201,8 @@ export declare class PlaywrightSurface {
170
201
  * leases the run's browsers from a provider. A CDP attach uses the
171
202
  * remote's browser, so only a local launch needs the browser here. The
172
203
  * download narrates through `info.log` and is bounded by the run's
173
- * interrupt alone, never by a launch budget.
204
+ * interrupt alone, never by a launch budget. Init scripts are read first
205
+ * so a missing file fails the run before any worker starts.
174
206
  */
175
207
  prepare(info: EnginePrepareInfo): Promise<EnginePrepareResult | void>;
176
208
  /** Releases the browsers `prepare` leased; a local launch or a `connect` has nothing to release. */
@@ -235,8 +267,14 @@ export declare class PlaywrightSurface {
235
267
  dispose(context: EngineCleanupContext): Promise<void>;
236
268
  /** Returns the active attempt, including its connection generation and references. */
237
269
  private requireSession;
270
+ /** Adds one attempt-scoped init script to the current context, for every document it creates from now on. */
271
+ addInitScript(source: string): Promise<void>;
238
272
  /** Registers one attempt-scoped route on the current context. */
239
273
  route(predicate: RoutePredicate, handler: RouteHandler): Promise<void>;
274
+ /** The configured headers a request to `url` carries, lowercased; `undefined` off the app's site. */
275
+ siteHeaders(url: string): Readonly<Record<string, string>> | undefined;
276
+ /** Resolves a file a test names against the project root, as config paths do, never `process.cwd()`. */
277
+ projectPath(file: string): string;
240
278
  /** Removes one registered route from the attempt and the current context. */
241
279
  unroute(predicate: RoutePredicate, handler: RouteHandler): Promise<void>;
242
280
  requirePage(): Page;
@@ -260,11 +298,13 @@ export declare class PlaywrightSurface {
260
298
  * The single entry of every operation: rethrows an error latched on an
261
299
  * unawaited path, refuses a cancelled operation, races `fn` against the
262
300
  * operation signal so an abort mid-call surfaces as `CANCELLED` instead of
263
- * waiting out Playwright, and translates raw errors at the contract
301
+ * waiting out Playwright, bounds it by the operation's budget so a call a
302
+ * hung page never answers is `OPERATION_TIMEOUT`, and translates raw errors at the contract
264
303
  * boundary. `translate` overrides the default translation for operations
265
- * with a documented retryable failure mode.
304
+ * with a documented retryable failure mode; `bound` is `test-code` for an
305
+ * operation that runs the test's own code, which the deadline never cuts.
266
306
  */
267
- guard<T>(operation: OperationContext, label: string, fn: (operation: OperationContext) => Promise<T>, translate?: (cause: unknown, label: string) => Error): Promise<T>;
307
+ guard<T>(operation: OperationContext, label: string, fn: (operation: OperationContext) => Promise<T>, translate?: (cause: unknown, label: string) => Error, bound?: OperationBound): Promise<T>;
268
308
  /** Opens one URL the harness resolved; the attempt's page is created on first use. */
269
309
  open(url: string, operation: OperationContext): Promise<void>;
270
310
  back(operation: OperationContext): Promise<void>;