@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/README.md
CHANGED
package/dist/.build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.15.5
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
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 -
|
|
@@ -41,6 +41,8 @@ export declare class AttemptSession {
|
|
|
41
41
|
private state;
|
|
42
42
|
private generation;
|
|
43
43
|
private observed;
|
|
44
|
+
/** Settles when the latest observation in line has finished; never rejects. */
|
|
45
|
+
private observing;
|
|
44
46
|
/** Until the attempt's first page opens: a persistent browser's own first tab can serve as that page. */
|
|
45
47
|
private firstPage;
|
|
46
48
|
private requestedViewport;
|
|
@@ -56,6 +58,12 @@ export declare class AttemptSession {
|
|
|
56
58
|
check(token: object): void;
|
|
57
59
|
/** An observation commits its references and freshness together against the same generation. */
|
|
58
60
|
observedGeneration(token: object): void;
|
|
61
|
+
/**
|
|
62
|
+
* Runs one observation after every earlier one has finished, so each
|
|
63
|
+
* publishes its references in turn instead of the later one superseding a
|
|
64
|
+
* capture still in flight. Time spent waiting comes out of the operation's.
|
|
65
|
+
*/
|
|
66
|
+
observeInTurn<T>(operation: OperationContext, capture: (operation: OperationContext) => Promise<T>): Promise<T>;
|
|
59
67
|
/** Observation-backed pointer and keyboard input needs evidence captured after recovery. */
|
|
60
68
|
requireObservation(): void;
|
|
61
69
|
/** Reads the only published binding. Pending work never replaces it halfway through setup. */
|
package/dist/attempt-session.js
CHANGED
|
@@ -13,6 +13,8 @@ export class AttemptSession {
|
|
|
13
13
|
state = { kind: 'empty' };
|
|
14
14
|
generation = {};
|
|
15
15
|
observed = true;
|
|
16
|
+
/** Settles when the latest observation in line has finished; never rejects. */
|
|
17
|
+
observing = Promise.resolve();
|
|
16
18
|
/** Until the attempt's first page opens: a persistent browser's own first tab can serve as that page. */
|
|
17
19
|
firstPage = true;
|
|
18
20
|
requestedViewport;
|
|
@@ -41,6 +43,25 @@ export class AttemptSession {
|
|
|
41
43
|
this.check(token);
|
|
42
44
|
this.observed = true;
|
|
43
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Runs one observation after every earlier one has finished, so each
|
|
48
|
+
* publishes its references in turn instead of the later one superseding a
|
|
49
|
+
* capture still in flight. Time spent waiting comes out of the operation's.
|
|
50
|
+
*/
|
|
51
|
+
async observeInTurn(operation, capture) {
|
|
52
|
+
const queued = Date.now();
|
|
53
|
+
const before = this.observing;
|
|
54
|
+
let finished;
|
|
55
|
+
const mine = new Promise((resolve) => { finished = resolve; });
|
|
56
|
+
this.observing = before.then(() => mine);
|
|
57
|
+
try {
|
|
58
|
+
await raceAbort(before, operation.signal, 'observe');
|
|
59
|
+
return await capture({ ...operation, timeoutMs: Math.max(1, operation.timeoutMs - (Date.now() - queued)) });
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
finished();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
44
65
|
/** Observation-backed pointer and keyboard input needs evidence captured after recovery. */
|
|
45
66
|
requireObservation() {
|
|
46
67
|
if (!this.observed)
|
|
@@ -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):
|
|
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
|
-
|
|
48
|
-
|
|
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 ??=
|
|
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.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { PlaywrightSurface } from './surface.ts';
|
|
|
23
23
|
*/
|
|
24
24
|
export type RouteFulfillResponse = {
|
|
25
25
|
status?: number;
|
|
26
|
+
/** Response headers; `set-cookie` may join several cookies with `\n`, as `route.fetch()` reports them. */
|
|
26
27
|
headers?: Record<string, string>;
|
|
27
28
|
/** Sets the `Content-Type` response header. */
|
|
28
29
|
contentType?: string;
|
|
@@ -31,7 +32,7 @@ export type RouteFulfillResponse = {
|
|
|
31
32
|
body?: never;
|
|
32
33
|
path?: never;
|
|
33
34
|
} | {
|
|
34
|
-
body: string;
|
|
35
|
+
body: string | Uint8Array;
|
|
35
36
|
json?: never;
|
|
36
37
|
path?: never;
|
|
37
38
|
} | {
|
|
@@ -49,8 +50,8 @@ export interface RouteContinueOverrides {
|
|
|
49
50
|
url?: string;
|
|
50
51
|
/** Request method. */
|
|
51
52
|
method?: string;
|
|
52
|
-
/** Replaces every request header; the configured `headers` for the app's site are added on top. */
|
|
53
|
-
headers?: Record<string, string>;
|
|
53
|
+
/** Replaces every request header; `undefined` drops one, and the configured `headers` for the app's site are added on top. */
|
|
54
|
+
headers?: Record<string, string | undefined>;
|
|
54
55
|
/** Request body. */
|
|
55
56
|
postData?: string;
|
|
56
57
|
}
|
|
@@ -78,8 +79,10 @@ export interface WebResponse {
|
|
|
78
79
|
readonly url: string;
|
|
79
80
|
/** HTTP status. */
|
|
80
81
|
readonly status: number;
|
|
81
|
-
/** Response headers, lower-cased names. */
|
|
82
|
+
/** Response headers, lower-cased names; `route.fetch()` joins several `set-cookie` values with `\n`, `waitForResponse` leaves cookie headers out. */
|
|
82
83
|
readonly headers: Readonly<Record<string, string>>;
|
|
84
|
+
/** Waits for the body and returns its bytes; rejects with `ACTION_FAILED` when the body could not be read. */
|
|
85
|
+
body(): Promise<Buffer>;
|
|
83
86
|
/** Waits for the body and parses it as JSON; rejects with `ACTION_FAILED` when the body could not be read. */
|
|
84
87
|
json<T = unknown>(): Promise<T>;
|
|
85
88
|
/** Waits for the body and reads it as text; rejects with `ACTION_FAILED` when the body could not be read. */
|
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';
|
|
@@ -23,7 +23,7 @@ import { parseContinue, parseFulfill, requireNoArguments } from './route-options
|
|
|
23
23
|
import { routePatternMatches, routePatternsEqual } from './route-pattern.js';
|
|
24
24
|
/**
|
|
25
25
|
* Reads the body once the response is known and keeps the outcome: a body
|
|
26
|
-
* that arrived
|
|
26
|
+
* that arrived returns a copy of its bytes on every call, a body the browser could not
|
|
27
27
|
* read rejects every call with `ACTION_FAILED` naming the cause. Swallowing
|
|
28
28
|
* that failure would hand the test an empty string the server never sent,
|
|
29
29
|
* indistinguishable from a real empty body. `failure` names the reason the
|
|
@@ -34,14 +34,27 @@ import { routePatternMatches, routePatternsEqual } from './route-pattern.js';
|
|
|
34
34
|
*/
|
|
35
35
|
async function readResponseBody(api, body, failure = () => undefined) {
|
|
36
36
|
try {
|
|
37
|
-
const
|
|
38
|
-
return () => Promise.resolve(
|
|
37
|
+
const bytes = await body();
|
|
38
|
+
return () => Promise.resolve(Buffer.from(bytes));
|
|
39
39
|
}
|
|
40
40
|
catch (cause) {
|
|
41
41
|
const error = new TestError('ACTION_FAILED', `${api}: response body could not be read: ${failure() ?? causeMessage(cause)}`);
|
|
42
42
|
return () => Promise.reject(error);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
/** The `WebResponse` over a Playwright response's metadata and a reader for its body bytes. */
|
|
46
|
+
function webResponse(response, body) {
|
|
47
|
+
const text = async () => new TextDecoder().decode(await body());
|
|
48
|
+
return {
|
|
49
|
+
url: response.url(),
|
|
50
|
+
status: response.status(),
|
|
51
|
+
headers: response.headers(),
|
|
52
|
+
body,
|
|
53
|
+
// oxlint-disable-next-line e2e/no-unchecked-json -- public API: the caller names T, unknown by default
|
|
54
|
+
json: async () => JSON.parse(await text()),
|
|
55
|
+
text,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
45
58
|
/**
|
|
46
59
|
* Runs `trigger` once and saves the first `count` downloads it starts, in
|
|
47
60
|
* start order, each attached as a `download` artifact. One budget bounds the
|
|
@@ -49,10 +62,7 @@ async function readResponseBody(api, body, failure = () => undefined) {
|
|
|
49
62
|
* downloads are canceled and nothing is attached after it.
|
|
50
63
|
*/
|
|
51
64
|
async function collectDownloads(surface, context, trigger, count, timeout, api) {
|
|
52
|
-
|
|
53
|
-
throw new TestError('INVALID_ARGUMENT', `${api} timeout must be positive and finite`);
|
|
54
|
-
}
|
|
55
|
-
const operation = context.operation(timeout);
|
|
65
|
+
const operation = context.operation(stepTimeout(timeout, api));
|
|
56
66
|
const waitMs = operation.timeoutMs;
|
|
57
67
|
const deadline = new Deadline(waitMs);
|
|
58
68
|
const scope = new AbortController();
|
|
@@ -157,12 +167,12 @@ export function createBrowserFixture(surface, context) {
|
|
|
157
167
|
* harness gives `app.open` - a document load is not an action and must not
|
|
158
168
|
* be cut at the action timeout.
|
|
159
169
|
*/
|
|
160
|
-
const navigation = (options, run) => {
|
|
161
|
-
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);
|
|
162
172
|
return surface.guard(operation, 'navigation', run);
|
|
163
173
|
};
|
|
164
174
|
/** An assertion-style budget: the given timeout or the assertion timeout, clamped to the test. */
|
|
165
|
-
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);
|
|
166
176
|
/** The page URL, read within the action timeout or, for a matcher poll, within the poll's deadline. */
|
|
167
177
|
const currentUrl = (deadline) => surface.guard(context.operation(deadline?.remaining()), 'url', async () => surface.requirePage().url());
|
|
168
178
|
/** The page title, read within the action timeout or, for a matcher poll, within the poll's deadline. */
|
|
@@ -171,7 +181,7 @@ export function createBrowserFixture(surface, context) {
|
|
|
171
181
|
const browser = {
|
|
172
182
|
goto(url, options) {
|
|
173
183
|
const resolved = context.app.resolveUrl(url);
|
|
174
|
-
return navigation(options, (operation) => surface.navigating(async () => {
|
|
184
|
+
return navigation('browser.goto', options, (operation) => surface.navigating(async () => {
|
|
175
185
|
const page = await surface.ensurePage();
|
|
176
186
|
await page.goto(resolved, {
|
|
177
187
|
waitUntil: options?.waitUntil ?? 'load',
|
|
@@ -179,13 +189,13 @@ export function createBrowserFixture(surface, context) {
|
|
|
179
189
|
});
|
|
180
190
|
}));
|
|
181
191
|
},
|
|
182
|
-
reload: (options) => navigation(options, (operation) => surface.navigating(async () => {
|
|
192
|
+
reload: (options) => navigation('browser.reload', options, (operation) => surface.navigating(async () => {
|
|
183
193
|
await surface.requirePage().reload({ waitUntil: 'load', timeout: operation.timeoutMs });
|
|
184
194
|
})),
|
|
185
|
-
back: (options) => navigation(options, (operation) => surface.navigating(async () => {
|
|
195
|
+
back: (options) => navigation('browser.back', options, (operation) => surface.navigating(async () => {
|
|
186
196
|
await surface.requirePage().goBack({ waitUntil: 'load', timeout: operation.timeoutMs });
|
|
187
197
|
})),
|
|
188
|
-
forward: (options) => navigation(options, (operation) => surface.navigating(async () => {
|
|
198
|
+
forward: (options) => navigation('browser.forward', options, (operation) => surface.navigating(async () => {
|
|
189
199
|
await surface.requirePage().goForward({ waitUntil: 'load', timeout: operation.timeoutMs });
|
|
190
200
|
})),
|
|
191
201
|
url: () => currentUrl(),
|
|
@@ -203,11 +213,19 @@ export function createBrowserFixture(surface, context) {
|
|
|
203
213
|
const evaluate = compileEvaluation(source, arg !== undefined);
|
|
204
214
|
// JSON safety is checked above; Playwright's recursive argument type cannot expand JsonValue.
|
|
205
215
|
const input = arg;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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);
|
|
211
229
|
},
|
|
212
230
|
async addInitScript(script, ...rest) {
|
|
213
231
|
const source = await testInitScriptSource(script, rest.length === 0 ? undefined : { arg: rest[0] }, (file) => surface.projectPath(file));
|
|
@@ -221,6 +239,12 @@ export function createBrowserFixture(surface, context) {
|
|
|
221
239
|
// is latched on the surface and fails the next step with its real cause.
|
|
222
240
|
const handleRoute = async (route) => {
|
|
223
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
|
+
};
|
|
224
248
|
const decide = (name) => {
|
|
225
249
|
if (decided) {
|
|
226
250
|
throw new TestError('ACTION_FAILED', `route handler already decided; ${name} called twice`);
|
|
@@ -236,11 +260,10 @@ export function createBrowserFixture(surface, context) {
|
|
|
236
260
|
await act();
|
|
237
261
|
}
|
|
238
262
|
catch (cause) {
|
|
239
|
-
await
|
|
263
|
+
await abort().catch(() => undefined);
|
|
240
264
|
throw cause;
|
|
241
265
|
}
|
|
242
266
|
};
|
|
243
|
-
const request = route.request();
|
|
244
267
|
const postData = request.postData();
|
|
245
268
|
const publicRoute = {
|
|
246
269
|
request: {
|
|
@@ -259,15 +282,7 @@ export function createBrowserFixture(surface, context) {
|
|
|
259
282
|
maxRedirects: 0,
|
|
260
283
|
});
|
|
261
284
|
try {
|
|
262
|
-
|
|
263
|
-
return {
|
|
264
|
-
url: response.url(),
|
|
265
|
-
status: response.status(),
|
|
266
|
-
headers: response.headers(),
|
|
267
|
-
// oxlint-disable-next-line e2e/no-unchecked-json -- public API: the caller names T, unknown by default
|
|
268
|
-
json: async () => JSON.parse(await text()),
|
|
269
|
-
text,
|
|
270
|
-
};
|
|
285
|
+
return webResponse(response, await readResponseBody('route.fetch', () => response.body()));
|
|
271
286
|
}
|
|
272
287
|
finally {
|
|
273
288
|
await response.dispose();
|
|
@@ -301,7 +316,7 @@ export function createBrowserFixture(surface, context) {
|
|
|
301
316
|
},
|
|
302
317
|
abort: async (...args) => {
|
|
303
318
|
requireNoArguments('route.abort', args);
|
|
304
|
-
await decideWith('abort',
|
|
319
|
+
await decideWith('abort', abort);
|
|
305
320
|
},
|
|
306
321
|
};
|
|
307
322
|
try {
|
|
@@ -315,7 +330,7 @@ export function createBrowserFixture(surface, context) {
|
|
|
315
330
|
? cause
|
|
316
331
|
: new TestError('ACTION_FAILED', `route handler failed: ${causeMessage(cause)}`));
|
|
317
332
|
if (!decided)
|
|
318
|
-
await
|
|
333
|
+
await abort().catch(() => undefined);
|
|
319
334
|
}
|
|
320
335
|
};
|
|
321
336
|
// Tracked so the attempt end waits for a handler still running and
|
|
@@ -339,28 +354,21 @@ export function createBrowserFixture(surface, context) {
|
|
|
339
354
|
},
|
|
340
355
|
waitForResponse(pattern, options) {
|
|
341
356
|
const wirePattern = toTextPattern(pattern);
|
|
342
|
-
const operation = context.operation(options?.timeout);
|
|
357
|
+
const operation = context.operation(stepTimeout(options?.timeout, 'browser.waitForResponse'));
|
|
343
358
|
return surface.guard(operation, 'waitForResponse', async (currentOperation) => {
|
|
344
359
|
const page = await surface.ensurePage();
|
|
345
360
|
const response = await page.waitForResponse((candidate) => routePatternMatches(wirePattern, candidate.url()), { timeout: currentOperation.timeoutMs });
|
|
346
361
|
// Started now, while the browser still holds the body, and awaited
|
|
347
|
-
// only by
|
|
362
|
+
// only by the body readers on a budget of their own: the timeout
|
|
348
363
|
// bounds the match, never a body still streaming in behind headers
|
|
349
364
|
// that already arrived.
|
|
350
365
|
const body = readResponseBody('waitForResponse', () => response.body(), () => response.request().failure()?.errorText);
|
|
351
|
-
const
|
|
366
|
+
const read = async () => {
|
|
352
367
|
const { signal, timeoutMs } = context.operation();
|
|
353
|
-
const
|
|
354
|
-
return
|
|
355
|
-
};
|
|
356
|
-
return {
|
|
357
|
-
url: response.url(),
|
|
358
|
-
status: response.status(),
|
|
359
|
-
headers: response.headers(),
|
|
360
|
-
// oxlint-disable-next-line e2e/no-unchecked-json -- public API: the caller names T, unknown by default
|
|
361
|
-
json: async () => JSON.parse(await text()),
|
|
362
|
-
text,
|
|
368
|
+
const bytes = await raceAbort(withTimeout(body, timeoutMs, () => new TestError('ACTION_FAILED', `waitForResponse: response body did not finish within ${timeoutMs}ms`)), signal, 'waitForResponse body');
|
|
369
|
+
return bytes();
|
|
363
370
|
};
|
|
371
|
+
return webResponse(response, read);
|
|
364
372
|
});
|
|
365
373
|
},
|
|
366
374
|
cookies: () => surface.guard(context.operation(), 'cookies', async () => {
|
|
@@ -477,13 +485,13 @@ export function createBrowserFixture(surface, context) {
|
|
|
477
485
|
forward: navigationCall,
|
|
478
486
|
url: action,
|
|
479
487
|
title: action,
|
|
480
|
-
waitForURL: { ...navigationCall, verifies: true, label:
|
|
488
|
+
waitForURL: { ...navigationCall, verifies: true, label: patternLabel },
|
|
481
489
|
evaluate: action,
|
|
482
490
|
addInitScript: { ...action, label: (script) => initScriptLabel(script) },
|
|
483
|
-
route: { ...action, label:
|
|
484
|
-
unroute: { ...action, label:
|
|
491
|
+
route: { ...action, label: patternLabel },
|
|
492
|
+
unroute: { ...action, label: patternLabel },
|
|
485
493
|
unrouteAll: action,
|
|
486
|
-
waitForResponse: { ...action, timeout: (_pattern, options) => options?.timeout, label:
|
|
494
|
+
waitForResponse: { ...action, timeout: (_pattern, options) => options?.timeout, label: patternLabel },
|
|
487
495
|
cookies: action,
|
|
488
496
|
setCookies: action,
|
|
489
497
|
clearCookies: action,
|
|
@@ -504,7 +512,7 @@ export function createBrowserFixture(surface, context) {
|
|
|
504
512
|
*/
|
|
505
513
|
function createBrowserExpectation(deps, negated = false) {
|
|
506
514
|
const poll = async (api, label, read, timeout) => {
|
|
507
|
-
const deadline = deps.deadlineFor(timeout);
|
|
515
|
+
const deadline = deps.deadlineFor(timeout, api);
|
|
508
516
|
// pollCondition calls onTimeout only after a read completed, so a sample is always there.
|
|
509
517
|
let last;
|
|
510
518
|
await pollCondition({
|
|
@@ -586,6 +594,10 @@ function requireSelector(method, selector) {
|
|
|
586
594
|
}
|
|
587
595
|
return selector;
|
|
588
596
|
}
|
|
597
|
+
/** A URL pattern as steps and errors name it: a string quoted, a RegExp as its literal. */
|
|
598
|
+
function patternLabel(pattern) {
|
|
599
|
+
return typeof pattern === 'string' ? JSON.stringify(pattern) : String(pattern);
|
|
600
|
+
}
|
|
589
601
|
/**
|
|
590
602
|
* The scope for the innermost of `frames` (outermost first). Every query and
|
|
591
603
|
* both escape hatches compile through the same `frame` chain, so a `locator`
|
|
@@ -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
|
+
}
|