@e2e-dev/web 0.12.0 → 0.13.0

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.13.0"}
package/dist/actions.d.ts CHANGED
@@ -13,10 +13,16 @@ export declare function dispatchLocatorAction(target: ActionTarget, action: Loca
13
13
  * behind it: no actionability wait, because there is no element to wait on,
14
14
  * and the page decides what the gesture lands on, as it does for a person.
15
15
  */
16
- export declare function dispatchPointerAction(page: Page, at: ViewportPoint, action: PointerAction): Promise<void>;
16
+ export declare function dispatchPointerAction(page: Page, at: ViewportPoint, action: PointerAction, signal?: AbortSignal): Promise<void>;
17
+ /** Translates the failure of an input operation with no element behind it: a pointer action at a point, or a keystroke. */
18
+ export declare function classifyInputError(cause: unknown, label: string): Error;
17
19
  /**
18
20
  * Classifies a failed action onto the error contract (see the table above
19
21
  * `POST_DISPATCH_PATTERN` in support.ts): stale, not actionable, possibly
20
- * committed, or an engine fault.
22
+ * committed, or an engine fault. A strict mode violation is a located match
23
+ * that turned ambiguous: stale like a detached one before any input, so the
24
+ * runner locates again and reports the ambiguity or acts on the one match
25
+ * left; possibly committed once input went out, as the second side of a drag
26
+ * resolves after the press.
21
27
  */
22
28
  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
2
  import { EngineError } from 'e2e/engine';
3
- import { asActionable, isClassified, isNavigationRace, isPwTimeout, message, nearestPixel, performElementSwipe, performPointDrag, performPointerDrag, performViewportSwipe, POST_DISPATCH_PATTERN, } from './support.js';
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. */
@@ -151,7 +151,7 @@ export async function dispatchLocatorAction(target, action, timeout, lookup) {
151
151
  * behind it: no actionability wait, because there is no element to wait on,
152
152
  * and the page decides what the gesture lands on, as it does for a person.
153
153
  */
154
- export async function dispatchPointerAction(page, at, action) {
154
+ export async function dispatchPointerAction(page, at, action, signal) {
155
155
  const { mouse } = page;
156
156
  const point = nearestPixel(at);
157
157
  switch (action.kind) {
@@ -171,9 +171,11 @@ export async function dispatchPointerAction(page, at, action) {
171
171
  await mouse.move(point.x, point.y);
172
172
  return;
173
173
  case 'dragTo':
174
+ await performPointDrag(mouse, point, action.target, undefined, signal);
175
+ return;
174
176
  case 'swipeTo':
175
177
  // A swipe along a path is a pointer drag on a document platform.
176
- await performPointDrag(mouse, point, action.target);
178
+ await performPointDrag(mouse, point, action.target, action.durationMs, signal);
177
179
  return;
178
180
  case 'swipe':
179
181
  // The pointer moves to the point first so the scrollable under it receives the wheel.
@@ -182,6 +184,23 @@ export async function dispatchPointerAction(page, at, action) {
182
184
  return;
183
185
  }
184
186
  }
187
+ /**
188
+ * An input operation the operation deadline cut off: Playwright never
189
+ * answered, so whether the input reached the page is unknown, and it must not
190
+ * be repeated blindly.
191
+ */
192
+ function inputCutOff(cause, label) {
193
+ if (!(cause instanceof EngineError) || cause.code !== 'OPERATION_TIMEOUT')
194
+ return undefined;
195
+ return new EngineError('ACTION_MAY_HAVE_COMMITTED', `${label} timed out before the page answered; its input may have been dispatched`, {
196
+ retryable: false,
197
+ cause,
198
+ });
199
+ }
200
+ /** Translates the failure of an input operation with no element behind it: a pointer action at a point, or a keystroke. */
201
+ export function classifyInputError(cause, label) {
202
+ return inputCutOff(cause, label) ?? translatePwError(cause, label);
203
+ }
185
204
  /**
186
205
  * Whether a failure of this action may carry the value it was given. A
187
206
  * sensitive fill's plaintext must never leave the engine: its message is
@@ -216,15 +235,25 @@ function actionabilitySummary(text) {
216
235
  /**
217
236
  * Classifies a failed action onto the error contract (see the table above
218
237
  * `POST_DISPATCH_PATTERN` in support.ts): stale, not actionable, possibly
219
- * committed, or an engine fault.
238
+ * committed, or an engine fault. A strict mode violation is a located match
239
+ * that turned ambiguous: stale like a detached one before any input, so the
240
+ * runner locates again and reports the ambiguity or acts on the one match
241
+ * left; possibly committed once input went out, as the second side of a drag
242
+ * resolves after the press.
220
243
  */
221
244
  export function classifyActionError(rawCause, action) {
245
+ const cut = inputCutOff(rawCause, action.kind);
246
+ if (cut !== undefined)
247
+ return cut;
222
248
  if (isClassified(rawCause))
223
249
  return rawCause;
224
250
  const text = redactSensitive(message(rawCause), action);
225
251
  const cause = isSensitive(action) ? undefined : rawCause;
226
252
  if (/strict mode violation/i.test(text)) {
227
- return new EngineError('ENGINE_FAILURE', text, { retryable: false, cause });
253
+ if (POST_DISPATCH_PATTERN.test(text)) {
254
+ return new EngineError('ACTION_MAY_HAVE_COMMITTED', `${action.kind} matched more than one element after its input was dispatched: ${text}`, { retryable: false, cause });
255
+ }
256
+ return new EngineError('NODE_STALE', text, { retryable: true, cause });
228
257
  }
229
258
  if (DETACHED_PATTERN.test(text)) {
230
259
  return new EngineError('NODE_STALE', text, { retryable: true, cause });
@@ -1,6 +1,7 @@
1
1
  import type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright-core';
2
2
  import { type EngineCleanupContext, type OperationContext, type VideoSegment, type ViewportSize } from 'e2e/engine';
3
3
  import { type CdpEndpointResolver, type SessionBinding } from './cdp-recovery.ts';
4
+ import { type OperationBound } from './operation-budget.ts';
4
5
  import { RefRegistry } from './refs.ts';
5
6
  import type { LeaseRecording } from './provider.ts';
6
7
  import type { WebScreencastOptions } from './surface.ts';
@@ -57,8 +58,8 @@ export declare class AttemptSession {
57
58
  private invalidate;
58
59
  /** Opens a context for this attempt, publishing only after its handlers are installed. */
59
60
  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>;
61
+ /** Shares one reconnect, then dispatches once with the time that remains, bounded by the operation's budget unless it runs test code. */
62
+ run<T>(operation: OperationContext, label: string, work: (operation: OperationContext) => Promise<T>, bound?: OperationBound): Promise<T>;
62
63
  /** Owns a candidate through configuration and recording setup, then publishes it once. */
63
64
  private transition;
64
65
  /** Creates the attempt's active page and records its exact target before exposing it. */
@@ -3,7 +3,7 @@ import { mkdirSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { EngineError, raceAbort, withinCleanupBudget } from 'e2e/engine';
5
5
  import { attachPersistent, recoveryFailed, targetIdentity } from './cdp-recovery.js';
6
- import { connectionAbort, withConnectionBudget } from './operation-budget.js';
6
+ import { connectionAbort, withConnectionBudget, withOperationDeadline } from './operation-budget.js';
7
7
  import { RefRegistry } from './refs.js';
8
8
  import { ProviderVideo } from './provider-video.js';
9
9
  import { invalidState, translatePwError } from './support.js';
@@ -83,8 +83,8 @@ export class AttemptSession {
83
83
  return binding;
84
84
  }));
85
85
  }
86
- /** Shares one reconnect, then dispatches once with the time that remains. */
87
- async run(operation, label, work) {
86
+ /** Shares one reconnect, then dispatches once with the time that remains, bounded by the operation's budget unless it runs test code. */
87
+ async run(operation, label, work, bound = 'deadline') {
88
88
  const signal = AbortSignal.any([operation.signal, this.lifetime.signal]);
89
89
  if (signal.aborted)
90
90
  throw connectionAbort(signal, label);
@@ -92,23 +92,26 @@ export class AttemptSession {
92
92
  throw this.state.error;
93
93
  const persistent = this.options.persistent;
94
94
  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 {
95
+ const endsAt = Date.now() + operation.timeoutMs;
96
+ // Recovery is the engine's own work, so the deadline bounds it whatever the operation runs.
97
+ if (persistent !== undefined && reconnect) {
98
+ await withConnectionBudget({ signal, timeoutMs: operation.timeoutMs }, label, async (remaining) => {
99
+ if (this.state.kind === 'pending') {
100
+ await raceAbort(this.state.work, remaining().signal, 'CDP recovery');
101
+ return;
102
+ }
102
103
  const previous = this.current();
103
104
  this.observed = false;
104
105
  await this.transition(previous, remaining(), async () => {
105
106
  await this.video.pageClosing();
106
107
  return attachPersistent(persistent.reconnect, remaining(), previous.identity);
107
108
  }, true);
108
- }
109
- const current = remaining();
110
- return raceAbort(() => work({ ...operation, ...current }), current.signal, label);
111
- });
109
+ });
110
+ }
111
+ const left = endsAt - Date.now();
112
+ if (left <= 0)
113
+ throw new EngineError('OPERATION_TIMEOUT', `${label} timed out`, { retryable: false });
114
+ return withOperationDeadline({ signal, timeoutMs: left }, label, (remaining) => work({ ...operation, ...remaining() }), bound);
112
115
  }
113
116
  /** Owns a candidate through configuration and recording setup, then publishes it once. */
114
117
  async transition(previous, budget, open, recover = false) {
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>;
package/dist/browser.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * says which URLs a test may open.
13
13
  */
14
14
  import { ConfigurationError, Deadline, describePattern, EngineError, matchesText, pollCondition, raceAbort, rejectUnknownOptions, TestError, toTextPattern, urlMatches, validateJsonValue, withTimeout, } from 'e2e/engine';
15
+ import { classifyInputError } from './actions.js';
15
16
  import { saveDownloadsTo, saveFromBrowser, saveLocally } from './downloads.js';
16
17
  import { isTestErrorCode, message as causeMessage, translatePwError } from './support.js';
17
18
  import { compileEvaluation } from './evaluation.js';
@@ -62,8 +63,10 @@ export function createBrowserFixture(surface, context) {
62
63
  };
63
64
  /** An assertion-style budget: the given timeout or the assertion timeout, clamped to the test. */
64
65
  const deadlineFor = (timeout) => new Deadline(context.operation(timeout ?? context.timeouts.assertion).timeoutMs);
65
- const currentUrl = () => surface.guard(context.operation(), 'url', async () => surface.requirePage().url());
66
- const currentTitle = () => surface.guard(context.operation(), 'title', () => surface.requirePage().title());
66
+ /** The page URL, read within the action timeout or, for a matcher poll, within the poll's deadline. */
67
+ const currentUrl = (deadline) => surface.guard(context.operation(deadline?.remaining()), 'url', async () => surface.requirePage().url());
68
+ /** The page title, read within the action timeout or, for a matcher poll, within the poll's deadline. */
69
+ const currentTitle = (deadline) => surface.guard(context.operation(deadline?.remaining()), 'title', () => surface.requirePage().title());
67
70
  const expectation = createBrowserExpectation({ currentUrl, currentTitle, baseHref, deadlineFor, context });
68
71
  const browser = {
69
72
  goto(url, options) {
@@ -85,8 +88,8 @@ export function createBrowserFixture(surface, context) {
85
88
  forward: (options) => navigation(options, async (operation) => {
86
89
  await surface.requirePage().goForward({ waitUntil: 'load', timeout: operation.timeoutMs });
87
90
  }),
88
- url: currentUrl,
89
- title: currentTitle,
91
+ url: () => currentUrl(),
92
+ title: () => currentTitle(),
90
93
  // The same poll as `expect(browser).toHaveURL`, exposed as a wait.
91
94
  waitForURL: (url, options) => {
92
95
  rejectUnknownOptions('browser.waitForURL', options, ['timeout']);
@@ -329,17 +332,17 @@ export function createBrowserFixture(surface, context) {
329
332
  return new TestError('ACTION_FAILED', `no download started within ${awaiting.waitMs}ms; the trigger resolved after ${awaiting.triggerMs}ms`, { cause });
330
333
  }
331
334
  return translated;
332
- });
335
+ }, 'test-code');
333
336
  },
334
337
  keyboard: {
335
- press: (key) => surface.guard(context.operation(), 'keyboard.press', () => surface.requirePage().keyboard.press(key)),
336
- type: (text) => surface.guard(context.operation(), 'keyboard.type', () => surface.requirePage().keyboard.type(text)),
338
+ press: (key) => surface.guard(context.operation(), 'keyboard.press', () => surface.requirePage().keyboard.press(key), classifyInputError),
339
+ type: (text) => surface.guard(context.operation(), 'keyboard.type', () => surface.requirePage().keyboard.type(text), classifyInputError),
337
340
  },
338
341
  mouse: {
339
- move: (x, y) => surface.guard(context.operation(), 'mouse.move', () => surface.requirePage().mouse.move(x, y)),
340
- wheel: (deltaX, deltaY) => surface.guard(context.operation(), 'mouse.wheel', () => surface.requirePage().mouse.wheel(deltaX, deltaY)),
341
- down: () => surface.guard(context.operation(), 'mouse.down', () => surface.requirePage().mouse.down()),
342
- up: () => surface.guard(context.operation(), 'mouse.up', () => surface.requirePage().mouse.up()),
342
+ move: (x, y) => surface.guard(context.operation(), 'mouse.move', () => surface.requirePage().mouse.move(x, y), classifyInputError),
343
+ wheel: (deltaX, deltaY) => surface.guard(context.operation(), 'mouse.wheel', () => surface.requirePage().mouse.wheel(deltaX, deltaY), classifyInputError),
344
+ down: () => surface.guard(context.operation(), 'mouse.down', () => surface.requirePage().mouse.down(), classifyInputError),
345
+ up: () => surface.guard(context.operation(), 'mouse.up', () => surface.requirePage().mouse.up(), classifyInputError),
343
346
  },
344
347
  };
345
348
  const action = { kind: 'resource' };
@@ -372,15 +375,26 @@ export function createBrowserFixture(surface, context) {
372
375
  mouse: { move: action, wheel: action, down: action, up: action },
373
376
  });
374
377
  }
375
- /** `expect(browser)` matchers: URL, title, and class polling against the assertion budget. */
378
+ /**
379
+ * `expect(browser)` matchers: URL, title, and class polling against the
380
+ * assertion budget. Every read is bounded by the poll's deadline, so a page
381
+ * that stops answering ends the assertion at its own timeout, and the
382
+ * failure reports the last read instead of reading a hung page again.
383
+ */
376
384
  function createBrowserExpectation(deps, negated = false) {
377
- const poll = async (api, label, condition, observed, timeout) => {
385
+ const poll = async (api, label, read, timeout) => {
386
+ const deadline = deps.deadlineFor(timeout);
387
+ // pollCondition calls onTimeout only after a read completed, so a sample is always there.
388
+ let last;
378
389
  await pollCondition({
379
- deadline: deps.deadlineFor(timeout),
390
+ deadline,
380
391
  signal: deps.context.signal,
381
392
  negated,
382
- evaluate: condition,
383
- onTimeout: async () => new TestError('ASSERTION_FAILED', `expect.${negated ? 'not.' : ''}${api} failed\nexpected: ${negated ? 'not ' : ''}${label}\nobserved: ${await observed()}`),
393
+ evaluate: async () => {
394
+ last = await read(deadline);
395
+ return last.matches;
396
+ },
397
+ onTimeout: (cause) => new TestError('ASSERTION_FAILED', `expect.${negated ? 'not.' : ''}${api} failed\nexpected: ${negated ? 'not ' : ''}${label}\nobserved: ${last?.observed ?? 'nothing'}`, cause === undefined ? undefined : { cause }),
384
398
  });
385
399
  };
386
400
  const validate = (api, options, keys = ['timeout']) => rejectUnknownOptions(`expect.${negated ? 'not.' : ''}${api}`, options, keys);
@@ -396,41 +410,39 @@ function createBrowserExpectation(deps, negated = false) {
396
410
  }
397
411
  const label = typeof expected === 'string' ? expected : String(expected);
398
412
  const target = deps.baseHref();
399
- return poll('toHaveURL', `URL ${label}${ignoreCase === true ? ' (ignoring case)' : ''}`, async () => urlMatches(await deps.currentUrl(), expected, target, ignoreCase), async () => `URL ${await deps.currentUrl()}`, options?.timeout);
413
+ return poll('toHaveURL', `URL ${label}${ignoreCase === true ? ' (ignoring case)' : ''}`, async (deadline) => {
414
+ const url = await deps.currentUrl(deadline);
415
+ return { matches: urlMatches(url, expected, target, ignoreCase), observed: `URL ${url}` };
416
+ }, options?.timeout);
400
417
  },
401
418
  toHaveTitle(expected, options) {
402
419
  validate('toHaveTitle', options);
403
420
  const pattern = toTextPattern(expected, { exact: true });
404
- return poll('toHaveTitle', `title ${describePattern(pattern)}`, async () => matchesText(await deps.currentTitle(), pattern), async () => `title ${JSON.stringify(await deps.currentTitle())}`, options?.timeout);
421
+ return poll('toHaveTitle', `title ${describePattern(pattern)}`, async (deadline) => {
422
+ const title = await deps.currentTitle(deadline);
423
+ return { matches: matchesText(title, pattern), observed: `title ${JSON.stringify(title)}` };
424
+ }, options?.timeout);
405
425
  },
406
426
  toHaveClass(target, expected, options) {
407
427
  validate('toHaveClass', options);
408
428
  const pattern = toTextPattern(expected, { exact: true });
409
- return poll('toHaveClass', `class ${describePattern(pattern)}`, async () => {
429
+ return poll('toHaveClass', `class ${describePattern(pattern)}`, async (deadline) => {
430
+ let value;
410
431
  try {
411
- const value = await target.getAttribute('class');
412
- if (value === null)
413
- return false;
414
- const normalized = value.trim().split(/\s+/).join(' ');
415
- return matchesText(normalized, pattern);
432
+ // A locator read takes the action timeout; the poll's deadline is the tighter bound.
433
+ value = await withTimeout(target.getAttribute('class'), deadline.remaining(), () => new EngineError('OPERATION_TIMEOUT', 'class read timed out', { retryable: false }));
416
434
  }
417
435
  catch (error) {
418
436
  if (isTestErrorCode(error, 'LOCATOR_NOT_FOUND'))
419
- return undefined;
420
- throw error;
421
- }
422
- }, async () => {
423
- try {
424
- const value = await target.getAttribute('class');
425
- if (value === null)
426
- return 'no class attribute';
427
- return `class ${JSON.stringify(value)}`;
428
- }
429
- catch (error) {
430
- if (isTestErrorCode(error, 'LOCATOR_NOT_FOUND'))
431
- return 'no node';
437
+ return { matches: undefined, observed: 'no node' };
432
438
  throw error;
433
439
  }
440
+ if (value === null)
441
+ return { matches: false, observed: 'no class attribute' };
442
+ return {
443
+ matches: matchesText(value.trim().split(/\s+/).join(' '), pattern),
444
+ observed: `class ${JSON.stringify(value)}`,
445
+ };
434
446
  }, options?.timeout);
435
447
  },
436
448
  };
@@ -9,11 +9,14 @@ type EvaluationResult = {
9
9
  export declare const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
10
10
  /**
11
11
  * Compiles the page-side error boundary without evaluating the caller's source in this process.
12
+ * The source is one expression: a function it evaluates to is called with the argument, any
13
+ * other value is the result.
12
14
  *
13
15
  * A function serialized with `toString()` carries whatever the loader compiled
14
- * it to. tsx runs esbuild with `keepNames`, which wraps every nested named
15
- * binding in a module-scoped `__name(target, name)` helper; the page has no
16
- * such helper, so the same one is declared next to the inlined source.
16
+ * it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
17
+ * wraps every nested named binding in a module-scoped `__name(target, name)`
18
+ * helper; the page has no such helper, so the same one is declared next to
19
+ * the inlined source for as long as this engine supports those runners.
17
20
  */
18
21
  export declare function compileEvaluation(source: string, hasArgument: boolean): (arg: unknown) => Promise<EvaluationResult>;
19
22
  export {};
@@ -5,11 +5,14 @@ import { message } from './support.js';
5
5
  export const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
6
6
  /**
7
7
  * Compiles the page-side error boundary without evaluating the caller's source in this process.
8
+ * The source is one expression: a function it evaluates to is called with the argument, any
9
+ * other value is the result.
8
10
  *
9
11
  * A function serialized with `toString()` carries whatever the loader compiled
10
- * it to. tsx runs esbuild with `keepNames`, which wraps every nested named
11
- * binding in a module-scoped `__name(target, name)` helper; the page has no
12
- * such helper, so the same one is declared next to the inlined source.
12
+ * it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
13
+ * wraps every nested named binding in a module-scoped `__name(target, name)`
14
+ * helper; the page has no such helper, so the same one is declared next to
15
+ * the inlined source for as long as this engine supports those runners.
13
16
  */
14
17
  export function compileEvaluation(source, hasArgument) {
15
18
  try {
@@ -17,7 +20,7 @@ export function compileEvaluation(source, hasArgument) {
17
20
  return (async () => {
18
21
  ${KEEP_NAMES_HELPER}
19
22
  try {
20
- return { ok: true, value: await (${source}\n)(${hasArgument ? 'arg' : ''}) };
23
+ return { ok: true, value: await ((result) => typeof result === 'function' ? result(${hasArgument ? 'arg' : ''}) : result)((${source}\n)) };
21
24
  } catch (cause) {
22
25
  let message;
23
26
  try {
@@ -219,7 +219,21 @@ export const readSemanticsFunction = (element, options) => {
219
219
  }
220
220
  case 'th': {
221
221
  const scope = (el.getAttribute('scope') ?? '').toLowerCase();
222
- return scope === 'row' || scope === 'rowgroup' ? 'rowheader' : 'columnheader';
222
+ if (scope === 'row' || scope === 'rowgroup')
223
+ return 'rowheader';
224
+ if (scope === 'col' || scope === 'colgroup')
225
+ return 'columnheader';
226
+ const previous = el.previousElementSibling;
227
+ const next = el.nextElementSibling;
228
+ if (previous === null && next === null) {
229
+ const row = el.parentElement;
230
+ const table = row?.tagName.toLowerCase() === 'tr' ? row.closest('table') : null;
231
+ return table !== null && table.rows.length <= 1 ? null : 'columnheader';
232
+ }
233
+ if (previous?.tagName.toLowerCase() === 'th' && next?.tagName.toLowerCase() === 'th')
234
+ return 'columnheader';
235
+ const hasDataNeighbor = [previous, next].some((cell) => cell?.tagName.toLowerCase() === 'td' && ((cell.textContent ?? '').trim() !== '' || cell.children.length > 0));
236
+ return hasDataNeighbor ? 'rowheader' : 'columnheader';
223
237
  }
224
238
  case 'dialog':
225
239
  return 'dialog';
package/dist/install.d.ts CHANGED
@@ -15,6 +15,8 @@ export interface InstallContext {
15
15
  readonly signal?: AbortSignal | undefined;
16
16
  /** Environment the installer runs with: the run's, so it fills the cache the workers will launch from. */
17
17
  readonly env: NodeJS.ProcessEnv;
18
+ /** Whether the run launches a window, which decides the chromium build it needs. */
19
+ readonly headed: boolean;
18
20
  }
19
21
  export interface EnsureBrowsersOptions {
20
22
  /**
@@ -34,7 +36,11 @@ export interface EnsureBrowsersOptions {
34
36
  readonly signal?: AbortSignal;
35
37
  /** The run's environment; defaults to this process's. */
36
38
  readonly env?: NodeJS.ProcessEnv;
39
+ /** Whether the run launches a window, which decides the chromium build it needs. Defaults to false. */
40
+ readonly headed?: boolean;
37
41
  }
42
+ /** The `playwright install` arguments for `names`: `--only-shell` on a headless chromium run. */
43
+ export declare function installArgs(names: readonly BrowserName[], headed: boolean): string[];
38
44
  /**
39
45
  * Ensures the given Playwright browsers are installed, downloading any
40
46
  * missing ones via `playwright-core install`. Called from the engine's `prepare`,
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
  });
@@ -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 every call while a trace snapshots a
15
+ * page whose renderer 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();
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;
@@ -297,11 +298,13 @@ export declare class PlaywrightSurface {
297
298
  * The single entry of every operation: rethrows an error latched on an
298
299
  * unawaited path, refuses a cancelled operation, races `fn` against the
299
300
  * operation signal so an abort mid-call surfaces as `CANCELLED` instead of
300
- * waiting out Playwright, and translates raw errors at the contract
301
+ * waiting out Playwright, bounds it by the operation's budget so a call a
302
+ * hung page never answers is `OPERATION_TIMEOUT`, and translates raw errors at the contract
301
303
  * boundary. `translate` overrides the default translation for operations
302
- * with a documented retryable failure mode.
304
+ * with a documented retryable failure mode; `bound` is `test-code` for an
305
+ * operation that runs the test's own code, which the deadline never cuts.
303
306
  */
304
- guard<T>(operation: OperationContext, label: string, fn: (operation: OperationContext) => Promise<T>, translate?: (cause: unknown, label: string) => Error): Promise<T>;
307
+ guard<T>(operation: OperationContext, label: string, fn: (operation: OperationContext) => Promise<T>, translate?: (cause: unknown, label: string) => Error, bound?: OperationBound): Promise<T>;
305
308
  /** Opens one URL the harness resolved; the attempt's page is created on first use. */
306
309
  open(url: string, operation: OperationContext): Promise<void>;
307
310
  back(operation: OperationContext): Promise<void>;
package/dist/surface.js CHANGED
@@ -10,7 +10,7 @@ import { mkdirSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { EngineError, raceAbort, TestError, withinCleanupBudget, } from 'e2e/engine';
12
12
  import { matchesText } from 'e2e/engine';
13
- import { classifyActionError, dispatchLocatorAction, dispatchPointerAction } from './actions.js';
13
+ import { classifyActionError, classifyInputError, dispatchLocatorAction, dispatchPointerAction } from './actions.js';
14
14
  import { BrowserConnection, connectCdp } from './browser-connection.js';
15
15
  import { AttemptSession } from './attempt-session.js';
16
16
  import { LeasedBrowsers } from './provider.js';
@@ -20,7 +20,7 @@ import { applyPostSteps, frameSelectors, projectExpression } from './locators.js
20
20
  import { ROOT_NODE_ID, toSemanticNode } from './observation.js';
21
21
  import { captureObservation } from './observation-capture.js';
22
22
  import { maskOptions, secureFieldMasks } from './observe.js';
23
- import { connectionAbort } from './operation-budget.js';
23
+ import { connectionAbort, withOperationDeadline } from './operation-budget.js';
24
24
  import { CLOSED_SHADOW_ROOTS_INIT_SCRIPT } from './closed-shadow.js';
25
25
  import { ConfiguredInitScripts } from './init-scripts.js';
26
26
  import { SECURE_FIELD_SELECTOR } from './read-node.js';
@@ -109,7 +109,7 @@ export class PlaywrightSurface {
109
109
  return this.leases.prepare(info);
110
110
  if (this.connect !== undefined)
111
111
  return;
112
- await ensureBrowsersInstalled([this.browserName], { env: info.env, signal: info.signal, log: info.log });
112
+ await ensureBrowsersInstalled([this.browserName], { env: info.env, signal: info.signal, log: info.log, headed: info.headed });
113
113
  }
114
114
  /** Releases the browsers `prepare` leased; a local launch or a `connect` has nothing to release. */
115
115
  async finish(info) {
@@ -395,16 +395,18 @@ export class PlaywrightSurface {
395
395
  * The single entry of every operation: rethrows an error latched on an
396
396
  * unawaited path, refuses a cancelled operation, races `fn` against the
397
397
  * operation signal so an abort mid-call surfaces as `CANCELLED` instead of
398
- * waiting out Playwright, and translates raw errors at the contract
398
+ * waiting out Playwright, bounds it by the operation's budget so a call a
399
+ * hung page never answers is `OPERATION_TIMEOUT`, and translates raw errors at the contract
399
400
  * boundary. `translate` overrides the default translation for operations
400
- * with a documented retryable failure mode.
401
+ * with a documented retryable failure mode; `bound` is `test-code` for an
402
+ * operation that runs the test's own code, which the deadline never cuts.
401
403
  */
402
- async guard(operation, label, fn, translate = translatePwError) {
404
+ async guard(operation, label, fn, translate = translatePwError, bound = 'deadline') {
403
405
  this.latch.throwPending();
404
406
  try {
405
407
  return this.session === undefined
406
- ? await raceAbort(() => fn(operation), operation.signal, label)
407
- : await this.session.run(operation, label, fn);
408
+ ? await withOperationDeadline(operation, label, (remaining) => fn({ ...operation, ...remaining() }), bound)
409
+ : await this.session.run(operation, label, fn, bound);
408
410
  }
409
411
  catch (cause) {
410
412
  throw translate(cause, label);
@@ -519,7 +521,7 @@ export class PlaywrightSurface {
519
521
  return matches.map(({ raw, index }) => {
520
522
  const pinned = handles?.[index];
521
523
  // A single match keeps the strict locator, so a ref that turns
522
- // ambiguous between locate and perform fails loud instead of acting
524
+ // ambiguous between locate and perform is stale instead of acting
523
525
  // on whichever element is first.
524
526
  const locator = reads.length === 1 ? projected.locator : projected.locator.nth(index);
525
527
  const id = refs.storeLocated(pinned === undefined ? { kind: 'locator', locator } : { kind: 'element', element: pinned });
@@ -545,10 +547,10 @@ export class PlaywrightSurface {
545
547
  }
546
548
  /** One pointer action at a viewport point in CSS pixels, with nothing resolved behind it; see `dispatchPointerAction`. */
547
549
  performAt(point, action, operation) {
548
- return this.guard(operation, `${action.kind} at point`, () => {
550
+ return this.guard(operation, `${action.kind} at point`, (currentOperation) => {
549
551
  this.requireSession().requireObservation();
550
- return dispatchPointerAction(this.requirePage(), point, action);
551
- });
552
+ return dispatchPointerAction(this.requirePage(), point, action, currentOperation.signal);
553
+ }, classifyInputError);
552
554
  }
553
555
  /**
554
556
  * Types into whatever holds focus. Keystrokes into a document body are
@@ -578,14 +580,14 @@ export class PlaywrightSurface {
578
580
  checkpoint();
579
581
  }
580
582
  await page.keyboard.type(text);
581
- });
583
+ }, classifyInputError);
582
584
  }
583
585
  /** Sends one key to whatever holds focus, in the contract's key grammar Playwright shares. */
584
586
  pressKey(key, operation) {
585
587
  return this.guard(operation, 'keyboard.press', () => {
586
588
  this.requireSession().requireObservation();
587
589
  return this.requirePage().keyboard.press(key);
588
- });
590
+ }, classifyInputError);
589
591
  }
590
592
  /**
591
593
  * Refuses focused typing when nothing that takes keystrokes has focus, in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e2e-dev/web",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Web engine for e2e agentic end-to-end testing, built on Playwright",
5
5
  "keywords": [
6
6
  "e2e",
@@ -50,18 +50,18 @@
50
50
  "playwright-core": "1.63.0"
51
51
  },
52
52
  "peerDependencies": {
53
- "e2e": ">=0.15.0 <1"
53
+ "e2e": ">=0.18.0 <1"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/node": "26.6.2",
57
- "e2e": "0.17.0",
57
+ "e2e": "0.18.0",
58
58
  "@types/pngjs": "6.0.5",
59
59
  "pngjs": "7.0.0",
60
60
  "typescript": "7.0.2",
61
61
  "vitest": "5.0.1"
62
62
  },
63
63
  "engines": {
64
- "node": ">=22.12.0"
64
+ "node": "^22.22.3 || >=24.8.0"
65
65
  },
66
66
  "scripts": {
67
67
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc --project tsconfig.build.json && node ../../scripts/stamp-dist.ts",