@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.
- package/README.md +1 -1
- package/dist/.build.json +1 -1
- package/dist/actions.d.ts +1 -1
- package/dist/actions.js +17 -6
- package/dist/attempt-session.d.ts +8 -0
- package/dist/attempt-session.js +21 -0
- package/dist/browser-connection.d.ts +2 -3
- package/dist/browser-connection.js +18 -5
- package/dist/browser.d.ts +7 -4
- package/dist/browser.js +65 -53
- package/dist/element-list.d.ts +28 -0
- package/dist/element-list.js +73 -0
- package/dist/evaluation.d.ts +20 -12
- package/dist/evaluation.js +107 -10
- package/dist/frame-document.d.ts +20 -0
- package/dist/frame-document.js +40 -0
- package/dist/in-page/read-semantics.js +3 -0
- package/dist/install.d.ts +2 -2
- package/dist/install.js +5 -5
- package/dist/locators.d.ts +9 -2
- package/dist/locators.js +31 -23
- package/dist/observation-capture.js +3 -3
- package/dist/observation.d.ts +12 -8
- package/dist/observation.js +40 -43
- package/dist/observe.d.ts +3 -2
- package/dist/observe.js +6 -5
- package/dist/read-node.d.ts +5 -0
- package/dist/refs.d.ts +17 -8
- package/dist/refs.js +20 -10
- package/dist/route-options.d.ts +7 -2
- package/dist/route-options.js +33 -9
- package/dist/support.d.ts +16 -3
- package/dist/support.js +31 -0
- package/dist/surface.d.ts +30 -8
- package/dist/surface.js +213 -100
- package/package.json +3 -3
package/dist/observation.js
CHANGED
|
@@ -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
|
|
40
|
-
|
|
41
|
-
|
|
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,
|
|
47
|
-
deps.commit(id,
|
|
48
|
-
scope.release(
|
|
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
|
|
102
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
...(
|
|
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
|
|
265
|
-
* viewport's space: shifted by the frame's box and clipped
|
|
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
|
|
269
|
-
|
|
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())));
|
package/dist/read-node.d.ts
CHANGED
|
@@ -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.
|
|
8
|
-
* observation: the whole generation is swapped atomically and
|
|
9
|
-
*
|
|
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,
|
|
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
|
|
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.
|
|
8
|
-
* observation: the whole generation is swapped atomically and
|
|
9
|
-
*
|
|
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
|
|
49
|
-
if (
|
|
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
|
|
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
|
|
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 === '
|
|
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
|
}
|
package/dist/route-options.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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;
|
package/dist/route-options.js
CHANGED
|
@@ -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:
|
|
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
|
-
/**
|
|
100
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
17
|
-
* always elements: the in-page observation walk records `Element`
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
425
|
-
* element
|
|
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 {};
|