@e2e-dev/web 0.11.2 → 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.
Files changed (53) hide show
  1. package/README.md +15 -9
  2. package/dist/.build.json +1 -1
  3. package/dist/actions.d.ts +9 -3
  4. package/dist/actions.js +87 -9
  5. package/dist/attempt-session.d.ts +4 -3
  6. package/dist/attempt-session.js +17 -14
  7. package/dist/bin.d.ts +2 -0
  8. package/dist/bin.js +3 -0
  9. package/dist/browser-connection.d.ts +7 -3
  10. package/dist/browser-connection.js +7 -1
  11. package/dist/browser.d.ts +44 -7
  12. package/dist/browser.js +135 -72
  13. package/dist/capture-scope.d.ts +1 -1
  14. package/dist/cdp-recovery.d.ts +1 -1
  15. package/dist/cdp-selectors.d.ts +1 -1
  16. package/dist/cli.d.ts +9 -0
  17. package/dist/cli.js +42 -0
  18. package/dist/dialogs.d.ts +1 -1
  19. package/dist/downloads.d.ts +1 -1
  20. package/dist/engine.d.ts +1 -1
  21. package/dist/engine.js +74 -16
  22. package/dist/evaluation.d.ts +8 -3
  23. package/dist/evaluation.js +10 -5
  24. package/dist/in-page/read-semantics.d.ts +1 -0
  25. package/dist/in-page/read-semantics.js +166 -40
  26. package/dist/index.d.ts +2 -1
  27. package/dist/init-scripts.d.ts +30 -0
  28. package/dist/init-scripts.js +105 -0
  29. package/dist/install.d.ts +14 -1
  30. package/dist/install.js +77 -20
  31. package/dist/locators.d.ts +1 -1
  32. package/dist/locators.js +30 -18
  33. package/dist/observation-capture.d.ts +1 -1
  34. package/dist/observation.d.ts +1 -1
  35. package/dist/observation.js +3 -3
  36. package/dist/observe.d.ts +1 -1
  37. package/dist/operation-budget.d.ts +14 -0
  38. package/dist/operation-budget.js +31 -0
  39. package/dist/protected-app.d.ts +20 -4
  40. package/dist/protected-app.js +35 -4
  41. package/dist/provider-video.d.ts +1 -1
  42. package/dist/read-node.d.ts +9 -2
  43. package/dist/route-options.d.ts +44 -0
  44. package/dist/route-options.js +125 -0
  45. package/dist/selector-engines.js +2 -0
  46. package/dist/shown-selector.d.ts +20 -0
  47. package/dist/shown-selector.js +27 -0
  48. package/dist/support.d.ts +18 -12
  49. package/dist/support.js +41 -20
  50. package/dist/surface.d.ts +46 -6
  51. package/dist/surface.js +60 -19
  52. package/dist/video.d.ts +1 -1
  53. package/package.json +10 -6
@@ -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
@@ -214,7 +219,21 @@ export const readSemanticsFunction = (element, options) => {
214
219
  }
215
220
  case 'th': {
216
221
  const scope = (el.getAttribute('scope') ?? '').toLowerCase();
217
- 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';
218
237
  }
219
238
  case 'dialog':
220
239
  return 'dialog';
@@ -262,11 +281,11 @@ export const readSemanticsFunction = (element, options) => {
262
281
  /** Computed style of any element, SVG included, or undefined in a document with no view. */
263
282
  const styleOf = (el) => el.ownerDocument.defaultView?.getComputedStyle(el);
264
283
  /** A walk from the top, with the elements that may not contribute again already visited. */
265
- const nameWalk = (visited, generated) => ({
284
+ const nameWalk = (visited, accessible) => ({
266
285
  inReference: false,
267
286
  hiddenAllowed: false,
268
287
  visited: new Set(visited),
269
- generated,
288
+ accessible,
270
289
  });
271
290
  /**
272
291
  * Private-use code points: icon-font glyphs (Font Awesome's `\f090`) that
@@ -415,21 +434,86 @@ export const readSemanticsFunction = (element, options) => {
415
434
  inReference: true,
416
435
  hiddenAllowed: isReferenceHidden(target),
417
436
  visited: walk.visited,
418
- generated: walk.generated,
437
+ accessible: walk.accessible,
419
438
  }));
420
439
  }
421
440
  if (contributions.join(' ') === '')
422
441
  return null;
423
442
  return contributions.map((text) => text.replace(/\s+/g, ' ').trim()).filter((name) => name !== '');
424
443
  };
444
+ /**
445
+ * What an embedded control contributes to a name computed through another
446
+ * element (accname 2C), by the rules Playwright's role selector applies, so
447
+ * `<button>Flash the screen <input value="3"> times</button>` is the button
448
+ * "Flash the screen 3 times": a textbox or searchbox its current value (an
449
+ * editable element its text), a combobox or listbox its selected options'
450
+ * names (a native select with none selected its first option, a text field
451
+ * with none its value), and a range widget its `aria-valuetext`, else its
452
+ * `aria-valuenow`, else its `value` attribute. A menu contributes nothing,
453
+ * and neither does a secure field, whose value no name may carry. Null for
454
+ * any other element, and for one its own `aria-labelledby` names, which is
455
+ * named, not embedded.
456
+ */
457
+ const embeddedControlNameOf = (el, walk) => {
458
+ const role = implicitRole(el);
459
+ if (role === null)
460
+ return null;
461
+ if (el.id !== '' && (el.getAttribute('aria-labelledby') ?? '').trim().split(/\s+/).indexOf(el.id) !== -1)
462
+ return null;
463
+ if (el.matches(options.secureFieldSelector))
464
+ return '';
465
+ if (role === 'textbox' || role === 'searchbox') {
466
+ return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value : el.textContent ?? '';
467
+ }
468
+ if (role === 'combobox' || role === 'listbox') {
469
+ let selected;
470
+ if (el instanceof HTMLSelectElement) {
471
+ selected = Array.from(el.selectedOptions);
472
+ if (selected.length === 0 && el.options.length > 0)
473
+ selected.push(el.options[0]);
474
+ }
475
+ else {
476
+ const listbox = role === 'combobox' ? ariaOwnedOf(el).find((owned) => implicitRole(owned) === 'listbox') : el;
477
+ selected = listbox === undefined
478
+ ? []
479
+ : ariaOwnedOf(listbox).filter((owned) => owned.getAttribute('aria-selected') === 'true' && implicitRole(owned) === 'option');
480
+ }
481
+ if (selected.length === 0 && el instanceof HTMLInputElement)
482
+ return el.value;
483
+ return selected.map((option) => contentNameOf(option, styleOf(option), walk)).join(' ');
484
+ }
485
+ if (role === 'progressbar' || role === 'scrollbar' || role === 'slider' || role === 'spinbutton' || role === 'meter') {
486
+ if (el.hasAttribute('aria-valuetext'))
487
+ return el.getAttribute('aria-valuetext') ?? '';
488
+ if (el.hasAttribute('aria-valuenow'))
489
+ return el.getAttribute('aria-valuenow') ?? '';
490
+ return el.getAttribute('value') ?? '';
491
+ }
492
+ return role === 'menu' ? '' : null;
493
+ };
494
+ /**
495
+ * Every element under `el` and every element its `aria-owns` names, with
496
+ * theirs: what a composite widget owns, as Playwright's role selector
497
+ * collects a combobox's listbox and a listbox's options.
498
+ */
499
+ const ariaOwnedOf = (el) => {
500
+ const owned = Array.from(el.querySelectorAll('*'));
501
+ for (const id of (el.getAttribute('aria-owns') ?? '').trim().split(/\s+/)) {
502
+ const target = id === '' ? null : referencedElementOf(el, id);
503
+ if (target !== null)
504
+ owned.push(target, ...Array.from(target.querySelectorAll('*')));
505
+ }
506
+ return owned;
507
+ };
425
508
  /**
426
509
  * What one element contributes to a name computed through another: as an
427
510
  * 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
511
+ * content (2F). Its own references, else its value as an embedded control,
512
+ * its `aria-label`, its `alt`, its children, then its `title`. So
513
+ * `<button><img alt="Search"></button>` is the button "Search", an icon
514
+ * whose only child is
431
515
  * `<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
516
+ * and a hidden `<span>` a button references still names it. Any other form
433
517
  * control's content is its value, not label text, so it contributes nothing.
434
518
  * The title is the fallback only for empty content, untrimmed as Playwright
435
519
  * reads it: the spaces a block-level child adds count, so a flex container's
@@ -445,6 +529,9 @@ export const readSemanticsFunction = (element, options) => {
445
529
  const referenced = referencedNamesOf(el, walk);
446
530
  if (referenced !== null)
447
531
  return referenced.join(' ');
532
+ const embedded = walk.accessible ? embeddedControlNameOf(el, walk) : null;
533
+ if (embedded !== null)
534
+ return embedded;
448
535
  const ariaLabel = el.getAttribute('aria-label');
449
536
  if (ariaLabel !== null && ariaLabel.trim() !== '')
450
537
  return ariaLabel.trim();
@@ -469,7 +556,7 @@ export const readSemanticsFunction = (element, options) => {
469
556
  * `.next::after { content: " \2192" }` is the button "Go →".
470
557
  */
471
558
  const childrenNameOf = (el, walk) => {
472
- let out = walk.generated ? beforeContentOf(el) : '';
559
+ let out = walk.accessible ? beforeContentOf(el) : '';
473
560
  for (const child of contentChildrenOf(el)) {
474
561
  if (child.nodeType === 3) {
475
562
  out += child.nodeValue ?? '';
@@ -482,7 +569,7 @@ export const readSemanticsFunction = (element, options) => {
482
569
  const block = child.tagName === 'BR' || (style?.display ?? 'inline') !== 'inline';
483
570
  out += block ? ` ${token} ` : token;
484
571
  }
485
- return walk.generated ? out + afterContentOf(el) : out;
572
+ return walk.accessible ? out + afterContentOf(el) : out;
486
573
  };
487
574
  /**
488
575
  * The nodes a name from content reads under an element, in the order the
@@ -516,15 +603,17 @@ export const readSemanticsFunction = (element, options) => {
516
603
  * its descendants' contributions, hidden and aria-hidden subtrees dropped (a
517
604
  * required-field marker, a decorative glyph), whitespace collapsed. The
518
605
  * 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.
606
+ * is inside it. `accessible` reads CSS generated content and embedded
607
+ * controls' values too, as an accessible name does and label text does not.
608
+ * `named` is the element the text names when that is not `el` (a control
609
+ * read through its `<label>`), which never contributes to its own name.
521
610
  */
522
- const nameTextOf = (el, generated) => {
611
+ const nameTextOf = (el, accessible, named = el) => {
523
612
  if (isNameHidden(el, styleOf(el)))
524
613
  return '';
525
614
  if (NAME_OPAQUE_TAGS.has(el.tagName))
526
615
  return '';
527
- return childrenNameOf(el, nameWalk([el], generated)).replace(/\s+/g, ' ').trim();
616
+ return childrenNameOf(el, nameWalk([el, named], accessible)).replace(/\s+/g, ' ').trim();
528
617
  };
529
618
  const NAME_OPAQUE_TAGS = new Set(['TEXTAREA', 'SELECT', 'INPUT', 'SCRIPT', 'STYLE']);
530
619
  /**
@@ -578,8 +667,14 @@ export const readSemanticsFunction = (element, options) => {
578
667
  const shown = labels.map(withoutGlyphs).filter((label) => label !== '');
579
668
  return shown.length === 0 ? null : shown;
580
669
  };
581
- /** Text owned directly by an element, excluding descendant elements. */
670
+ /**
671
+ * Text owned directly by an element, excluding descendant elements. An
672
+ * element with `content-visibility: hidden` renders none of it, as its
673
+ * `innerText` reads empty, so it owns none.
674
+ */
582
675
  const directTextOf = memoized((el) => {
676
+ if (styleOf(el)?.getPropertyValue('content-visibility') === 'hidden')
677
+ return '';
583
678
  let out = '';
584
679
  for (const child of Array.from(el.childNodes)) {
585
680
  if (child.nodeType === 3)
@@ -598,7 +693,7 @@ export const readSemanticsFunction = (element, options) => {
598
693
  const labels = associatedLabels(el);
599
694
  if (labels.length > 0) {
600
695
  const joined = labels
601
- .map((label) => nameTextOf(label, true))
696
+ .map((label) => nameTextOf(label, true, el))
602
697
  .join(' ')
603
698
  .trim();
604
699
  if (joined !== '')
@@ -752,25 +847,42 @@ export const readSemanticsFunction = (element, options) => {
752
847
  return false;
753
848
  };
754
849
  /**
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,
850
+ * True when an ancestor in the flat tree has `content-visibility: hidden`:
851
+ * it keeps its own box and skips rendering everything under it, slotted
852
+ * and shadow content included, as `checkVisibility()` reads it. Memoized,
853
+ * so siblings under one ancestor share the walk up.
854
+ */
855
+ const isSkippedContent = memoized((el) => {
856
+ const parent = el.assignedSlot ?? parentOrHostOf(el);
857
+ if (parent === null)
858
+ return false;
859
+ return styleOf(parent)?.getPropertyValue('content-visibility') === 'hidden' || isSkippedContent(parent);
860
+ });
861
+ /**
862
+ * Render visibility, the one Playwright's `toBeVisible` and `toBeHidden`
863
+ * read, so a semantic `hidden` state and the platform's own filter agree:
757
864
  * `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.
865
+ * SVG included), content a `content-visibility: hidden` ancestor skips or a
866
+ * closed `<details>` folds away, and a box with no width or no height.
867
+ * `aria-hidden` is not in it: it removes a node from the accessibility tree
868
+ * (`hidesSubtree`, a role query) while the node still paints, as a spinner
869
+ * marked decorative does. `display: contents` generates no box of its own
870
+ * while every child still paints (Shopify's one-page checkout form is one),
871
+ * so such an element is shown when some child element or text is, and
872
+ * hidden when nothing under it is. Its text paints in its own `visibility`,
873
+ * which a range's box does not reflect, so text under a hidden one is
874
+ * hidden, where Playwright calls it visible.
763
875
  */
764
876
  const isHidden = (el, style = styleOf(el)) => {
765
- if (el.getAttribute('aria-hidden') === 'true')
766
- return true;
767
877
  if (style === undefined)
768
878
  return true;
879
+ if (isSkippedContent(el))
880
+ return true;
769
881
  if (style.display === 'contents') {
770
882
  for (let child = el.firstChild; child !== null; child = child.nextSibling) {
771
883
  if (child instanceof Element && !isHidden(child))
772
884
  return false;
773
- if (child.nodeType === Node.TEXT_NODE && isVisibleText(child))
885
+ if (child.nodeType === Node.TEXT_NODE && style.visibility === 'visible' && isVisibleText(child))
774
886
  return false;
775
887
  }
776
888
  return true;
@@ -783,18 +895,20 @@ export const readSemanticsFunction = (element, options) => {
783
895
  return !(rect.width > 0 && rect.height > 0);
784
896
  };
785
897
  /**
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`).
898
+ * What takes the element and everything under it out of the tree walk: an
899
+ * `aria-hidden` or inert subtree, which the accessibility tree drops though
900
+ * it may paint (`isInert`), and `display: none` or a closed `<details>`
901
+ * body, which no descendant can undo. A `visibility` other than `visible`
902
+ * is not in it, since a descendant may set `visibility: visible` and paint
903
+ * again; nor is a box with no size: a zero-height `<html>` or wrapper still
904
+ * shows the fixed, absolute, and overflowing descendants laid out past its
905
+ * edges. The walk goes on through both and only the element itself stays
906
+ * unlisted.
792
907
  */
793
908
  const hidesSubtree = (el, style) => el.getAttribute('aria-hidden') === 'true' ||
794
909
  isInert(el, style) ||
795
910
  style === undefined ||
796
911
  style.display === 'none' ||
797
- (style.display !== 'contents' && style.visibility !== 'visible') ||
798
912
  isInClosedDetails(el);
799
913
  /**
800
914
  * Inert content takes no input and is hidden from assistive technology, as
@@ -1034,9 +1148,13 @@ export const readSemanticsFunction = (element, options) => {
1034
1148
  let checked = null;
1035
1149
  let selectedState = null;
1036
1150
  if (el instanceof HTMLInputElement) {
1037
- if (el.type === 'checkbox' || el.type === 'radio')
1151
+ // A checkbox or radio submits its value attribute (`on` by default)
1152
+ // whatever its checked state, so a locator read reports both; the tree
1153
+ // drops that token as it drops an option's.
1154
+ const checkable = el.type === 'checkbox' || el.type === 'radio';
1155
+ if (checkable)
1038
1156
  checked = el.checked;
1039
- else
1157
+ if (!checkable || projection.checkableValue)
1040
1158
  value = el.value;
1041
1159
  }
1042
1160
  else if (el instanceof HTMLTextAreaElement) {
@@ -1134,7 +1252,10 @@ export const readSemanticsFunction = (element, options) => {
1134
1252
  const isDocumentRoot = projection.documentRoot && tag === 'html';
1135
1253
  if (isDocumentRoot)
1136
1254
  name = el.ownerDocument.title;
1137
- const rect = el.getBoundingClientRect();
1255
+ // No layout box at all (`display: none`, an empty `display: contents`) is
1256
+ // no rect, as Playwright's `boundingBox()` answers null; a box with no
1257
+ // size, or one `visibility: hidden` keeps, is still a rect.
1258
+ const rect = el.getClientRects().length === 0 ? null : el.getBoundingClientRect();
1138
1259
  const role = isDocumentRoot ? 'document' : roleOf(el, tag);
1139
1260
  let level = null;
1140
1261
  if (role === 'heading') {
@@ -1168,11 +1289,13 @@ export const readSemanticsFunction = (element, options) => {
1168
1289
  level,
1169
1290
  attributes,
1170
1291
  testId: el.getAttribute(options.testIdAttribute),
1171
- rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1292
+ rect: rect === null ? null : { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
1172
1293
  };
1173
1294
  };
1174
1295
  // 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.
1296
+ // discriminant narrows the value but not the generic, hence the casts.
1297
+ if (options.mode.kind === 'hidden')
1298
+ return isHidden(element);
1176
1299
  if (options.mode.kind === 'node') {
1177
1300
  return describe(element);
1178
1301
  }
@@ -1280,6 +1403,9 @@ export const readSemanticsFunction = (element, options) => {
1280
1403
  }
1281
1404
  if (OPAQUE_TAGS.indexOf(tag) !== -1)
1282
1405
  return;
1406
+ // The element keeps its box, and nothing under it renders (`isSkippedContent`).
1407
+ if (style?.getPropertyValue('content-visibility') === 'hidden')
1408
+ return;
1283
1409
  for (const child of Array.from(el.children))
1284
1410
  walk(child, nextParent);
1285
1411
  // 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;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Init scripts: test code every document of an attempt runs after it is
3
+ * created and before any of its own scripts, in every tab and frame, as
4
+ * Playwright's `addInitScript` runs it. The configured ones apply to every
5
+ * attempt; `browser.addInitScript` adds more for one attempt.
6
+ */
7
+ import { readFile } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { ConfigurationError, TestError, validateJsonValue } from 'e2e/engine';
10
+ import { KEEP_NAMES_HELPER } from './evaluation.js';
11
+ import { message } from './support.js';
12
+ /** Refuses, at config load, a `web({ initScripts })` value that is not a list of scripts. */
13
+ export function validateInitScripts(scripts) {
14
+ if (!Array.isArray(scripts)) {
15
+ throw new ConfigurationError('INVALID_CONFIG', 'web({ initScripts }) must be an array of scripts');
16
+ }
17
+ // Indexed, not `forEach`, so a hole in a sparse array is refused like `undefined`.
18
+ for (let index = 0; index < scripts.length; index += 1) {
19
+ assertInitScript(scripts[index], (problem) => new ConfigurationError('INVALID_CONFIG', `web({ initScripts })[${index}] ${problem}`));
20
+ }
21
+ }
22
+ /** The configured `initScripts`, read into page source once per process and handed to each attempt as the start of its own list. */
23
+ export class ConfiguredInitScripts {
24
+ scripts;
25
+ sources = [];
26
+ constructor(scripts) {
27
+ this.scripts = scripts;
28
+ }
29
+ /** Reads every script, a `path` against the project root; a file that cannot be read is `INVALID_CONFIG`. */
30
+ async load(projectRoot) {
31
+ if (this.scripts.length === 0)
32
+ return;
33
+ this.sources = await Promise.all(this.scripts.map(async (script, index) => {
34
+ try {
35
+ return await initScriptSource(script, undefined, (file) => path.resolve(projectRoot, file));
36
+ }
37
+ catch (cause) {
38
+ throw new ConfigurationError('INVALID_CONFIG', `web({ initScripts })[${index}] ${message(cause)}`, { cause });
39
+ }
40
+ }));
41
+ }
42
+ /** A new attempt's init scripts: the configured ones, to which `browser.addInitScript` appends. */
43
+ forAttempt() {
44
+ return [...this.sources];
45
+ }
46
+ }
47
+ /**
48
+ * Validates one script `browser.addInitScript` was given and reads it into
49
+ * page source, a `path` through `resolvePath`. Only a function takes an
50
+ * argument, as in Playwright.
51
+ */
52
+ export async function testInitScriptSource(script, argument, resolvePath) {
53
+ assertInitScript(script, (problem) => new TestError('INVALID_ARGUMENT', `browser.addInitScript script ${problem}`));
54
+ if (argument !== undefined && typeof script !== 'function') {
55
+ throw new TestError('INVALID_ARGUMENT', 'browser.addInitScript takes an argument only with a function script');
56
+ }
57
+ validateJsonValue(argument?.arg, 'addInitScript argument');
58
+ try {
59
+ return await initScriptSource(script, argument?.arg, resolvePath);
60
+ }
61
+ catch (cause) {
62
+ throw new TestError('INVALID_ARGUMENT', `browser.addInitScript ${message(cause)}`, { cause });
63
+ }
64
+ }
65
+ /** The step label of a script: the file a `path` names, else its kind, never its source. */
66
+ export function initScriptLabel(script) {
67
+ if (typeof script === 'object' && script !== null && 'path' in script)
68
+ return String(script.path);
69
+ return typeof script === 'function' ? 'function' : 'source';
70
+ }
71
+ /** Throws what `fail` makes of the problem unless `script` is one init script. */
72
+ function assertInitScript(script, fail) {
73
+ if (typeof script === 'string' || typeof script === 'function')
74
+ return;
75
+ if (typeof script !== 'object' || script === null || Array.isArray(script)) {
76
+ const got = script === null ? 'null' : Array.isArray(script) ? 'an array' : typeof script;
77
+ throw fail(`must be a string of source, a { path }, or a function, got ${got}`);
78
+ }
79
+ const unknown = Object.keys(script).filter((key) => key !== 'path');
80
+ if (unknown.length > 0)
81
+ throw fail(`takes only path, got ${unknown.join(', ')}`);
82
+ if (!('path' in script) || typeof script.path !== 'string' || script.path === '')
83
+ throw fail('path must be a non-empty string');
84
+ }
85
+ /**
86
+ * The page source of one script. A function is called with its JSON argument
87
+ * as Playwright calls it, beside the `__name` helper tsx's output needs; the
88
+ * argument is parsed in the page, since an object literal would turn an own
89
+ * `__proto__` key into the prototype. A
90
+ * file gets a `sourceURL`, so a stack trace in the page names it.
91
+ */
92
+ async function initScriptSource(script, arg, resolvePath) {
93
+ if (typeof script === 'string')
94
+ return script;
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})();`;
97
+ }
98
+ const file = resolvePath(script.path);
99
+ try {
100
+ return `${await readFile(file, 'utf8')}\n//# sourceURL=${file.replace(/[\r\n]/g, '')}`;
101
+ }
102
+ catch (cause) {
103
+ throw new Error(`path cannot be read: ${file} (${cause.code ?? message(cause)})`, { cause });
104
+ }
105
+ }
package/dist/install.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  /** First-run browser provisioning for the Playwright engine. */
2
2
  import { type BrowserName } from './browser-connection.ts';
3
+ /**
4
+ * Runs the pinned Playwright CLI with `args`, sharing this process's terminal,
5
+ * and resolves to its exit code, 128 plus the signal number when a signal
6
+ * ended it. SIGINT and SIGTERM sent to this process reach the child, so a
7
+ * cancelled CI job does not leave a download running. Backs the `e2e-web` command.
8
+ */
9
+ export declare function runPlaywrightCli(args: readonly string[]): Promise<number>;
3
10
  /** What an installer receives besides the browser names. */
4
11
  export interface InstallContext {
5
12
  /** Receives every line the installer prints: the download URL, progress, warnings. */
@@ -8,6 +15,8 @@ export interface InstallContext {
8
15
  readonly signal?: AbortSignal | undefined;
9
16
  /** Environment the installer runs with: the run's, so it fills the cache the workers will launch from. */
10
17
  readonly env: NodeJS.ProcessEnv;
18
+ /** Whether the run launches a window, which decides the chromium build it needs. */
19
+ readonly headed: boolean;
11
20
  }
12
21
  export interface EnsureBrowsersOptions {
13
22
  /**
@@ -27,10 +36,14 @@ export interface EnsureBrowsersOptions {
27
36
  readonly signal?: AbortSignal;
28
37
  /** The run's environment; defaults to this process's. */
29
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;
30
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[];
31
44
  /**
32
45
  * Ensures the given Playwright browsers are installed, downloading any
33
- * missing ones via `playwright install`. Called from the engine's `prepare`,
46
+ * missing ones via `playwright-core install`. Called from the engine's `prepare`,
34
47
  * once per run before any worker starts, so a download is never charged
35
48
  * against a launch timeout and never runs once per worker. Concurrent
36
49
  * installs (two runs at once) are still safe: the Playwright CLI serializes