@e2e-dev/web 0.15.5-nightly-20261104030231 → 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.
@@ -5,6 +5,8 @@
5
5
  import { EngineError, sameSite, OBSERVED_NAME_LIMIT, OBSERVED_TEXT_LIMIT, } from 'e2e/engine';
6
6
  import { readDocumentSemanticsFunction, SECURE_FIELD_SELECTOR, } from './read-node.js';
7
7
  import { CaptureScope } from './capture-scope.js';
8
+ import { ElementList } from './element-list.js';
9
+ import { hasReadableDocument } from './frame-document.js';
8
10
  /**
9
11
  * The id of every observation's root: the main document's element under one
10
12
  * id the engine mints, stable across observations and navigations, so the
@@ -36,16 +38,22 @@ export async function captureDocument(deps, host, options) {
36
38
  // A child document's handles are published only once it captured whole; a
37
39
  // frame that fails midway leaves no reachable ids behind.
38
40
  const staged = [];
39
- const stage = (id, element) => {
40
- if (scope.own(element))
41
- staged.push([id, element]);
41
+ const accepted = new Map();
42
+ const stage = (id, target) => {
43
+ let owned = accepted.get(target.list);
44
+ if (owned === undefined) {
45
+ owned = scope.own(target.list.handle);
46
+ accepted.set(target.list, owned);
47
+ }
48
+ if (owned)
49
+ staged.push([id, target]);
42
50
  };
43
51
  return scope.run(async () => {
44
52
  const captured = await captureInto(deps, scope, stage, host, framePath, budget, signal);
45
53
  scope.check();
46
- for (const [id, element] of staged) {
47
- deps.commit(id, element);
48
- scope.release(element);
54
+ for (const [id, target] of staged) {
55
+ deps.commit(id, target);
56
+ scope.release(target.list.handle);
49
57
  }
50
58
  return captured;
51
59
  });
@@ -98,8 +106,8 @@ async function captureInto(deps, scope, stage, host, framePath, budget, signal)
98
106
  if (framePath.length === 0 && ids.length > 0)
99
107
  ids[0] = ROOT_NODE_ID;
100
108
  const elementsHandle = await scope.read(() => captured.getProperty('elements'), (handle) => scope.own(handle));
101
- const elements = await collectElementHandles(scope, elementsHandle, nodes.length);
102
- elements.forEach((element, index) => stage(ids[index], element));
109
+ const list = new ElementList(elementsHandle);
110
+ ids.forEach((id, index) => stage(id, { kind: 'listed', list, index }));
103
111
  let nodeCount = nodes.length;
104
112
  let truncated = walkTruncated === true;
105
113
  const frameChildren = new Map();
@@ -109,7 +117,10 @@ async function captureInto(deps, scope, stage, host, framePath, budget, signal)
109
117
  continue;
110
118
  // A frame with no document (detached, never loaded) shows nothing, so
111
119
  // nothing is missing from the tree.
112
- const frame = await scope.read(() => elements[index].contentFrame().catch(() => null));
120
+ // Owned by the scope and released with it: the frame's element is read
121
+ // here, and an action reaches it later through the list like any node.
122
+ const element = await scope.read(() => list.fetch(index), (handle) => scope.own(handle));
123
+ const frame = await scope.read(() => element.contentFrame().catch(() => null));
113
124
  if (frame === null)
114
125
  continue;
115
126
  // Only frames on the app's site enter observations. Third-party
@@ -118,6 +129,9 @@ async function captureInto(deps, scope, stage, host, framePath, budget, signal)
118
129
  // boundary nodes by design, so leaving them out is not truncation.
119
130
  if (!isOnSiteFrame(frame.url(), deps.site))
120
131
  continue;
132
+ // A frame whose navigation has not committed shows nothing yet.
133
+ if (!(await scope.read(() => hasReadableDocument(frame, element))))
134
+ continue;
121
135
  // From here the frame's document is content the model should see: any
122
136
  // reason it is not read (depth, budget, deadline, a failed or timed-out
123
137
  // capture) leaves the tree incomplete, and the snapshot must say so.
@@ -130,7 +144,7 @@ async function captureInto(deps, scope, stage, host, framePath, budget, signal)
130
144
  truncated = true;
131
145
  continue;
132
146
  }
133
- const inset = await scope.read(() => elements[index].evaluate(frameContentInset).catch(() => undefined));
147
+ const inset = await scope.read(() => element.evaluate(frameContentInset).catch(() => undefined));
134
148
  if (inset === undefined) {
135
149
  truncated = true;
136
150
  continue;
@@ -170,31 +184,6 @@ function isOnSiteFrame(url, site) {
170
184
  const protocol = URL.canParse(url) ? new URL(url).protocol : undefined;
171
185
  return protocol !== 'data:' && sameSite(url, site);
172
186
  }
173
- /**
174
- * Reads one element handle per observed node from the in-page element array.
175
- * `asElement` types handles as `ElementHandle<Node>`, but the observation walk
176
- * records `Element` nodes only, so the narrowing is safe by construction.
177
- */
178
- async function collectElementHandles(scope, elementsHandle, count) {
179
- const properties = await scope.read(() => elementsHandle.getProperties(), (handles) => {
180
- for (const handle of handles.values())
181
- scope.own(handle);
182
- });
183
- const elements = [];
184
- for (let index = 0; index < count; index += 1) {
185
- const property = properties.get(String(index));
186
- const element = (property?.asElement() ?? null);
187
- if (element === null) {
188
- // The in-page array outlived its document (a navigation committed while
189
- // the handles were being read back). The capture is repeatable.
190
- throw new EngineError('NODE_STALE', `observation node ${index} lost its element`, {
191
- retryable: true,
192
- });
193
- }
194
- elements.push(element);
195
- }
196
- return elements;
197
- }
198
187
  /**
199
188
  * Rebuilds the observation tree from the depth-first node list. Descendants
200
189
  * always follow their parent, so children are complete before a parent is
@@ -252,21 +241,28 @@ export function contentBox(border, inset) {
252
241
  }
253
242
  /** The tree with every box translated into the frame's space and clipped to its box; a box left empty by the clip is dropped. */
254
243
  function placeInFrame(node, frame) {
255
- const { rect, ...rest } = node;
256
- const placed = rect === undefined ? undefined : placeRectInFrame(rect, frame);
244
+ const { rect, origin, ...rest } = node;
257
245
  return {
258
246
  ...rest,
259
- ...(placed === undefined ? {} : { rect: placed }),
247
+ ...(rect === undefined ? {} : placeBoxInFrame(rect, origin, frame)),
260
248
  ...(node.children === undefined ? {} : { children: node.children.map((child) => placeInFrame(child, frame)) }),
261
249
  };
262
250
  }
263
251
  /**
264
- * A box a child document measured against its own viewport, in the top-level
265
- * viewport's space: shifted by the frame's box and clipped to it; undefined
266
- * when nothing of it is inside the frame.
252
+ * A node box a child document measured against its own viewport, in the
253
+ * top-level viewport's space: `rect` shifted by the frame's box and clipped
254
+ * to it, empty when nothing of it is inside the frame. `origin` is shifted
255
+ * but never clipped, and a clip that moves the box's corner keeps the
256
+ * unclipped corner as `origin`, so an offset into the node lands where it
257
+ * would at the top level.
267
258
  */
268
- export function placeRectInFrame(rect, frame) {
269
- return clipRect(offsetRect(rect, frame), frame);
259
+ export function placeBoxInFrame(rect, origin, frame) {
260
+ const placed = clipRect(offsetRect(rect, frame), frame);
261
+ if (placed === undefined)
262
+ return {};
263
+ const corner = origin ?? rect;
264
+ const shifted = { x: corner.x + frame.x, y: corner.y + frame.y };
265
+ return shifted.x === placed.x && shifted.y === placed.y ? { rect: placed } : { rect: placed, origin: shifted };
270
266
  }
271
267
  function offsetRect(rect, by) {
272
268
  return { x: rect.x + by.x, y: rect.y + by.y, width: rect.width, height: rect.height };
@@ -309,6 +305,7 @@ export function toSemanticNode(ref, raw, children = [], framePath = []) {
309
305
  ...(raw.level !== null ? { level: raw.level } : {}),
310
306
  attributes: raw.attributes,
311
307
  ...(raw.rect !== null ? { rect: raw.rect } : {}),
308
+ ...(raw.origin !== null ? { origin: raw.origin } : {}),
312
309
  ...(framePath.length > 0 ? { framePath } : {}),
313
310
  ...(children.length > 0 ? { children } : {}),
314
311
  };
package/dist/observe.d.ts CHANGED
@@ -8,9 +8,10 @@ import type { ObservationPixels, OperationContext, ViewportSize } from 'e2e/engi
8
8
  * frames an observation walked makes the masked set a superset of the observed
9
9
  * secure set - the direction that is safe. Shared by the observation pixels
10
10
  * and the artifact screenshot so both are redacted at the source by the same
11
- * rule.
11
+ * rule. A frame with no readable document shows nothing to mask, and probing
12
+ * it would wait for its navigation to commit.
12
13
  */
13
- export declare function secureFieldMasks(page: Page): Locator[];
14
+ export declare function secureFieldMasks(page: Page): Promise<Locator[]>;
14
15
  /** Screenshot options that apply the secure-field masks; empty when there is no frame to mask. */
15
16
  export declare function maskOptions(masks: readonly Locator[]): Partial<{
16
17
  mask: Locator[];
package/dist/observe.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Masked pixel capture for an observation. */
2
2
  import { CaptureScope } from './capture-scope.js';
3
3
  import { CLOSED_SHADOW_SELECTOR_ENGINE } from './closed-shadow.js';
4
+ import { readableFrames } from './frame-document.js';
4
5
  import { SECURE_FIELD_SELECTOR } from './read-node.js';
5
6
  import { withHiddenCaret } from './screenshot-caret.js';
6
7
  /**
@@ -19,11 +20,11 @@ const MASK_COLOR = '#000000';
19
20
  * frames an observation walked makes the masked set a superset of the observed
20
21
  * secure set - the direction that is safe. Shared by the observation pixels
21
22
  * and the artifact screenshot so both are redacted at the source by the same
22
- * rule.
23
+ * rule. A frame with no readable document shows nothing to mask, and probing
24
+ * it would wait for its navigation to commit.
23
25
  */
24
- export function secureFieldMasks(page) {
25
- return page
26
- .frames()
26
+ export async function secureFieldMasks(page) {
27
+ return (await readableFrames(page.frames()))
27
28
  .flatMap((frame) => [
28
29
  frame.locator(SECURE_FIELD_SELECTOR),
29
30
  frame.locator(`${CLOSED_SHADOW_SELECTOR_ENGINE}=${SECURE_FIELD_SELECTOR}`),
@@ -49,7 +50,7 @@ export async function capturePixels(page, operation, viewport) {
49
50
  const deadline = Date.now() + operation.timeoutMs;
50
51
  const scope = new CaptureScope(deadline, operation.signal);
51
52
  return scope.run(async () => {
52
- const masks = secureFieldMasks(page);
53
+ const masks = await scope.read(() => secureFieldMasks(page));
53
54
  // A frame that detaches mid-sweep contributes nothing to the count, which can
54
55
  // only push the observation toward withholding the image.
55
56
  const counts = await scope.read(() => Promise.allSettled(masks.map((mask) => mask.count())));
@@ -55,6 +55,11 @@ export interface RawNodeData {
55
55
  width: number;
56
56
  height: number;
57
57
  } | null;
58
+ /** The element's padding-box corner in the same viewport, when a border puts it past `rect`'s; null otherwise. */
59
+ origin: {
60
+ x: number;
61
+ y: number;
62
+ } | null;
58
63
  }
59
64
  /** One observed node plus its position in the flattened depth-first tree. */
60
65
  export interface RawObservedNode extends RawNodeData {
package/dist/refs.d.ts CHANGED
@@ -4,17 +4,24 @@
4
4
  *
5
5
  * Two populations with different lifetimes live here. Locator-backed refs
6
6
  * from `locate` hold no live handles and are pruned oldest-first past a
7
- * bound. Handle-backed refs from `observe` form one generation per
8
- * observation: the whole generation is swapped atomically and the superseded
9
- * one disposed in a sweep, so locator-ref eviction can never destroy a handle
7
+ * bound. List-backed refs from `observe` form one generation per
8
+ * observation: the whole generation is swapped atomically and its element
9
+ * lists disposed in a sweep, so locator-ref eviction can never destroy a list
10
10
  * an in-flight observation still references.
11
11
  */
12
12
  import { type EngineSnapshot, type NodeRef } from 'e2e/engine';
13
+ import type { ElementList } from './element-list.ts';
13
14
  import type { ActionTarget } from './support.ts';
15
+ /** An observed node: one position in the element list of the document it was read from. */
16
+ export interface ListedTarget {
17
+ readonly kind: 'listed';
18
+ readonly list: ElementList;
19
+ readonly index: number;
20
+ }
14
21
  /** A complete observation and the private handles that back its semantic refs. */
15
22
  export interface CapturedObservation {
16
23
  readonly snapshot: EngineSnapshot;
17
- readonly generation: Map<string, ActionTarget>;
24
+ readonly generation: Map<string, ListedTarget>;
18
25
  }
19
26
  export declare class RefRegistry {
20
27
  private counter;
@@ -28,15 +35,17 @@ export declare class RefRegistry {
28
35
  storeLocated(target: ActionTarget): string;
29
36
  /**
30
37
  * Ids are the engine's; revisions are the harness's. The adapter already
31
- * rejected a ref from a superseded resolution, so lookup is by id alone.
38
+ * rejected a ref from a superseded resolution, so lookup is by id alone. An
39
+ * observed node's element handle is made here, the first time an action
40
+ * reaches for it.
32
41
  */
33
- lookup(ref: NodeRef): ActionTarget;
42
+ lookup(ref: NodeRef): Promise<ActionTarget>;
34
43
  /** Starts a capture, preventing older in-flight observations from publishing afterward. */
35
44
  beginCapture(): number;
36
45
  /** Publishes only the current capture; pixel-only recovery also retires located refs. */
37
46
  publish(capture: number, { snapshot, generation }: CapturedObservation): void;
38
47
  /** Releases every ref of the attempt: located and observed alike. */
39
48
  clear(): void;
40
- /** Disposes every element handle in one observation generation. */
41
- static dispose(generation: ReadonlyMap<string, ActionTarget>): void;
49
+ /** Disposes every element handle in one generation, each list once however many nodes it backs. */
50
+ static dispose(generation: ReadonlyMap<string, ActionTarget | ListedTarget>): void;
42
51
  }
package/dist/refs.js CHANGED
@@ -4,9 +4,9 @@
4
4
  *
5
5
  * Two populations with different lifetimes live here. Locator-backed refs
6
6
  * from `locate` hold no live handles and are pruned oldest-first past a
7
- * bound. Handle-backed refs from `observe` form one generation per
8
- * observation: the whole generation is swapped atomically and the superseded
9
- * one disposed in a sweep, so locator-ref eviction can never destroy a handle
7
+ * bound. List-backed refs from `observe` form one generation per
8
+ * observation: the whole generation is swapped atomically and its element
9
+ * lists disposed in a sweep, so locator-ref eviction can never destroy a list
10
10
  * an in-flight observation still references.
11
11
  */
12
12
  import { EngineError } from 'e2e/engine';
@@ -42,14 +42,19 @@ export class RefRegistry {
42
42
  }
43
43
  /**
44
44
  * Ids are the engine's; revisions are the harness's. The adapter already
45
- * rejected a ref from a superseded resolution, so lookup is by id alone.
45
+ * rejected a ref from a superseded resolution, so lookup is by id alone. An
46
+ * observed node's element handle is made here, the first time an action
47
+ * reaches for it.
46
48
  */
47
- lookup(ref) {
48
- const target = this.located.get(ref.id) ?? this.observation.get(ref.id);
49
- if (target === undefined) {
49
+ async lookup(ref) {
50
+ const located = this.located.get(ref.id);
51
+ if (located !== undefined)
52
+ return located;
53
+ const observed = this.observation.get(ref.id);
54
+ if (observed === undefined) {
50
55
  throw new EngineError('NODE_STALE', `node reference ${ref.id} is stale`, { retryable: true });
51
56
  }
52
- return target;
57
+ return { kind: 'element', element: await observed.list.at(observed.index) };
53
58
  }
54
59
  /** Starts a capture, preventing older in-flight observations from publishing afterward. */
55
60
  beginCapture() {
@@ -74,11 +79,16 @@ export class RefRegistry {
74
79
  RefRegistry.dispose(this.located);
75
80
  this.located.clear();
76
81
  }
77
- /** Disposes every element handle in one observation generation. */
82
+ /** Disposes every element handle in one generation, each list once however many nodes it backs. */
78
83
  static dispose(generation) {
84
+ const lists = new Set();
79
85
  for (const target of generation.values()) {
80
- if (target.kind === 'element')
86
+ if (target.kind === 'listed')
87
+ lists.add(target.list);
88
+ else if (target.kind === 'element')
81
89
  void target.element.dispose().catch(() => undefined);
82
90
  }
91
+ for (const list of lists)
92
+ list.dispose();
83
93
  }
84
94
  }
@@ -13,11 +13,16 @@ export interface FulfillDecision {
13
13
  readonly headers: Record<string, string>;
14
14
  readonly contentType?: string;
15
15
  readonly json?: JsonValue;
16
- readonly body?: string;
16
+ /** A `Buffer` for bytes, which Playwright sends unchanged. */
17
+ readonly body?: string | Buffer;
17
18
  /** Absolute; Playwright reads it and derives `Content-Type` from its extension when none is given. */
18
19
  readonly path?: string;
19
20
  }
20
- /** What `route.continue` hands Playwright once the overrides are validated; `headers` before the site's are merged in. */
21
+ /**
22
+ * What `route.continue` hands Playwright once the overrides are validated;
23
+ * `headers` before the site's are merged in, a header set to `undefined`
24
+ * left out so the request goes without it.
25
+ */
21
26
  export interface ContinueDecision {
22
27
  readonly url?: string;
23
28
  readonly method?: string;
@@ -7,6 +7,7 @@
7
7
  * the same `resolveUrl` `goto` uses.
8
8
  */
9
9
  import { statSync } from 'node:fs';
10
+ import { types } from 'node:util';
10
11
  import { rejectUnknownOptions, TestError, validateJsonValue } from 'e2e/engine';
11
12
  import { headerProblem } from './protected-app.js';
12
13
  const FULFILL_KEYS = ['status', 'headers', 'contentType', 'json', 'body', 'path'];
@@ -35,7 +36,7 @@ export function parseFulfill(response, resolvePath) {
35
36
  const file = path === undefined ? undefined : resolvePath(requireString('route.fulfill', 'path', path, true));
36
37
  if (file !== undefined)
37
38
  requireFile(file);
38
- const replied = requireHeaders('route.fulfill', headers) ?? {};
39
+ const replied = requireHeaders('route.fulfill', headers, 'response') ?? {};
39
40
  const type = contentType === undefined
40
41
  ? Object.entries(replied).find(([name]) => name.toLowerCase() === 'content-type')?.[1]
41
42
  : requireString('route.fulfill', 'contentType', contentType, true);
@@ -44,7 +45,7 @@ export function parseFulfill(response, resolvePath) {
44
45
  headers: replied,
45
46
  ...(type === undefined ? {} : { contentType: type }),
46
47
  ...(json === undefined ? {} : { json: json }),
47
- ...(body === undefined ? {} : { body: requireString('route.fulfill', 'body', body, false) }),
48
+ ...(body === undefined ? {} : { body: requireBody(body) }),
48
49
  ...(file === undefined ? {} : { path: file }),
49
50
  };
50
51
  }
@@ -65,7 +66,7 @@ export function parseContinue(overrides, requestUrl, resolveUrl) {
65
66
  throw new TestError('INVALID_ARGUMENT', `route.continue url must keep the request's ${from} scheme; got ${to}`);
66
67
  }
67
68
  }
68
- const replaced = requireHeaders('route.continue', headers);
69
+ const replaced = requireHeaders('route.continue', headers, 'request');
69
70
  return {
70
71
  ...(target === undefined ? {} : { url: target }),
71
72
  ...(method === undefined ? {} : { method: requireString('route.continue', 'method', method, true) }),
@@ -96,19 +97,42 @@ function requireString(api, key, value, nonempty) {
96
97
  }
97
98
  return value;
98
99
  }
99
- /** A header map the browser can send, or `undefined` when none was given. */
100
- function requireHeaders(api, value) {
100
+ /**
101
+ * A header map the browser can send, or `undefined` when none was given. A
102
+ * request header set to `undefined` is dropped, so the request goes without
103
+ * it. A response `set-cookie` may join several cookies with `\n`, the way
104
+ * Playwright reports them, and each line is checked as its own value; any
105
+ * other line break is refused as header injection.
106
+ */
107
+ function requireHeaders(api, value, kind) {
101
108
  if (value === undefined)
102
109
  return undefined;
103
110
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
104
111
  throw new TestError('INVALID_ARGUMENT', `${api} headers must map names to string values`);
105
112
  }
113
+ const headers = [];
106
114
  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}`);
115
+ if (entry === undefined && kind === 'request')
116
+ continue;
117
+ if (typeof entry !== 'string')
118
+ throw new TestError('INVALID_ARGUMENT', `${api} ${headerProblem(name, entry)}`);
119
+ const lines = kind === 'response' && name.toLowerCase() === 'set-cookie' ? entry.split('\n') : [entry];
120
+ for (const line of lines) {
121
+ const problem = headerProblem(name, line);
122
+ if (problem !== undefined)
123
+ throw new TestError('INVALID_ARGUMENT', `${api} ${problem}`);
124
+ }
125
+ headers.push([name, entry]);
110
126
  }
111
- return value;
127
+ return Object.fromEntries(headers);
128
+ }
129
+ /** A fulfill body: a string as is, or bytes as a `Buffer` over the same memory so Playwright sends them unchanged. */
130
+ function requireBody(value) {
131
+ if (typeof value === 'string')
132
+ return value;
133
+ if (types.isUint8Array(value))
134
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
135
+ throw new TestError('INVALID_ARGUMENT', 'route.fulfill body must be a string, Buffer, or Uint8Array');
112
136
  }
113
137
  /** Rejects a path that does not name a regular file; synchronous so the check lands before the decision. */
114
138
  function requireFile(file) {
package/dist/support.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Shared error translation, filename, and swipe helpers for the Playwright engine. */
2
- import type { ElementHandle, Locator as PwLocator, Mouse, Page } from 'playwright-core';
2
+ import type { ElementHandle, FrameLocator, 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
+ import { TestError } from 'e2e/engine';
4
5
  export declare const DEFAULT_VIEWPORT: {
5
6
  readonly width: 1280;
6
7
  readonly height: 720;
@@ -13,8 +14,9 @@ export declare const DEFAULT_VIEWPORT: {
13
14
  export declare function currentViewport(page: Page): Promise<ViewportSize>;
14
15
  /**
15
16
  * A resolved node is addressed either by a deterministic locator expression or
16
- * by a live element handle captured during one agent observation. Handles are
17
- * always elements: the in-page observation walk records `Element` nodes only.
17
+ * by an element handle made from one agent observation's element list.
18
+ * Handles are always elements: the in-page observation walk records `Element`
19
+ * nodes only.
18
20
  */
19
21
  export type ActionTarget = {
20
22
  readonly kind: 'locator';
@@ -127,6 +129,17 @@ export declare const POST_DISPATCH_PATTERN: RegExp;
127
129
  export declare function translatePwError(cause: unknown, operation: string): Error;
128
130
  /** Whether a Playwright failure describes a read that lost its document to a navigation. */
129
131
  export declare function isNavigationRace(cause: unknown): boolean;
132
+ /**
133
+ * The failure of an operation whose in-page code the page's own scripts broke,
134
+ * as `EVALUATE_FAILED`, or undefined when something else failed. A page that
135
+ * deletes or replaces a JavaScript built-in (`Map`, `Object`) breaks every
136
+ * script Playwright runs in its main world, e2e's locate reads included. Told
137
+ * apart by asking the frame in both worlds: Playwright's utility world still
138
+ * reads the document before and after a main-world query that runs no e2e
139
+ * code fails, so the frame lives throughout and e2e's own code is not the
140
+ * cause.
141
+ */
142
+ export declare function pageScriptFailure(scope: Page | FrameLocator, cause: unknown, operation: string, timeoutMs: number): Promise<TestError | undefined>;
130
143
  /**
131
144
  * Like translatePwError, but a read that lost its document to a navigation
132
145
  * becomes retryable NODE_STALE so the runner re-reads the new document instead
package/dist/support.js CHANGED
@@ -283,6 +283,37 @@ const NAVIGATION_RACE_PATTERN = /execution context was destroyed|because of a na
283
283
  export function isNavigationRace(cause) {
284
284
  return NAVIGATION_RACE_PATTERN.test(message(cause));
285
285
  }
286
+ /**
287
+ * The failure of an operation whose in-page code the page's own scripts broke,
288
+ * as `EVALUATE_FAILED`, or undefined when something else failed. A page that
289
+ * deletes or replaces a JavaScript built-in (`Map`, `Object`) breaks every
290
+ * script Playwright runs in its main world, e2e's locate reads included. Told
291
+ * apart by asking the frame in both worlds: Playwright's utility world still
292
+ * reads the document before and after a main-world query that runs no e2e
293
+ * code fails, so the frame lives throughout and e2e's own code is not the
294
+ * cause.
295
+ */
296
+ export async function pageScriptFailure(scope, cause, operation, timeoutMs) {
297
+ if (isClassified(cause) || isPwTimeout(cause) || isNavigationRace(cause))
298
+ return undefined;
299
+ const root = scope.locator(':root');
300
+ /** Whether Playwright's utility world still reads the frame's document. */
301
+ const alive = () => root.count().then(() => true, () => false);
302
+ if (!(await alive()))
303
+ return undefined;
304
+ try {
305
+ await root.evaluate(() => 0, undefined, { timeout: Math.max(timeoutMs, 1) });
306
+ return undefined;
307
+ }
308
+ catch (probe) {
309
+ if (isPwTimeout(probe) || isNavigationRace(probe))
310
+ return undefined;
311
+ }
312
+ // A crash or a closed target between the two probes fails this read too.
313
+ if (!(await alive()))
314
+ return undefined;
315
+ return new TestError('EVALUATE_FAILED', `${operation} failed: the page deleted or replaced a JavaScript built-in that in-page automation code needs: ${message(cause)}`, { cause });
316
+ }
286
317
  /**
287
318
  * Like translatePwError, but a read that lost its document to a navigation
288
319
  * becomes retryable NODE_STALE so the runner re-reads the new document instead
package/dist/surface.d.ts CHANGED
@@ -6,7 +6,7 @@
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-core';
9
+ import type { BrowserContext, Page, Request, Route } from 'playwright-core';
10
10
  import { type EngineAttemptContext, type EngineCleanupContext, type EngineDoctorCheck, type EngineDoctorInfo, 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';
@@ -206,6 +206,8 @@ export declare class PlaywrightSurface {
206
206
  private artifactsDir;
207
207
  /** Navigations the engine has in flight, which the app log leaves to the step that started them. */
208
208
  private ownNavigations;
209
+ /** One entry per navigation in flight, set to the URL of a document request a test route aborted during it. */
210
+ private readonly navigationAborts;
209
211
  /** What the attempt tells its trace beside the app log; absent between attempts. */
210
212
  private trace;
211
213
  private artifactCounter;
@@ -344,8 +346,18 @@ export declare class PlaywrightSurface {
344
346
  back(operation: OperationContext): Promise<void>;
345
347
  /** Sets the viewport for the agent's `resize` verb, as `browser.setViewport` does, and shows the screen after it. */
346
348
  resize(size: ViewportSize, operation: OperationContext): Promise<void>;
347
- /** Runs one navigation the engine itself makes, so the app log leaves it to the step that asked for it. */
349
+ /**
350
+ * Runs one navigation the engine itself makes, so the app log leaves it to
351
+ * the step that asked for it. A navigation that fails after a test route
352
+ * aborted its document request failed because the test aborted it: an
353
+ * action failure, not an engine fault.
354
+ */
348
355
  navigating<T>(work: () => Promise<T>): Promise<T>;
356
+ /**
357
+ * Records a request a test route is about to abort, when it loads the
358
+ * document of the attempt's page. Never throws: the abort must follow.
359
+ */
360
+ noteRouteAbort(request: Request): void;
349
361
  /** Resolves with what an action resolved with once the trace has the screen it left (`TraceFeed.screenAfterAction`); an action that failed leaves none. */
350
362
  private acted;
351
363
  /** Restarts the document while retaining this attempt's context and storage. */
@@ -362,6 +374,10 @@ export declare class PlaywrightSurface {
362
374
  * reported before either of those, so a position is among shown matches.
363
375
  */
364
376
  locate(expression: LocatorExpression, operation: OperationContext): Promise<readonly SemanticNode[]>;
377
+ /** The body of `locate`, inside its guard. */
378
+ private locateNow;
379
+ /** The page or frame an expression's frame selectors lead to, unresolved. */
380
+ private frameScope;
365
381
  /**
366
382
  * One action on a located or observed node. A `swipe` on the observation
367
383
  * root is the viewport swipe: a wheel gesture sized by the viewport, with no
@@ -398,10 +414,13 @@ export declare class PlaywrightSurface {
398
414
  * the same chain; a frame-scoped `count` is 0 until that frame's document
399
415
  * has loaded, which is what makes `FRAME_NOT_FOUND` worth retrying. Returns
400
416
  * the innermost frame's content box in the top-level viewport, where its
401
- * document's viewport starts: undefined for an expression in the main
402
- * document, null for a frame with no box.
417
+ * document's viewport starts (undefined for an expression in the main
418
+ * document, null for a frame with no box), and
419
+ * whether a frame along the chain is hidden: a frame element's own `hidden`
420
+ * state, read in the document around it, so a cross-origin frame reads too.
421
+ * Every node in a hidden frame is hidden, and so are the frames under it.
403
422
  */
404
- private frameBox;
423
+ private frameOf;
405
424
  /**
406
425
  * Reserves one artifact path under the attempt directory: a per-attempt
407
426
  * counter keeps names unique, the label keeps them readable.
@@ -421,15 +440,18 @@ export declare class PlaywrightSurface {
421
440
  restoreState(state: EngineState, operation: OperationContext): Promise<void>;
422
441
  /**
423
442
  * Captures one atomic semantic observation. Secure fields are masked in the
424
- * page before the tree leaves the engine, and every node keeps a live
425
- * element handle valid until the next observation replaces the generation.
443
+ * page before the tree leaves the engine, and every node stays reachable
444
+ * through its document's element list until the next observation replaces
445
+ * the generation; an action makes the node's handle on first use.
426
446
  *
427
447
  * With `options.pixels`, masked viewport pixels are captured alongside the
428
448
  * tree rather than after it, so the image and the node geometry describe the
429
449
  * page as closely in time as two engine calls can.
430
450
  */
431
451
  observe(operation: OperationContext, options?: EngineObserveOptions): Promise<EngineSnapshot>;
432
- /** Captures privately, then publishes only into the page and ref generation that requested it. */
452
+ /** Captures privately in the session's turn, then publishes only into the page and ref generation that requested it. */
433
453
  private captureObservation;
454
+ /** One observation that holds the session's turn. */
455
+ private captureInTurn;
434
456
  }
435
457
  export {};