@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/install.js CHANGED
@@ -1,29 +1,45 @@
1
1
  /** First-run browser provisioning for the Playwright engine. */
2
2
  import { spawn } from 'node:child_process';
3
- import { existsSync } from 'node:fs';
3
+ import { existsSync, readdirSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { EngineError, InfrastructureError } from 'e2e/engine';
8
8
  import { browserType } from './browser-connection.js';
9
+ /** Whether `chromium_headless_shell-<revision>` under `browsersPath` finished installing and holds its executable. */
10
+ function headlessShellInstalled(browsersPath, revision) {
11
+ const shellRoot = path.join(browsersPath, `chromium_headless_shell-${revision}`);
12
+ if (!existsSync(path.join(shellRoot, 'INSTALLATION_COMPLETE')))
13
+ return false;
14
+ const executable = process.platform === 'win32' ? 'chrome-headless-shell.exe' : 'chrome-headless-shell';
15
+ return readdirSync(shellRoot, { withFileTypes: true }).some((entry) => entry.isDirectory() && existsSync(path.join(shellRoot, entry.name, executable)));
16
+ }
9
17
  /**
10
- * Whether the browser's executable exists on disk, or undefined when this
11
- * process cannot tell. Playwright resolves its browser cache from
12
- * `PLAYWRIGHT_BROWSERS_PATH` at module load, so the in-process check is only
13
- * authoritative when the run's environment agrees with this process's. A run
14
- * pointed at another cache is left to the CLI, which resolves against the
15
- * environment it is spawned with and is a no-op when nothing is missing.
18
+ * Whether the build the run launches (headless shell for headless chromium, else the full build)
19
+ * is on disk, or undefined when `env` points at another cache than this process loaded.
16
20
  */
17
- function isBrowserInstalled(name, env) {
21
+ function isBrowserInstalled(name, env, headed) {
18
22
  if (env['PLAYWRIGHT_BROWSERS_PATH'] !== process.env['PLAYWRIGHT_BROWSERS_PATH'])
19
23
  return undefined;
20
24
  try {
21
- return existsSync(browserType(name).executablePath());
25
+ const executable = browserType(name).executablePath();
26
+ if (headed || name !== 'chromium')
27
+ return existsSync(executable);
28
+ return headlessShellAt(executable);
22
29
  }
23
30
  catch {
24
31
  return false;
25
32
  }
26
33
  }
34
+ /** Whether the shell beside `executable`'s full build is installed. */
35
+ function headlessShellAt(executable) {
36
+ const marker = `${path.sep}chromium-`;
37
+ const at = executable.lastIndexOf(marker);
38
+ if (at === -1)
39
+ return false;
40
+ const revision = executable.slice(at + marker.length).split(path.sep)[0] ?? '';
41
+ return revision !== '' && headlessShellInstalled(executable.slice(0, at), revision);
42
+ }
27
43
  /** The `node` arguments that run the pinned `playwright-core` CLI with `args`. */
28
44
  function playwrightCliArgs(args) {
29
45
  const require = createRequire(import.meta.url);
@@ -37,7 +53,10 @@ function playwrightCliArgs(args) {
37
53
  */
38
54
  export function runPlaywrightCli(args) {
39
55
  return new Promise((resolve, reject) => {
40
- const child = spawn(process.execPath, playwrightCliArgs(args), { stdio: 'inherit' });
56
+ const child = spawn(process.execPath, playwrightCliArgs(args), {
57
+ stdio: 'inherit',
58
+ env: installEnvironment(),
59
+ });
41
60
  const forward = (signal) => child.kill(signal);
42
61
  process.on('SIGINT', forward);
43
62
  process.on('SIGTERM', forward);
@@ -55,6 +74,16 @@ export function runPlaywrightCli(args) {
55
74
  });
56
75
  });
57
76
  }
77
+ /** The `playwright install` arguments for `names`: `--only-shell` on a headless chromium run. */
78
+ export function installArgs(names, headed) {
79
+ if (headed || !names.includes('chromium'))
80
+ return [...names];
81
+ return ['--only-shell', ...names];
82
+ }
83
+ /** `env` with `PLAYWRIGHT_SKIP_BROWSER_GC=1` unless already set, so an install keeps other tools' browsers. */
84
+ function installEnvironment(env = process.env) {
85
+ return { ...env, PLAYWRIGHT_SKIP_BROWSER_GC: env['PLAYWRIGHT_SKIP_BROWSER_GC'] ?? '1' };
86
+ }
58
87
  /**
59
88
  * Ensures the given Playwright browsers are installed, downloading any
60
89
  * missing ones via `playwright-core install`. Called from the engine's `prepare`,
@@ -65,7 +94,8 @@ export function runPlaywrightCli(args) {
65
94
  */
66
95
  export async function ensureBrowsersInstalled(names, options = {}) {
67
96
  const env = options.env ?? process.env;
68
- const isInstalled = options.isInstalled ?? ((name) => isBrowserInstalled(name, env));
97
+ const headed = options.headed ?? false;
98
+ const isInstalled = options.isInstalled ?? ((name) => isBrowserInstalled(name, env, headed));
69
99
  const verdicts = new Map([...new Set(names)].map((name) => [name, isInstalled(name)]));
70
100
  const missing = [...verdicts.keys()].filter((name) => verdicts.get(name) !== true);
71
101
  if (missing.length === 0)
@@ -78,7 +108,7 @@ export async function ensureBrowsersInstalled(names, options = {}) {
78
108
  if (knownMissing)
79
109
  log(`Downloading missing Playwright browsers (first run): ${missing.join(', ')}...`);
80
110
  const install = options.install ?? runPlaywrightInstall;
81
- await install(missing, { log, signal: options.signal, env });
111
+ await install(missing, { log, signal: options.signal, env: installEnvironment(env), headed });
82
112
  if (knownMissing)
83
113
  log('Browser download complete.');
84
114
  }
@@ -109,13 +139,13 @@ function forwardLines(stream, log) {
109
139
  stream.on('end', () => flush(pending));
110
140
  }
111
141
  /** Spawns `node <playwright-core>/cli.js install <names>`, narrating its output through `log`. */
112
- function runPlaywrightInstall(names, { log, signal, env }) {
142
+ function runPlaywrightInstall(names, { log, signal, env, headed }) {
113
143
  return new Promise((resolve, reject) => {
114
144
  if (signal?.aborted === true) {
115
145
  reject(new EngineError('CANCELLED', 'browser install cancelled', { retryable: false }));
116
146
  return;
117
147
  }
118
- const child = spawn(process.execPath, playwrightCliArgs(['install', ...names]), {
148
+ const child = spawn(process.execPath, playwrightCliArgs(['install', ...installArgs(names, headed)]), {
119
149
  stdio: ['ignore', 'pipe', 'pipe'],
120
150
  env,
121
151
  });
@@ -2,10 +2,9 @@
2
2
  import type { Page } from 'playwright-core';
3
3
  import { type EngineObserveOptions, type OperationContext } from 'e2e/engine';
4
4
  import { RefRegistry, type CapturedObservation } from './refs.ts';
5
- interface CaptureSettings {
5
+ export interface CaptureSettings {
6
6
  readonly testIdAttribute: string;
7
7
  readonly site: string | undefined;
8
8
  }
9
9
  /** Captures under one deadline, retaining no refs from a failed or abandoned document. */
10
10
  export declare function captureObservation(page: Page, refs: RefRegistry, settings: CaptureSettings, operation: OperationContext, options: EngineObserveOptions | undefined): Promise<CapturedObservation>;
11
- export {};
@@ -59,4 +59,20 @@ export interface CapturedDocument {
59
59
  * observation. The node budget is shared across all documents.
60
60
  */
61
61
  export declare function captureDocument(deps: CaptureDeps, host: DocumentHost, options: CaptureOptions): Promise<CapturedDocument>;
62
+ type Rect = NonNullable<SemanticNode['rect']>;
63
+ /**
64
+ * Where a frame element's content box, the box its document's viewport
65
+ * fills, sits inside its border box: past the border and the padding. Runs in
66
+ * the page.
67
+ */
68
+ export declare function frameContentInset(element: Element): Rect;
69
+ /** A frame element's content box, from its border box and `frameContentInset`. */
70
+ export declare function contentBox(border: Rect, inset: Rect): Rect;
71
+ /**
72
+ * A box a child document measured against its own viewport, in the top-level
73
+ * viewport's space: shifted by the frame's box and clipped to it; undefined
74
+ * when nothing of it is inside the frame.
75
+ */
76
+ export declare function placeRectInFrame(rect: Rect, frame: Rect): Rect | undefined;
62
77
  export declare function toSemanticNode(ref: NodeRef, raw: RawNodeData, children?: readonly SemanticNode[], framePath?: readonly string[]): SemanticNode;
78
+ export {};
@@ -129,6 +129,11 @@ async function captureInto(deps, scope, stage, host, framePath, budget, signal)
129
129
  truncated = true;
130
130
  continue;
131
131
  }
132
+ const inset = await scope.read(() => elements[index].evaluate(frameContentInset).catch(() => undefined));
133
+ if (inset === undefined) {
134
+ truncated = true;
135
+ continue;
136
+ }
132
137
  const child = await scope.read(() => captureDocument({ ...deps, commit: stage }, frame, {
133
138
  framePath: [...framePath, selector],
134
139
  budget: remaining,
@@ -139,7 +144,7 @@ async function captureInto(deps, scope, stage, host, framePath, budget, signal)
139
144
  truncated = true;
140
145
  continue;
141
146
  }
142
- frameChildren.set(index, child.tree);
147
+ frameChildren.set(index, { tree: child.tree, inset });
143
148
  nodeCount += child.nodeCount;
144
149
  truncated ||= child.truncated;
145
150
  }
@@ -205,16 +210,16 @@ function assembleTree(nodes, ids, framePath, frameChildren) {
205
210
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
206
211
  const raw = nodes[index];
207
212
  const embedded = frameChildren.get(index);
208
- // A child document measured its nodes against its own viewport. Shifted by
209
- // the boundary element's box and clipped to it, every box in the tree is
210
- // in the top-level viewport's CSS pixels, the space the screenshot and a
211
- // point tap share, and a child that overflows its frame cannot claim a
212
- // point over the page around it. The shift is the frame's border box; a
213
- // bordered iframe is off by its border width, which is within a tap
214
- // target. A frame under a CSS transform is not unwound: its boxes are
215
- // where the untransformed frame would put them.
216
- if (embedded !== undefined && raw.rect !== null)
217
- childLists[index].unshift(placeInFrame(embedded, raw.rect));
213
+ // A child document measured its nodes against its own viewport, which
214
+ // starts at the frame element's content box. Shifted by that box and
215
+ // clipped to it, every box in the tree is in the top-level viewport's CSS
216
+ // pixels, the space the screenshot and a point tap share, and a child that
217
+ // overflows its frame cannot claim a point over the page around it. A
218
+ // frame under a CSS transform is not unwound: its boxes are where the
219
+ // untransformed frame would put them.
220
+ if (embedded !== undefined && raw.rect !== null) {
221
+ childLists[index].unshift(placeInFrame(embedded.tree, contentBox(raw.rect, embedded.inset)));
222
+ }
218
223
  const node = toSemanticNode({ id: ids[index], revision: '' }, raw, childLists[index], framePath);
219
224
  built[index] = node;
220
225
  if (raw.parent >= 0)
@@ -222,16 +227,46 @@ function assembleTree(nodes, ids, framePath, frameChildren) {
222
227
  }
223
228
  return built[0];
224
229
  }
230
+ /**
231
+ * Where a frame element's content box, the box its document's viewport
232
+ * fills, sits inside its border box: past the border and the padding. Runs in
233
+ * the page.
234
+ */
235
+ export function frameContentInset(element) {
236
+ const style = getComputedStyle(element);
237
+ const left = Number.parseFloat(style.paddingLeft) || 0;
238
+ const top = Number.parseFloat(style.paddingTop) || 0;
239
+ const right = Number.parseFloat(style.paddingRight) || 0;
240
+ const bottom = Number.parseFloat(style.paddingBottom) || 0;
241
+ return {
242
+ x: element.clientLeft + left,
243
+ y: element.clientTop + top,
244
+ width: Math.max(0, element.clientWidth - left - right),
245
+ height: Math.max(0, element.clientHeight - top - bottom),
246
+ };
247
+ }
248
+ /** A frame element's content box, from its border box and `frameContentInset`. */
249
+ export function contentBox(border, inset) {
250
+ return { x: border.x + inset.x, y: border.y + inset.y, width: inset.width, height: inset.height };
251
+ }
225
252
  /** The tree with every box translated into the frame's space and clipped to its box; a box left empty by the clip is dropped. */
226
253
  function placeInFrame(node, frame) {
227
254
  const { rect, ...rest } = node;
228
- const placed = rect === undefined ? undefined : clipRect(offsetRect(rect, frame), frame);
255
+ const placed = rect === undefined ? undefined : placeRectInFrame(rect, frame);
229
256
  return {
230
257
  ...rest,
231
258
  ...(placed === undefined ? {} : { rect: placed }),
232
259
  ...(node.children === undefined ? {} : { children: node.children.map((child) => placeInFrame(child, frame)) }),
233
260
  };
234
261
  }
262
+ /**
263
+ * A box a child document measured against its own viewport, in the top-level
264
+ * viewport's space: shifted by the frame's box and clipped to it; undefined
265
+ * when nothing of it is inside the frame.
266
+ */
267
+ export function placeRectInFrame(rect, frame) {
268
+ return clipRect(offsetRect(rect, frame), frame);
269
+ }
235
270
  function offsetRect(rect, by) {
236
271
  return { x: rect.x + by.x, y: rect.y + by.y, width: rect.width, height: rect.height };
237
272
  }
@@ -6,5 +6,19 @@ export interface ConnectionBudget {
6
6
  }
7
7
  /** Preserves the reason for an engine-owned deadline without relabeling caller cancellation. */
8
8
  export declare function connectionAbort(signal: AbortSignal, label: string): EngineError;
9
+ /**
10
+ * Whether the operation's deadline may end it. `test-code` is for an
11
+ * operation that runs the test's own code (a download trigger): that code
12
+ * keeps its own steps and their budgets, so the operation is not cut under
13
+ * it, and only cancellation ends the wait.
14
+ */
15
+ export type OperationBound = 'deadline' | 'test-code';
16
+ /**
17
+ * Bounds one operation by its budget: a call still pending at the deadline
18
+ * is abandoned as `OPERATION_TIMEOUT` instead of holding the test until its
19
+ * own timeout. `remaining` hands `work` the time left before Playwright's
20
+ * own timeout, the one to pass Playwright.
21
+ */
22
+ export declare function withOperationDeadline<T>(budget: ConnectionBudget, label: string, work: (remaining: () => ConnectionBudget) => Promise<T>, bound?: OperationBound): Promise<T>;
9
23
  /** Bounds the entire transition, aborts abandoned work, and never grants a fresh dispatch budget. */
10
24
  export declare function withConnectionBudget<T>(budget: ConnectionBudget, label: string, work: (remaining: () => ConnectionBudget) => Promise<T>): Promise<T>;
@@ -6,6 +6,37 @@ export function connectionAbort(signal, label) {
6
6
  ? signal.reason
7
7
  : new EngineError('CANCELLED', `${label} cancelled`, { retryable: false });
8
8
  }
9
+ /**
10
+ * How much sooner than an operation's deadline Playwright's own timeout
11
+ * fires: its error names what blocked an action, or that the input was
12
+ * already dispatched, and this lead lets that answer arrive before the
13
+ * deadline does. The deadline is for the calls Playwright never answers: an
14
+ * evaluate, which takes no timeout, and a point tap on a page whose renderer
15
+ * is stuck in a script. A budget shorter than twice the
16
+ * lead splits in half.
17
+ */
18
+ const PLAYWRIGHT_TIMEOUT_LEAD_MS = 250;
19
+ /**
20
+ * Bounds one operation by its budget: a call still pending at the deadline
21
+ * is abandoned as `OPERATION_TIMEOUT` instead of holding the test until its
22
+ * own timeout. `remaining` hands `work` the time left before Playwright's
23
+ * own timeout, the one to pass Playwright.
24
+ */
25
+ export function withOperationDeadline(budget, label, work, bound = 'deadline') {
26
+ if (bound === 'test-code') {
27
+ const endsAt = Date.now() + budget.timeoutMs;
28
+ return raceAbort(() => work(() => {
29
+ if (budget.signal.aborted)
30
+ throw connectionAbort(budget.signal, label);
31
+ return { signal: budget.signal, timeoutMs: Math.max(1, endsAt - Date.now()) };
32
+ }), budget.signal, label);
33
+ }
34
+ return withConnectionBudget(budget, label, (remaining) => work(() => {
35
+ const current = remaining();
36
+ const lead = Math.min(PLAYWRIGHT_TIMEOUT_LEAD_MS, current.timeoutMs / 2);
37
+ return { signal: current.signal, timeoutMs: Math.ceil(current.timeoutMs - lead) };
38
+ }));
39
+ }
9
40
  /** Bounds the entire transition, aborts abandoned work, and never grants a fresh dispatch budget. */
10
41
  export async function withConnectionBudget(budget, label, work) {
11
42
  const controller = new AbortController();
@@ -13,8 +13,6 @@ export declare class ProviderVideo implements AttemptVideo {
13
13
  private readonly artifactsDir;
14
14
  /** The recording covers the browser, so no page starts a segment of its own. */
15
15
  readonly isArmed = false;
16
- /** The provider records the browser from outside; the page's screencast stays the trace's. */
17
- readonly startsScreencast = false;
18
16
  private started;
19
17
  /** The stop in flight, shared: a close that retries while a timed-out stop is still running waits for it instead of stopping twice. */
20
18
  private stopping;
@@ -13,8 +13,6 @@ export class ProviderVideo {
13
13
  artifactsDir;
14
14
  /** The recording covers the browser, so no page starts a segment of its own. */
15
15
  isArmed = false;
16
- /** The provider records the browser from outside; the page's screencast stays the trace's. */
17
- startsScreencast = false;
18
16
  started;
19
17
  /** The stop in flight, shared: a close that retries while a timed-out stop is still running waits for it instead of stopping twice. */
20
18
  stopping;
package/dist/support.d.ts CHANGED
@@ -102,19 +102,23 @@ export declare function isTestErrorCode(cause: unknown, code: string): cause is
102
102
  * | `TimeoutError` on a read, navigation, or artifact call | OPERATION_TIMEOUT |
103
103
  * | `TimeoutError` on an action, log ends before the input dispatch | NOT_ACTIONABLE |
104
104
  * | `TimeoutError` on an action, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
105
+ * | operation deadline cut off an action, pointer, or keyboard call | ACTION_MAY_HAVE_COMMITTED |
105
106
  * | element detached / not attached / no element / resolved hidden | NODE_STALE (retryable) |
106
107
  * | execution context destroyed / frame detached by a navigation | NODE_STALE (retryable) |
108
+ * | strict mode violation, log ends before the input dispatch | NODE_STALE (retryable) |
109
+ * | strict mode violation, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
107
110
  * | not an input / not editable / not checkable | NOT_ACTIONABLE |
108
- * | strict mode violation, anything else | ENGINE_FAILURE |
111
+ * | anything else | ENGINE_FAILURE |
109
112
  *
110
113
  * The action split is read from the call log Playwright appends to a timeout
111
- * message: `performing <x> action`, `<x> action done`, and `waiting for
112
- * scheduled navigations to finish` are only logged once the input is being (or
113
- * has been) dispatched, so a timeout whose log reaches them is uncertain and
114
- * the harness must not blindly repeat it. Every earlier line (`waiting for
115
- * element to be visible, enabled and stable`, `scrolling into view if
116
- * needed`, `retrying <x> action`) precedes dispatch and is a plain
117
- * actionability miss.
114
+ * or strict mode message: `performing <x> action`, `<x> action done`, and
115
+ * `waiting for scheduled navigations to finish` are only logged once the input
116
+ * is being (or has been) dispatched, so a failure whose log reaches them is
117
+ * uncertain and the harness must not blindly repeat it. Every earlier line
118
+ * (`waiting for element to be visible, enabled and stable`, `scrolling into
119
+ * view if needed`, `retrying <x> action`) precedes dispatch and is a plain
120
+ * actionability miss. Only whole `- ` log lines count, so a locator or element
121
+ * text quoting those words never does.
118
122
  */
119
123
  export declare const POST_DISPATCH_PATTERN: RegExp;
120
124
  /** Translates an unexpected Playwright error at the contract boundary. */
@@ -142,7 +146,7 @@ export declare function performViewportSwipe(page: Page, direction: ScrollDirect
142
146
  * CSS pixel instead.
143
147
  */
144
148
  export declare function nearestPixel(point: ViewportPoint): ViewportPoint;
145
- /** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion. */
146
- export declare function performPointDrag(mouse: Mouse, start: ViewportPoint, end: ViewportPoint): Promise<void>;
149
+ /** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion; paced over `durationMs` when given. */
150
+ export declare function performPointDrag(mouse: Mouse, start: ViewportPoint, end: ViewportPoint, durationMs?: number, signal?: AbortSignal): Promise<void>;
147
151
  export declare function performElementSwipe(target: ActionTarget, direction: ScrollDirection, momentum: Momentum, timeout: number): Promise<void>;
148
152
  export {};
package/dist/support.js CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Shared error translation, filename, and swipe helpers for the Playwright engine. */
2
+ import { setTimeout } from 'node:timers/promises';
2
3
  import { EngineError, withinCleanupBudget } from 'e2e/engine';
3
4
  import { ConfigurationError, InfrastructureError, TestError } from 'e2e/engine';
4
5
  export const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
@@ -193,11 +194,12 @@ export class ErrorLatch {
193
194
  }
194
195
  }
195
196
  /**
196
- * The runner error classes as their `name` reads. A project loads this engine
197
- * from its config file in one module realm (tsx) while the runner's core runs
198
- * in another, so a runner error reaching the engine is often a `TestError`
199
- * from a copy of `e2e` this module never imported: `instanceof` says no while
200
- * `name` and `code` still tell the truth.
197
+ * The runner error classes as their `name` reads. A project can load this
198
+ * engine against a copy of `e2e` other than the one the runner's core runs
199
+ * from (and e2e before 0.17 always loaded config in a module realm of its
200
+ * own), so a runner error reaching the engine can be a `TestError` this
201
+ * module never imported: `instanceof` says no while `name` and `code` still
202
+ * tell the truth.
201
203
  */
202
204
  const CLASSIFIED_NAMES = new Set([
203
205
  'EngineError',
@@ -236,21 +238,25 @@ export function isTestErrorCode(cause, code) {
236
238
  * | `TimeoutError` on a read, navigation, or artifact call | OPERATION_TIMEOUT |
237
239
  * | `TimeoutError` on an action, log ends before the input dispatch | NOT_ACTIONABLE |
238
240
  * | `TimeoutError` on an action, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
241
+ * | operation deadline cut off an action, pointer, or keyboard call | ACTION_MAY_HAVE_COMMITTED |
239
242
  * | element detached / not attached / no element / resolved hidden | NODE_STALE (retryable) |
240
243
  * | execution context destroyed / frame detached by a navigation | NODE_STALE (retryable) |
244
+ * | strict mode violation, log ends before the input dispatch | NODE_STALE (retryable) |
245
+ * | strict mode violation, log shows the dispatch started | ACTION_MAY_HAVE_COMMITTED |
241
246
  * | not an input / not editable / not checkable | NOT_ACTIONABLE |
242
- * | strict mode violation, anything else | ENGINE_FAILURE |
247
+ * | anything else | ENGINE_FAILURE |
243
248
  *
244
249
  * The action split is read from the call log Playwright appends to a timeout
245
- * message: `performing <x> action`, `<x> action done`, and `waiting for
246
- * scheduled navigations to finish` are only logged once the input is being (or
247
- * has been) dispatched, so a timeout whose log reaches them is uncertain and
248
- * the harness must not blindly repeat it. Every earlier line (`waiting for
249
- * element to be visible, enabled and stable`, `scrolling into view if
250
- * needed`, `retrying <x> action`) precedes dispatch and is a plain
251
- * actionability miss.
250
+ * or strict mode message: `performing <x> action`, `<x> action done`, and
251
+ * `waiting for scheduled navigations to finish` are only logged once the input
252
+ * is being (or has been) dispatched, so a failure whose log reaches them is
253
+ * uncertain and the harness must not blindly repeat it. Every earlier line
254
+ * (`waiting for element to be visible, enabled and stable`, `scrolling into
255
+ * view if needed`, `retrying <x> action`) precedes dispatch and is a plain
256
+ * actionability miss. Only whole `- ` log lines count, so a locator or element
257
+ * text quoting those words never does.
252
258
  */
253
- export const POST_DISPATCH_PATTERN = /performing \w+ action|\w+ action done|waiting for scheduled navigations to finish/i;
259
+ export const POST_DISPATCH_PATTERN = /^\s*- (performing \w+ action|[\w ]+ action done|waiting for scheduled navigations to finish)\s*$/im;
254
260
  /** Translates an unexpected Playwright error at the contract boundary. */
255
261
  export function translatePwError(cause, operation) {
256
262
  if (isClassified(cause))
@@ -321,16 +327,31 @@ export async function performViewportSwipe(page, direction, momentum) {
321
327
  export function nearestPixel(point) {
322
328
  return { x: Math.round(point.x), y: Math.round(point.y) };
323
329
  }
324
- /** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion. */
325
- export async function performPointDrag(mouse, start, end) {
330
+ /** A pointer drag from one viewport point to another, on whole pixels, with an intermediate move so drag handlers see motion; paced over `durationMs` when given. */
331
+ export async function performPointDrag(mouse, start, end, durationMs, signal) {
326
332
  const from = nearestPixel(start);
327
333
  const to = nearestPixel(end);
328
334
  const middle = nearestPixel({ x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 });
329
335
  await mouse.move(from.x, from.y);
330
336
  await mouse.down();
331
- await mouse.move(middle.x, middle.y);
332
- await mouse.move(to.x, to.y);
333
- await mouse.up();
337
+ try {
338
+ if (durationMs !== undefined) {
339
+ const steps = Math.max(2, Math.round(durationMs / 16));
340
+ const startedAt = performance.now();
341
+ for (let step = 1; step <= steps; step += 1) {
342
+ const due = startedAt + (durationMs * step) / steps;
343
+ await setTimeout(Math.max(0, due - performance.now()), undefined, { signal });
344
+ await mouse.move(from.x + ((to.x - from.x) * step) / steps, from.y + ((to.y - from.y) * step) / steps);
345
+ }
346
+ }
347
+ else {
348
+ await mouse.move(middle.x, middle.y);
349
+ await mouse.move(to.x, to.y);
350
+ }
351
+ }
352
+ finally {
353
+ await mouse.up();
354
+ }
334
355
  }
335
356
  export async function performElementSwipe(target, direction, momentum, timeout) {
336
357
  // Hover first: it auto-waits for visibility on both target kinds, so the
package/dist/surface.d.ts CHANGED
@@ -11,6 +11,7 @@ import { type EngineAttemptContext, type EngineCleanupContext, type EngineInitIn
11
11
  import { type BrowserName } from './browser-connection.ts';
12
12
  import { type BrowserProvider, type LeaseDownloads } from './provider.ts';
13
13
  import { DialogRouter } from './dialogs.ts';
14
+ import { type OperationBound } from './operation-budget.ts';
14
15
  import { type WebInitScript } from './init-scripts.ts';
15
16
  import { ErrorLatch } from './support.ts';
16
17
  type RoutePredicate = (url: URL) => boolean;
@@ -106,8 +107,7 @@ export interface WebOptions {
106
107
  * page, deterministic and agent-driven alike. Injecting headers routes
107
108
  * every request of the attempt, which turns the browser's HTTP cache off
108
109
  * and blocks service workers (a worker's requests bypass routing, so a
109
- * page it controlled would reach the gate bare), and a Playwright trace
110
- * records request headers.
110
+ * page it controlled would reach the gate bare).
111
111
  */
112
112
  readonly headers?: Readonly<Record<string, string>>;
113
113
  /**
@@ -186,6 +186,10 @@ export declare class PlaywrightSurface {
186
186
  private projectRoot;
187
187
  private headed;
188
188
  private artifactsDir;
189
+ /** Navigations the engine has in flight, which the app log leaves to the step that started them. */
190
+ private ownNavigations;
191
+ /** What the attempt tells its trace beside the app log; absent between attempts. */
192
+ private trace;
189
193
  private artifactCounter;
190
194
  /**
191
195
  * Attempt-scoped network routes. Registered on the
@@ -260,8 +264,8 @@ export declare class PlaywrightSurface {
260
264
  * Releases attempt resources, the worker's shared browser process, and a
261
265
  * lease the worker acquired for itself: three independent tasks, in that
262
266
  * order, every one attempted whatever the earlier ones did, and the first
263
- * failure reported once all ran. A billed browser outlives a failed trace
264
- * flush otherwise.
267
+ * failure reported once all ran. A billed browser outlives a failed
268
+ * recording flush otherwise.
265
269
  */
266
270
  dispose(context: EngineCleanupContext): Promise<void>;
267
271
  /** Returns the active attempt, including its connection generation and references. */
@@ -276,6 +280,12 @@ export declare class PlaywrightSurface {
276
280
  projectPath(file: string): string;
277
281
  /** Removes one registered route from the attempt and the current context. */
278
282
  unroute(predicate: RoutePredicate, handler: RouteHandler): Promise<void>;
283
+ /**
284
+ * The attempt's open page, or undefined, for a reader beside the test (the
285
+ * trace): unlike `requirePage`, it leaves an error a handler latched for
286
+ * the test's next operation to throw.
287
+ */
288
+ private openPage;
279
289
  requirePage(): Page;
280
290
  requireContext(): BrowserContext;
281
291
  /**
@@ -297,14 +307,20 @@ export declare class PlaywrightSurface {
297
307
  * The single entry of every operation: rethrows an error latched on an
298
308
  * unawaited path, refuses a cancelled operation, races `fn` against the
299
309
  * operation signal so an abort mid-call surfaces as `CANCELLED` instead of
300
- * waiting out Playwright, and translates raw errors at the contract
310
+ * waiting out Playwright, bounds it by the operation's budget so a call a
311
+ * hung page never answers is `OPERATION_TIMEOUT`, and translates raw errors at the contract
301
312
  * boundary. `translate` overrides the default translation for operations
302
- * with a documented retryable failure mode.
313
+ * with a documented retryable failure mode; `bound` is `test-code` for an
314
+ * operation that runs the test's own code, which the deadline never cuts.
303
315
  */
304
- guard<T>(operation: OperationContext, label: string, fn: (operation: OperationContext) => Promise<T>, translate?: (cause: unknown, label: string) => Error): Promise<T>;
316
+ guard<T>(operation: OperationContext, label: string, fn: (operation: OperationContext) => Promise<T>, translate?: (cause: unknown, label: string) => Error, bound?: OperationBound): Promise<T>;
305
317
  /** Opens one URL the harness resolved; the attempt's page is created on first use. */
306
318
  open(url: string, operation: OperationContext): Promise<void>;
307
319
  back(operation: OperationContext): Promise<void>;
320
+ /** Runs one navigation the engine itself makes, so the app log leaves it to the step that asked for it. */
321
+ navigating<T>(work: () => Promise<T>): Promise<T>;
322
+ /** Resolves with what an action resolved with once the trace has the screen it left (`TraceFeed.screenAfterAction`); an action that failed leaves none. */
323
+ private acted;
308
324
  /** Restarts the document while retaining this attempt's context and storage. */
309
325
  restart(operation: OperationContext): Promise<void>;
310
326
  /** Replaces the context with a clean one and opens its blank page. */
@@ -353,9 +369,12 @@ export declare class PlaywrightSurface {
353
369
  * Checks that every frame selector along the expression matches exactly one
354
370
  * element, each counted inside the frame before it, the way `project` walks
355
371
  * the same chain; a frame-scoped `count` is 0 until that frame's document
356
- * has loaded, which is what makes `FRAME_NOT_FOUND` worth retrying.
372
+ * has loaded, which is what makes `FRAME_NOT_FOUND` worth retrying. Returns
373
+ * the innermost frame's content box in the top-level viewport, where its
374
+ * document's viewport starts: undefined for an expression in the main
375
+ * document, null for a frame with no box.
357
376
  */
358
- private validateFrames;
377
+ private frameBox;
359
378
  /**
360
379
  * Reserves one artifact path under the attempt directory: a per-attempt
361
380
  * counter keeps names unique, the label keeps them readable.
@@ -366,11 +385,7 @@ export declare class PlaywrightSurface {
366
385
  };
367
386
  /** Redacted at the source: secure fields in every frame are masked, as in observation pixels. */
368
387
  screenshot(label: string | undefined, operation: OperationContext): Promise<string>;
369
- /** Starts tracing the current attempt. */
370
- startTrace(operation: OperationContext): Promise<void>;
371
- /** Returns every trace segment finalized by the current attempt. */
372
- stopTrace(operation: OperationContext): Promise<string | readonly string[]>;
373
- /** Starts the attempt's video before a trace chooses its screencast dimensions. */
388
+ /** Starts the attempt's video. */
374
389
  startVideo(operation: OperationContext): Promise<void>;
375
390
  /** Finishes the attempt's recording and returns its finalized segments. */
376
391
  stopVideo(operation: OperationContext): Promise<readonly VideoSegment[]>;