@e2e-dev/web 0.13.0 → 0.14.0-nightly-20261008205824
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/dist/.build.json +1 -1
- package/dist/actions.d.ts +2 -1
- package/dist/actions.js +45 -28
- package/dist/app-log.d.ts +20 -0
- package/dist/app-log.js +127 -0
- package/dist/attempt-session.d.ts +2 -17
- package/dist/attempt-session.js +2 -77
- package/dist/browser.js +10 -10
- package/dist/dialogs.js +5 -0
- package/dist/engine.js +0 -2
- package/dist/evaluation.d.ts +2 -0
- package/dist/evaluation.js +31 -0
- package/dist/in-page/read-semantics.js +140 -15
- package/dist/init-scripts.js +2 -2
- package/dist/observation-capture.d.ts +1 -2
- package/dist/observation.d.ts +16 -0
- package/dist/observation.js +47 -12
- package/dist/operation-budget.js +2 -2
- package/dist/provider-video.d.ts +0 -2
- package/dist/provider-video.js +0 -2
- package/dist/surface.d.ts +23 -11
- package/dist/surface.js +105 -34
- package/dist/trace-feed.d.ts +40 -0
- package/dist/trace-feed.js +93 -0
- package/dist/video.d.ts +0 -4
- package/dist/video.js +23 -5
- package/package.json +3 -3
package/dist/.build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.
|
|
1
|
+
{"version":"0.14.0-nightly-20261008205824"}
|
package/dist/actions.d.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
import type { Page } from 'playwright-core';
|
|
3
3
|
import { type LocatorAction, type NodeRef, type PointerAction, type ViewportPoint } from 'e2e/engine';
|
|
4
4
|
import { type ActionTarget } from './support.ts';
|
|
5
|
+
import type { ConnectionBudget } from './operation-budget.ts';
|
|
5
6
|
/**
|
|
6
7
|
* Dispatches one deterministic locator action onto a Playwright target with the
|
|
7
8
|
* platform's own actionability checks. `lookup` resolves the second ref of a
|
|
8
9
|
* drag; everything else needs only the target.
|
|
9
10
|
*/
|
|
10
|
-
export declare function dispatchLocatorAction(target: ActionTarget, action: LocatorAction,
|
|
11
|
+
export declare function dispatchLocatorAction(target: ActionTarget, action: LocatorAction, operation: ConnectionBudget, lookup: (ref: NodeRef) => ActionTarget): Promise<void>;
|
|
11
12
|
/**
|
|
12
13
|
* Dispatches one pointer action at a viewport point with nothing resolved
|
|
13
14
|
* behind it: no actionability wait, because there is no element to wait on,
|
package/dist/actions.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Locator action dispatch for the Playwright engine. */
|
|
2
|
-
import { EngineError } from 'e2e/engine';
|
|
2
|
+
import { Deadline, EngineError, pollCondition } from 'e2e/engine';
|
|
3
3
|
import { asActionable, isClassified, isNavigationRace, isPwTimeout, message, nearestPixel, performElementSwipe, performPointDrag, performPointerDrag, performViewportSwipe, POST_DISPATCH_PATTERN, translatePwError, } from './support.js';
|
|
4
4
|
/** How long a long press holds the button when the action names no duration. */
|
|
5
5
|
const DEFAULT_LONG_PRESS_MS = 500;
|
|
@@ -11,19 +11,25 @@ function heldKeys(action) {
|
|
|
11
11
|
const DETACHED_PATTERN = /element (is |was )?(detached|not attached)/i;
|
|
12
12
|
/**
|
|
13
13
|
* Sets a checkbox, switch, or radio to `checked` with one click, as
|
|
14
|
-
* Playwright's `check` does, apart from the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
14
|
+
* Playwright's `check` does, apart from the reads after the click. Playwright
|
|
15
|
+
* reads the state once; here it is polled until the operation's deadline, so
|
|
16
|
+
* a control that commits its new state after an await or a timer passes, and
|
|
17
|
+
* one that never changes fails at the deadline. A control that is gone by
|
|
18
|
+
* the first read (an app that swaps a picked radio for its selected view, or
|
|
19
|
+
* navigates on change) took the click, so the action is done, where
|
|
20
|
+
* Playwright reports it detached as if the click never happened. One that
|
|
21
|
+
* goes later, still in its old state, may have been replaced for any reason,
|
|
22
|
+
* so it fails rather than passing on a click the app rejected. Whatever took
|
|
23
|
+
* its place is not read: the next observation or assertion shows it, as it
|
|
24
|
+
* does after a tap.
|
|
25
|
+
* The reads and the click share one element, so the state before and after
|
|
26
|
+
* the click is one control's.
|
|
21
27
|
*/
|
|
22
|
-
async function setChecked(target, checked,
|
|
23
|
-
const deadline =
|
|
28
|
+
async function setChecked(target, checked, operation) {
|
|
29
|
+
const deadline = new Deadline(operation.timeoutMs);
|
|
24
30
|
// Playwright reads a timeout of 0 as no timeout at all.
|
|
25
|
-
const remaining = () => Math.max(1, deadline
|
|
26
|
-
const element = target.kind === 'element' ? target.element : await target.locator.elementHandle({ timeout });
|
|
31
|
+
const remaining = () => Math.max(1, deadline.remaining());
|
|
32
|
+
const element = target.kind === 'element' ? target.element : await target.locator.elementHandle({ timeout: operation.timeoutMs });
|
|
27
33
|
const verb = checked ? 'check' : 'uncheck';
|
|
28
34
|
try {
|
|
29
35
|
if ((await element.isChecked()) === checked)
|
|
@@ -34,20 +40,30 @@ async function setChecked(target, checked, timeout) {
|
|
|
34
40
|
});
|
|
35
41
|
}
|
|
36
42
|
await element.click({ timeout: remaining() });
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
const unchanged = (detail = '') => new EngineError('NOT_ACTIONABLE', `${verb} clicked the control but its checked state did not change${detail}`, {
|
|
44
|
+
retryable: false,
|
|
45
|
+
});
|
|
46
|
+
let firstRead = true;
|
|
47
|
+
await pollCondition({
|
|
48
|
+
deadline,
|
|
49
|
+
signal: operation.signal,
|
|
50
|
+
negated: false,
|
|
51
|
+
evaluate: async () => {
|
|
52
|
+
const first = firstRead;
|
|
53
|
+
firstRead = false;
|
|
54
|
+
try {
|
|
55
|
+
return (await element.isChecked()) === checked;
|
|
56
|
+
}
|
|
57
|
+
catch (cause) {
|
|
58
|
+
if (!DETACHED_PATTERN.test(message(cause)) && !isNavigationRace(cause))
|
|
59
|
+
throw cause;
|
|
60
|
+
if (first)
|
|
61
|
+
return true;
|
|
62
|
+
throw unchanged(' before the control was replaced');
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
onTimeout: () => unchanged(),
|
|
66
|
+
});
|
|
51
67
|
}
|
|
52
68
|
finally {
|
|
53
69
|
if (target.kind === 'locator')
|
|
@@ -63,7 +79,8 @@ function isRadio(element) {
|
|
|
63
79
|
* platform's own actionability checks. `lookup` resolves the second ref of a
|
|
64
80
|
* drag; everything else needs only the target.
|
|
65
81
|
*/
|
|
66
|
-
export async function dispatchLocatorAction(target, action,
|
|
82
|
+
export async function dispatchLocatorAction(target, action, operation, lookup) {
|
|
83
|
+
const timeout = operation.timeoutMs;
|
|
67
84
|
const locator = asActionable(target);
|
|
68
85
|
switch (action.kind) {
|
|
69
86
|
case 'tap':
|
|
@@ -91,7 +108,7 @@ export async function dispatchLocatorAction(target, action, timeout, lookup) {
|
|
|
91
108
|
return;
|
|
92
109
|
case 'check':
|
|
93
110
|
case 'uncheck':
|
|
94
|
-
await setChecked(target, action.kind === 'check',
|
|
111
|
+
await setChecked(target, action.kind === 'check', operation);
|
|
95
112
|
return;
|
|
96
113
|
case 'focus':
|
|
97
114
|
// ElementHandle.focus takes no timeout: the element is already resolved.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the page did on its own during an attempt: for the harness's app log,
|
|
3
|
+
* console output, exceptions nothing caught, requests that failed, and
|
|
4
|
+
* responses with an error status; apart from it, where the page went (a
|
|
5
|
+
* navigation, a new tab, a frame loading). These are the first things to
|
|
6
|
+
* read when a step fails because the app broke rather than the test.
|
|
7
|
+
*/
|
|
8
|
+
import type { BrowserContext, Page } from 'playwright-core';
|
|
9
|
+
import type { AppLogEntry } from 'e2e/engine';
|
|
10
|
+
/** Where the app log tells where the page went, and what it asks the engine to tell its navigations from the app's. */
|
|
11
|
+
export interface NavigationLog {
|
|
12
|
+
/** Takes one line saying where the page went. */
|
|
13
|
+
readonly navigated: (line: string) => void;
|
|
14
|
+
/** Whether a navigation of the test's page to `url` is one the engine started, which its step already tells. */
|
|
15
|
+
readonly ownNavigation: (url: string) => boolean;
|
|
16
|
+
/** The page the test drives; every other page is a tab the app opened. */
|
|
17
|
+
readonly testPage: () => Page | undefined;
|
|
18
|
+
}
|
|
19
|
+
/** Reports the context's console output, uncaught exceptions, failed requests, and navigations to `log`, from now on. */
|
|
20
|
+
export declare function installAppLog(context: BrowserContext, log: (entry: AppLogEntry) => void, navigation: NavigationLog): void;
|
package/dist/app-log.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the page did on its own during an attempt: for the harness's app log,
|
|
3
|
+
* console output, exceptions nothing caught, requests that failed, and
|
|
4
|
+
* responses with an error status; apart from it, where the page went (a
|
|
5
|
+
* navigation, a new tab, a frame loading). These are the first things to
|
|
6
|
+
* read when a step fails because the app broke rather than the test.
|
|
7
|
+
*/
|
|
8
|
+
/** Console message types worth a line; `debug` and the rest are the app talking to itself. */
|
|
9
|
+
const CONSOLE_LEVELS = {
|
|
10
|
+
error: 'error',
|
|
11
|
+
assert: 'error',
|
|
12
|
+
warning: 'warning',
|
|
13
|
+
log: 'info',
|
|
14
|
+
info: 'info',
|
|
15
|
+
};
|
|
16
|
+
/** Documents that are no place the app went: a fresh tab, an inline frame. */
|
|
17
|
+
const NO_PLACE = new Set(['about:blank', 'about:srcdoc']);
|
|
18
|
+
/** Chrome's console echo of a failed load, which the network lines already tell. */
|
|
19
|
+
const LOAD_ECHO = 'Failed to load resource:';
|
|
20
|
+
/**
|
|
21
|
+
* Whether a failed request is one the browser cancelled because the page
|
|
22
|
+
* moved on, not the app failing: Chromium's and Firefox's abort codes, and
|
|
23
|
+
* WebKit's text, read the way Playwright reads each browser's cancellation.
|
|
24
|
+
*/
|
|
25
|
+
function isCancelled(errorText) {
|
|
26
|
+
return errorText === 'net::ERR_ABORTED' || errorText === 'NS_BINDING_ABORTED' || errorText.includes('cancelled');
|
|
27
|
+
}
|
|
28
|
+
/** Reports the context's console output, uncaught exceptions, failed requests, and navigations to `log`, from now on. */
|
|
29
|
+
export function installAppLog(context, log, navigation) {
|
|
30
|
+
const report = (entry) => {
|
|
31
|
+
try {
|
|
32
|
+
log(entry);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// The harness's sink never fails a page event.
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const went = (line) => {
|
|
39
|
+
try {
|
|
40
|
+
navigation.navigated(line);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// The harness's sink never fails a page event.
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
context.on('console', (message) => {
|
|
47
|
+
const level = CONSOLE_LEVELS[message.type()];
|
|
48
|
+
if (level !== undefined && !message.text().startsWith(LOAD_ECHO))
|
|
49
|
+
report({ source: 'console', level, text: consoleText(message) });
|
|
50
|
+
});
|
|
51
|
+
context.on('weberror', (webError) => {
|
|
52
|
+
const error = webError.error();
|
|
53
|
+
const top = error.stack?.split('\n').find((line) => line.trim().startsWith('at '))?.trim();
|
|
54
|
+
report({ source: 'error', level: 'error', text: `${error.name}: ${error.message}${top === undefined ? '' : ` ${top}`}` });
|
|
55
|
+
});
|
|
56
|
+
context.on('requestfailed', (request) => {
|
|
57
|
+
const failure = request.failure()?.errorText ?? 'failed';
|
|
58
|
+
if (isCancelled(failure))
|
|
59
|
+
return;
|
|
60
|
+
report({ source: 'network', level: 'error', text: `${requestLine(request)} ${failure}` });
|
|
61
|
+
});
|
|
62
|
+
context.on('response', (response) => {
|
|
63
|
+
const status = response.status();
|
|
64
|
+
if (status < 400 || isMissingFavicon(response))
|
|
65
|
+
return;
|
|
66
|
+
report({ source: 'network', level: status >= 500 ? 'error' : 'warning', text: responseLine(response) });
|
|
67
|
+
});
|
|
68
|
+
const watch = (page) => {
|
|
69
|
+
// A tab's first document is told by its opening.
|
|
70
|
+
let opened = false;
|
|
71
|
+
page.on('framenavigated', (frame) => {
|
|
72
|
+
const tab = page !== navigation.testPage();
|
|
73
|
+
if (tab && frame === page.mainFrame() && !opened && !NO_PLACE.has(frame.url())) {
|
|
74
|
+
opened = true;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const line = navigationLine(page, frame, tab, navigation);
|
|
78
|
+
if (line !== undefined)
|
|
79
|
+
went(line);
|
|
80
|
+
});
|
|
81
|
+
// A tab the app opened (`window.open`, a `target="_blank"` link); the test stays on its own page.
|
|
82
|
+
page.on('popup', (tab) => {
|
|
83
|
+
const at = NO_PLACE.has(tab.url()) ? '' : ` at ${tab.url()}`;
|
|
84
|
+
went(`the app opened a new tab${at}; the test stays on its page`);
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
for (const page of context.pages())
|
|
88
|
+
watch(page);
|
|
89
|
+
context.on('page', watch);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `navigated to /checkout`, `the new tab navigated to /help`, `frame "pay"
|
|
93
|
+
* loaded /embed`: one navigation as a line, or undefined for one not worth
|
|
94
|
+
* telling.
|
|
95
|
+
*/
|
|
96
|
+
function navigationLine(page, frame, tab, navigation) {
|
|
97
|
+
const url = frame.url();
|
|
98
|
+
if (NO_PLACE.has(url) || url.startsWith('data:'))
|
|
99
|
+
return undefined;
|
|
100
|
+
if (frame === page.mainFrame()) {
|
|
101
|
+
if (tab)
|
|
102
|
+
return `the new tab navigated to ${url}`;
|
|
103
|
+
if (navigation.ownNavigation(url))
|
|
104
|
+
return undefined;
|
|
105
|
+
return `navigated to ${url}`;
|
|
106
|
+
}
|
|
107
|
+
const name = frame.name();
|
|
108
|
+
return `frame${name === '' ? '' : ` "${name}"`} loaded ${url}`;
|
|
109
|
+
}
|
|
110
|
+
/** `message (at /app.js:12)`: the text, and where it was logged from when the browser says. */
|
|
111
|
+
function consoleText(message) {
|
|
112
|
+
const { url, lineNumber } = message.location();
|
|
113
|
+
return url === '' ? message.text() : `${message.text()} (at ${url}:${lineNumber + 1})`;
|
|
114
|
+
}
|
|
115
|
+
/** A browser's own request for a site icon the app never served: noise on every page of most apps. */
|
|
116
|
+
function isMissingFavicon(response) {
|
|
117
|
+
return response.status() === 404 && new URL(response.url()).pathname === '/favicon.ico';
|
|
118
|
+
}
|
|
119
|
+
/** `GET https://app.test/api/todos`: the request line a network entry starts with. */
|
|
120
|
+
function requestLine(request) {
|
|
121
|
+
return `${request.method()} ${request.url()}`;
|
|
122
|
+
}
|
|
123
|
+
/** `GET https://app.test/api/todos 500 Internal Server Error`: the request line and the status it got. */
|
|
124
|
+
function responseLine(response) {
|
|
125
|
+
const text = response.statusText();
|
|
126
|
+
return `${requestLine(response.request())} ${response.status()}${text === '' ? '' : ` ${text}`}`;
|
|
127
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** Owns one attempt's live binding, recovery, references, and recordings. */
|
|
1
2
|
import type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright-core';
|
|
2
3
|
import { type EngineCleanupContext, type OperationContext, type VideoSegment, type ViewportSize } from 'e2e/engine';
|
|
3
4
|
import { type CdpEndpointResolver, type SessionBinding } from './cdp-recovery.ts';
|
|
@@ -39,9 +40,6 @@ export declare class AttemptSession {
|
|
|
39
40
|
private observed;
|
|
40
41
|
/** Until the attempt's first page opens: a persistent browser's own first tab can serve as that page. */
|
|
41
42
|
private firstPage;
|
|
42
|
-
private tracing;
|
|
43
|
-
private traceSegments;
|
|
44
|
-
private traceParts;
|
|
45
43
|
private requestedViewport;
|
|
46
44
|
constructor(options: SessionOptions);
|
|
47
45
|
/** Captures an immutable connection generation for publication after asynchronous reads. */
|
|
@@ -86,24 +84,11 @@ export declare class AttemptSession {
|
|
|
86
84
|
close(budget: EngineCleanupContext): Promise<void>;
|
|
87
85
|
/** Ordinary contexts are engine-owned; persistent browser processes belong to the host. */
|
|
88
86
|
private release;
|
|
89
|
-
/** Allocates trace paths inside this attempt's immutable artifact directory. */
|
|
90
|
-
private tracePath;
|
|
91
|
-
/** Saves a replaced context's trace when it can still flush it. */
|
|
92
|
-
private closeTraceSegment;
|
|
93
|
-
/** Begins tracing the currently committed context. */
|
|
94
|
-
startTrace(): Promise<void>;
|
|
95
|
-
/** Returns finalized segments, including any retained before context replacement failed. */
|
|
96
|
-
private stopTrace;
|
|
97
87
|
/**
|
|
98
88
|
* Starts video once the page is open, so a provider's recording of the
|
|
99
|
-
* browser starts on the attempt's own tab.
|
|
100
|
-
* trace, which sizes it for itself, so a trace already running (a host
|
|
101
|
-
* that starts the video mid-attempt) is split around a screencast's start:
|
|
102
|
-
* its segment so far is saved, and it resumes on the video's screencast.
|
|
89
|
+
* browser starts on the attempt's own tab.
|
|
103
90
|
*/
|
|
104
91
|
startVideo(signal: AbortSignal): Promise<void>;
|
|
105
|
-
/** Collects finalized trace segments even when connection replacement failed. */
|
|
106
|
-
collectTrace(operation: OperationContext): Promise<string | readonly string[]>;
|
|
107
92
|
/** Collects this attempt's video independently of a failed connection. */
|
|
108
93
|
collectVideo(operation: OperationContext): Promise<readonly VideoSegment[]>;
|
|
109
94
|
/** Terminal artifact collection needs its own budget, but no successful UI dispatch. */
|
package/dist/attempt-session.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
/** Owns one attempt's live binding, recovery, references, and recordings. */
|
|
2
|
-
import { mkdirSync } from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
1
|
import { EngineError, raceAbort, withinCleanupBudget } from 'e2e/engine';
|
|
5
2
|
import { attachPersistent, recoveryFailed, targetIdentity } from './cdp-recovery.js';
|
|
6
3
|
import { connectionAbort, withConnectionBudget, withOperationDeadline } from './operation-budget.js';
|
|
@@ -8,7 +5,6 @@ import { RefRegistry } from './refs.js';
|
|
|
8
5
|
import { ProviderVideo } from './provider-video.js';
|
|
9
6
|
import { invalidState, translatePwError } from './support.js';
|
|
10
7
|
import { VideoRecorder } from './video.js';
|
|
11
|
-
const TRACE_OPTIONS = { screenshots: true, snapshots: true };
|
|
12
8
|
export class AttemptSession {
|
|
13
9
|
options;
|
|
14
10
|
refs = new RefRegistry();
|
|
@@ -19,9 +15,6 @@ export class AttemptSession {
|
|
|
19
15
|
observed = true;
|
|
20
16
|
/** Until the attempt's first page opens: a persistent browser's own first tab can serve as that page. */
|
|
21
17
|
firstPage = true;
|
|
22
|
-
tracing = false;
|
|
23
|
-
traceSegments = 0;
|
|
24
|
-
traceParts = [];
|
|
25
18
|
requestedViewport;
|
|
26
19
|
constructor(options) {
|
|
27
20
|
this.options = options;
|
|
@@ -118,9 +111,6 @@ export class AttemptSession {
|
|
|
118
111
|
if (budget.signal.aborted)
|
|
119
112
|
throw connectionAbort(budget.signal, 'connection');
|
|
120
113
|
this.invalidate();
|
|
121
|
-
const resumeTrace = recover && this.tracing;
|
|
122
|
-
if (recover)
|
|
123
|
-
this.tracing = false;
|
|
124
114
|
const running = Promise.resolve().then(async () => {
|
|
125
115
|
if (budget.signal.aborted)
|
|
126
116
|
throw connectionAbort(budget.signal, 'connection');
|
|
@@ -137,13 +127,10 @@ export class AttemptSession {
|
|
|
137
127
|
await candidate.page.setViewportSize(size);
|
|
138
128
|
await this.video.pageOpened(candidate.page);
|
|
139
129
|
}
|
|
140
|
-
if (resumeTrace)
|
|
141
|
-
await candidate.context.tracing.start(TRACE_OPTIONS);
|
|
142
130
|
if (budget.signal.aborted)
|
|
143
131
|
throw connectionAbort(budget.signal, 'connection');
|
|
144
132
|
if (this.state !== pending)
|
|
145
133
|
throw connectionAbort(this.lifetime.signal, 'connection');
|
|
146
|
-
this.tracing = resumeTrace;
|
|
147
134
|
this.state = { kind: 'ready', binding: candidate };
|
|
148
135
|
}
|
|
149
136
|
catch (cause) {
|
|
@@ -227,19 +214,15 @@ export class AttemptSession {
|
|
|
227
214
|
this.invalidate();
|
|
228
215
|
const token = this.token();
|
|
229
216
|
await this.video.pageClosing();
|
|
230
|
-
const resume = this.video.isArmed ? await this.closeTraceSegment(binding.context) : false;
|
|
231
217
|
for (const page of binding.context.pages())
|
|
232
218
|
await page.close();
|
|
233
219
|
this.check(token);
|
|
234
220
|
this.state = { kind: 'ready', binding: { ...binding, page: null } };
|
|
235
221
|
await this.ensurePage();
|
|
236
|
-
if (resume)
|
|
237
|
-
await this.startTrace();
|
|
238
222
|
}
|
|
239
223
|
/** Replaces an ordinary context; persistent recovery never advertises this capability. */
|
|
240
224
|
async replace(storageState, operation) {
|
|
241
225
|
const previous = this.current();
|
|
242
|
-
const resume = await this.closeTraceSegment(previous.context);
|
|
243
226
|
await this.video.pageClosing();
|
|
244
227
|
await this.transition(previous, operation, async () => {
|
|
245
228
|
await previous.context.close();
|
|
@@ -248,8 +231,6 @@ export class AttemptSession {
|
|
|
248
231
|
});
|
|
249
232
|
if (this.video.isArmed)
|
|
250
233
|
await this.ensurePage();
|
|
251
|
-
if (resume)
|
|
252
|
-
await this.startTrace();
|
|
253
234
|
}
|
|
254
235
|
/** Ends this attempt immediately; late work can only access this retired owner. */
|
|
255
236
|
async close(budget) {
|
|
@@ -263,74 +244,18 @@ export class AttemptSession {
|
|
|
263
244
|
if (binding === undefined)
|
|
264
245
|
return;
|
|
265
246
|
await withinCleanupBudget(this.video.abandon(budget.signal), budget);
|
|
266
|
-
if (this.tracing)
|
|
267
|
-
await withinCleanupBudget(binding.context.tracing.stop(), budget);
|
|
268
247
|
await withinCleanupBudget(this.release(binding), budget);
|
|
269
248
|
}
|
|
270
249
|
/** Ordinary contexts are engine-owned; persistent browser processes belong to the host. */
|
|
271
250
|
release(binding) {
|
|
272
251
|
return this.options.persistent === undefined ? binding.context.close() : binding.browser.close();
|
|
273
252
|
}
|
|
274
|
-
/** Allocates trace paths inside this attempt's immutable artifact directory. */
|
|
275
|
-
tracePath(name) {
|
|
276
|
-
const relative = path.posix.join('trace', `${name}.zip`);
|
|
277
|
-
const absolute = path.join(this.options.artifactsDir, relative);
|
|
278
|
-
mkdirSync(path.dirname(absolute), { recursive: true });
|
|
279
|
-
return { relative, absolute };
|
|
280
|
-
}
|
|
281
|
-
/** Saves a replaced context's trace when it can still flush it. */
|
|
282
|
-
async closeTraceSegment(context) {
|
|
283
|
-
if (!this.tracing)
|
|
284
|
-
return false;
|
|
285
|
-
this.tracing = false;
|
|
286
|
-
this.traceSegments += 1;
|
|
287
|
-
const { relative, absolute } = this.tracePath(`trace-part${this.traceSegments}`);
|
|
288
|
-
try {
|
|
289
|
-
await context.tracing.stop({ path: absolute });
|
|
290
|
-
this.traceParts.push(relative);
|
|
291
|
-
}
|
|
292
|
-
catch { /* A disconnected context may have lost its final trace segment. */ }
|
|
293
|
-
return true;
|
|
294
|
-
}
|
|
295
|
-
/** Begins tracing the currently committed context. */
|
|
296
|
-
async startTrace() {
|
|
297
|
-
const token = this.token();
|
|
298
|
-
await this.current().context.tracing.start(TRACE_OPTIONS);
|
|
299
|
-
this.check(token);
|
|
300
|
-
this.tracing = true;
|
|
301
|
-
}
|
|
302
|
-
/** Returns finalized segments, including any retained before context replacement failed. */
|
|
303
|
-
async stopTrace() {
|
|
304
|
-
const parts = this.traceParts;
|
|
305
|
-
this.traceParts = [];
|
|
306
|
-
if (parts.length > 0 && !this.tracing)
|
|
307
|
-
return parts;
|
|
308
|
-
const { relative, absolute } = this.tracePath('trace');
|
|
309
|
-
await this.current().context.tracing.stop({ path: absolute });
|
|
310
|
-
this.tracing = false;
|
|
311
|
-
return parts.length === 0 ? relative : [...parts, relative];
|
|
312
|
-
}
|
|
313
253
|
/**
|
|
314
254
|
* Starts video once the page is open, so a provider's recording of the
|
|
315
|
-
* browser starts on the attempt's own tab.
|
|
316
|
-
* trace, which sizes it for itself, so a trace already running (a host
|
|
317
|
-
* that starts the video mid-attempt) is split around a screencast's start:
|
|
318
|
-
* its segment so far is saved, and it resumes on the video's screencast.
|
|
255
|
+
* browser starts on the attempt's own tab.
|
|
319
256
|
*/
|
|
320
257
|
async startVideo(signal) {
|
|
321
|
-
|
|
322
|
-
const resume = this.video.startsScreencast ? await this.closeTraceSegment(this.current().context) : false;
|
|
323
|
-
try {
|
|
324
|
-
await this.video.arm(page, signal);
|
|
325
|
-
}
|
|
326
|
-
finally {
|
|
327
|
-
if (resume)
|
|
328
|
-
await this.startTrace();
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
/** Collects finalized trace segments even when connection replacement failed. */
|
|
332
|
-
collectTrace(operation) {
|
|
333
|
-
return this.finalize(operation, 'trace', () => this.stopTrace());
|
|
258
|
+
await this.video.arm(await this.ensurePage(), signal);
|
|
334
259
|
}
|
|
335
260
|
/** Collects this attempt's video independently of a failed connection. */
|
|
336
261
|
collectVideo(operation) {
|
package/dist/browser.js
CHANGED
|
@@ -15,7 +15,7 @@ import { ConfigurationError, Deadline, describePattern, EngineError, matchesText
|
|
|
15
15
|
import { classifyInputError } from './actions.js';
|
|
16
16
|
import { saveDownloadsTo, saveFromBrowser, saveLocally } from './downloads.js';
|
|
17
17
|
import { isTestErrorCode, message as causeMessage, translatePwError } from './support.js';
|
|
18
|
-
import { compileEvaluation } from './evaluation.js';
|
|
18
|
+
import { compileEvaluation, pageFunctionSource } from './evaluation.js';
|
|
19
19
|
import { initScriptLabel, testInitScriptSource } from './init-scripts.js';
|
|
20
20
|
import { lowercaseNames } from './protected-app.js';
|
|
21
21
|
import { parseContinue, parseFulfill, requireNoArguments } from './route-options.js';
|
|
@@ -71,23 +71,23 @@ export function createBrowserFixture(surface, context) {
|
|
|
71
71
|
const browser = {
|
|
72
72
|
goto(url, options) {
|
|
73
73
|
const resolved = context.app.resolveUrl(url);
|
|
74
|
-
return navigation(options, async (
|
|
74
|
+
return navigation(options, (operation) => surface.navigating(async () => {
|
|
75
75
|
const page = await surface.ensurePage();
|
|
76
76
|
await page.goto(resolved, {
|
|
77
77
|
waitUntil: options?.waitUntil ?? 'load',
|
|
78
78
|
timeout: operation.timeoutMs,
|
|
79
79
|
});
|
|
80
|
-
});
|
|
80
|
+
}));
|
|
81
81
|
},
|
|
82
|
-
reload: (options) => navigation(options, async (
|
|
82
|
+
reload: (options) => navigation(options, (operation) => surface.navigating(async () => {
|
|
83
83
|
await surface.requirePage().reload({ waitUntil: 'load', timeout: operation.timeoutMs });
|
|
84
|
-
}),
|
|
85
|
-
back: (options) => navigation(options, async (
|
|
84
|
+
})),
|
|
85
|
+
back: (options) => navigation(options, (operation) => surface.navigating(async () => {
|
|
86
86
|
await surface.requirePage().goBack({ waitUntil: 'load', timeout: operation.timeoutMs });
|
|
87
|
-
}),
|
|
88
|
-
forward: (options) => navigation(options, async (
|
|
87
|
+
})),
|
|
88
|
+
forward: (options) => navigation(options, (operation) => surface.navigating(async () => {
|
|
89
89
|
await surface.requirePage().goForward({ waitUntil: 'load', timeout: operation.timeoutMs });
|
|
90
|
-
}),
|
|
90
|
+
})),
|
|
91
91
|
url: () => currentUrl(),
|
|
92
92
|
title: () => currentTitle(),
|
|
93
93
|
// The same poll as `expect(browser).toHaveURL`, exposed as a wait.
|
|
@@ -98,7 +98,7 @@ export function createBrowserFixture(surface, context) {
|
|
|
98
98
|
locator: (selector) => context.locator({ kind: 'selector', selector: requireSelector('browser.locator', selector) }),
|
|
99
99
|
frameLocator: (selector) => frameScreen(context, [requireSelector('browser.frameLocator', selector)]),
|
|
100
100
|
async evaluate(fn, arg) {
|
|
101
|
-
const source = typeof fn === 'string' ? fn : fn
|
|
101
|
+
const source = typeof fn === 'string' ? fn : pageFunctionSource(fn);
|
|
102
102
|
validateJsonValue(arg, 'evaluate argument');
|
|
103
103
|
const evaluate = compileEvaluation(source, arg !== undefined);
|
|
104
104
|
// JSON safety is checked above; Playwright's recursive argument type cannot expand JsonValue.
|
package/dist/dialogs.js
CHANGED
|
@@ -45,15 +45,18 @@ export class DialogRouter {
|
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
47
|
let decided = false;
|
|
48
|
+
let answered = false;
|
|
48
49
|
const publicDialog = {
|
|
49
50
|
message: dialog.message(),
|
|
50
51
|
accept: async (text) => {
|
|
51
52
|
decided = true;
|
|
52
53
|
await dialog.accept(text);
|
|
54
|
+
answered = true;
|
|
53
55
|
},
|
|
54
56
|
dismiss: async () => {
|
|
55
57
|
decided = true;
|
|
56
58
|
await dialog.dismiss();
|
|
59
|
+
answered = true;
|
|
57
60
|
},
|
|
58
61
|
};
|
|
59
62
|
try {
|
|
@@ -81,6 +84,8 @@ export class DialogRouter {
|
|
|
81
84
|
retryable: false,
|
|
82
85
|
cause,
|
|
83
86
|
}));
|
|
87
|
+
if (!answered)
|
|
88
|
+
await dialog.dismiss().catch(() => undefined);
|
|
84
89
|
}
|
|
85
90
|
}
|
|
86
91
|
}
|
package/dist/engine.js
CHANGED
|
@@ -104,8 +104,6 @@ export function web(options = {}) {
|
|
|
104
104
|
},
|
|
105
105
|
artifacts: {
|
|
106
106
|
screenshot: (label, operation) => surface.screenshot(label, operation),
|
|
107
|
-
startTrace: (operation) => surface.startTrace(operation),
|
|
108
|
-
stopTrace: (operation) => surface.stopTrace(operation),
|
|
109
107
|
startVideo: (operation) => surface.startVideo(operation),
|
|
110
108
|
stopVideo: (operation) => surface.stopVideo(operation),
|
|
111
109
|
},
|
package/dist/evaluation.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ type EvaluationResult = {
|
|
|
7
7
|
};
|
|
8
8
|
/** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
|
|
9
9
|
export declare const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
|
|
10
|
+
/** A function expression for page code, including ordinary and async method shorthand. Never runs it here. */
|
|
11
|
+
export declare function pageFunctionSource(fn: (...args: never[]) => unknown): string;
|
|
10
12
|
/**
|
|
11
13
|
* Compiles the page-side error boundary without evaluating the caller's source in this process.
|
|
12
14
|
* The source is one expression: a function it evaluates to is called with the argument, any
|
package/dist/evaluation.js
CHANGED
|
@@ -3,6 +3,37 @@ import { TestError } from 'e2e/engine';
|
|
|
3
3
|
import { message } from './support.js';
|
|
4
4
|
/** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
|
|
5
5
|
export const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
|
|
6
|
+
/** A function expression for page code, including ordinary and async method shorthand. Never runs it here. */
|
|
7
|
+
export function pageFunctionSource(fn) {
|
|
8
|
+
const source = fn.toString().trim();
|
|
9
|
+
const parse = (text) => new Function(`return (${text}\n);`);
|
|
10
|
+
try {
|
|
11
|
+
parse(source);
|
|
12
|
+
return source;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Method toString() omits the function keyword; an arrow or function expression already parsed above.
|
|
16
|
+
const ordinary = `function ${source}`;
|
|
17
|
+
let expression;
|
|
18
|
+
try {
|
|
19
|
+
parse(ordinary);
|
|
20
|
+
expression = ordinary;
|
|
21
|
+
}
|
|
22
|
+
catch (cause) {
|
|
23
|
+
if (!/^async\b/.test(source)) {
|
|
24
|
+
throw new TestError('EVALUATE_FAILED', 'page function cannot be serialized', { cause });
|
|
25
|
+
}
|
|
26
|
+
expression = `async function ${source.slice('async'.length)}`;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
parse(expression);
|
|
30
|
+
return expression;
|
|
31
|
+
}
|
|
32
|
+
catch (cause) {
|
|
33
|
+
throw new TestError('EVALUATE_FAILED', 'page function cannot be serialized', { cause });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
6
37
|
/**
|
|
7
38
|
* Compiles the page-side error boundary without evaluating the caller's source in this process.
|
|
8
39
|
* The source is one expression: a function it evaluates to is called with the argument, any
|