@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.
- package/README.md +15 -9
- package/dist/.build.json +1 -1
- package/dist/actions.d.ts +9 -3
- package/dist/actions.js +87 -9
- package/dist/attempt-session.d.ts +4 -3
- package/dist/attempt-session.js +17 -14
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/browser-connection.d.ts +7 -3
- package/dist/browser-connection.js +7 -1
- package/dist/browser.d.ts +44 -7
- package/dist/browser.js +135 -72
- package/dist/capture-scope.d.ts +1 -1
- package/dist/cdp-recovery.d.ts +1 -1
- package/dist/cdp-selectors.d.ts +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +42 -0
- package/dist/dialogs.d.ts +1 -1
- package/dist/downloads.d.ts +1 -1
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +74 -16
- package/dist/evaluation.d.ts +8 -3
- package/dist/evaluation.js +10 -5
- package/dist/in-page/read-semantics.d.ts +1 -0
- package/dist/in-page/read-semantics.js +166 -40
- package/dist/index.d.ts +2 -1
- package/dist/init-scripts.d.ts +30 -0
- package/dist/init-scripts.js +105 -0
- package/dist/install.d.ts +14 -1
- package/dist/install.js +77 -20
- package/dist/locators.d.ts +1 -1
- package/dist/locators.js +30 -18
- package/dist/observation-capture.d.ts +1 -1
- package/dist/observation.d.ts +1 -1
- package/dist/observation.js +3 -3
- package/dist/observe.d.ts +1 -1
- package/dist/operation-budget.d.ts +14 -0
- package/dist/operation-budget.js +31 -0
- package/dist/protected-app.d.ts +20 -4
- package/dist/protected-app.js +35 -4
- package/dist/provider-video.d.ts +1 -1
- package/dist/read-node.d.ts +9 -2
- package/dist/route-options.d.ts +44 -0
- package/dist/route-options.js +125 -0
- package/dist/selector-engines.js +2 -0
- package/dist/shown-selector.d.ts +20 -0
- package/dist/shown-selector.js +27 -0
- package/dist/support.d.ts +18 -12
- package/dist/support.js +41 -20
- package/dist/surface.d.ts +46 -6
- package/dist/surface.js +60 -19
- package/dist/video.d.ts +1 -1
- 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,
|
|
58
|
-
* document named by its title
|
|
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
|
-
|
|
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,
|
|
284
|
+
const nameWalk = (visited, accessible) => ({
|
|
266
285
|
inReference: false,
|
|
267
286
|
hiddenAllowed: false,
|
|
268
287
|
visited: new Set(visited),
|
|
269
|
-
|
|
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
|
-
|
|
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
|
|
429
|
-
* children, then its `title`. So
|
|
430
|
-
* the button "Search", an icon
|
|
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.
|
|
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.
|
|
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.
|
|
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. `
|
|
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,
|
|
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],
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
756
|
-
*
|
|
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
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
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
|
-
*
|
|
787
|
-
*
|
|
788
|
-
* it
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
* is
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|