@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 CHANGED
@@ -1 +1 @@
1
- {"version":"0.13.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, 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,
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 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.
@@ -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,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. A screencast is shared with the
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. */
@@ -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. A screencast is shared with the
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
- const page = await this.ensurePage();
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 (operation) => {
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 (operation) => {
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 (operation) => {
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 (operation) => {
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.toString();
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
  },
@@ -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
@@ -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