@e2e-dev/web 0.11.2 → 0.12.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.
Files changed (50) hide show
  1. package/README.md +15 -9
  2. package/dist/.build.json +1 -1
  3. package/dist/actions.d.ts +1 -1
  4. package/dist/actions.js +54 -5
  5. package/dist/attempt-session.d.ts +1 -1
  6. package/dist/bin.d.ts +2 -0
  7. package/dist/bin.js +3 -0
  8. package/dist/browser-connection.d.ts +7 -3
  9. package/dist/browser-connection.js +7 -1
  10. package/dist/browser.d.ts +43 -6
  11. package/dist/browser.js +87 -36
  12. package/dist/capture-scope.d.ts +1 -1
  13. package/dist/cdp-recovery.d.ts +1 -1
  14. package/dist/cdp-selectors.d.ts +1 -1
  15. package/dist/cli.d.ts +9 -0
  16. package/dist/cli.js +42 -0
  17. package/dist/dialogs.d.ts +1 -1
  18. package/dist/downloads.d.ts +1 -1
  19. package/dist/engine.d.ts +1 -1
  20. package/dist/engine.js +74 -16
  21. package/dist/evaluation.d.ts +2 -0
  22. package/dist/evaluation.js +3 -1
  23. package/dist/in-page/read-semantics.d.ts +1 -0
  24. package/dist/in-page/read-semantics.js +151 -39
  25. package/dist/index.d.ts +2 -1
  26. package/dist/init-scripts.d.ts +30 -0
  27. package/dist/init-scripts.js +105 -0
  28. package/dist/install.d.ts +8 -1
  29. package/dist/install.js +35 -8
  30. package/dist/locators.d.ts +1 -1
  31. package/dist/locators.js +30 -18
  32. package/dist/observation-capture.d.ts +1 -1
  33. package/dist/observation.d.ts +1 -1
  34. package/dist/observation.js +3 -3
  35. package/dist/observe.d.ts +1 -1
  36. package/dist/protected-app.d.ts +20 -4
  37. package/dist/protected-app.js +35 -4
  38. package/dist/provider-video.d.ts +1 -1
  39. package/dist/read-node.d.ts +9 -2
  40. package/dist/route-options.d.ts +44 -0
  41. package/dist/route-options.js +125 -0
  42. package/dist/selector-engines.js +2 -0
  43. package/dist/shown-selector.d.ts +20 -0
  44. package/dist/shown-selector.js +27 -0
  45. package/dist/support.d.ts +4 -2
  46. package/dist/support.js +1 -1
  47. package/dist/surface.d.ts +40 -3
  48. package/dist/surface.js +44 -5
  49. package/dist/video.d.ts +1 -1
  50. package/package.json +9 -5
package/dist/engine.js CHANGED
@@ -10,6 +10,8 @@ import { createRequire } from 'node:module';
10
10
  import { asBrowserProvider } from './provider.js';
11
11
  import { PlaywrightSurface } from './surface.js';
12
12
  import { createBrowserFixture } from './browser.js';
13
+ import { validateInitScripts } from './init-scripts.js';
14
+ import { FIELD_VALUE_CONTROL, headerProblem } from './protected-app.js';
13
15
  /** App fields only a device target reads. */
14
16
  const NATIVE_APP_KEYS = ['bundleId', 'appPath', 'launchArguments', 'permissions'];
15
17
  /** Creates one Playwright engine: one browser per worker, one context per attempt. */
@@ -46,6 +48,12 @@ export function web(options = {}) {
46
48
  validateTestIdAttribute(options.testIdAttribute);
47
49
  if (options.userAgent !== undefined)
48
50
  validateUserAgent(options.userAgent, options.headers);
51
+ if (options.locale !== undefined)
52
+ validateLocale(options.locale, options.headers);
53
+ if (options.timezoneId !== undefined)
54
+ validateTimezoneId(options.timezoneId);
55
+ if (options.initScripts !== undefined)
56
+ validateInitScripts(options.initScripts);
49
57
  if (options.screencast !== undefined)
50
58
  validateScreencast(options.screencast);
51
59
  const reconnecting = options.connect?.reconnectEndpoint !== undefined;
@@ -55,8 +63,9 @@ export function web(options = {}) {
55
63
  // A per-attempt lease rides the same persistent context as `reconnectEndpoint`, with the same limits.
56
64
  const recoverable = reconnecting || provider?.scope === 'attempt';
57
65
  const mode = provider === undefined ? 'connect.reconnectEndpoint' : `browser provider "${provider.name}" with scope "attempt"`;
58
- if (recoverable && (options.headers !== undefined || options.basicAuth !== undefined || options.userAgent !== undefined)) {
59
- throw new ConfigurationError('INVALID_CONFIG', `${mode} uses a persistent context; headers, basicAuth, and userAgent require a newly created context`);
66
+ const creationOptions = CREATION_OPTION_KEYS.filter((key) => options[key] !== undefined);
67
+ if (recoverable && creationOptions.length > 0) {
68
+ throw new ConfigurationError('INVALID_CONFIG', `${mode} uses a persistent context; ${creationOptions.join(', ')} ${creationOptions.length === 1 ? 'requires' : 'require'} a newly created context`);
60
69
  }
61
70
  const surface = new PlaywrightSurface(options);
62
71
  const handle = defineEngine({
@@ -125,7 +134,12 @@ const WEB_OPTION_KEYS = Object.keys({
125
134
  basicAuth: true,
126
135
  testIdAttribute: true,
127
136
  userAgent: true,
137
+ locale: true,
138
+ timezoneId: true,
139
+ initScripts: true,
128
140
  });
141
+ /** The options applied when the engine creates a browser context, which a persistent context never is. */
142
+ const CREATION_OPTION_KEYS = ['headers', 'basicAuth', 'userAgent', 'locale', 'timezoneId'];
129
143
  /**
130
144
  * What a persistent context rules out, named with its cause: `what` replaces
131
145
  * the browser context, which `mode` never does. A per-attempt lease starts
@@ -135,11 +149,6 @@ function persistentContextLimit(what, mode, leased) {
135
149
  const remedy = leased ? '; every attempt already starts on a fresh browser, and scope "worker" can clear one mid-test' : '';
136
150
  return new ConfigurationError('UNSUPPORTED_CAPABILITY', `${what} is unavailable with ${mode}: it replaces the browser context, and the attempt rides one persistent context${remedy}`);
137
151
  }
138
- /** An HTTP header field name: one or more `token` characters (RFC 9110). */
139
- const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
140
- /** A control character no HTTP field value may carry; a horizontal tab is the one the grammar allows. */
141
- // oxlint-disable-next-line no-control-regex -- the control characters are the point
142
- const FIELD_VALUE_CONTROL = /[\u0000-\u0008\u000A-\u001F\u007F]/;
143
152
  /** True for a plain object; the shape both options take. Config runs as JavaScript, so the types alone are no guard. */
144
153
  function isRecord(value) {
145
154
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -155,15 +164,9 @@ function validateHeaders(headers) {
155
164
  throw new ConfigurationError('INVALID_CONFIG', 'web({ headers }) must be an object of header name to value');
156
165
  }
157
166
  for (const [name, value] of Object.entries(headers)) {
158
- if (!HEADER_NAME.test(name)) {
159
- throw new ConfigurationError('INVALID_CONFIG', `web({ headers }) has an invalid header name: "${name}"`);
160
- }
161
- if (typeof value !== 'string') {
162
- throw new ConfigurationError('INVALID_CONFIG', `web({ headers }) header "${name}" must be a string, got ${typeof value}`);
163
- }
164
- if (FIELD_VALUE_CONTROL.test(value)) {
165
- throw new ConfigurationError('INVALID_CONFIG', `web({ headers }) header "${name}" must not contain a control character`);
166
- }
167
+ const problem = headerProblem(name, value);
168
+ if (problem !== undefined)
169
+ throw new ConfigurationError('INVALID_CONFIG', `web({ headers }) ${problem}`);
167
170
  }
168
171
  }
169
172
  /** Refuses credentials the browser could not present: a missing field, or a `:` in the user name (RFC 7617). */
@@ -199,6 +202,61 @@ function validateUserAgent(userAgent, headers) {
199
202
  throw new ConfigurationError('INVALID_CONFIG', 'web({ userAgent }) and a user-agent header in web({ headers }) conflict; set userAgent only');
200
203
  }
201
204
  }
205
+ /**
206
+ * Refuses a locale that is not a BCP 47 tag, and one an `accept-language`
207
+ * header would override on the app's site while `navigator.language` kept
208
+ * reporting it.
209
+ */
210
+ function validateLocale(locale, headers) {
211
+ if (typeof locale !== 'string' || locale === '' || !isLanguageTag(locale)) {
212
+ throw new ConfigurationError('INVALID_CONFIG', `web({ locale }) must be a BCP 47 language tag such as "de-DE", got ${JSON.stringify(locale)}`);
213
+ }
214
+ if (headers !== undefined && Object.keys(headers).some((name) => name.toLowerCase() === 'accept-language')) {
215
+ throw new ConfigurationError('INVALID_CONFIG', 'web({ locale }) and an accept-language header in web({ headers }) conflict; set locale only');
216
+ }
217
+ }
218
+ /**
219
+ * True when `tag` names a language the browser can run in: a Unicode locale
220
+ * `Intl.Locale` parses, other than the undetermined `und`, which Chromium
221
+ * refuses at context creation as it does a private-use-only tag.
222
+ */
223
+ function isLanguageTag(tag) {
224
+ try {
225
+ const { language } = new Intl.Locale(tag);
226
+ return language !== undefined && language !== 'und';
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ }
232
+ /**
233
+ * Refuses, at config load, a time zone the browser would otherwise refuse at
234
+ * the first context. Best effort: Node cannot tell a miscased alias
235
+ * (`us/eastern`) from the real one, so the browser's own `Invalid timezone ID`
236
+ * stays the final word.
237
+ */
238
+ function validateTimezoneId(timezoneId) {
239
+ if (typeof timezoneId !== 'string' || timezoneId === '' || !isTimeZone(timezoneId)) {
240
+ throw new ConfigurationError('INVALID_CONFIG', `web({ timezoneId }) must be an IANA time zone such as "Europe/Berlin", got ${JSON.stringify(timezoneId)}`);
241
+ }
242
+ }
243
+ /**
244
+ * True when `timeZone` is an IANA name `Intl` resolves, spelled with its own
245
+ * case. Node also takes an offset (`+01:00`) and any casing (`europe/berlin`),
246
+ * both of which Chromium refuses; an alias resolving to another name
247
+ * (`US/Eastern`) is fine.
248
+ */
249
+ function isTimeZone(timeZone) {
250
+ if (/^[+-]/.test(timeZone))
251
+ return false;
252
+ try {
253
+ const resolved = new Intl.DateTimeFormat('en-US', { timeZone }).resolvedOptions().timeZone;
254
+ return resolved === timeZone || resolved.toLowerCase() !== timeZone.toLowerCase();
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ }
202
260
  /**
203
261
  * An attribute name the document grammar accepts (XML `Name`, ASCII): a value
204
262
  * outside it could never be on an element, so `getByTestId` would match nothing
@@ -5,6 +5,8 @@ type EvaluationResult = {
5
5
  ok: false;
6
6
  message: string;
7
7
  };
8
+ /** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
9
+ export declare const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
8
10
  /**
9
11
  * Compiles the page-side error boundary without evaluating the caller's source in this process.
10
12
  *
@@ -1,6 +1,8 @@
1
1
  /** Separates exceptions thrown by trusted page code from failures of the evaluation transport. */
2
2
  import { TestError } from 'e2e/engine';
3
3
  import { message } from './support.js';
4
+ /** The `__name` helper tsx's `keepNames` output calls, declared beside test code serialized into the page. */
5
+ export const KEEP_NAMES_HELPER = "const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });";
4
6
  /**
5
7
  * Compiles the page-side error boundary without evaluating the caller's source in this process.
6
8
  *
@@ -13,7 +15,7 @@ export function compileEvaluation(source, hasArgument) {
13
15
  try {
14
16
  return new Function('arg', `
15
17
  return (async () => {
16
- const __name = (target, value) => Object.defineProperty(target, 'name', { value, configurable: true });
18
+ ${KEEP_NAMES_HELPER}
17
19
  try {
18
20
  return { ok: true, value: await (${source}\n)(${hasArgument ? 'arg' : ''}) };
19
21
  } catch (cause) {
@@ -13,6 +13,7 @@ import type { SemanticMode, SemanticResult } from '../read-node.ts';
13
13
  * `mode.kind === 'node'` reads exactly one element for locator reads, in
14
14
  * the selector engine's task that found it (`read-selector.ts`,
15
15
  * `label-selector.ts`), so the element is always in its document.
16
+ * `mode.kind === 'hidden'` answers that one element's `states.hidden` alone.
16
17
  * `mode.kind === 'tree'` walks the subtree for one agent observation and
17
18
  * returns live element handles aligned with the flattened node list. The two
18
19
  * modes also project nodes differently; those differences are data (see
@@ -12,6 +12,7 @@
12
12
  * `mode.kind === 'node'` reads exactly one element for locator reads, in
13
13
  * the selector engine's task that found it (`read-selector.ts`,
14
14
  * `label-selector.ts`), so the element is always in its document.
15
+ * `mode.kind === 'hidden'` answers that one element's `states.hidden` alone.
15
16
  * `mode.kind === 'tree'` walks the subtree for one agent observation and
16
17
  * returns live element handles aligned with the flattened node list. The two
17
18
  * modes also project nodes differently; those differences are data (see
@@ -54,8 +55,10 @@ export const readSemanticsFunction = (element, options) => {
54
55
  /**
55
56
  * How the active mode projects one node, expressed as data so `describe`
56
57
  * stays branch-free. Tree mode is the model-bound projection: bounded text,
57
- * a bounded attribute projection, hrefs reduced to origin+path, and the root
58
- * document named by its title. Node mode is the full locator-read surface.
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.
59
62
  */
60
63
  const projection = options.mode.kind === 'tree'
61
64
  ? {
@@ -65,6 +68,7 @@ export const readSemanticsFunction = (element, options) => {
65
68
  redactHref: true,
66
69
  directTextOnly: true,
67
70
  documentRoot: true,
71
+ checkableValue: false,
68
72
  }
69
73
  : {
70
74
  attributes: null,
@@ -73,6 +77,7 @@ export const readSemanticsFunction = (element, options) => {
73
77
  redactHref: false,
74
78
  directTextOnly: false,
75
79
  documentRoot: false,
80
+ checkableValue: true,
76
81
  };
77
82
  /**
78
83
  * Per-element memo for the facts several passes need. A tree walk asks for
@@ -262,11 +267,11 @@ export const readSemanticsFunction = (element, options) => {
262
267
  /** Computed style of any element, SVG included, or undefined in a document with no view. */
263
268
  const styleOf = (el) => el.ownerDocument.defaultView?.getComputedStyle(el);
264
269
  /** A walk from the top, with the elements that may not contribute again already visited. */
265
- const nameWalk = (visited, generated) => ({
270
+ const nameWalk = (visited, accessible) => ({
266
271
  inReference: false,
267
272
  hiddenAllowed: false,
268
273
  visited: new Set(visited),
269
- generated,
274
+ accessible,
270
275
  });
271
276
  /**
272
277
  * Private-use code points: icon-font glyphs (Font Awesome's `\f090`) that
@@ -415,21 +420,86 @@ export const readSemanticsFunction = (element, options) => {
415
420
  inReference: true,
416
421
  hiddenAllowed: isReferenceHidden(target),
417
422
  visited: walk.visited,
418
- generated: walk.generated,
423
+ accessible: walk.accessible,
419
424
  }));
420
425
  }
421
426
  if (contributions.join(' ') === '')
422
427
  return null;
423
428
  return contributions.map((text) => text.replace(/\s+/g, ' ').trim()).filter((name) => name !== '');
424
429
  };
430
+ /**
431
+ * What an embedded control contributes to a name computed through another
432
+ * element (accname 2C), by the rules Playwright's role selector applies, so
433
+ * `<button>Flash the screen <input value="3"> times</button>` is the button
434
+ * "Flash the screen 3 times": a textbox or searchbox its current value (an
435
+ * editable element its text), a combobox or listbox its selected options'
436
+ * names (a native select with none selected its first option, a text field
437
+ * with none its value), and a range widget its `aria-valuetext`, else its
438
+ * `aria-valuenow`, else its `value` attribute. A menu contributes nothing,
439
+ * and neither does a secure field, whose value no name may carry. Null for
440
+ * any other element, and for one its own `aria-labelledby` names, which is
441
+ * named, not embedded.
442
+ */
443
+ const embeddedControlNameOf = (el, walk) => {
444
+ const role = implicitRole(el);
445
+ if (role === null)
446
+ return null;
447
+ if (el.id !== '' && (el.getAttribute('aria-labelledby') ?? '').trim().split(/\s+/).indexOf(el.id) !== -1)
448
+ return null;
449
+ if (el.matches(options.secureFieldSelector))
450
+ return '';
451
+ if (role === 'textbox' || role === 'searchbox') {
452
+ return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value : el.textContent ?? '';
453
+ }
454
+ if (role === 'combobox' || role === 'listbox') {
455
+ let selected;
456
+ if (el instanceof HTMLSelectElement) {
457
+ selected = Array.from(el.selectedOptions);
458
+ if (selected.length === 0 && el.options.length > 0)
459
+ selected.push(el.options[0]);
460
+ }
461
+ else {
462
+ const listbox = role === 'combobox' ? ariaOwnedOf(el).find((owned) => implicitRole(owned) === 'listbox') : el;
463
+ selected = listbox === undefined
464
+ ? []
465
+ : ariaOwnedOf(listbox).filter((owned) => owned.getAttribute('aria-selected') === 'true' && implicitRole(owned) === 'option');
466
+ }
467
+ if (selected.length === 0 && el instanceof HTMLInputElement)
468
+ return el.value;
469
+ return selected.map((option) => contentNameOf(option, styleOf(option), walk)).join(' ');
470
+ }
471
+ if (role === 'progressbar' || role === 'scrollbar' || role === 'slider' || role === 'spinbutton' || role === 'meter') {
472
+ if (el.hasAttribute('aria-valuetext'))
473
+ return el.getAttribute('aria-valuetext') ?? '';
474
+ if (el.hasAttribute('aria-valuenow'))
475
+ return el.getAttribute('aria-valuenow') ?? '';
476
+ return el.getAttribute('value') ?? '';
477
+ }
478
+ return role === 'menu' ? '' : null;
479
+ };
480
+ /**
481
+ * Every element under `el` and every element its `aria-owns` names, with
482
+ * theirs: what a composite widget owns, as Playwright's role selector
483
+ * collects a combobox's listbox and a listbox's options.
484
+ */
485
+ const ariaOwnedOf = (el) => {
486
+ const owned = Array.from(el.querySelectorAll('*'));
487
+ for (const id of (el.getAttribute('aria-owns') ?? '').trim().split(/\s+/)) {
488
+ const target = id === '' ? null : referencedElementOf(el, id);
489
+ if (target !== null)
490
+ owned.push(target, ...Array.from(target.querySelectorAll('*')));
491
+ }
492
+ return owned;
493
+ };
425
494
  /**
426
495
  * What one element contributes to a name computed through another: as an
427
496
  * aria-labelledby target (accname 2B) or as a descendant read for name from
428
- * content (2F). Its own references, else its `aria-label`, its `alt`, its
429
- * children, then its `title`. So `<button><img alt="Search"></button>` is
430
- * the button "Search", an icon whose only child is
497
+ * content (2F). Its own references, else its value as an embedded control,
498
+ * its `aria-label`, its `alt`, its children, then its `title`. So
499
+ * `<button><img alt="Search"></button>` is the button "Search", an icon
500
+ * whose only child is
431
501
  * `<svg aria-labelledby="t"><title id="t">Close</title></svg>` is "Close",
432
- * and a hidden `<span>` a button references still names it. A form
502
+ * and a hidden `<span>` a button references still names it. Any other form
433
503
  * control's content is its value, not label text, so it contributes nothing.
434
504
  * The title is the fallback only for empty content, untrimmed as Playwright
435
505
  * reads it: the spaces a block-level child adds count, so a flex container's
@@ -445,6 +515,9 @@ export const readSemanticsFunction = (element, options) => {
445
515
  const referenced = referencedNamesOf(el, walk);
446
516
  if (referenced !== null)
447
517
  return referenced.join(' ');
518
+ const embedded = walk.accessible ? embeddedControlNameOf(el, walk) : null;
519
+ if (embedded !== null)
520
+ return embedded;
448
521
  const ariaLabel = el.getAttribute('aria-label');
449
522
  if (ariaLabel !== null && ariaLabel.trim() !== '')
450
523
  return ariaLabel.trim();
@@ -469,7 +542,7 @@ export const readSemanticsFunction = (element, options) => {
469
542
  * `.next::after { content: " \2192" }` is the button "Go →".
470
543
  */
471
544
  const childrenNameOf = (el, walk) => {
472
- let out = walk.generated ? beforeContentOf(el) : '';
545
+ let out = walk.accessible ? beforeContentOf(el) : '';
473
546
  for (const child of contentChildrenOf(el)) {
474
547
  if (child.nodeType === 3) {
475
548
  out += child.nodeValue ?? '';
@@ -482,7 +555,7 @@ export const readSemanticsFunction = (element, options) => {
482
555
  const block = child.tagName === 'BR' || (style?.display ?? 'inline') !== 'inline';
483
556
  out += block ? ` ${token} ` : token;
484
557
  }
485
- return walk.generated ? out + afterContentOf(el) : out;
558
+ return walk.accessible ? out + afterContentOf(el) : out;
486
559
  };
487
560
  /**
488
561
  * The nodes a name from content reads under an element, in the order the
@@ -516,15 +589,17 @@ export const readSemanticsFunction = (element, options) => {
516
589
  * its descendants' contributions, hidden and aria-hidden subtrees dropped (a
517
590
  * required-field marker, a decorative glyph), whitespace collapsed. The
518
591
  * element's own attributes are `accessibleName`'s business; this reads what
519
- * is inside it. `generated` reads CSS generated content too, as an
520
- * accessible name does and label text does not.
592
+ * is inside it. `accessible` reads CSS generated content and embedded
593
+ * controls' values too, as an accessible name does and label text does not.
594
+ * `named` is the element the text names when that is not `el` (a control
595
+ * read through its `<label>`), which never contributes to its own name.
521
596
  */
522
- const nameTextOf = (el, generated) => {
597
+ const nameTextOf = (el, accessible, named = el) => {
523
598
  if (isNameHidden(el, styleOf(el)))
524
599
  return '';
525
600
  if (NAME_OPAQUE_TAGS.has(el.tagName))
526
601
  return '';
527
- return childrenNameOf(el, nameWalk([el], generated)).replace(/\s+/g, ' ').trim();
602
+ return childrenNameOf(el, nameWalk([el, named], accessible)).replace(/\s+/g, ' ').trim();
528
603
  };
529
604
  const NAME_OPAQUE_TAGS = new Set(['TEXTAREA', 'SELECT', 'INPUT', 'SCRIPT', 'STYLE']);
530
605
  /**
@@ -578,8 +653,14 @@ export const readSemanticsFunction = (element, options) => {
578
653
  const shown = labels.map(withoutGlyphs).filter((label) => label !== '');
579
654
  return shown.length === 0 ? null : shown;
580
655
  };
581
- /** Text owned directly by an element, excluding descendant elements. */
656
+ /**
657
+ * Text owned directly by an element, excluding descendant elements. An
658
+ * element with `content-visibility: hidden` renders none of it, as its
659
+ * `innerText` reads empty, so it owns none.
660
+ */
582
661
  const directTextOf = memoized((el) => {
662
+ if (styleOf(el)?.getPropertyValue('content-visibility') === 'hidden')
663
+ return '';
583
664
  let out = '';
584
665
  for (const child of Array.from(el.childNodes)) {
585
666
  if (child.nodeType === 3)
@@ -598,7 +679,7 @@ export const readSemanticsFunction = (element, options) => {
598
679
  const labels = associatedLabels(el);
599
680
  if (labels.length > 0) {
600
681
  const joined = labels
601
- .map((label) => nameTextOf(label, true))
682
+ .map((label) => nameTextOf(label, true, el))
602
683
  .join(' ')
603
684
  .trim();
604
685
  if (joined !== '')
@@ -752,25 +833,42 @@ export const readSemanticsFunction = (element, options) => {
752
833
  return false;
753
834
  };
754
835
  /**
755
- * The visibility Playwright's `toBeHidden` reads, so a semantic `hidden`
756
- * state and the platform's own filter agree: `aria-hidden` on the element,
836
+ * True when an ancestor in the flat tree has `content-visibility: hidden`:
837
+ * it keeps its own box and skips rendering everything under it, slotted
838
+ * and shadow content included, as `checkVisibility()` reads it. Memoized,
839
+ * so siblings under one ancestor share the walk up.
840
+ */
841
+ const isSkippedContent = memoized((el) => {
842
+ const parent = el.assignedSlot ?? parentOrHostOf(el);
843
+ if (parent === null)
844
+ return false;
845
+ return styleOf(parent)?.getPropertyValue('content-visibility') === 'hidden' || isSkippedContent(parent);
846
+ });
847
+ /**
848
+ * Render visibility, the one Playwright's `toBeVisible` and `toBeHidden`
849
+ * read, so a semantic `hidden` state and the platform's own filter agree:
757
850
  * `display: none` or a `visibility` other than `visible` on any element (an
758
- * SVG included), content a closed `<details>` folds away, and a box with no
759
- * width or no height. `display: contents` generates no box of its own while
760
- * every child still paints (Shopify's one-page checkout form is one), so
761
- * such an element is shown when some child element or text is, and hidden
762
- * when nothing under it is.
851
+ * SVG included), content a `content-visibility: hidden` ancestor skips or a
852
+ * closed `<details>` folds away, and a box with no width or no height.
853
+ * `aria-hidden` is not in it: it removes a node from the accessibility tree
854
+ * (`hidesSubtree`, a role query) while the node still paints, as a spinner
855
+ * marked decorative does. `display: contents` generates no box of its own
856
+ * while every child still paints (Shopify's one-page checkout form is one),
857
+ * so such an element is shown when some child element or text is, and
858
+ * hidden when nothing under it is. Its text paints in its own `visibility`,
859
+ * which a range's box does not reflect, so text under a hidden one is
860
+ * hidden, where Playwright calls it visible.
763
861
  */
764
862
  const isHidden = (el, style = styleOf(el)) => {
765
- if (el.getAttribute('aria-hidden') === 'true')
766
- return true;
767
863
  if (style === undefined)
768
864
  return true;
865
+ if (isSkippedContent(el))
866
+ return true;
769
867
  if (style.display === 'contents') {
770
868
  for (let child = el.firstChild; child !== null; child = child.nextSibling) {
771
869
  if (child instanceof Element && !isHidden(child))
772
870
  return false;
773
- if (child.nodeType === Node.TEXT_NODE && isVisibleText(child))
871
+ if (child.nodeType === Node.TEXT_NODE && style.visibility === 'visible' && isVisibleText(child))
774
872
  return false;
775
873
  }
776
874
  return true;
@@ -783,18 +881,20 @@ export const readSemanticsFunction = (element, options) => {
783
881
  return !(rect.width > 0 && rect.height > 0);
784
882
  };
785
883
  /**
786
- * The part of `isHidden` that holds for everything under the element too,
787
- * which is what lets the tree walk stop there. A box with no size is not in
788
- * it: a zero-height `<html>` or wrapper still shows the fixed, absolute, and
789
- * overflowing descendants laid out past its edges, so the walk goes on
790
- * through it and only the element itself stays unlisted. An inert element
791
- * is in it too (`isInert`).
884
+ * What takes the element and everything under it out of the tree walk: an
885
+ * `aria-hidden` or inert subtree, which the accessibility tree drops though
886
+ * it may paint (`isInert`), and `display: none` or a closed `<details>`
887
+ * body, which no descendant can undo. A `visibility` other than `visible`
888
+ * is not in it, since a descendant may set `visibility: visible` and paint
889
+ * again; nor is a box with no size: a zero-height `<html>` or wrapper still
890
+ * shows the fixed, absolute, and overflowing descendants laid out past its
891
+ * edges. The walk goes on through both and only the element itself stays
892
+ * unlisted.
792
893
  */
793
894
  const hidesSubtree = (el, style) => el.getAttribute('aria-hidden') === 'true' ||
794
895
  isInert(el, style) ||
795
896
  style === undefined ||
796
897
  style.display === 'none' ||
797
- (style.display !== 'contents' && style.visibility !== 'visible') ||
798
898
  isInClosedDetails(el);
799
899
  /**
800
900
  * Inert content takes no input and is hidden from assistive technology, as
@@ -1034,9 +1134,13 @@ export const readSemanticsFunction = (element, options) => {
1034
1134
  let checked = null;
1035
1135
  let selectedState = null;
1036
1136
  if (el instanceof HTMLInputElement) {
1037
- if (el.type === 'checkbox' || el.type === 'radio')
1137
+ // A checkbox or radio submits its value attribute (`on` by default)
1138
+ // whatever its checked state, so a locator read reports both; the tree
1139
+ // drops that token as it drops an option's.
1140
+ const checkable = el.type === 'checkbox' || el.type === 'radio';
1141
+ if (checkable)
1038
1142
  checked = el.checked;
1039
- else
1143
+ if (!checkable || projection.checkableValue)
1040
1144
  value = el.value;
1041
1145
  }
1042
1146
  else if (el instanceof HTMLTextAreaElement) {
@@ -1134,7 +1238,10 @@ export const readSemanticsFunction = (element, options) => {
1134
1238
  const isDocumentRoot = projection.documentRoot && tag === 'html';
1135
1239
  if (isDocumentRoot)
1136
1240
  name = el.ownerDocument.title;
1137
- const rect = el.getBoundingClientRect();
1241
+ // No layout box at all (`display: none`, an empty `display: contents`) is
1242
+ // no rect, as Playwright's `boundingBox()` answers null; a box with no
1243
+ // size, or one `visibility: hidden` keeps, is still a rect.
1244
+ const rect = el.getClientRects().length === 0 ? null : el.getBoundingClientRect();
1138
1245
  const role = isDocumentRoot ? 'document' : roleOf(el, tag);
1139
1246
  let level = null;
1140
1247
  if (role === 'heading') {
@@ -1168,11 +1275,13 @@ export const readSemanticsFunction = (element, options) => {
1168
1275
  level,
1169
1276
  attributes,
1170
1277
  testId: el.getAttribute(options.testIdAttribute),
1171
- rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1278
+ rect: rect === null ? null : { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1172
1279
  };
1173
1280
  };
1174
1281
  // The conditional return type resolves per call site; inside the body the
1175
- // discriminant narrows the value but not the generic, hence the two casts.
1282
+ // discriminant narrows the value but not the generic, hence the casts.
1283
+ if (options.mode.kind === 'hidden')
1284
+ return isHidden(element);
1176
1285
  if (options.mode.kind === 'node') {
1177
1286
  return describe(element);
1178
1287
  }
@@ -1280,6 +1389,9 @@ export const readSemanticsFunction = (element, options) => {
1280
1389
  }
1281
1390
  if (OPAQUE_TAGS.indexOf(tag) !== -1)
1282
1391
  return;
1392
+ // The element keeps its box, and nothing under it renders (`isSkippedContent`).
1393
+ if (style?.getPropertyValue('content-visibility') === 'hidden')
1394
+ return;
1283
1395
  for (const child of Array.from(el.children))
1284
1396
  walk(child, nextParent);
1285
1397
  // A shadow root is part of what the user sees, so it is part of what the
package/dist/index.d.ts CHANGED
@@ -8,10 +8,11 @@ import type { Browser } from './browser.ts';
8
8
  export { web, surfaceOf } from './engine.ts';
9
9
  export type { PlaywrightLiveSurface } from './engine.ts';
10
10
  export type { WebBasicAuth, WebConnectOptions, WebOptions, WebScreencastOptions } from './surface.ts';
11
+ export type { WebInitScript } from './init-scripts.ts';
11
12
  export type { BrowserDownloadContext, BrowserLease, BrowserProvider, BrowserProviderDownloads, BrowserProviderScope, BrowserReleaseContext, BrowserRequest, } from './provider.ts';
12
13
  export type { BrowserName } from './browser-connection.ts';
13
14
  export type { Dialog, DialogHandler } from './dialogs.ts';
14
- export type { Browser, BrowserExpectation, Cookie, CookieFields, FrameScreen, RouteFulfillResponse, WebResponse, WebRoute, } from './browser.ts';
15
+ export type { Browser, BrowserExpectation, Cookie, CookieFields, FrameScreen, RouteContinueOverrides, RouteFulfillResponse, WebResponse, WebRoute, } from './browser.ts';
15
16
  /**
16
17
  * `test` typed with this engine's contributed `browser` fixture. The same
17
18
  * runtime `test` as `e2e`'s; only the fixture types differ.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * One init script: a string of JavaScript source, a `{ path }` to a file of
3
+ * it relative to the project root, or a function serialized into the page,
4
+ * which can close over nothing from the test process.
5
+ */
6
+ export type WebInitScript = string | {
7
+ readonly path: string;
8
+ } | (() => unknown);
9
+ /** Refuses, at config load, a `web({ initScripts })` value that is not a list of scripts. */
10
+ export declare function validateInitScripts(scripts: unknown): void;
11
+ /** The configured `initScripts`, read into page source once per process and handed to each attempt as the start of its own list. */
12
+ export declare class ConfiguredInitScripts {
13
+ private readonly scripts;
14
+ private sources;
15
+ constructor(scripts: readonly WebInitScript[]);
16
+ /** Reads every script, a `path` against the project root; a file that cannot be read is `INVALID_CONFIG`. */
17
+ load(projectRoot: string): Promise<void>;
18
+ /** A new attempt's init scripts: the configured ones, to which `browser.addInitScript` appends. */
19
+ forAttempt(): string[];
20
+ }
21
+ /**
22
+ * Validates one script `browser.addInitScript` was given and reads it into
23
+ * page source, a `path` through `resolvePath`. Only a function takes an
24
+ * argument, as in Playwright.
25
+ */
26
+ export declare function testInitScriptSource(script: unknown, argument: {
27
+ readonly arg: unknown;
28
+ } | undefined, resolvePath: (file: string) => string): Promise<string>;
29
+ /** The step label of a script: the file a `path` names, else its kind, never its source. */
30
+ export declare function initScriptLabel(script: unknown): string;