@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/browser.js CHANGED
@@ -12,9 +12,10 @@
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
- import { compileEvaluation } from './evaluation.js';
18
+ import { compileEvaluation, pageFunctionSource } from './evaluation.js';
18
19
  import { initScriptLabel, testInitScriptSource } from './init-scripts.js';
19
20
  import { lowercaseNames } from './protected-app.js';
20
21
  import { parseContinue, parseFulfill, requireNoArguments } from './route-options.js';
@@ -62,31 +63,33 @@ 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) {
70
73
  const resolved = context.app.resolveUrl(url);
71
- return navigation(options, async (operation) => {
74
+ return navigation(options, (operation) => surface.navigating(async () => {
72
75
  const page = await surface.ensurePage();
73
76
  await page.goto(resolved, {
74
77
  waitUntil: options?.waitUntil ?? 'load',
75
78
  timeout: operation.timeoutMs,
76
79
  });
77
- });
80
+ }));
78
81
  },
79
- reload: (options) => navigation(options, async (operation) => {
82
+ reload: (options) => navigation(options, (operation) => surface.navigating(async () => {
80
83
  await surface.requirePage().reload({ waitUntil: 'load', timeout: operation.timeoutMs });
81
- }),
82
- back: (options) => navigation(options, async (operation) => {
84
+ })),
85
+ back: (options) => navigation(options, (operation) => surface.navigating(async () => {
83
86
  await surface.requirePage().goBack({ waitUntil: 'load', timeout: operation.timeoutMs });
84
- }),
85
- forward: (options) => navigation(options, async (operation) => {
87
+ })),
88
+ forward: (options) => navigation(options, (operation) => surface.navigating(async () => {
86
89
  await surface.requirePage().goForward({ waitUntil: 'load', timeout: operation.timeoutMs });
87
- }),
88
- url: currentUrl,
89
- title: currentTitle,
90
+ })),
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']);
@@ -95,7 +98,7 @@ export function createBrowserFixture(surface, context) {
95
98
  locator: (selector) => context.locator({ kind: 'selector', selector: requireSelector('browser.locator', selector) }),
96
99
  frameLocator: (selector) => frameScreen(context, [requireSelector('browser.frameLocator', selector)]),
97
100
  async evaluate(fn, arg) {
98
- const source = typeof fn === 'string' ? fn : fn.toString();
101
+ const source = typeof fn === 'string' ? fn : pageFunctionSource(fn);
99
102
  validateJsonValue(arg, 'evaluate argument');
100
103
  const evaluate = compileEvaluation(source, arg !== undefined);
101
104
  // JSON safety is checked above; Playwright's recursive argument type cannot expand JsonValue.
@@ -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
  };
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,13 +7,18 @@ 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.
14
+ * The source is one expression: a function it evaluates to is called with the argument, any
15
+ * other value is the result.
12
16
  *
13
17
  * 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.
18
+ * it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
19
+ * wraps every nested named binding in a module-scoped `__name(target, name)`
20
+ * helper; the page has no such helper, so the same one is declared next to
21
+ * the inlined source for as long as this engine supports those runners.
17
22
  */
18
23
  export declare function compileEvaluation(source: string, hasArgument: boolean): (arg: unknown) => Promise<EvaluationResult>;
19
24
  export {};
@@ -3,13 +3,47 @@ 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.
39
+ * The source is one expression: a function it evaluates to is called with the argument, any
40
+ * other value is the result.
8
41
  *
9
42
  * 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.
43
+ * it to. e2e before 0.17 loaded tests through tsx, whose esbuild `keepNames`
44
+ * wraps every nested named binding in a module-scoped `__name(target, name)`
45
+ * helper; the page has no such helper, so the same one is declared next to
46
+ * the inlined source for as long as this engine supports those runners.
13
47
  */
14
48
  export function compileEvaluation(source, hasArgument) {
15
49
  try {
@@ -17,7 +51,7 @@ export function compileEvaluation(source, hasArgument) {
17
51
  return (async () => {
18
52
  ${KEEP_NAMES_HELPER}
19
53
  try {
20
- return { ok: true, value: await (${source}\n)(${hasArgument ? 'arg' : ''}) };
54
+ return { ok: true, value: await ((result) => typeof result === 'function' ? result(${hasArgument ? 'arg' : ''}) : result)((${source}\n)) };
21
55
  } catch (cause) {
22
56
  let message;
23
57
  try {
@@ -54,11 +54,11 @@ export const readSemanticsFunction = (element, options) => {
54
54
  const PLACEHOLDER_NAMED_INPUT_TYPES = ['text', 'password', 'number', 'search', 'tel', 'email', 'url'];
55
55
  /**
56
56
  * How the active mode projects one node, expressed as data so `describe`
57
- * stays branch-free. Tree mode is the model-bound projection: bounded text,
58
- * a bounded attribute projection, hrefs reduced to origin+path, the root
59
- * document named by its title, and no value on a checkbox or radio, whose
60
- * value attribute is an app token its checked state already says more
61
- * than. Node mode is the full locator-read surface.
57
+ * stays branch-free. Tree mode is the model-bound projection: bounded line
58
+ * text (`lineTextOf`), a bounded attribute projection, hrefs reduced to
59
+ * origin+path, the root document named by its title, and no value on a
60
+ * checkbox or radio, whose value attribute is an app token its checked
61
+ * state already says more than. Node mode is the full locator-read surface.
62
62
  */
63
63
  const projection = options.mode.kind === 'tree'
64
64
  ? {
@@ -66,7 +66,7 @@ export const readSemanticsFunction = (element, options) => {
66
66
  textLimit: options.mode.textLimit,
67
67
  nameLimit: options.mode.nameLimit,
68
68
  redactHref: true,
69
- directTextOnly: true,
69
+ lineText: true,
70
70
  documentRoot: true,
71
71
  checkableValue: false,
72
72
  }
@@ -75,7 +75,7 @@ export const readSemanticsFunction = (element, options) => {
75
75
  textLimit: null,
76
76
  nameLimit: null,
77
77
  redactHref: false,
78
- directTextOnly: false,
78
+ lineText: false,
79
79
  documentRoot: false,
80
80
  checkableValue: true,
81
81
  };
@@ -111,7 +111,15 @@ export const readSemanticsFunction = (element, options) => {
111
111
  * HTML-AAM scopes it to sectioning content and to the ARIA roles that stand
112
112
  * for it, so a `<div role="article">` scopes it like an `<article>`.
113
113
  */
114
- const isPageLevel = (el) => el.closest(SECTIONING_SCOPE) === null;
114
+ const isPageLevel = (el) => {
115
+ for (let candidate = el; candidate !== null;) {
116
+ if (candidate.closest(SECTIONING_SCOPE) !== null)
117
+ return false;
118
+ const root = candidate.getRootNode();
119
+ candidate = root instanceof ShadowRoot ? root.host : null;
120
+ }
121
+ return true;
122
+ };
115
123
  /**
116
124
  * The root of a contenteditable region: editable itself, under a parent that
117
125
  * is not. A rich-text editor (ProseMirror, TipTap, Lexical, Slate) renders
@@ -219,7 +227,21 @@ export const readSemanticsFunction = (element, options) => {
219
227
  }
220
228
  case 'th': {
221
229
  const scope = (el.getAttribute('scope') ?? '').toLowerCase();
222
- return scope === 'row' || scope === 'rowgroup' ? 'rowheader' : 'columnheader';
230
+ if (scope === 'row' || scope === 'rowgroup')
231
+ return 'rowheader';
232
+ if (scope === 'col' || scope === 'colgroup')
233
+ return 'columnheader';
234
+ const previous = el.previousElementSibling;
235
+ const next = el.nextElementSibling;
236
+ if (previous === null && next === null) {
237
+ const row = el.parentElement;
238
+ const table = row?.tagName.toLowerCase() === 'tr' ? row.closest('table') : null;
239
+ return table !== null && table.rows.length <= 1 ? null : 'columnheader';
240
+ }
241
+ if (previous?.tagName.toLowerCase() === 'th' && next?.tagName.toLowerCase() === 'th')
242
+ return 'columnheader';
243
+ const hasDataNeighbor = [previous, next].some((cell) => cell?.tagName.toLowerCase() === 'td' && ((cell.textContent ?? '').trim() !== '' || cell.children.length > 0));
244
+ return hasDataNeighbor ? 'rowheader' : 'columnheader';
223
245
  }
224
246
  case 'dialog':
225
247
  return 'dialog';
@@ -354,7 +376,7 @@ export const readSemanticsFunction = (element, options) => {
354
376
  /** An element's `::after` contribution, once per read. */
355
377
  const afterContentOf = memoized((el) => generatedContentOf(el, '::after'));
356
378
  /** True for a subtree the name computation drops: aria-hidden, or hidden by style as innerText leaves it out. */
357
- const isNameHidden = (el, style) => el.getAttribute('aria-hidden') === 'true' ||
379
+ const isNameHidden = (el, style) => (el.getAttribute('aria-hidden') ?? '').toLowerCase() === 'true' ||
358
380
  (style !== undefined && (style.display === 'none' || style.visibility === 'hidden'));
359
381
  /** `alt` of an element HTML-AAM names by it: an `<img>` or an `<input type="image">`. */
360
382
  const altOf = (el) => {
@@ -385,7 +407,7 @@ export const readSemanticsFunction = (element, options) => {
385
407
  * under an `aria-hidden` ancestor, which excludes it from the tree as
386
408
  * surely as its own attribute would.
387
409
  */
388
- const isReferenceHidden = (el) => isHidden(el) || el.closest('[aria-hidden="true"]') !== null;
410
+ const isReferenceHidden = (el) => isHidden(el) || el.closest('[aria-hidden="true" i]') !== null;
389
411
  /**
390
412
  * The element an id names for `el`, looked up in `el`'s own tree: an IDREF
391
413
  * resolves in the tree scope it is written in, so a shadow tree's ids are
@@ -668,6 +690,121 @@ export const readSemanticsFunction = (element, options) => {
668
690
  }
669
691
  return out.replace(/\s+/g, ' ').trim();
670
692
  });
693
+ /**
694
+ * Elements whose content is not text in a line: a form control's options
695
+ * or value, a picture, a frame, a drawn surface. Like a line break, each
696
+ * separates the words around it.
697
+ */
698
+ const LINE_BREAKING_TAGS = [...SKIP_TAGS, ...OPAQUE_TAGS, 'iframe', 'img', 'input', 'select', 'textarea', 'br'];
699
+ /**
700
+ * True when an element's text flows in its parent's line: a visible
701
+ * inline-level box (`inline`, `inline-block`, `ruby`), or `display:
702
+ * contents`, whose children lay out as the parent's own. Visible as the
703
+ * walk reads it (`isHidden`): a box with no size shows no words. A slot
704
+ * has no box and shows the nodes assigned to it, so only its visibility
705
+ * counts: a hidden one hides the text they inherit it with, and a child
706
+ * that shows itself again is listed on its own.
707
+ */
708
+ const flowsInLine = memoized((el) => {
709
+ if (LINE_BREAKING_TAGS.indexOf(el.tagName.toLowerCase()) !== -1 || isEditingHost(el))
710
+ return false;
711
+ const style = styleOf(el);
712
+ if (style === undefined || hidesSubtree(el, style))
713
+ return false;
714
+ if (el instanceof HTMLSlotElement ? style.visibility !== 'visible' : isHidden(el, style))
715
+ return false;
716
+ return isInlineLevel(style);
717
+ });
718
+ /** True for a box laid out in its parent's line, or for `display: contents`, which lays out none of its own. */
719
+ const isInlineLevel = (style) => style.display.startsWith('inline') || style.display.startsWith('ruby') || style.display === 'contents';
720
+ /**
721
+ * The nodes an element renders in its place, as the walk reaches them: a
722
+ * shadow host's shadow tree (a closed one through the record), a slot's
723
+ * assigned nodes or else its fallback content, any other element's children.
724
+ */
725
+ const renderedChildrenOf = (el) => {
726
+ const shadow = shadowRootOf(el);
727
+ if (shadow !== null)
728
+ return Array.from(shadow.childNodes);
729
+ if (el instanceof HTMLSlotElement) {
730
+ const assigned = el.assignedNodes();
731
+ if (assigned.length > 0)
732
+ return assigned;
733
+ }
734
+ return Array.from(el.childNodes);
735
+ };
736
+ /**
737
+ * An element's rendered text with each inline descendant's text in place.
738
+ * Anything else in the line reads as a space, except a hidden inline
739
+ * element (a `<wbr>`, a zero-size span), which shows nothing.
740
+ */
741
+ const lineRunOf = (el) => {
742
+ let out = '';
743
+ for (const child of renderedChildrenOf(el)) {
744
+ if (child.nodeType === 3)
745
+ out += child.nodeValue ?? '';
746
+ if (child.nodeType !== 1)
747
+ continue;
748
+ const inner = child;
749
+ if (flowsInLine(inner)) {
750
+ out += lineRunOf(inner);
751
+ continue;
752
+ }
753
+ const style = styleOf(inner);
754
+ if (style === undefined || hidesSubtree(inner, style))
755
+ continue;
756
+ const breaking = LINE_BREAKING_TAGS.indexOf(inner.tagName.toLowerCase()) !== -1;
757
+ if (breaking || !isInlineLevel(style) || !isHidden(inner, style))
758
+ out += ' ';
759
+ }
760
+ return out;
761
+ };
762
+ /**
763
+ * The text an element owns, read as a person reads its line: its own text
764
+ * with every inline descendant's text in place, so `<p>Read our <a>privacy
765
+ * policy</a> for details.</p>` reads as the whole sentence while the link
766
+ * stays a node of its own. Block-level descendants are nodes of their own
767
+ * and only separate words. An element with no text of its own owns none: a
768
+ * wrapper of elements leaves them to say what they say (`ownsLine`).
769
+ */
770
+ const lineTextOf = memoized((el) => ownsLine(el) ? lineRunOf(el).replace(/\s+/g, ' ').trim() : '');
771
+ /**
772
+ * True when an element reads its own line: it has text of its own, or it
773
+ * offers an action inside an ancestor's line, where its words are what a
774
+ * person aims at.
775
+ */
776
+ const ownsLine = (el) => directTextOf(el) !== '' || (offersAction(el) && isReadInLine(el));
777
+ /**
778
+ * True when an element with no role still says it can be acted on: it is
779
+ * focusable through `tabindex`, it has a click handler set as an attribute
780
+ * or a property (`onclick`), or it is an `<a>` with no `href`, which an app
781
+ * wires up by script. Such an element stays listed inside a line, so it
782
+ * keeps an id to act on. A handler added with `addEventListener` leaves
783
+ * nothing the page can read.
784
+ */
785
+ const offersAction = memoized((el) => {
786
+ const tabindex = el.getAttribute('tabindex');
787
+ if (tabindex !== null && /^\s*[-+]?\d/.test(tabindex))
788
+ return true;
789
+ if (el.tagName.toLowerCase() === 'a' || el.hasAttribute('onclick'))
790
+ return true;
791
+ return el instanceof HTMLElement && el.onclick !== null;
792
+ });
793
+ /**
794
+ * True when an element's text is already read in an ancestor's line
795
+ * (`lineTextOf`), so the element has nothing to add on text alone. A line
796
+ * longer than the projection's text bound is cut, so its inline
797
+ * descendants stay listed and nothing past the cut is lost.
798
+ */
799
+ const isReadInLine = memoized((el) => {
800
+ const parent = el.assignedSlot ?? parentOrHostOf(el);
801
+ if (parent === null || !flowsInLine(el))
802
+ return false;
803
+ if (!ownsLine(parent))
804
+ return isReadInLine(parent);
805
+ const fits = projection.textLimit === null || lineTextOf(parent).length <= projection.textLimit;
806
+ return fits && !isHidden(parent);
807
+ });
671
808
  const accessibleName = memoized((el) => {
672
809
  // accname reads a labelledby reference (2B) before the element's own aria-label (2C).
673
810
  const referenced = referencedNamesOf(el, nameWalk([], true));
@@ -891,7 +1028,7 @@ export const readSemanticsFunction = (element, options) => {
891
1028
  * edges. The walk goes on through both and only the element itself stays
892
1029
  * unlisted.
893
1030
  */
894
- const hidesSubtree = (el, style) => el.getAttribute('aria-hidden') === 'true' ||
1031
+ const hidesSubtree = (el, style) => (el.getAttribute('aria-hidden') ?? '').toLowerCase() === 'true' ||
895
1032
  isInert(el, style) ||
896
1033
  style === undefined ||
897
1034
  style.display === 'none' ||
@@ -1180,7 +1317,7 @@ export const readSemanticsFunction = (element, options) => {
1180
1317
  if (selectedState === null) {
1181
1318
  const ariaSelected = el.getAttribute('aria-selected');
1182
1319
  if (ariaSelected !== null)
1183
- selectedState = ariaSelected === 'true';
1320
+ selectedState = ariaSelected.toLowerCase() === 'true';
1184
1321
  }
1185
1322
  const disabled = isDisabled(el);
1186
1323
  const ariaExpanded = el.getAttribute('aria-expanded');
@@ -1224,8 +1361,8 @@ export const readSemanticsFunction = (element, options) => {
1224
1361
  let text;
1225
1362
  if (secure)
1226
1363
  text = '';
1227
- else if (projection.directTextOnly)
1228
- text = directTextOf(el);
1364
+ else if (projection.lineText)
1365
+ text = lineTextOf(el);
1229
1366
  else
1230
1367
  text = textOf(el);
1231
1368
  if (projection.textLimit !== null)
@@ -1320,7 +1457,9 @@ export const readSemanticsFunction = (element, options) => {
1320
1457
  return true;
1321
1458
  if (accessibleName(el) !== null)
1322
1459
  return true;
1323
- return directTextOf(el) !== '';
1460
+ if (offersAction(el) && isReadInLine(el))
1461
+ return lineTextOf(el) !== '';
1462
+ return directTextOf(el) !== '' && !isReadInLine(el);
1324
1463
  };
1325
1464
  const walk = (el, parent) => {
1326
1465
  if (truncated)
@@ -7,7 +7,7 @@
7
7
  import { readFile } from 'node:fs/promises';
8
8
  import path from 'node:path';
9
9
  import { ConfigurationError, TestError, validateJsonValue } from 'e2e/engine';
10
- import { KEEP_NAMES_HELPER } from './evaluation.js';
10
+ import { KEEP_NAMES_HELPER, pageFunctionSource } from './evaluation.js';
11
11
  import { message } from './support.js';
12
12
  /** Refuses, at config load, a `web({ initScripts })` value that is not a list of scripts. */
13
13
  export function validateInitScripts(scripts) {
@@ -93,7 +93,7 @@ async function initScriptSource(script, arg, resolvePath) {
93
93
  if (typeof script === 'string')
94
94
  return script;
95
95
  if (typeof script === 'function') {
96
- return `(() => {\n${KEEP_NAMES_HELPER}\n(${script.toString()}\n)(${arg === undefined ? '' : `JSON.parse(${JSON.stringify(JSON.stringify(arg))})`});\n})();`;
96
+ return `(() => {\n${KEEP_NAMES_HELPER}\n(${pageFunctionSource(script)}\n)(${arg === undefined ? '' : `JSON.parse(${JSON.stringify(JSON.stringify(arg))})`});\n})();`;
97
97
  }
98
98
  const file = resolvePath(script.path);
99
99
  try {
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`,