@e2e-dev/web 0.15.5-nightly-20261105030216 → 0.15.5

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.
package/README.md CHANGED
@@ -146,4 +146,4 @@ ones the engine launches. Under pnpm, run the bin by name:
146
146
 
147
147
  ## Documentation
148
148
 
149
- Full documentation lives at [e2e.tester.army/docs](https://e2e.tester.army/docs).
149
+ Full documentation lives at [e2e.com/docs](https://e2e.com/docs).
package/dist/.build.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.15.5-nightly-20261105030216"}
1
+ {"version":"0.15.5"}
package/dist/actions.d.ts CHANGED
@@ -8,7 +8,7 @@ import type { ConnectionBudget } from './operation-budget.ts';
8
8
  * platform's own actionability checks. `lookup` resolves the second ref of a
9
9
  * drag; everything else needs only the target.
10
10
  */
11
- export declare function dispatchLocatorAction(target: ActionTarget, action: LocatorAction, operation: ConnectionBudget, lookup: (ref: NodeRef) => ActionTarget): Promise<void>;
11
+ export declare function dispatchLocatorAction(target: ActionTarget, action: LocatorAction, operation: ConnectionBudget, lookup: (ref: NodeRef) => Promise<ActionTarget>): Promise<void>;
12
12
  /**
13
13
  * Dispatches one pointer action at a viewport point with nothing resolved
14
14
  * behind it: no actionability wait, because there is no element to wait on,
package/dist/actions.js CHANGED
@@ -34,13 +34,15 @@ async function setChecked(target, checked, operation) {
34
34
  try {
35
35
  if ((await element.isChecked()) === checked)
36
36
  return;
37
- if (!checked && (await isRadio(element))) {
37
+ const radio = checked ? undefined : await radioKind(element);
38
+ if (radio === 'native') {
38
39
  throw new EngineError('NOT_ACTIONABLE', 'uncheck cannot clear a radio button; select another radio in its group', {
39
40
  retryable: false,
40
41
  });
41
42
  }
42
43
  await element.click({ timeout: remaining() });
43
- const unchanged = (detail = '') => new EngineError('NOT_ACTIONABLE', `${verb} clicked the control but its checked state did not change${detail}`, {
44
+ const hint = radio === 'aria' ? '; a radio usually clears only when another radio in its group is selected' : '';
45
+ const unchanged = (detail = '') => new EngineError('NOT_ACTIONABLE', `${verb} clicked the control but its checked state did not change${detail}${hint}`, {
44
46
  retryable: false,
45
47
  });
46
48
  let firstRead = true;
@@ -70,9 +72,17 @@ async function setChecked(target, checked, operation) {
70
72
  void element.dispose().catch(() => undefined);
71
73
  }
72
74
  }
73
- /** Whether a checkable element is a radio, which a click can select but never clear. */
74
- function isRadio(element) {
75
- return element.evaluate((node) => (node instanceof HTMLInputElement && node.type === 'radio') || node.getAttribute('role') === 'radio');
75
+ /**
76
+ * Which kind of radio a checkable element is, if any. A native radio can be
77
+ * selected but never cleared by a click. An ARIA radio is clicked like any
78
+ * control, as Playwright does: its script decides what a click does.
79
+ */
80
+ function radioKind(element) {
81
+ return element.evaluate((node) => {
82
+ if (node instanceof HTMLInputElement && node.type === 'radio')
83
+ return 'native';
84
+ return node.getAttribute('role') === 'radio' ? 'aria' : undefined;
85
+ });
76
86
  }
77
87
  /**
78
88
  * Attaches files to a file input, or to a control that opens a native file
@@ -190,7 +200,8 @@ export async function dispatchLocatorAction(target, action, operation, lookup) {
190
200
  await uploadFiles(target, action.paths, operation);
191
201
  return;
192
202
  case 'dragTo': {
193
- const other = lookup(action.target);
203
+ const other = await lookup(action.target);
204
+ operation.signal.throwIfAborted();
194
205
  // Playwright's own drag when both sides are locators: it waits for
195
206
  // actionability on each and reports better failures than a pointer
196
207
  // sequence can. Anything else - an observed reference on either side -
@@ -1,6 +1,5 @@
1
1
  /** Shares one browser process connection across a worker's attempts. */
2
- import type { Browser } from 'playwright-core';
3
- import { chromium } from 'playwright-core';
2
+ import type { Browser, BrowserType } from 'playwright-core';
4
3
  /** Every browser the engine launches, in the order the CLI lists them. */
5
4
  export declare const BROWSER_NAMES: readonly ['chromium', 'firefox', 'webkit'];
6
5
  export type BrowserName = (typeof BROWSER_NAMES)[number];
@@ -27,7 +26,7 @@ export declare class BrowserConnection {
27
26
  dispose(): Promise<void>;
28
27
  }
29
28
  /** Maps a browser name to its Playwright BrowserType. */
30
- export declare function browserType(name: BrowserName): typeof chromium;
29
+ export declare function browserType(name: BrowserName): Promise<BrowserType>;
31
30
  /**
32
31
  * Attaches to a remote browser over the Chrome DevTools Protocol. CDP attach
33
32
  * is chromium-only; a hosted-browser engine (a per-run cloud session) resolves
@@ -1,6 +1,11 @@
1
1
  /** Shares one browser process connection across a worker's attempts. */
2
- import { chromium, firefox, selectors, webkit } from 'playwright-core';
3
2
  import { SELECTOR_ENGINES } from './selector-engines.js';
3
+ /**
4
+ * Playwright, imported the first time a browser is looked up, launched, or
5
+ * attached to. A process that only reads the config (`e2e list`, a run whose
6
+ * browsers come from a provider) never loads it.
7
+ */
8
+ const loadPlaywright = () => import('playwright-core');
4
9
  /** Every browser the engine launches, in the order the CLI lists them. */
5
10
  export const BROWSER_NAMES = ['chromium', 'firefox', 'webkit'];
6
11
  /** Whether `name` is a browser the engine launches. */
@@ -44,8 +49,12 @@ export class BrowserConnection {
44
49
  return this.acquire(name, headed, timeoutMs, connect);
45
50
  this.pending = undefined;
46
51
  }
47
- await registerSelectorEngines();
48
- const launching = connect !== undefined ? connect() : browserType(name).launch({ headless: !headed, timeout: timeoutMs, ...RUNNER_OWNS_SIGNALS });
52
+ // Set before anything awaits: the first call loads Playwright, and a `dispose`
53
+ // that arrives meanwhile must close the browser this launch produces, while a
54
+ // second caller waits for it instead of launching another.
55
+ const launching = registerSelectorEngines().then(() => connect !== undefined
56
+ ? connect()
57
+ : browserType(name).then((type) => type.launch({ headless: !headed, timeout: timeoutMs, ...RUNNER_OWNS_SIGNALS })));
49
58
  this.pending = launching;
50
59
  try {
51
60
  return await launching;
@@ -69,7 +78,8 @@ export class BrowserConnection {
69
78
  }
70
79
  }
71
80
  /** Maps a browser name to its Playwright BrowserType. */
72
- export function browserType(name) {
81
+ export async function browserType(name) {
82
+ const { chromium, firefox, webkit } = await loadPlaywright();
73
83
  if (name === 'firefox')
74
84
  return firefox;
75
85
  if (name === 'webkit')
@@ -84,6 +94,7 @@ export function browserType(name) {
84
94
  */
85
95
  export async function connectCdp(endpoint, timeoutMs) {
86
96
  await registerSelectorEngines();
97
+ const { chromium } = await loadPlaywright();
87
98
  return chromium.connectOverCDP(endpoint, { timeout: timeoutMs });
88
99
  }
89
100
  /** Registration is process-wide and rejected twice; one shared promise serves every connection. */
@@ -97,6 +108,8 @@ function registerSelectorEngines() {
97
108
  // Playwright serializes a function argument with `toString()` and invokes it
98
109
  // in the page, so each factory is materialized from its source here to keep
99
110
  // the in-page code self-contained, the same way the reader is assembled.
100
- selectorEngines ??= Promise.all(SELECTOR_ENGINES.map(({ name, source }) => selectors.register(name, new Function(`return ${source};`)()))).then(() => undefined);
111
+ selectorEngines ??= loadPlaywright()
112
+ .then(({ selectors }) => Promise.all(SELECTOR_ENGINES.map(({ name, source }) => selectors.register(name, new Function(`return ${source};`)()))))
113
+ .then(() => undefined);
101
114
  return selectorEngines;
102
115
  }
package/dist/browser.js CHANGED
@@ -11,11 +11,11 @@
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 { ConfigurationError, Deadline, describePattern, EngineError, matchesText, obj, pollCondition, raceAbort, rejectUnknownOptions, TestError, toTextPattern, urlMatches, validateJsonValue, withTimeout, } from 'e2e/engine';
14
+ import { ConfigurationError, Deadline, describePattern, EngineError, matchesText, obj, pollCondition, raceAbort, stepTimeout, rejectUnknownOptions, TestError, toTextPattern, urlMatches, validateJsonValue, withTimeout, } from 'e2e/engine';
15
15
  import { classifyInputError } from './actions.js';
16
16
  import { saveDownloadsTo, saveFromBrowser, saveLocally } from './downloads.js';
17
- import { isTestErrorCode, message as causeMessage, translatePwError } from './support.js';
18
- import { compileEvaluation, pageFunctionSource } from './evaluation.js';
17
+ import { isTestErrorCode, message as causeMessage, pageScriptFailure, translatePwError } from './support.js';
18
+ import { compileEvaluation, decodeEvaluation, pageFunctionSource } from './evaluation.js';
19
19
  import { initScriptLabel, testInitScriptSource } from './init-scripts.js';
20
20
  import { MEDIA_KEYS, mediaLabel, mediaProblem } from './media.js';
21
21
  import { lowercaseNames } from './protected-app.js';
@@ -62,10 +62,7 @@ function webResponse(response, body) {
62
62
  * downloads are canceled and nothing is attached after it.
63
63
  */
64
64
  async function collectDownloads(surface, context, trigger, count, timeout, api) {
65
- if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) {
66
- throw new TestError('INVALID_ARGUMENT', `${api} timeout must be positive and finite`);
67
- }
68
- const operation = context.operation(timeout);
65
+ const operation = context.operation(stepTimeout(timeout, api));
69
66
  const waitMs = operation.timeoutMs;
70
67
  const deadline = new Deadline(waitMs);
71
68
  const scope = new AbortController();
@@ -170,12 +167,12 @@ export function createBrowserFixture(surface, context) {
170
167
  * harness gives `app.open` - a document load is not an action and must not
171
168
  * be cut at the action timeout.
172
169
  */
173
- const navigation = (options, run) => {
174
- const operation = context.operation(options?.timeout ?? context.timeouts.test);
170
+ const navigation = (api, options, run) => {
171
+ const operation = context.operation(stepTimeout(options?.timeout, api) ?? context.timeouts.test);
175
172
  return surface.guard(operation, 'navigation', run);
176
173
  };
177
174
  /** An assertion-style budget: the given timeout or the assertion timeout, clamped to the test. */
178
- const deadlineFor = (timeout) => new Deadline(context.operation(timeout ?? context.timeouts.assertion).timeoutMs);
175
+ const deadlineFor = (timeout, api) => new Deadline(context.operation(stepTimeout(timeout, api) ?? context.timeouts.assertion).timeoutMs);
179
176
  /** The page URL, read within the action timeout or, for a matcher poll, within the poll's deadline. */
180
177
  const currentUrl = (deadline) => surface.guard(context.operation(deadline?.remaining()), 'url', async () => surface.requirePage().url());
181
178
  /** The page title, read within the action timeout or, for a matcher poll, within the poll's deadline. */
@@ -184,7 +181,7 @@ export function createBrowserFixture(surface, context) {
184
181
  const browser = {
185
182
  goto(url, options) {
186
183
  const resolved = context.app.resolveUrl(url);
187
- return navigation(options, (operation) => surface.navigating(async () => {
184
+ return navigation('browser.goto', options, (operation) => surface.navigating(async () => {
188
185
  const page = await surface.ensurePage();
189
186
  await page.goto(resolved, {
190
187
  waitUntil: options?.waitUntil ?? 'load',
@@ -192,13 +189,13 @@ export function createBrowserFixture(surface, context) {
192
189
  });
193
190
  }));
194
191
  },
195
- reload: (options) => navigation(options, (operation) => surface.navigating(async () => {
192
+ reload: (options) => navigation('browser.reload', options, (operation) => surface.navigating(async () => {
196
193
  await surface.requirePage().reload({ waitUntil: 'load', timeout: operation.timeoutMs });
197
194
  })),
198
- back: (options) => navigation(options, (operation) => surface.navigating(async () => {
195
+ back: (options) => navigation('browser.back', options, (operation) => surface.navigating(async () => {
199
196
  await surface.requirePage().goBack({ waitUntil: 'load', timeout: operation.timeoutMs });
200
197
  })),
201
- forward: (options) => navigation(options, (operation) => surface.navigating(async () => {
198
+ forward: (options) => navigation('browser.forward', options, (operation) => surface.navigating(async () => {
202
199
  await surface.requirePage().goForward({ waitUntil: 'load', timeout: operation.timeoutMs });
203
200
  })),
204
201
  url: () => currentUrl(),
@@ -216,11 +213,19 @@ export function createBrowserFixture(surface, context) {
216
213
  const evaluate = compileEvaluation(source, arg !== undefined);
217
214
  // JSON safety is checked above; Playwright's recursive argument type cannot expand JsonValue.
218
215
  const input = arg;
219
- const result = await surface.guard(context.operation(), 'evaluate', () => surface.requirePage().evaluate(evaluate, input));
220
- if (!result.ok)
221
- throw new TestError('EVALUATE_FAILED', result.message);
222
- validateJsonValue(result.value, 'evaluate result');
223
- return result.value;
216
+ // A handle, not a value: the boundary resolves to a string, which Playwright
217
+ // keeps out of its in-page serializer when it is taken back from a handle.
218
+ const report = await surface.guard(context.operation(), 'evaluate', async (operation) => {
219
+ const page = surface.requirePage();
220
+ try {
221
+ const handle = await page.evaluateHandle(evaluate, input);
222
+ return await handle.jsonValue();
223
+ }
224
+ catch (cause) {
225
+ throw (await pageScriptFailure(page, cause, 'evaluate', operation.timeoutMs)) ?? cause;
226
+ }
227
+ });
228
+ return decodeEvaluation(report);
224
229
  },
225
230
  async addInitScript(script, ...rest) {
226
231
  const source = await testInitScriptSource(script, rest.length === 0 ? undefined : { arg: rest[0] }, (file) => surface.projectPath(file));
@@ -234,6 +239,12 @@ export function createBrowserFixture(surface, context) {
234
239
  // is latched on the surface and fails the next step with its real cause.
235
240
  const handleRoute = async (route) => {
236
241
  let decided = false;
242
+ const request = route.request();
243
+ // Noted before the abort, so a navigation it fails reads as aborted by the test.
244
+ const abort = async () => {
245
+ surface.noteRouteAbort(request);
246
+ await route.abort();
247
+ };
237
248
  const decide = (name) => {
238
249
  if (decided) {
239
250
  throw new TestError('ACTION_FAILED', `route handler already decided; ${name} called twice`);
@@ -249,11 +260,10 @@ export function createBrowserFixture(surface, context) {
249
260
  await act();
250
261
  }
251
262
  catch (cause) {
252
- await route.abort().catch(() => undefined);
263
+ await abort().catch(() => undefined);
253
264
  throw cause;
254
265
  }
255
266
  };
256
- const request = route.request();
257
267
  const postData = request.postData();
258
268
  const publicRoute = {
259
269
  request: {
@@ -306,7 +316,7 @@ export function createBrowserFixture(surface, context) {
306
316
  },
307
317
  abort: async (...args) => {
308
318
  requireNoArguments('route.abort', args);
309
- await decideWith('abort', () => route.abort());
319
+ await decideWith('abort', abort);
310
320
  },
311
321
  };
312
322
  try {
@@ -320,7 +330,7 @@ export function createBrowserFixture(surface, context) {
320
330
  ? cause
321
331
  : new TestError('ACTION_FAILED', `route handler failed: ${causeMessage(cause)}`));
322
332
  if (!decided)
323
- await route.abort().catch(() => undefined);
333
+ await abort().catch(() => undefined);
324
334
  }
325
335
  };
326
336
  // Tracked so the attempt end waits for a handler still running and
@@ -344,7 +354,7 @@ export function createBrowserFixture(surface, context) {
344
354
  },
345
355
  waitForResponse(pattern, options) {
346
356
  const wirePattern = toTextPattern(pattern);
347
- const operation = context.operation(options?.timeout);
357
+ const operation = context.operation(stepTimeout(options?.timeout, 'browser.waitForResponse'));
348
358
  return surface.guard(operation, 'waitForResponse', async (currentOperation) => {
349
359
  const page = await surface.ensurePage();
350
360
  const response = await page.waitForResponse((candidate) => routePatternMatches(wirePattern, candidate.url()), { timeout: currentOperation.timeoutMs });
@@ -502,7 +512,7 @@ export function createBrowserFixture(surface, context) {
502
512
  */
503
513
  function createBrowserExpectation(deps, negated = false) {
504
514
  const poll = async (api, label, read, timeout) => {
505
- const deadline = deps.deadlineFor(timeout);
515
+ const deadline = deps.deadlineFor(timeout, api);
506
516
  // pollCondition calls onTimeout only after a read completed, so a sample is always there.
507
517
  let last;
508
518
  await pollCondition({
@@ -0,0 +1,28 @@
1
+ /** The elements one document's observation walk read, kept behind a single in-page array. */
2
+ import type { ElementHandle, JSHandle } from 'playwright-core';
3
+ /**
4
+ * One handle to the in-page array the reader filled, and the element handles
5
+ * made from it on demand.
6
+ *
7
+ * An observation can list thousands of nodes and an action touches a few, so a
8
+ * handle per node would cost a round trip to create and another to release for
9
+ * every node, every observation. The array keeps each element alive, which is
10
+ * what a handle did: a ref names the element the walk read, never whatever sits
11
+ * at that position now. Disposing the list releases the array and every handle
12
+ * made from it.
13
+ */
14
+ export declare class ElementList {
15
+ readonly handle: JSHandle;
16
+ private readonly resolved;
17
+ private disposed;
18
+ constructor(handle: JSHandle);
19
+ /**
20
+ * A new handle to one element of the walk. The caller owns it and disposes
21
+ * it; a missing element, or a document replaced since the walk, is a stale node.
22
+ */
23
+ fetch(index: number): Promise<ElementHandle<Element>>;
24
+ /** The element of one node, made once and kept until the list is disposed. */
25
+ at(index: number): Promise<ElementHandle<Element>>;
26
+ /** Releases the array and every element made from it; safe to call again. */
27
+ dispose(): void;
28
+ }
@@ -0,0 +1,73 @@
1
+ /** The elements one document's observation walk read, kept behind a single in-page array. */
2
+ import { EngineError } from 'e2e/engine';
3
+ import { navigationStaleOr } from './support.js';
4
+ /**
5
+ * One handle to the in-page array the reader filled, and the element handles
6
+ * made from it on demand.
7
+ *
8
+ * An observation can list thousands of nodes and an action touches a few, so a
9
+ * handle per node would cost a round trip to create and another to release for
10
+ * every node, every observation. The array keeps each element alive, which is
11
+ * what a handle did: a ref names the element the walk read, never whatever sits
12
+ * at that position now. Disposing the list releases the array and every handle
13
+ * made from it.
14
+ */
15
+ export class ElementList {
16
+ handle;
17
+ resolved = new Map();
18
+ disposed = false;
19
+ constructor(handle) {
20
+ this.handle = handle;
21
+ }
22
+ /**
23
+ * A new handle to one element of the walk. The caller owns it and disposes
24
+ * it; a missing element, or a document replaced since the walk, is a stale node.
25
+ */
26
+ async fetch(index) {
27
+ const handle = await this.handle
28
+ .evaluateHandle((elements, at) => elements[at], index)
29
+ .catch((cause) => {
30
+ throw navigationStaleOr(cause, 'resolve observed node');
31
+ });
32
+ const element = handle.asElement();
33
+ if (element === null) {
34
+ void handle.dispose().catch(() => undefined);
35
+ throw new EngineError('NODE_STALE', `observation node ${String(index)} lost its element`, { retryable: true });
36
+ }
37
+ return element;
38
+ }
39
+ /** The element of one node, made once and kept until the list is disposed. */
40
+ at(index) {
41
+ if (this.disposed)
42
+ return Promise.reject(staleList());
43
+ let element = this.resolved.get(index);
44
+ if (element === undefined) {
45
+ element = this.fetch(index).then((made) => {
46
+ if (!this.disposed)
47
+ return made;
48
+ void made.dispose().catch(() => undefined);
49
+ throw staleList();
50
+ }, (cause) => {
51
+ this.resolved.delete(index);
52
+ throw this.disposed ? staleList() : cause;
53
+ });
54
+ this.resolved.set(index, element);
55
+ }
56
+ return element;
57
+ }
58
+ /** Releases the array and every element made from it; safe to call again. */
59
+ dispose() {
60
+ if (this.disposed)
61
+ return;
62
+ this.disposed = true;
63
+ for (const element of this.resolved.values()) {
64
+ void element.then((made) => made.dispose()).catch(() => undefined);
65
+ }
66
+ this.resolved.clear();
67
+ void this.handle.dispose().catch(() => undefined);
68
+ }
69
+ }
70
+ /** The observation the list belonged to was replaced or cleared, so its refs no longer bind. */
71
+ function staleList() {
72
+ return new EngineError('NODE_STALE', 'observation node references were released', { retryable: true });
73
+ }
@@ -1,18 +1,25 @@
1
- type EvaluationResult = {
2
- ok: true;
3
- value: unknown;
4
- } | {
5
- ok: false;
6
- message: string;
7
- };
8
- /** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
9
- export declare const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
1
+ /** Separates exceptions thrown by trusted page code from failures of the evaluation transport. */
2
+ import type { JsonValue } from 'e2e';
3
+ /** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page; it takes `defineProperty` before that code runs. */
4
+ export declare const KEEP_NAMES_HELPER = "const __name = ((define) => (target, value) => define(target, 'name', { value, configurable: true }))(({}).constructor.defineProperty);";
10
5
  /** A function expression for page code, including ordinary and async method shorthand. Never runs it here. */
11
6
  export declare function pageFunctionSource(fn: (...args: never[]) => unknown): string;
7
+ /**
8
+ * Builds, in the page, the encoder of an evaluation result into JSON text.
9
+ * Called before the caller's code runs, it takes the intrinsics it needs from
10
+ * literals, so code that deletes or replaces `Array`, `Object`, or `JSON`
11
+ * cannot reach it. Only JSON passes: `null`, booleans, finite numbers,
12
+ * strings, arrays, and plain objects. Anything else throws `{ invalid }` with
13
+ * the reason `validateJsonValue` gives. Embedded into the page as source, so
14
+ * it captures nothing from this module.
15
+ */
16
+ export declare function pageValueEncoder(): (root: unknown) => string | undefined;
12
17
  /**
13
18
  * Compiles the page-side error boundary without evaluating the caller's source in this process.
14
19
  * The source is one expression: a function it evaluates to is called with the argument, any
15
- * other value is the result.
20
+ * other value is the result. The boundary resolves to JSON text, a primitive the caller takes
21
+ * back without Playwright serializing it in the page, so a page that deleted or replaced a
22
+ * global the serializer needs still returns its result; `decodeEvaluation` reads it.
16
23
  *
17
24
  * A function serialized with `toString()` carries whatever the loader compiled
18
25
  * it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
@@ -20,5 +27,6 @@ export declare function pageFunctionSource(fn: (...args: never[]) => unknown): s
20
27
  * helper; the page has no such helper, so the same one is declared next to
21
28
  * the inlined source for as long as this engine supports those runners.
22
29
  */
23
- export declare function compileEvaluation(source: string, hasArgument: boolean): (arg: unknown) => Promise<EvaluationResult>;
24
- export {};
30
+ export declare function compileEvaluation(source: string, hasArgument: boolean): (arg: unknown) => Promise<string>;
31
+ /** The result of the boundary `compileEvaluation` compiled, from the JSON text it resolved to; throws what the page threw. */
32
+ export declare function decodeEvaluation(text: string): JsonValue | undefined;
@@ -1,8 +1,7 @@
1
- /** Separates exceptions thrown by trusted page code from failures of the evaluation transport. */
2
1
  import { TestError } from 'e2e/engine';
3
2
  import { message } from './support.js';
4
- /** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
5
- export const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
3
+ /** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page; it takes `defineProperty` before that code runs. */
4
+ export const KEEP_NAMES_HELPER = "const __name = ((define) => (target, value) => define(target, 'name', { value, configurable: true }))(({}).constructor.defineProperty);";
6
5
  /** A function expression for page code, including ordinary and async method shorthand. Never runs it here. */
7
6
  export function pageFunctionSource(fn) {
8
7
  const source = fn.toString().trim();
@@ -34,10 +33,82 @@ export function pageFunctionSource(fn) {
34
33
  }
35
34
  }
36
35
  }
36
+ /**
37
+ * Builds, in the page, the encoder of an evaluation result into JSON text.
38
+ * Called before the caller's code runs, it takes the intrinsics it needs from
39
+ * literals, so code that deletes or replaces `Array`, `Object`, or `JSON`
40
+ * cannot reach it. Only JSON passes: `null`, booleans, finite numbers,
41
+ * strings, arrays, and plain objects. Anything else throws `{ invalid }` with
42
+ * the reason `validateJsonValue` gives. Embedded into the page as source, so
43
+ * it captures nothing from this module.
44
+ */
45
+ export function pageValueEncoder() {
46
+ const { getPrototypeOf, keys } = ({}).constructor;
47
+ const objectPrototype = getPrototypeOf({});
48
+ const { isArray } = [].constructor;
49
+ const charCodeAt = ''.charCodeAt;
50
+ const hex = '0123456789abcdef';
51
+ const invalid = (reason) => ({ invalid: reason });
52
+ const quote = (text) => {
53
+ let out = '"';
54
+ for (let i = 0; i < text.length; i++) {
55
+ const code = charCodeAt.call(text, i);
56
+ const escape = code === 34 || code === 92 || code < 32 || code === 0x2028 || code === 0x2029 || (code >= 0xd800 && code <= 0xdfff);
57
+ out += escape ? `\\u${hex[code >> 12]}${hex[(code >> 8) & 15]}${hex[(code >> 4) & 15]}${hex[code & 15]}` : text[i];
58
+ }
59
+ return `${out}"`;
60
+ };
61
+ const ancestors = [];
62
+ const encode = (value) => {
63
+ if (value === null)
64
+ return 'null';
65
+ switch (typeof value) {
66
+ case 'string':
67
+ return quote(value);
68
+ case 'boolean':
69
+ return value ? 'true' : 'false';
70
+ case 'number':
71
+ if (value !== value || value === Infinity || value === -Infinity)
72
+ throw invalid('contains a non-finite number');
73
+ return `${value}`;
74
+ case 'object':
75
+ break;
76
+ default:
77
+ throw invalid(`must be JSON-safe, found ${typeof value}`);
78
+ }
79
+ const array = isArray(value);
80
+ const prototype = getPrototypeOf(value);
81
+ if (!array && prototype !== objectPrototype && prototype !== null)
82
+ throw invalid('must be JSON-safe');
83
+ for (let i = 0; i < ancestors.length; i++) {
84
+ if (ancestors[i] === value)
85
+ throw invalid('contains a cycle');
86
+ }
87
+ ancestors[ancestors.length] = value;
88
+ let text = '';
89
+ if (array) {
90
+ for (let i = 0; i < value.length; i++)
91
+ text += `${i === 0 ? '' : ','}${encode(value[i])}`;
92
+ text = `[${text}]`;
93
+ }
94
+ else {
95
+ const record = value;
96
+ const names = keys(record);
97
+ for (let i = 0; i < names.length; i++)
98
+ text += `${i === 0 ? '' : ','}${quote(names[i])}:${encode(record[names[i]])}`;
99
+ text = `{${text}}`;
100
+ }
101
+ ancestors.length -= 1;
102
+ return text;
103
+ };
104
+ return (root) => (root === undefined ? undefined : encode(root));
105
+ }
37
106
  /**
38
107
  * Compiles the page-side error boundary without evaluating the caller's source in this process.
39
108
  * The source is one expression: a function it evaluates to is called with the argument, any
40
- * other value is the result.
109
+ * other value is the result. The boundary resolves to JSON text, a primitive the caller takes
110
+ * back without Playwright serializing it in the page, so a page that deleted or replaced a
111
+ * global the serializer needs still returns its result; `decodeEvaluation` reads it.
41
112
  *
42
113
  * A function serialized with `toString()` carries whatever the loader compiled
43
114
  * it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
@@ -48,23 +119,49 @@ export function pageFunctionSource(fn) {
48
119
  export function compileEvaluation(source, hasArgument) {
49
120
  try {
50
121
  return new Function('arg', `
51
- return (async () => {
52
- ${KEEP_NAMES_HELPER}
122
+ ${KEEP_NAMES_HELPER}
123
+ return (async (encode, run) => {
124
+ const report = (ok, key, text) => \`{"ok":\${ok}\${text === undefined ? '' : \`,"\${key}":\${text}\`}}\`;
125
+ let value;
53
126
  try {
54
- return { ok: true, value: await ((result) => typeof result === 'function' ? result(${hasArgument ? 'arg' : ''}) : result)((${source}\n)) };
127
+ value = await run();
55
128
  } catch (cause) {
56
129
  let message;
57
130
  try {
58
- message = typeof cause?.message === 'string' ? cause.message : String(cause);
131
+ message = typeof cause?.message === 'string' ? cause.message : \`\${cause}\`;
59
132
  } catch {
60
133
  message = 'Page evaluation threw an unprintable value';
61
134
  }
62
- return { ok: false, message };
135
+ return report(false, 'message', encode(message));
63
136
  }
64
- })();
137
+ try {
138
+ return report(true, 'value', encode(value));
139
+ } catch (cause) {
140
+ if (cause !== null && typeof cause === 'object' && typeof cause.invalid === 'string') {
141
+ return report(false, 'invalid', encode(cause.invalid));
142
+ }
143
+ return '{"ok":false,"message":"evaluate result cannot be read"}';
144
+ }
145
+ })((${pageValueEncoder.toString()})(), async () => ((result) => typeof result === 'function' ? result(${hasArgument ? 'arg' : ''}) : result)((${source}\n)));
65
146
  `);
66
147
  }
67
148
  catch (cause) {
68
149
  throw new TestError('EVALUATE_FAILED', message(cause), { cause });
69
150
  }
70
151
  }
152
+ /** The result of the boundary `compileEvaluation` compiled, from the JSON text it resolved to; throws what the page threw. */
153
+ export function decodeEvaluation(text) {
154
+ let report;
155
+ try {
156
+ // oxlint-disable-next-line e2e/no-unchecked-json -- the text is what compileEvaluation's own boundary wrote
157
+ report = JSON.parse(text);
158
+ }
159
+ catch (cause) {
160
+ throw new TestError('EVALUATE_FAILED', 'page evaluation returned an unreadable result', { cause });
161
+ }
162
+ if (report.ok)
163
+ return report.value;
164
+ if ('invalid' in report)
165
+ throw new TestError('INVALID_ARGUMENT', `evaluate result ${report.invalid}`);
166
+ throw new TestError('EVALUATE_FAILED', report.message);
167
+ }
@@ -1460,6 +1460,8 @@ export const readSemanticsFunction = (element, options) => {
1460
1460
  // no rect, as Playwright's `boundingBox()` answers null; a box with no
1461
1461
  // size, or one `visibility: hidden` keeps, is still a rect.
1462
1462
  const rect = el.getClientRects().length === 0 ? null : el.getBoundingClientRect();
1463
+ const borderLeft = Number.parseFloat(style?.borderLeftWidth ?? '') || 0;
1464
+ const borderTop = Number.parseFloat(style?.borderTopWidth ?? '') || 0;
1463
1465
  const role = isDocumentRoot ? 'document' : roleOf(el, tag);
1464
1466
  let level = null;
1465
1467
  if (role === 'heading') {
@@ -1494,6 +1496,7 @@ export const readSemanticsFunction = (element, options) => {
1494
1496
  attributes,
1495
1497
  testId: el.getAttribute(options.testIdAttribute),
1496
1498
  rect: rect === null ? null : { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1499
+ origin: rect === null || (borderLeft === 0 && borderTop === 0) ? null : { x: rect.x + borderLeft, y: rect.y + borderTop },
1497
1500
  };
1498
1501
  };
1499
1502
  // The conditional return type resolves per call site; inside the body the
package/dist/install.d.ts CHANGED
@@ -13,7 +13,7 @@ export declare function runPlaywrightCli(args: readonly string[]): Promise<numbe
13
13
  * disk, a `warn` when the first run will download it, or none when `env`
14
14
  * points at another browser cache than this process loaded.
15
15
  */
16
- export declare function browserCheck(name: BrowserName, env: NodeJS.ProcessEnv, headed: boolean, isInstalled?: (name: BrowserName) => boolean | undefined): EngineDoctorCheck[];
16
+ export declare function browserCheck(name: BrowserName, env: NodeJS.ProcessEnv, headed: boolean, isInstalled?: (name: BrowserName) => boolean | undefined | Promise<boolean | undefined>): Promise<EngineDoctorCheck[]>;
17
17
  /** What an installer receives besides the browser names. */
18
18
  export interface InstallContext {
19
19
  /** Receives every line the installer prints: the download URL, progress, warnings. */
@@ -36,7 +36,7 @@ export interface EnsureBrowsersOptions {
36
36
  * Injected detection for tests; defaults to an executable existence check.
37
37
  * Undefined means "cannot tell here": the installer runs and decides.
38
38
  */
39
- readonly isInstalled?: (name: BrowserName) => boolean | undefined;
39
+ readonly isInstalled?: (name: BrowserName) => boolean | undefined | Promise<boolean | undefined>;
40
40
  /** Injected installer for tests; defaults to spawning the Playwright CLI. */
41
41
  readonly install?: (names: readonly BrowserName[], context: InstallContext) => Promise<void>;
42
42
  /** Aborts a download in progress; the run's interrupt owns it. */