@e2e-dev/web 0.12.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 CHANGED
@@ -1 +1 @@
1
- {"version":"0.12.0"}
1
+ {"version":"0.14.0-nightly-20261008205824"}
package/dist/actions.d.ts CHANGED
@@ -2,21 +2,28 @@
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, timeout: number, lookup: (ref: NodeRef) => ActionTarget): Promise<void>;
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,
14
15
  * and the page decides what the gesture lands on, as it does for a person.
15
16
  */
16
- export declare function dispatchPointerAction(page: Page, at: ViewportPoint, action: PointerAction): Promise<void>;
17
+ export declare function dispatchPointerAction(page: Page, at: ViewportPoint, action: PointerAction, signal?: AbortSignal): Promise<void>;
18
+ /** Translates the failure of an input operation with no element behind it: a pointer action at a point, or a keystroke. */
19
+ export declare function classifyInputError(cause: unknown, label: string): Error;
17
20
  /**
18
21
  * Classifies a failed action onto the error contract (see the table above
19
22
  * `POST_DISPATCH_PATTERN` in support.ts): stale, not actionable, possibly
20
- * committed, or an engine fault.
23
+ * committed, or an engine fault. A strict mode violation is a located match
24
+ * that turned ambiguous: stale like a detached one before any input, so the
25
+ * runner locates again and reports the ambiguity or acts on the one match
26
+ * left; possibly committed once input went out, as the second side of a drag
27
+ * resolves after the press.
21
28
  */
22
29
  export declare function classifyActionError(rawCause: unknown, action: LocatorAction): Error;
package/dist/actions.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Locator action dispatch for the Playwright engine. */
2
- import { EngineError } from 'e2e/engine';
3
- import { asActionable, isClassified, isNavigationRace, isPwTimeout, message, nearestPixel, performElementSwipe, performPointDrag, performPointerDrag, performViewportSwipe, POST_DISPATCH_PATTERN, } from './support.js';
2
+ import { Deadline, EngineError, pollCondition } from 'e2e/engine';
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;
6
6
  /** The keys a click holds, as Playwright's `modifiers` takes them; the contract spells them the same. */
@@ -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 read after the click: a control
15
- * that is gone by then (an app that swaps a picked radio for its selected
16
- * view, or navigates on change) took the click, so the action is done, where
17
- * Playwright reports it detached as if the click never happened. Whatever
18
- * took its place is not read: the next observation or assertion shows it,
19
- * as it does after a tap. The reads and the click share one element, so the state before and after the click
20
- * is one control's.
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, timeout) {
23
- const deadline = Date.now() + timeout;
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 - Date.now());
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
- let after;
38
- try {
39
- after = await element.isChecked();
40
- }
41
- catch (cause) {
42
- if (DETACHED_PATTERN.test(message(cause)) || isNavigationRace(cause))
43
- return;
44
- throw cause;
45
- }
46
- if (after !== checked) {
47
- throw new EngineError('NOT_ACTIONABLE', `${verb} clicked the control but its checked state did not change`, {
48
- retryable: false,
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, timeout, lookup) {
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', timeout);
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.
@@ -151,7 +168,7 @@ export async function dispatchLocatorAction(target, action, timeout, lookup) {
151
168
  * behind it: no actionability wait, because there is no element to wait on,
152
169
  * and the page decides what the gesture lands on, as it does for a person.
153
170
  */
154
- export async function dispatchPointerAction(page, at, action) {
171
+ export async function dispatchPointerAction(page, at, action, signal) {
155
172
  const { mouse } = page;
156
173
  const point = nearestPixel(at);
157
174
  switch (action.kind) {
@@ -171,9 +188,11 @@ export async function dispatchPointerAction(page, at, action) {
171
188
  await mouse.move(point.x, point.y);
172
189
  return;
173
190
  case 'dragTo':
191
+ await performPointDrag(mouse, point, action.target, undefined, signal);
192
+ return;
174
193
  case 'swipeTo':
175
194
  // A swipe along a path is a pointer drag on a document platform.
176
- await performPointDrag(mouse, point, action.target);
195
+ await performPointDrag(mouse, point, action.target, action.durationMs, signal);
177
196
  return;
178
197
  case 'swipe':
179
198
  // The pointer moves to the point first so the scrollable under it receives the wheel.
@@ -182,6 +201,23 @@ export async function dispatchPointerAction(page, at, action) {
182
201
  return;
183
202
  }
184
203
  }
204
+ /**
205
+ * An input operation the operation deadline cut off: Playwright never
206
+ * answered, so whether the input reached the page is unknown, and it must not
207
+ * be repeated blindly.
208
+ */
209
+ function inputCutOff(cause, label) {
210
+ if (!(cause instanceof EngineError) || cause.code !== 'OPERATION_TIMEOUT')
211
+ return undefined;
212
+ return new EngineError('ACTION_MAY_HAVE_COMMITTED', `${label} timed out before the page answered; its input may have been dispatched`, {
213
+ retryable: false,
214
+ cause,
215
+ });
216
+ }
217
+ /** Translates the failure of an input operation with no element behind it: a pointer action at a point, or a keystroke. */
218
+ export function classifyInputError(cause, label) {
219
+ return inputCutOff(cause, label) ?? translatePwError(cause, label);
220
+ }
185
221
  /**
186
222
  * Whether a failure of this action may carry the value it was given. A
187
223
  * sensitive fill's plaintext must never leave the engine: its message is
@@ -216,15 +252,25 @@ function actionabilitySummary(text) {
216
252
  /**
217
253
  * Classifies a failed action onto the error contract (see the table above
218
254
  * `POST_DISPATCH_PATTERN` in support.ts): stale, not actionable, possibly
219
- * committed, or an engine fault.
255
+ * committed, or an engine fault. A strict mode violation is a located match
256
+ * that turned ambiguous: stale like a detached one before any input, so the
257
+ * runner locates again and reports the ambiguity or acts on the one match
258
+ * left; possibly committed once input went out, as the second side of a drag
259
+ * resolves after the press.
220
260
  */
221
261
  export function classifyActionError(rawCause, action) {
262
+ const cut = inputCutOff(rawCause, action.kind);
263
+ if (cut !== undefined)
264
+ return cut;
222
265
  if (isClassified(rawCause))
223
266
  return rawCause;
224
267
  const text = redactSensitive(message(rawCause), action);
225
268
  const cause = isSensitive(action) ? undefined : rawCause;
226
269
  if (/strict mode violation/i.test(text)) {
227
- return new EngineError('ENGINE_FAILURE', text, { retryable: false, cause });
270
+ if (POST_DISPATCH_PATTERN.test(text)) {
271
+ return new EngineError('ACTION_MAY_HAVE_COMMITTED', `${action.kind} matched more than one element after its input was dispatched: ${text}`, { retryable: false, cause });
272
+ }
273
+ return new EngineError('NODE_STALE', text, { retryable: true, cause });
228
274
  }
229
275
  if (DETACHED_PATTERN.test(text)) {
230
276
  return new EngineError('NODE_STALE', text, { retryable: true, cause });
@@ -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;
@@ -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,6 +1,8 @@
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';
5
+ import { type OperationBound } from './operation-budget.ts';
4
6
  import { RefRegistry } from './refs.ts';
5
7
  import type { LeaseRecording } from './provider.ts';
6
8
  import type { WebScreencastOptions } from './surface.ts';
@@ -38,9 +40,6 @@ export declare class AttemptSession {
38
40
  private observed;
39
41
  /** Until the attempt's first page opens: a persistent browser's own first tab can serve as that page. */
40
42
  private firstPage;
41
- private tracing;
42
- private traceSegments;
43
- private traceParts;
44
43
  private requestedViewport;
45
44
  constructor(options: SessionOptions);
46
45
  /** Captures an immutable connection generation for publication after asynchronous reads. */
@@ -57,8 +56,8 @@ export declare class AttemptSession {
57
56
  private invalidate;
58
57
  /** Opens a context for this attempt, publishing only after its handlers are installed. */
59
58
  start(signal: AbortSignal): Promise<void>;
60
- /** Shares one reconnect, then dispatches once with the time that remains. */
61
- run<T>(operation: OperationContext, label: string, work: (operation: OperationContext) => Promise<T>): Promise<T>;
59
+ /** Shares one reconnect, then dispatches once with the time that remains, bounded by the operation's budget unless it runs test code. */
60
+ run<T>(operation: OperationContext, label: string, work: (operation: OperationContext) => Promise<T>, bound?: OperationBound): Promise<T>;
62
61
  /** Owns a candidate through configuration and recording setup, then publishes it once. */
63
62
  private transition;
64
63
  /** Creates the attempt's active page and records its exact target before exposing it. */
@@ -85,24 +84,11 @@ export declare class AttemptSession {
85
84
  close(budget: EngineCleanupContext): Promise<void>;
86
85
  /** Ordinary contexts are engine-owned; persistent browser processes belong to the host. */
87
86
  private release;
88
- /** Allocates trace paths inside this attempt's immutable artifact directory. */
89
- private tracePath;
90
- /** Saves a replaced context's trace when it can still flush it. */
91
- private closeTraceSegment;
92
- /** Begins tracing the currently committed context. */
93
- startTrace(): Promise<void>;
94
- /** Returns finalized segments, including any retained before context replacement failed. */
95
- private stopTrace;
96
87
  /**
97
88
  * Starts video once the page is open, so a provider's recording of the
98
- * browser starts on the attempt's own tab. A screencast is shared with the
99
- * trace, which sizes it for itself, so a trace already running (a host
100
- * that starts the video mid-attempt) is split around a screencast's start:
101
- * its segment so far is saved, and it resumes on the video's screencast.
89
+ * browser starts on the attempt's own tab.
102
90
  */
103
91
  startVideo(signal: AbortSignal): Promise<void>;
104
- /** Collects finalized trace segments even when connection replacement failed. */
105
- collectTrace(operation: OperationContext): Promise<string | readonly string[]>;
106
92
  /** Collects this attempt's video independently of a failed connection. */
107
93
  collectVideo(operation: OperationContext): Promise<readonly VideoSegment[]>;
108
94
  /** Terminal artifact collection needs its own budget, but no successful UI dispatch. */
@@ -1,14 +1,10 @@
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
- import { connectionAbort, withConnectionBudget } from './operation-budget.js';
3
+ import { connectionAbort, withConnectionBudget, withOperationDeadline } from './operation-budget.js';
7
4
  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;
@@ -83,8 +76,8 @@ export class AttemptSession {
83
76
  return binding;
84
77
  }));
85
78
  }
86
- /** Shares one reconnect, then dispatches once with the time that remains. */
87
- async run(operation, label, work) {
79
+ /** Shares one reconnect, then dispatches once with the time that remains, bounded by the operation's budget unless it runs test code. */
80
+ async run(operation, label, work, bound = 'deadline') {
88
81
  const signal = AbortSignal.any([operation.signal, this.lifetime.signal]);
89
82
  if (signal.aborted)
90
83
  throw connectionAbort(signal, label);
@@ -92,32 +85,32 @@ export class AttemptSession {
92
85
  throw this.state.error;
93
86
  const persistent = this.options.persistent;
94
87
  const reconnect = (this.state.kind === 'pending' || (this.state.kind === 'ready' && !this.state.binding.browser.isConnected()));
95
- if (persistent === undefined || !reconnect)
96
- return raceAbort(() => work({ ...operation, signal }), signal, label);
97
- return withConnectionBudget({ ...operation, signal }, label, async (remaining) => {
98
- if (this.state.kind === 'pending') {
99
- await raceAbort(this.state.work, remaining().signal, 'CDP recovery');
100
- }
101
- else {
88
+ const endsAt = Date.now() + operation.timeoutMs;
89
+ // Recovery is the engine's own work, so the deadline bounds it whatever the operation runs.
90
+ if (persistent !== undefined && reconnect) {
91
+ await withConnectionBudget({ signal, timeoutMs: operation.timeoutMs }, label, async (remaining) => {
92
+ if (this.state.kind === 'pending') {
93
+ await raceAbort(this.state.work, remaining().signal, 'CDP recovery');
94
+ return;
95
+ }
102
96
  const previous = this.current();
103
97
  this.observed = false;
104
98
  await this.transition(previous, remaining(), async () => {
105
99
  await this.video.pageClosing();
106
100
  return attachPersistent(persistent.reconnect, remaining(), previous.identity);
107
101
  }, true);
108
- }
109
- const current = remaining();
110
- return raceAbort(() => work({ ...operation, ...current }), current.signal, label);
111
- });
102
+ });
103
+ }
104
+ const left = endsAt - Date.now();
105
+ if (left <= 0)
106
+ throw new EngineError('OPERATION_TIMEOUT', `${label} timed out`, { retryable: false });
107
+ return withOperationDeadline({ signal, timeoutMs: left }, label, (remaining) => work({ ...operation, ...remaining() }), bound);
112
108
  }
113
109
  /** Owns a candidate through configuration and recording setup, then publishes it once. */
114
110
  async transition(previous, budget, open, recover = false) {
115
111
  if (budget.signal.aborted)
116
112
  throw connectionAbort(budget.signal, 'connection');
117
113
  this.invalidate();
118
- const resumeTrace = recover && this.tracing;
119
- if (recover)
120
- this.tracing = false;
121
114
  const running = Promise.resolve().then(async () => {
122
115
  if (budget.signal.aborted)
123
116
  throw connectionAbort(budget.signal, 'connection');
@@ -134,13 +127,10 @@ export class AttemptSession {
134
127
  await candidate.page.setViewportSize(size);
135
128
  await this.video.pageOpened(candidate.page);
136
129
  }
137
- if (resumeTrace)
138
- await candidate.context.tracing.start(TRACE_OPTIONS);
139
130
  if (budget.signal.aborted)
140
131
  throw connectionAbort(budget.signal, 'connection');
141
132
  if (this.state !== pending)
142
133
  throw connectionAbort(this.lifetime.signal, 'connection');
143
- this.tracing = resumeTrace;
144
134
  this.state = { kind: 'ready', binding: candidate };
145
135
  }
146
136
  catch (cause) {
@@ -224,19 +214,15 @@ export class AttemptSession {
224
214
  this.invalidate();
225
215
  const token = this.token();
226
216
  await this.video.pageClosing();
227
- const resume = this.video.isArmed ? await this.closeTraceSegment(binding.context) : false;
228
217
  for (const page of binding.context.pages())
229
218
  await page.close();
230
219
  this.check(token);
231
220
  this.state = { kind: 'ready', binding: { ...binding, page: null } };
232
221
  await this.ensurePage();
233
- if (resume)
234
- await this.startTrace();
235
222
  }
236
223
  /** Replaces an ordinary context; persistent recovery never advertises this capability. */
237
224
  async replace(storageState, operation) {
238
225
  const previous = this.current();
239
- const resume = await this.closeTraceSegment(previous.context);
240
226
  await this.video.pageClosing();
241
227
  await this.transition(previous, operation, async () => {
242
228
  await previous.context.close();
@@ -245,8 +231,6 @@ export class AttemptSession {
245
231
  });
246
232
  if (this.video.isArmed)
247
233
  await this.ensurePage();
248
- if (resume)
249
- await this.startTrace();
250
234
  }
251
235
  /** Ends this attempt immediately; late work can only access this retired owner. */
252
236
  async close(budget) {
@@ -260,74 +244,18 @@ export class AttemptSession {
260
244
  if (binding === undefined)
261
245
  return;
262
246
  await withinCleanupBudget(this.video.abandon(budget.signal), budget);
263
- if (this.tracing)
264
- await withinCleanupBudget(binding.context.tracing.stop(), budget);
265
247
  await withinCleanupBudget(this.release(binding), budget);
266
248
  }
267
249
  /** Ordinary contexts are engine-owned; persistent browser processes belong to the host. */
268
250
  release(binding) {
269
251
  return this.options.persistent === undefined ? binding.context.close() : binding.browser.close();
270
252
  }
271
- /** Allocates trace paths inside this attempt's immutable artifact directory. */
272
- tracePath(name) {
273
- const relative = path.posix.join('trace', `${name}.zip`);
274
- const absolute = path.join(this.options.artifactsDir, relative);
275
- mkdirSync(path.dirname(absolute), { recursive: true });
276
- return { relative, absolute };
277
- }
278
- /** Saves a replaced context's trace when it can still flush it. */
279
- async closeTraceSegment(context) {
280
- if (!this.tracing)
281
- return false;
282
- this.tracing = false;
283
- this.traceSegments += 1;
284
- const { relative, absolute } = this.tracePath(`trace-part${this.traceSegments}`);
285
- try {
286
- await context.tracing.stop({ path: absolute });
287
- this.traceParts.push(relative);
288
- }
289
- catch { /* A disconnected context may have lost its final trace segment. */ }
290
- return true;
291
- }
292
- /** Begins tracing the currently committed context. */
293
- async startTrace() {
294
- const token = this.token();
295
- await this.current().context.tracing.start(TRACE_OPTIONS);
296
- this.check(token);
297
- this.tracing = true;
298
- }
299
- /** Returns finalized segments, including any retained before context replacement failed. */
300
- async stopTrace() {
301
- const parts = this.traceParts;
302
- this.traceParts = [];
303
- if (parts.length > 0 && !this.tracing)
304
- return parts;
305
- const { relative, absolute } = this.tracePath('trace');
306
- await this.current().context.tracing.stop({ path: absolute });
307
- this.tracing = false;
308
- return parts.length === 0 ? relative : [...parts, relative];
309
- }
310
253
  /**
311
254
  * Starts video once the page is open, so a provider's recording of the
312
- * browser starts on the attempt's own tab. A screencast is shared with the
313
- * trace, which sizes it for itself, so a trace already running (a host
314
- * that starts the video mid-attempt) is split around a screencast's start:
315
- * its segment so far is saved, and it resumes on the video's screencast.
255
+ * browser starts on the attempt's own tab.
316
256
  */
317
257
  async startVideo(signal) {
318
- const page = await this.ensurePage();
319
- const resume = this.video.startsScreencast ? await this.closeTraceSegment(this.current().context) : false;
320
- try {
321
- await this.video.arm(page, signal);
322
- }
323
- finally {
324
- if (resume)
325
- await this.startTrace();
326
- }
327
- }
328
- /** Collects finalized trace segments even when connection replacement failed. */
329
- collectTrace(operation) {
330
- return this.finalize(operation, 'trace', () => this.stopTrace());
258
+ await this.video.arm(await this.ensurePage(), signal);
331
259
  }
332
260
  /** Collects this attempt's video independently of a failed connection. */
333
261
  collectVideo(operation) {
package/dist/browser.d.ts CHANGED
@@ -169,7 +169,7 @@ export interface Browser extends Expectable<BrowserExpectation> {
169
169
  locator(selector: string): Locator;
170
170
  /** Creates a screen query scope inside one iframe. */
171
171
  frameLocator(selector: string): FrameScreen;
172
- /** Evaluates trusted test code in the page. */
172
+ /** Evaluates trusted test code in the page: a function, or a string expression whose value is returned, called first if it is a function. */
173
173
  evaluate<T extends JsonValue>(fn: string | (() => T | Promise<T>)): Promise<T>;
174
174
  /** Evaluates trusted test code with one required JSON-safe argument. */
175
175
  evaluate<T extends JsonValue, Arg extends JsonValue>(fn: string | ((arg: Arg) => T | Promise<T>), arg: Arg): Promise<T>;