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