@e2e-dev/web 0.11.0-canary-20260928184528
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/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +138 -0
- package/dist/.build.json +1 -0
- package/dist/actions.d.ts +23 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +174 -0
- package/dist/actions.js.map +1 -0
- package/dist/attempt-session.d.ts +77 -0
- package/dist/attempt-session.d.ts.map +1 -0
- package/dist/attempt-session.js +293 -0
- package/dist/attempt-session.js.map +1 -0
- package/dist/browser-connection.d.ts +34 -0
- package/dist/browser-connection.d.ts.map +1 -0
- package/dist/browser-connection.js +86 -0
- package/dist/browser-connection.js.map +1 -0
- package/dist/capture-scope.d.ts +21 -0
- package/dist/capture-scope.d.ts.map +1 -0
- package/dist/capture-scope.js +61 -0
- package/dist/capture-scope.js.map +1 -0
- package/dist/cdp-recovery.d.ts +29 -0
- package/dist/cdp-recovery.d.ts.map +1 -0
- package/dist/cdp-recovery.js +84 -0
- package/dist/cdp-recovery.js.map +1 -0
- package/dist/cdp-selectors.d.ts +15 -0
- package/dist/cdp-selectors.d.ts.map +1 -0
- package/dist/cdp-selectors.js +52 -0
- package/dist/cdp-selectors.js.map +1 -0
- package/dist/closed-shadow.d.ts +64 -0
- package/dist/closed-shadow.d.ts.map +1 -0
- package/dist/closed-shadow.js +142 -0
- package/dist/closed-shadow.js.map +1 -0
- package/dist/dialogs.d.ts +38 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.js +87 -0
- package/dist/dialogs.js.map +1 -0
- package/dist/engine.d.ts +26 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +176 -0
- package/dist/engine.js.map +1 -0
- package/dist/evaluation.d.ts +18 -0
- package/dist/evaluation.d.ts.map +1 -0
- package/dist/evaluation.js +35 -0
- package/dist/evaluation.js.map +1 -0
- package/dist/in-page/read-semantics.d.ts +28 -0
- package/dist/in-page/read-semantics.d.ts.map +1 -0
- package/dist/in-page/read-semantics.js +1178 -0
- package/dist/in-page/read-semantics.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/install.d.ts +40 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +117 -0
- package/dist/install.js.map +1 -0
- package/dist/label-selector.d.ts +37 -0
- package/dist/label-selector.d.ts.map +1 -0
- package/dist/label-selector.js +53 -0
- package/dist/label-selector.js.map +1 -0
- package/dist/locators.d.ts +76 -0
- package/dist/locators.d.ts.map +1 -0
- package/dist/locators.js +289 -0
- package/dist/locators.js.map +1 -0
- package/dist/observation-capture.d.ts +12 -0
- package/dist/observation-capture.d.ts.map +1 -0
- package/dist/observation-capture.js +93 -0
- package/dist/observation-capture.js.map +1 -0
- package/dist/observation.d.ts +63 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/observation.js +280 -0
- package/dist/observation.js.map +1 -0
- package/dist/observe.d.ts +51 -0
- package/dist/observe.d.ts.map +1 -0
- package/dist/observe.js +105 -0
- package/dist/observe.js.map +1 -0
- package/dist/operation-budget.d.ts +11 -0
- package/dist/operation-budget.d.ts.map +1 -0
- package/dist/operation-budget.js +40 -0
- package/dist/operation-budget.js.map +1 -0
- package/dist/protected-app.d.ts +32 -0
- package/dist/protected-app.d.ts.map +1 -0
- package/dist/protected-app.js +39 -0
- package/dist/protected-app.js.map +1 -0
- package/dist/provider.d.ts +158 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +311 -0
- package/dist/provider.js.map +1 -0
- package/dist/read-node.d.ts +152 -0
- package/dist/read-node.d.ts.map +1 -0
- package/dist/read-node.js +42 -0
- package/dist/read-node.js.map +1 -0
- package/dist/refs.d.ts +43 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +85 -0
- package/dist/refs.js.map +1 -0
- package/dist/route-pattern.d.ts +13 -0
- package/dist/route-pattern.d.ts.map +1 -0
- package/dist/route-pattern.js +55 -0
- package/dist/route-pattern.js.map +1 -0
- package/dist/selector-engines.d.ts +6 -0
- package/dist/selector-engines.d.ts.map +1 -0
- package/dist/selector-engines.js +8 -0
- package/dist/selector-engines.js.map +1 -0
- package/dist/support.d.ts +147 -0
- package/dist/support.d.ts.map +1 -0
- package/dist/support.js +366 -0
- package/dist/support.js.map +1 -0
- package/dist/surface.d.ts +313 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +691 -0
- package/dist/surface.js.map +1 -0
- package/dist/video.d.ts +59 -0
- package/dist/video.d.ts.map +1 -0
- package/dist/video.js +116 -0
- package/dist/video.js.map +1 -0
- package/dist/web.d.ts +191 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +382 -0
- package/dist/web.js.map +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-page semantic reader. Playwright serializes it with `toString()` and
|
|
3
|
+
* runs it inside the page (`read-node.ts` assembles the three page functions
|
|
4
|
+
* from its source), so it must stay one self-contained function: no imports
|
|
5
|
+
* beyond types, no module-scope references, every constant inside the body.
|
|
6
|
+
* That is why it has a file of its own.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Serialized into the page by Playwright. Must stay self-contained: no outer
|
|
10
|
+
* captures beyond its two arguments.
|
|
11
|
+
*
|
|
12
|
+
* `mode.kind === 'node'` reads exactly one element for locator reads. An
|
|
13
|
+
* element no longer in its document reads as detached with its value alone:
|
|
14
|
+
* a handle taken a moment earlier can point at a node the page has since
|
|
15
|
+
* replaced, and what such a node reports (no boxes, so hidden) describes
|
|
16
|
+
* nothing on screen.
|
|
17
|
+
* `mode.kind === 'tree'` walks the subtree for one agent observation and
|
|
18
|
+
* returns live element handles aligned with the flattened node list. The two
|
|
19
|
+
* modes also project nodes differently; those differences are data (see
|
|
20
|
+
* `projection` below), not scattered branches.
|
|
21
|
+
*/
|
|
22
|
+
export const readSemanticsFunction = (element, options) => {
|
|
23
|
+
const SKIP_TAGS = [
|
|
24
|
+
'script',
|
|
25
|
+
'style',
|
|
26
|
+
'noscript',
|
|
27
|
+
'template',
|
|
28
|
+
'head',
|
|
29
|
+
'meta',
|
|
30
|
+
'link',
|
|
31
|
+
'title',
|
|
32
|
+
'base',
|
|
33
|
+
'param',
|
|
34
|
+
'source',
|
|
35
|
+
'track',
|
|
36
|
+
'col',
|
|
37
|
+
'colgroup',
|
|
38
|
+
'frame',
|
|
39
|
+
'frameset',
|
|
40
|
+
'object',
|
|
41
|
+
'embed',
|
|
42
|
+
];
|
|
43
|
+
const OPAQUE_TAGS = ['svg', 'math', 'canvas', 'video', 'audio'];
|
|
44
|
+
/**
|
|
45
|
+
* Tags a `contenteditable` never makes an editing host. A drawn or embedded
|
|
46
|
+
* surface and a void element hold no DOM text to edit; a native control (a
|
|
47
|
+
* link, a button, a form field) has a role and a value of its own, so a fill
|
|
48
|
+
* reaches it as what it is. Chromium reports `isContentEditable` on all of
|
|
49
|
+
* them regardless.
|
|
50
|
+
*/
|
|
51
|
+
const NON_HOST_TAGS = [...OPAQUE_TAGS, 'iframe', 'img', 'hr', 'br', 'wbr', 'area', 'a', 'button', 'input', 'textarea', 'select'];
|
|
52
|
+
/** Options listed under one select; a country picker's tail is not worth the tokens. */
|
|
53
|
+
const MAX_SELECT_OPTIONS = 60;
|
|
54
|
+
/** Input types whose accessible name falls back to the placeholder (HTML-AAM 4.1.1). */
|
|
55
|
+
const PLACEHOLDER_NAMED_INPUT_TYPES = ['text', 'password', 'number', 'search', 'tel', 'email', 'url'];
|
|
56
|
+
/**
|
|
57
|
+
* How the active mode projects one node, expressed as data so `describe`
|
|
58
|
+
* stays branch-free. Tree mode is the model-bound projection: bounded text,
|
|
59
|
+
* a bounded attribute projection, hrefs reduced to origin+path, and the root
|
|
60
|
+
* document named by its title. Node mode is the full locator-read surface.
|
|
61
|
+
*/
|
|
62
|
+
const projection = options.mode.kind === 'tree'
|
|
63
|
+
? {
|
|
64
|
+
attributes: ['type', 'autocomplete', 'href', 'role', 'placeholder'],
|
|
65
|
+
textLimit: options.mode.textLimit,
|
|
66
|
+
nameLimit: options.mode.nameLimit,
|
|
67
|
+
redactHref: true,
|
|
68
|
+
directTextOnly: true,
|
|
69
|
+
documentRoot: true,
|
|
70
|
+
}
|
|
71
|
+
: {
|
|
72
|
+
attributes: null,
|
|
73
|
+
textLimit: null,
|
|
74
|
+
nameLimit: null,
|
|
75
|
+
redactHref: false,
|
|
76
|
+
directTextOnly: false,
|
|
77
|
+
documentRoot: false,
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Per-element memo for the facts several passes need. A tree walk asks for
|
|
81
|
+
* an element's role, name, and direct text from `isInteresting`, `describe`,
|
|
82
|
+
* and the empty-box test; each is a pure function of the element for the
|
|
83
|
+
* duration of one read, and `innerText` in particular forces layout.
|
|
84
|
+
*/
|
|
85
|
+
const memoized = (compute) => {
|
|
86
|
+
const cache = new Map();
|
|
87
|
+
return (el) => {
|
|
88
|
+
if (cache.has(el))
|
|
89
|
+
return cache.get(el);
|
|
90
|
+
const value = compute(el);
|
|
91
|
+
cache.set(el, value);
|
|
92
|
+
return value;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* True when an element carries a name of its own (HTML-AAM: `aria-label`,
|
|
97
|
+
* `aria-labelledby`, or `title`). A `<form>` or `<section>` is a landmark
|
|
98
|
+
* only when named; unnamed, it is a plain container.
|
|
99
|
+
*/
|
|
100
|
+
const hasOwnName = (el) => ['aria-label', 'aria-labelledby', 'title'].some((attribute) => (el.getAttribute(attribute) ?? '').trim() !== '');
|
|
101
|
+
const SECTIONING_SCOPE = [
|
|
102
|
+
'article', 'aside', 'main', 'nav', 'section',
|
|
103
|
+
...['article', 'complementary', 'main', 'navigation', 'region'].map((role) => `[role~="${role}"]`),
|
|
104
|
+
].join(', ');
|
|
105
|
+
/**
|
|
106
|
+
* True when a `<header>` or `<footer>` is the page's, not an article's or a
|
|
107
|
+
* section's: only then is it the `banner` or `contentinfo` landmark.
|
|
108
|
+
* HTML-AAM scopes it to sectioning content and to the ARIA roles that stand
|
|
109
|
+
* for it, so a `<div role="article">` scopes it like an `<article>`.
|
|
110
|
+
*/
|
|
111
|
+
const isPageLevel = (el) => el.closest(SECTIONING_SCOPE) === null;
|
|
112
|
+
/**
|
|
113
|
+
* The root of a contenteditable region: editable itself, under a parent that
|
|
114
|
+
* is not. A rich-text editor (ProseMirror, TipTap, Lexical, Slate) renders
|
|
115
|
+
* its document as such a host with block children; the host is the control
|
|
116
|
+
* a person types into and the blocks are its content, so only the host is a
|
|
117
|
+
* textbox, whatever its tag: an `<article contenteditable>` is the editor,
|
|
118
|
+
* not an article. A canvas made editable to collect keystrokes is a drawn
|
|
119
|
+
* field the keyboard reaches, not a textbox; see `NON_HOST_TAGS`.
|
|
120
|
+
*/
|
|
121
|
+
const isEditingHost = (el) => el instanceof HTMLElement &&
|
|
122
|
+
el.isContentEditable &&
|
|
123
|
+
NON_HOST_TAGS.indexOf(el.tagName.toLowerCase()) === -1 &&
|
|
124
|
+
!(el.parentElement instanceof HTMLElement && el.parentElement.isContentEditable);
|
|
125
|
+
const implicitRole = memoized((el) => {
|
|
126
|
+
const explicit = el.getAttribute('role');
|
|
127
|
+
if (explicit !== null && explicit.trim() !== '') {
|
|
128
|
+
const first = explicit.trim().split(/\s+/)[0] ?? null;
|
|
129
|
+
// The vocabulary spells ARIA's `img` as `image`.
|
|
130
|
+
return first === 'img' ? 'image' : first;
|
|
131
|
+
}
|
|
132
|
+
// Ahead of the tag: an editor's host is the control, whatever landmark or
|
|
133
|
+
// structure its tag would otherwise be.
|
|
134
|
+
if (isEditingHost(el))
|
|
135
|
+
return 'textbox';
|
|
136
|
+
const tag = el.tagName.toLowerCase();
|
|
137
|
+
const type = (el.getAttribute('type') ?? '').toLowerCase();
|
|
138
|
+
switch (tag) {
|
|
139
|
+
case 'a':
|
|
140
|
+
return el.hasAttribute('href') ? 'link' : null;
|
|
141
|
+
case 'button':
|
|
142
|
+
return 'button';
|
|
143
|
+
// HTML-AAM: a select that shows several rows is a listbox; only a
|
|
144
|
+
// one-row drop-down is a combobox.
|
|
145
|
+
case 'select':
|
|
146
|
+
return el.hasAttribute('multiple') || el.size > 1 ? 'listbox' : 'combobox';
|
|
147
|
+
case 'textarea':
|
|
148
|
+
return 'textbox';
|
|
149
|
+
case 'img':
|
|
150
|
+
return el.getAttribute('alt') === '' ? 'presentation' : 'image';
|
|
151
|
+
case 'nav':
|
|
152
|
+
return 'navigation';
|
|
153
|
+
case 'main':
|
|
154
|
+
return 'main';
|
|
155
|
+
case 'aside':
|
|
156
|
+
return 'complementary';
|
|
157
|
+
case 'header':
|
|
158
|
+
return isPageLevel(el) ? 'banner' : null;
|
|
159
|
+
case 'footer':
|
|
160
|
+
return isPageLevel(el) ? 'contentinfo' : null;
|
|
161
|
+
case 'section':
|
|
162
|
+
return hasOwnName(el) ? 'region' : null;
|
|
163
|
+
case 'form':
|
|
164
|
+
return hasOwnName(el) ? 'form' : null;
|
|
165
|
+
case 'article':
|
|
166
|
+
return 'article';
|
|
167
|
+
case 'figure':
|
|
168
|
+
return 'figure';
|
|
169
|
+
case 'hr':
|
|
170
|
+
return 'separator';
|
|
171
|
+
case 'progress':
|
|
172
|
+
return 'progressbar';
|
|
173
|
+
case 'meter':
|
|
174
|
+
return 'meter';
|
|
175
|
+
// HTML-AAM: `<menu>` is a list; only `role="menu"` is a menu.
|
|
176
|
+
case 'menu':
|
|
177
|
+
return 'list';
|
|
178
|
+
case 'fieldset':
|
|
179
|
+
case 'details':
|
|
180
|
+
case 'optgroup':
|
|
181
|
+
case 'address':
|
|
182
|
+
return 'group';
|
|
183
|
+
case 'option':
|
|
184
|
+
return 'option';
|
|
185
|
+
case 'h1':
|
|
186
|
+
case 'h2':
|
|
187
|
+
case 'h3':
|
|
188
|
+
case 'h4':
|
|
189
|
+
case 'h5':
|
|
190
|
+
case 'h6':
|
|
191
|
+
return 'heading';
|
|
192
|
+
case 'li':
|
|
193
|
+
return 'listitem';
|
|
194
|
+
case 'ul':
|
|
195
|
+
case 'ol':
|
|
196
|
+
return 'list';
|
|
197
|
+
case 'table':
|
|
198
|
+
return 'table';
|
|
199
|
+
// Rows and cells carry the structure a table's controls belong to: a
|
|
200
|
+
// "Delete" button means one thing per row, and only the row says which.
|
|
201
|
+
case 'thead':
|
|
202
|
+
case 'tbody':
|
|
203
|
+
case 'tfoot':
|
|
204
|
+
return 'rowgroup';
|
|
205
|
+
case 'tr':
|
|
206
|
+
return 'row';
|
|
207
|
+
// A grid's cells are the interactive kind, as Playwright reads them.
|
|
208
|
+
case 'td': {
|
|
209
|
+
const table = el.closest('table');
|
|
210
|
+
const tableRole = table?.getAttribute('role')?.trim().split(/\s+/)[0];
|
|
211
|
+
return tableRole === 'grid' || tableRole === 'treegrid' ? 'gridcell' : 'cell';
|
|
212
|
+
}
|
|
213
|
+
case 'th': {
|
|
214
|
+
const scope = (el.getAttribute('scope') ?? '').toLowerCase();
|
|
215
|
+
return scope === 'row' || scope === 'rowgroup' ? 'rowheader' : 'columnheader';
|
|
216
|
+
}
|
|
217
|
+
case 'dialog':
|
|
218
|
+
return 'dialog';
|
|
219
|
+
case 'output':
|
|
220
|
+
return 'status';
|
|
221
|
+
case 'input':
|
|
222
|
+
// HTML-AAM: a text field with suggestions from a datalist is a combobox.
|
|
223
|
+
// `list` resolves the attribute to a datalist element, null for anything else.
|
|
224
|
+
// The `type` property, not the attribute: a type the browser does not know is `text`.
|
|
225
|
+
if (el.list !== null && ['text', 'search', 'tel', 'url', 'email'].indexOf(el.type) !== -1)
|
|
226
|
+
return 'combobox';
|
|
227
|
+
switch (type) {
|
|
228
|
+
case 'button':
|
|
229
|
+
case 'submit':
|
|
230
|
+
case 'reset':
|
|
231
|
+
case 'image':
|
|
232
|
+
// A file input is its picker button, as Chrome and Playwright read it.
|
|
233
|
+
case 'file':
|
|
234
|
+
return 'button';
|
|
235
|
+
case 'checkbox':
|
|
236
|
+
return 'checkbox';
|
|
237
|
+
case 'radio':
|
|
238
|
+
return 'radio';
|
|
239
|
+
case 'range':
|
|
240
|
+
return 'slider';
|
|
241
|
+
case 'number':
|
|
242
|
+
return 'spinbutton';
|
|
243
|
+
case 'search':
|
|
244
|
+
return 'searchbox';
|
|
245
|
+
case 'hidden':
|
|
246
|
+
return null;
|
|
247
|
+
default:
|
|
248
|
+
return 'textbox';
|
|
249
|
+
}
|
|
250
|
+
default:
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
/** Rendered text as a person sees it: innerText, aria-hidden glyphs included. */
|
|
255
|
+
const textOf = (el) => {
|
|
256
|
+
if (el instanceof HTMLElement)
|
|
257
|
+
return el.innerText;
|
|
258
|
+
return el.textContent ?? '';
|
|
259
|
+
};
|
|
260
|
+
/** Computed style of any element, SVG included, or undefined in a document with no view. */
|
|
261
|
+
const styleOf = (el) => el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
262
|
+
/** A walk from the top, with the elements that may not contribute again already visited. */
|
|
263
|
+
const nameWalk = (visited) => ({
|
|
264
|
+
inReference: false,
|
|
265
|
+
hiddenAllowed: false,
|
|
266
|
+
visited: new Set(visited),
|
|
267
|
+
});
|
|
268
|
+
/** True for a subtree the name computation drops: aria-hidden, or hidden by style as innerText leaves it out. */
|
|
269
|
+
const isNameHidden = (el, style) => el.getAttribute('aria-hidden') === 'true' ||
|
|
270
|
+
(style !== undefined && (style.display === 'none' || style.visibility === 'hidden'));
|
|
271
|
+
/** `alt` of an element HTML-AAM names by it: an `<img>` or an `<input type="image">`. */
|
|
272
|
+
const altOf = (el) => {
|
|
273
|
+
const named = el instanceof HTMLImageElement || (el instanceof HTMLInputElement && el.type === 'image');
|
|
274
|
+
const alt = named ? el.getAttribute('alt') : null;
|
|
275
|
+
return alt !== null && alt.trim() !== '' ? alt.trim() : null;
|
|
276
|
+
};
|
|
277
|
+
/**
|
|
278
|
+
* True for a referenced target accname 2A reads whole: hidden itself, or
|
|
279
|
+
* under an `aria-hidden` ancestor, which excludes it from the tree as
|
|
280
|
+
* surely as its own attribute would.
|
|
281
|
+
*/
|
|
282
|
+
const isReferenceHidden = (el) => isHidden(el) || el.closest('[aria-hidden="true"]') !== null;
|
|
283
|
+
/**
|
|
284
|
+
* The element an id names for `el`, looked up in `el`'s own tree: an IDREF
|
|
285
|
+
* resolves in the tree scope it is written in, so a shadow tree's ids are
|
|
286
|
+
* its own and `getElementById` on the document never sees them. A root of
|
|
287
|
+
* either kind answers for its tree once the reader holds the element.
|
|
288
|
+
*/
|
|
289
|
+
const referencedElementOf = (el, id) => {
|
|
290
|
+
const root = el.getRootNode();
|
|
291
|
+
return root instanceof Document || root instanceof DocumentFragment ? root.getElementById(id) : null;
|
|
292
|
+
};
|
|
293
|
+
/**
|
|
294
|
+
* Each aria-labelledby target's contribution (accname 2B), in attribute
|
|
295
|
+
* order, unnamed targets dropped; null when the element references nothing
|
|
296
|
+
* that names it. A hidden target is read whole, as a screen reader reads it,
|
|
297
|
+
* and a reference inside a target is not followed, so this is null inside a
|
|
298
|
+
* reference traversal too.
|
|
299
|
+
*/
|
|
300
|
+
const referencedNamesOf = (el, walk) => {
|
|
301
|
+
if (walk.inReference)
|
|
302
|
+
return null;
|
|
303
|
+
const ids = (el.getAttribute('aria-labelledby') ?? '').trim();
|
|
304
|
+
if (ids === '')
|
|
305
|
+
return null;
|
|
306
|
+
const names = [];
|
|
307
|
+
for (const id of ids.split(/\s+/)) {
|
|
308
|
+
const target = referencedElementOf(el, id);
|
|
309
|
+
if (target === null)
|
|
310
|
+
continue;
|
|
311
|
+
const name = contentNameOf(target, styleOf(target), {
|
|
312
|
+
inReference: true,
|
|
313
|
+
hiddenAllowed: isReferenceHidden(target),
|
|
314
|
+
visited: walk.visited,
|
|
315
|
+
})
|
|
316
|
+
.replace(/\s+/g, ' ')
|
|
317
|
+
.trim();
|
|
318
|
+
if (name !== '')
|
|
319
|
+
names.push(name);
|
|
320
|
+
}
|
|
321
|
+
return names.length === 0 ? null : names;
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* What one element contributes to a name computed through another: as an
|
|
325
|
+
* aria-labelledby target (accname 2B) or as a descendant read for name from
|
|
326
|
+
* content (2F). Its own references, else its `aria-label`, its `alt`, its
|
|
327
|
+
* children, then its `title`. So `<button><img alt="Search"></button>` is
|
|
328
|
+
* the button "Search", an icon whose only child is
|
|
329
|
+
* `<svg aria-labelledby="t"><title id="t">Close</title></svg>` is "Close",
|
|
330
|
+
* and a hidden `<span>` a button references still names it. A form
|
|
331
|
+
* control's content is its value, not label text, so it contributes nothing.
|
|
332
|
+
*/
|
|
333
|
+
const contentNameOf = (el, style, walk) => {
|
|
334
|
+
if (walk.visited.has(el))
|
|
335
|
+
return '';
|
|
336
|
+
walk.visited.add(el);
|
|
337
|
+
if (!walk.hiddenAllowed && isNameHidden(el, style))
|
|
338
|
+
return '';
|
|
339
|
+
const referenced = referencedNamesOf(el, walk);
|
|
340
|
+
if (referenced !== null)
|
|
341
|
+
return referenced.join(' ');
|
|
342
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
343
|
+
if (ariaLabel !== null && ariaLabel.trim() !== '')
|
|
344
|
+
return ariaLabel.trim();
|
|
345
|
+
const alt = altOf(el);
|
|
346
|
+
if (alt !== null)
|
|
347
|
+
return alt;
|
|
348
|
+
if (NAME_OPAQUE_TAGS.has(el.tagName))
|
|
349
|
+
return '';
|
|
350
|
+
const content = childrenNameOf(el, walk);
|
|
351
|
+
if (content.trim() !== '')
|
|
352
|
+
return content;
|
|
353
|
+
const title = el.getAttribute('title');
|
|
354
|
+
return title === null ? '' : title.trim();
|
|
355
|
+
};
|
|
356
|
+
/**
|
|
357
|
+
* The children's contributions joined as Playwright's role selector joins
|
|
358
|
+
* them: a space on each side of a block-level child and of a `<br>`, none
|
|
359
|
+
* around an inline one. `<img alt="Search">Go` reads "SearchGo" and
|
|
360
|
+
* `<div>A</div><div>B</div>` reads "A B", so the name a role query matched
|
|
361
|
+
* is the name the node reports.
|
|
362
|
+
*/
|
|
363
|
+
const childrenNameOf = (el, walk) => {
|
|
364
|
+
let out = '';
|
|
365
|
+
for (const child of contentChildrenOf(el)) {
|
|
366
|
+
if (child.nodeType === 3) {
|
|
367
|
+
out += child.nodeValue ?? '';
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (!(child instanceof Element))
|
|
371
|
+
continue;
|
|
372
|
+
const style = styleOf(child);
|
|
373
|
+
const token = contentNameOf(child, style, walk);
|
|
374
|
+
const block = child.tagName === 'BR' || (style?.display ?? 'inline') !== 'inline';
|
|
375
|
+
out += block ? ` ${token} ` : token;
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
};
|
|
379
|
+
/**
|
|
380
|
+
* The nodes a name from content reads under an element, in the order the
|
|
381
|
+
* page composes them: a `<slot>` reads what is assigned to it, and a host
|
|
382
|
+
* reads its light children (a slotted one skipped, its slot reads it) and
|
|
383
|
+
* then its shadow tree, so `<button>` whose text lives behind a shadow root
|
|
384
|
+
* is named by that text as Playwright's role selector names it. Which light
|
|
385
|
+
* children are slotted is read from the root's slots, not from each child's
|
|
386
|
+
* `assignedSlot`: a closed root hides that property from the outside while
|
|
387
|
+
* the tracked root's slots still list their assigned nodes.
|
|
388
|
+
*/
|
|
389
|
+
const contentChildrenOf = (el) => {
|
|
390
|
+
if (el instanceof HTMLSlotElement) {
|
|
391
|
+
const assigned = el.assignedNodes();
|
|
392
|
+
if (assigned.length > 0)
|
|
393
|
+
return assigned;
|
|
394
|
+
}
|
|
395
|
+
const shadow = shadowRootOf(el);
|
|
396
|
+
if (shadow === null)
|
|
397
|
+
return Array.from(el.childNodes);
|
|
398
|
+
const slotted = new Set();
|
|
399
|
+
for (const slot of Array.from(shadow.querySelectorAll('slot'))) {
|
|
400
|
+
for (const node of slot.assignedNodes())
|
|
401
|
+
slotted.add(node);
|
|
402
|
+
}
|
|
403
|
+
const own = Array.from(el.childNodes).filter((child) => !slotted.has(child));
|
|
404
|
+
return own.concat(Array.from(shadow.childNodes));
|
|
405
|
+
};
|
|
406
|
+
/**
|
|
407
|
+
* Text for an accessible name from an element's own content (accname 2F):
|
|
408
|
+
* its descendants' contributions, hidden and aria-hidden subtrees dropped (a
|
|
409
|
+
* required-field marker, a decorative glyph), whitespace collapsed. The
|
|
410
|
+
* element's own attributes are `accessibleName`'s business; this reads what
|
|
411
|
+
* is inside it.
|
|
412
|
+
*/
|
|
413
|
+
const nameTextOf = (el) => {
|
|
414
|
+
if (isNameHidden(el, styleOf(el)))
|
|
415
|
+
return '';
|
|
416
|
+
if (NAME_OPAQUE_TAGS.has(el.tagName))
|
|
417
|
+
return '';
|
|
418
|
+
return childrenNameOf(el, nameWalk([el])).replace(/\s+/g, ' ').trim();
|
|
419
|
+
};
|
|
420
|
+
const NAME_OPAQUE_TAGS = new Set(['TEXTAREA', 'SELECT', 'INPUT', 'SCRIPT', 'STYLE']);
|
|
421
|
+
/**
|
|
422
|
+
* Roles named from their content (accname 2F, the list Playwright's role
|
|
423
|
+
* selector uses), plus `listitem`, `status`, and `alert`, which the tree
|
|
424
|
+
* has always named so a list row or a message reads as one line.
|
|
425
|
+
*/
|
|
426
|
+
const NAME_FROM_CONTENT_ROLES = new Set([
|
|
427
|
+
'button',
|
|
428
|
+
'cell',
|
|
429
|
+
'checkbox',
|
|
430
|
+
'columnheader',
|
|
431
|
+
'gridcell',
|
|
432
|
+
'heading',
|
|
433
|
+
'link',
|
|
434
|
+
'menuitem',
|
|
435
|
+
'menuitemcheckbox',
|
|
436
|
+
'menuitemradio',
|
|
437
|
+
'option',
|
|
438
|
+
'radio',
|
|
439
|
+
'row',
|
|
440
|
+
'rowheader',
|
|
441
|
+
'switch',
|
|
442
|
+
'tab',
|
|
443
|
+
'tooltip',
|
|
444
|
+
'treeitem',
|
|
445
|
+
'listitem',
|
|
446
|
+
'status',
|
|
447
|
+
'alert',
|
|
448
|
+
]);
|
|
449
|
+
/** HTML-AAM: the child element that names its parent when nothing ARIA does. */
|
|
450
|
+
const NAMING_CHILD_TAGS = { fieldset: 'legend', figure: 'figcaption', table: 'caption' };
|
|
451
|
+
/** The `<label>` elements associated with a labelable element (button, input, meter, output, progress, select, textarea). */
|
|
452
|
+
const associatedLabels = (el) => {
|
|
453
|
+
const labels = el.labels;
|
|
454
|
+
return labels === undefined || labels === null ? [] : Array.from(labels);
|
|
455
|
+
};
|
|
456
|
+
/** The label sources an exact label query may match; see `RawNodeData.labels`. */
|
|
457
|
+
const labelsOf = (el) => {
|
|
458
|
+
const labels = [];
|
|
459
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
460
|
+
if (ariaLabel !== null && ariaLabel.trim() !== '')
|
|
461
|
+
labels.push(ariaLabel.trim());
|
|
462
|
+
for (const name of referencedNamesOf(el, nameWalk([])) ?? [])
|
|
463
|
+
labels.push(name);
|
|
464
|
+
for (const label of associatedLabels(el)) {
|
|
465
|
+
const text = nameTextOf(label);
|
|
466
|
+
if (text !== '')
|
|
467
|
+
labels.push(text);
|
|
468
|
+
}
|
|
469
|
+
return labels.length === 0 ? null : labels;
|
|
470
|
+
};
|
|
471
|
+
/** Text owned directly by an element, excluding descendant elements. */
|
|
472
|
+
const directTextOf = memoized((el) => {
|
|
473
|
+
let out = '';
|
|
474
|
+
for (const child of Array.from(el.childNodes)) {
|
|
475
|
+
if (child.nodeType === 3)
|
|
476
|
+
out += child.nodeValue ?? '';
|
|
477
|
+
}
|
|
478
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
479
|
+
});
|
|
480
|
+
const accessibleName = memoized((el) => {
|
|
481
|
+
// accname reads a labelledby reference (2B) before the element's own aria-label (2C).
|
|
482
|
+
const referenced = referencedNamesOf(el, nameWalk([]));
|
|
483
|
+
if (referenced !== null)
|
|
484
|
+
return referenced.join(' ');
|
|
485
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
486
|
+
if (ariaLabel !== null && ariaLabel.trim() !== '')
|
|
487
|
+
return ariaLabel.trim();
|
|
488
|
+
const labels = associatedLabels(el);
|
|
489
|
+
if (labels.length > 0) {
|
|
490
|
+
const joined = labels
|
|
491
|
+
.map((label) => nameTextOf(label))
|
|
492
|
+
.join(' ')
|
|
493
|
+
.trim();
|
|
494
|
+
if (joined !== '')
|
|
495
|
+
return joined;
|
|
496
|
+
}
|
|
497
|
+
const alt = altOf(el);
|
|
498
|
+
if (alt !== null)
|
|
499
|
+
return alt;
|
|
500
|
+
const captionTag = NAMING_CHILD_TAGS[el.tagName.toLowerCase()];
|
|
501
|
+
if (captionTag !== undefined) {
|
|
502
|
+
const caption = Array.from(el.children).find((child) => child.tagName.toLowerCase() === captionTag);
|
|
503
|
+
const text = caption === undefined ? '' : nameTextOf(caption);
|
|
504
|
+
if (text !== '')
|
|
505
|
+
return text;
|
|
506
|
+
}
|
|
507
|
+
if (el instanceof HTMLInputElement && (el.type === 'button' || el.type === 'submit' || el.type === 'reset')) {
|
|
508
|
+
if (el.value.trim() !== '')
|
|
509
|
+
return el.value.trim();
|
|
510
|
+
// HTML-AAM: a submit or reset button with no value reads its default label.
|
|
511
|
+
if (el.type === 'submit')
|
|
512
|
+
return 'Submit';
|
|
513
|
+
if (el.type === 'reset')
|
|
514
|
+
return 'Reset';
|
|
515
|
+
}
|
|
516
|
+
const role = implicitRole(el);
|
|
517
|
+
if (role !== null && NAME_FROM_CONTENT_ROLES.has(role)) {
|
|
518
|
+
const text = nameTextOf(el);
|
|
519
|
+
if (text !== '')
|
|
520
|
+
return text;
|
|
521
|
+
}
|
|
522
|
+
const title = el.getAttribute('title');
|
|
523
|
+
if (title !== null && title.trim() !== '')
|
|
524
|
+
return title.trim();
|
|
525
|
+
// HTML-AAM names an unlabeled text control by its placeholder, after the
|
|
526
|
+
// title: a bare search box is `textbox "Search…"`, not an anonymous field.
|
|
527
|
+
if (isPlaceholderNamed(el)) {
|
|
528
|
+
const placeholder = el.getAttribute('placeholder');
|
|
529
|
+
if (placeholder !== null && placeholder.trim() !== '')
|
|
530
|
+
return placeholder.trim();
|
|
531
|
+
const ariaPlaceholder = el.getAttribute('aria-placeholder');
|
|
532
|
+
if (ariaPlaceholder !== null && ariaPlaceholder.trim() !== '')
|
|
533
|
+
return ariaPlaceholder.trim();
|
|
534
|
+
if (isEditingHost(el))
|
|
535
|
+
return editorPlaceholderOf(el);
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
538
|
+
});
|
|
539
|
+
/**
|
|
540
|
+
* The controls a placeholder may name: text-like inputs and textareas
|
|
541
|
+
* (HTML-AAM 4.1.1), and the textbox and searchbox roles `aria-placeholder`
|
|
542
|
+
* applies to, an editing host among them.
|
|
543
|
+
*/
|
|
544
|
+
const isPlaceholderNamed = (el) => {
|
|
545
|
+
if (el instanceof HTMLTextAreaElement)
|
|
546
|
+
return true;
|
|
547
|
+
if (el instanceof HTMLInputElement)
|
|
548
|
+
return PLACEHOLDER_NAMED_INPUT_TYPES.indexOf(el.type) !== -1;
|
|
549
|
+
const role = implicitRole(el);
|
|
550
|
+
return role === 'textbox' || role === 'searchbox';
|
|
551
|
+
};
|
|
552
|
+
/**
|
|
553
|
+
* The placeholder a rich-text editor paints from `data-placeholder`, the one
|
|
554
|
+
* convention its frameworks share: Quill sets it on the host; ProseMirror
|
|
555
|
+
* and TipTap set it on the first empty block and drop it once the document
|
|
556
|
+
* has content, as the painted hint goes.
|
|
557
|
+
*/
|
|
558
|
+
const editorPlaceholderOf = (host) => {
|
|
559
|
+
const own = host.getAttribute('data-placeholder');
|
|
560
|
+
if (own !== null && own.trim() !== '')
|
|
561
|
+
return own.trim();
|
|
562
|
+
for (const block of Array.from(host.querySelectorAll('[data-placeholder]'))) {
|
|
563
|
+
if ((block.textContent ?? '').trim() !== '')
|
|
564
|
+
continue;
|
|
565
|
+
const placeholder = block.getAttribute('data-placeholder');
|
|
566
|
+
if (placeholder !== null && placeholder.trim() !== '')
|
|
567
|
+
return placeholder.trim();
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
};
|
|
571
|
+
/**
|
|
572
|
+
* Closed shadow roots the context's init script recorded, when it ran in this
|
|
573
|
+
* document. Same literal as `CLOSED_SHADOW_ROOTS_KEY`; the reader cannot import it.
|
|
574
|
+
*/
|
|
575
|
+
const closedShadowRoots = globalThis[Symbol.for('e2e.closedShadowRoots')];
|
|
576
|
+
const shadowRootOf = (el) => el.shadowRoot ?? closedShadowRoots?.get(el) ?? null;
|
|
577
|
+
/**
|
|
578
|
+
* The element that holds focus, through every shadow root on the way.
|
|
579
|
+
* `document.activeElement` is retargeted to the outermost host and each
|
|
580
|
+
* root's `activeElement` names the next hop, so the chain is followed until
|
|
581
|
+
* a root holds no focus of its own; a closed root is reached through the
|
|
582
|
+
* record, as everywhere else in the reader.
|
|
583
|
+
*/
|
|
584
|
+
const focusedElementOf = (doc) => {
|
|
585
|
+
let active = doc.activeElement;
|
|
586
|
+
while (active !== null) {
|
|
587
|
+
const next = shadowRootOf(active)?.activeElement ?? null;
|
|
588
|
+
if (next === null)
|
|
589
|
+
return active;
|
|
590
|
+
active = next;
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
};
|
|
594
|
+
const focusedElement = focusedElementOf(element.ownerDocument);
|
|
595
|
+
/**
|
|
596
|
+
* The text selected inside a focused field: the slice between an input's or
|
|
597
|
+
* textarea's selection ends, or the document selection when it lies within
|
|
598
|
+
* an editing host. Null for a collapsed caret, so a node with no selection
|
|
599
|
+
* reads exactly as before.
|
|
600
|
+
*/
|
|
601
|
+
const selectedTextOf = (el) => {
|
|
602
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
603
|
+
const start = el.selectionStart;
|
|
604
|
+
const end = el.selectionEnd;
|
|
605
|
+
if (start === null || end === null || start === end)
|
|
606
|
+
return null;
|
|
607
|
+
return el.value.slice(Math.min(start, end), Math.max(start, end));
|
|
608
|
+
}
|
|
609
|
+
if (!isEditingHost(el))
|
|
610
|
+
return null;
|
|
611
|
+
const selection = el.ownerDocument.getSelection();
|
|
612
|
+
if (selection === null || selection.rangeCount === 0 || selection.isCollapsed)
|
|
613
|
+
return null;
|
|
614
|
+
if (!el.contains(selection.getRangeAt(0).commonAncestorContainer))
|
|
615
|
+
return null;
|
|
616
|
+
const text = selection.toString();
|
|
617
|
+
return text === '' ? null : text;
|
|
618
|
+
};
|
|
619
|
+
/** True for a text node that lays out to a box a person can see. */
|
|
620
|
+
const isVisibleText = (node) => {
|
|
621
|
+
const range = node.ownerDocument.createRange();
|
|
622
|
+
range.selectNode(node);
|
|
623
|
+
const rect = range.getBoundingClientRect();
|
|
624
|
+
return rect.width > 0 && rect.height > 0;
|
|
625
|
+
};
|
|
626
|
+
/**
|
|
627
|
+
* True for content a closed `<details>` folds away: anything under it that
|
|
628
|
+
* is not in its `<summary>`. Every closed ancestor is asked, so a summary's
|
|
629
|
+
* own controls stay visible while the closed body's do not, nested
|
|
630
|
+
* `<details>` and their summaries included.
|
|
631
|
+
*/
|
|
632
|
+
const isInClosedDetails = (el) => {
|
|
633
|
+
let details = el.parentElement?.closest('details') ?? null;
|
|
634
|
+
while (details !== null) {
|
|
635
|
+
if (!details.open) {
|
|
636
|
+
const summary = details.querySelector(':scope > summary');
|
|
637
|
+
if (summary === null || !summary.contains(el))
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
details = details.parentElement?.closest('details') ?? null;
|
|
641
|
+
}
|
|
642
|
+
return false;
|
|
643
|
+
};
|
|
644
|
+
/**
|
|
645
|
+
* The visibility Playwright's `toBeHidden` reads, so a semantic `hidden`
|
|
646
|
+
* state and the platform's own filter agree: `aria-hidden` on the element,
|
|
647
|
+
* `display: none` or a `visibility` other than `visible` on any element (an
|
|
648
|
+
* SVG included), content a closed `<details>` folds away, and a box with no
|
|
649
|
+
* width or no height. `display: contents` generates no box of its own while
|
|
650
|
+
* every child still paints (Shopify's one-page checkout form is one), so
|
|
651
|
+
* such an element is shown when some child element or text is, and hidden
|
|
652
|
+
* when nothing under it is.
|
|
653
|
+
*/
|
|
654
|
+
const isHidden = (el, style = styleOf(el)) => {
|
|
655
|
+
if (el.getAttribute('aria-hidden') === 'true')
|
|
656
|
+
return true;
|
|
657
|
+
if (style === undefined)
|
|
658
|
+
return true;
|
|
659
|
+
if (style.display === 'contents') {
|
|
660
|
+
for (let child = el.firstChild; child !== null; child = child.nextSibling) {
|
|
661
|
+
if (child instanceof Element && !isHidden(child))
|
|
662
|
+
return false;
|
|
663
|
+
if (child.nodeType === Node.TEXT_NODE && isVisibleText(child))
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
if (style.display === 'none' || style.visibility !== 'visible')
|
|
669
|
+
return true;
|
|
670
|
+
if (isInClosedDetails(el))
|
|
671
|
+
return true;
|
|
672
|
+
const rect = el.getBoundingClientRect();
|
|
673
|
+
return !(rect.width > 0 && rect.height > 0);
|
|
674
|
+
};
|
|
675
|
+
/**
|
|
676
|
+
* The part of `isHidden` that holds for everything under the element too,
|
|
677
|
+
* which is what lets the tree walk stop there. A box with no size is not in
|
|
678
|
+
* it: a zero-height `<html>` or wrapper still shows the fixed, absolute, and
|
|
679
|
+
* overflowing descendants laid out past its edges, so the walk goes on
|
|
680
|
+
* through it and only the element itself stays unlisted. An inert element
|
|
681
|
+
* is in it too (`isInert`).
|
|
682
|
+
*/
|
|
683
|
+
const hidesSubtree = (el, style) => el.getAttribute('aria-hidden') === 'true' ||
|
|
684
|
+
isInert(el, style) ||
|
|
685
|
+
style === undefined ||
|
|
686
|
+
style.display === 'none' ||
|
|
687
|
+
(style.display !== 'contents' && style.visibility !== 'visible') ||
|
|
688
|
+
isInClosedDetails(el);
|
|
689
|
+
/**
|
|
690
|
+
* Inert content takes no input and is hidden from assistive technology, as
|
|
691
|
+
* Chrome's tree drops it, though it still paints: listing it would hand the
|
|
692
|
+
* agent controls that ignore every action. Chromium computes the inherited
|
|
693
|
+
* `interactivity: inert`, which follows the flat tree, so a light child
|
|
694
|
+
* slotted into an inert slot of a closed root counts; the attribute covers a
|
|
695
|
+
* browser that does not compute it. A modal dialog's inertness of the rest
|
|
696
|
+
* of the page is not in the style and stays listed.
|
|
697
|
+
*/
|
|
698
|
+
const isInert = (el, style) => el.hasAttribute('inert') || style?.getPropertyValue('interactivity') === 'inert';
|
|
699
|
+
/** Smallest side, in CSS pixels, an empty box must have to be worth reporting. */
|
|
700
|
+
const MIN_BOX_SIDE = 12;
|
|
701
|
+
/** True when a computed style paints something a person can see. */
|
|
702
|
+
const hasPaint = (style) => {
|
|
703
|
+
if (style.backgroundImage !== 'none' && style.backgroundImage !== '')
|
|
704
|
+
return true;
|
|
705
|
+
// A fully transparent color serializes with a zero alpha component.
|
|
706
|
+
const background = style.backgroundColor;
|
|
707
|
+
if (background !== '' && background !== 'transparent' && !/,\s*0\)$/.test(background)) {
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
if (parseFloat(style.outlineWidth) > 0 && style.outlineStyle !== 'none')
|
|
711
|
+
return true;
|
|
712
|
+
const sides = ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'];
|
|
713
|
+
const styles = ['borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle'];
|
|
714
|
+
for (let index = 0; index < sides.length; index += 1) {
|
|
715
|
+
const width = parseFloat(style[sides[index]]);
|
|
716
|
+
const kind = style[styles[index]];
|
|
717
|
+
if (width > 0 && kind !== 'none' && kind !== 'hidden')
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
return false;
|
|
721
|
+
};
|
|
722
|
+
/**
|
|
723
|
+
* True when an element is an empty painted rectangle: no children, no text,
|
|
724
|
+
* no role, no name — and yet plainly visible, because it has a border, an
|
|
725
|
+
* outline, or a background of its own.
|
|
726
|
+
*
|
|
727
|
+
* This is the one element class defined by being empty. A drop zone, a canvas,
|
|
728
|
+
* a chart placeholder, a colour swatch: a person sees a rectangle and can aim
|
|
729
|
+
* at it, so a tree that omits it cannot describe the page it is describing.
|
|
730
|
+
* `dragTo` in particular has no usable destination without it.
|
|
731
|
+
*
|
|
732
|
+
* The size floor and the paint requirement are what keep this from admitting
|
|
733
|
+
* every layout div: a spacer, a clearfix, or a zero-alpha wrapper paints
|
|
734
|
+
* nothing and is not something anyone can point at.
|
|
735
|
+
*/
|
|
736
|
+
const isVisibleEmptyBox = memoized((el) => {
|
|
737
|
+
const style = styleOf(el);
|
|
738
|
+
if (style === undefined)
|
|
739
|
+
return false;
|
|
740
|
+
if (el.children.length > 0)
|
|
741
|
+
return false;
|
|
742
|
+
const explicit = (el.getAttribute('role') ?? '').trim();
|
|
743
|
+
if (explicit !== '')
|
|
744
|
+
return false;
|
|
745
|
+
if (directTextOf(el) !== '')
|
|
746
|
+
return false;
|
|
747
|
+
const rect = el.getBoundingClientRect();
|
|
748
|
+
if (rect.width < MIN_BOX_SIDE || rect.height < MIN_BOX_SIDE)
|
|
749
|
+
return false;
|
|
750
|
+
return hasPaint(style);
|
|
751
|
+
});
|
|
752
|
+
/**
|
|
753
|
+
* Reduces a URL to origin and path, dropping userinfo, query, and fragment.
|
|
754
|
+
* Bounded: hrefs are shown to the model as link hints, not resolved, so a
|
|
755
|
+
* long path only buys tokens.
|
|
756
|
+
*/
|
|
757
|
+
const HREF_LIMIT = 80;
|
|
758
|
+
const originAndPath = (value, base) => {
|
|
759
|
+
try {
|
|
760
|
+
const url = new URL(value, base);
|
|
761
|
+
return `${url.origin}${url.pathname}`.slice(0, HREF_LIMIT);
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
return (value.split('?')[0]?.split('#')[0] ?? '').slice(0, HREF_LIMIT);
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
/** Attributes that name an element rather than describe where it sits. */
|
|
768
|
+
const NAMING_ATTRIBUTES = [options.testIdAttribute, 'name'];
|
|
769
|
+
/**
|
|
770
|
+
* A document-unique attribute selector for one element, when it has one.
|
|
771
|
+
*
|
|
772
|
+
* A test ID names an element by definition; a form control's `name` names it
|
|
773
|
+
* because the server reads it, which is also why it outlives redesigns. Ids
|
|
774
|
+
* are deliberately absent: a framework that mints them per render
|
|
775
|
+
* (`#firstName-aepj7PyFWSmXAkB8bb91h`) would make every selector single-use.
|
|
776
|
+
*/
|
|
777
|
+
const namedSelectorOf = (el) => {
|
|
778
|
+
for (const attribute of NAMING_ATTRIBUTES) {
|
|
779
|
+
const value = el.getAttribute(attribute);
|
|
780
|
+
if (value === null || value === '')
|
|
781
|
+
continue;
|
|
782
|
+
const selector = `[${attribute}="${value.replace(/["\\]/g, '\\$&')}"]`;
|
|
783
|
+
let unique;
|
|
784
|
+
try {
|
|
785
|
+
unique = el.ownerDocument.querySelectorAll(selector).length === 1;
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (unique)
|
|
791
|
+
return selector;
|
|
792
|
+
}
|
|
793
|
+
return null;
|
|
794
|
+
};
|
|
795
|
+
/**
|
|
796
|
+
* Path to one element in its own document, and whether anything along it names
|
|
797
|
+
* the element rather than counting positions to it.
|
|
798
|
+
*
|
|
799
|
+
* The walk stops at the first ancestor something names, so an anchored path is
|
|
800
|
+
* only positional *below* that ancestor. An unanchored path is positional all
|
|
801
|
+
* the way from `body`, which makes it fragile for a reason that has nothing to
|
|
802
|
+
* do with the element: a chat widget, a consent frame, or a portal appended
|
|
803
|
+
* anywhere above shifts every `nth-child` index beneath it.
|
|
804
|
+
*
|
|
805
|
+
* Each `namedSelectorOf` probe is a document-wide `querySelectorAll`, so this
|
|
806
|
+
* is deliberately not called for every node of a tree walk; see `projection`.
|
|
807
|
+
*/
|
|
808
|
+
const pathTo = (el) => {
|
|
809
|
+
const named = namedSelectorOf(el);
|
|
810
|
+
if (named !== null)
|
|
811
|
+
return { selector: named, anchored: true };
|
|
812
|
+
const parts = [];
|
|
813
|
+
let current = el;
|
|
814
|
+
while (current !== null && current.tagName.toLowerCase() !== 'html') {
|
|
815
|
+
const parent = current.parentElement;
|
|
816
|
+
if (parent === null)
|
|
817
|
+
break;
|
|
818
|
+
const index = Array.prototype.indexOf.call(parent.children, current) + 1;
|
|
819
|
+
parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`);
|
|
820
|
+
const anchor = namedSelectorOf(parent);
|
|
821
|
+
if (anchor !== null)
|
|
822
|
+
return { selector: `${anchor} > ${parts.join(' > ')}`, anchored: true };
|
|
823
|
+
current = parent;
|
|
824
|
+
}
|
|
825
|
+
return { selector: parts.join(' > '), anchored: false };
|
|
826
|
+
};
|
|
827
|
+
/**
|
|
828
|
+
* The selector used to re-enter one iframe. Unanchored is fine here: it is
|
|
829
|
+
* resolved against the document it was just read from, within this
|
|
830
|
+
* observation, and never stored. This is the one place a selector is
|
|
831
|
+
* derived: each `namedSelectorOf` probe is a document-wide query, and no
|
|
832
|
+
* runner consumes a per-node selector, so neither read mode pays for one.
|
|
833
|
+
*/
|
|
834
|
+
const frameSelectorOf = (el) => pathTo(el).selector;
|
|
835
|
+
/**
|
|
836
|
+
* The role both read modes report for one element.
|
|
837
|
+
*
|
|
838
|
+
* A tree walk and a single-node re-read have to agree: the runner acts on a
|
|
839
|
+
* node it selected from an observation by re-reading that node and requiring
|
|
840
|
+
* the same signature, so a role the walk invents and the re-read does not
|
|
841
|
+
* makes the node unactionable. Anything the walk used to attach after the fact
|
|
842
|
+
* belongs here instead.
|
|
843
|
+
*/
|
|
844
|
+
const roleOf = (el, tag) => {
|
|
845
|
+
const implicit = implicitRole(el);
|
|
846
|
+
if (implicit !== null)
|
|
847
|
+
return implicit;
|
|
848
|
+
if (tag === 'iframe')
|
|
849
|
+
return 'iframe';
|
|
850
|
+
return isVisibleEmptyBox(el) ? 'box' : null;
|
|
851
|
+
};
|
|
852
|
+
/**
|
|
853
|
+
* The roles WAI-ARIA 1.2 lets `aria-disabled` apply to, the same list
|
|
854
|
+
* Playwright's `toBeDisabled` consults. Widgets and the composites that hold
|
|
855
|
+
* them; a plain container or text takes no disabled state.
|
|
856
|
+
*/
|
|
857
|
+
const ARIA_DISABLED_ROLES = [
|
|
858
|
+
'application', 'button', 'composite', 'gridcell', 'group', 'input', 'link', 'menuitem', 'scrollbar',
|
|
859
|
+
'separator', 'tab', 'checkbox', 'columnheader', 'combobox', 'grid', 'listbox', 'menu', 'menubar',
|
|
860
|
+
'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'radiogroup', 'row', 'rowheader', 'searchbox',
|
|
861
|
+
'select', 'slider', 'spinbutton', 'switch', 'tablist', 'textbox', 'toolbar', 'tree', 'treegrid', 'treeitem',
|
|
862
|
+
];
|
|
863
|
+
/** The parent `aria-disabled` inherits across: the tree parent, or the host at a shadow root. */
|
|
864
|
+
const parentOrHostOf = (el) => {
|
|
865
|
+
if (el.parentElement !== null)
|
|
866
|
+
return el.parentElement;
|
|
867
|
+
const root = el.getRootNode();
|
|
868
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
869
|
+
};
|
|
870
|
+
/**
|
|
871
|
+
* What the nearest `aria-disabled` on the element or above it says
|
|
872
|
+
* (WAI-ARIA: the state applies to every descendant), across shadow
|
|
873
|
+
* boundaries, `false` cutting the chain. Memoized so a subtree under one
|
|
874
|
+
* disabled ancestor costs one walk, not one per node.
|
|
875
|
+
*/
|
|
876
|
+
const ariaDisabledInChain = memoized((el) => {
|
|
877
|
+
const attribute = (el.getAttribute('aria-disabled') ?? '').toLowerCase();
|
|
878
|
+
if (attribute === 'true')
|
|
879
|
+
return true;
|
|
880
|
+
if (attribute === 'false')
|
|
881
|
+
return false;
|
|
882
|
+
const parent = parentOrHostOf(el);
|
|
883
|
+
return parent === null ? false : ariaDisabledInChain(parent);
|
|
884
|
+
});
|
|
885
|
+
/**
|
|
886
|
+
* The disabled state a person meets, not the attribute the element carries.
|
|
887
|
+
* `:disabled` is the browser's answer for a form control: its own attribute,
|
|
888
|
+
* a disabled fieldset above it (except inside that fieldset's first legend),
|
|
889
|
+
* a disabled optgroup. An `aria-disabled="true"` on the element counts for
|
|
890
|
+
* any role; inherited from an ancestor, it reaches the roles the state
|
|
891
|
+
* applies to, as Playwright reads it.
|
|
892
|
+
*/
|
|
893
|
+
const isDisabled = (el) => {
|
|
894
|
+
if (el.matches(':disabled'))
|
|
895
|
+
return true;
|
|
896
|
+
if (el.getAttribute('aria-disabled') === 'true')
|
|
897
|
+
return true;
|
|
898
|
+
const role = implicitRole(el);
|
|
899
|
+
return role !== null && ARIA_DISABLED_ROLES.indexOf(role) !== -1 && ariaDisabledInChain(el);
|
|
900
|
+
};
|
|
901
|
+
const describe = (el, style = styleOf(el)) => {
|
|
902
|
+
const tag = el.tagName.toLowerCase();
|
|
903
|
+
const autocomplete = (el.getAttribute('autocomplete') ?? '').toLowerCase();
|
|
904
|
+
let value = null;
|
|
905
|
+
let checked = null;
|
|
906
|
+
let selectedState = null;
|
|
907
|
+
if (el instanceof HTMLInputElement) {
|
|
908
|
+
if (el.type === 'checkbox' || el.type === 'radio')
|
|
909
|
+
checked = el.checked;
|
|
910
|
+
else
|
|
911
|
+
value = el.value;
|
|
912
|
+
}
|
|
913
|
+
else if (el instanceof HTMLTextAreaElement) {
|
|
914
|
+
value = el.value;
|
|
915
|
+
}
|
|
916
|
+
else if (el instanceof HTMLSelectElement) {
|
|
917
|
+
value = el.value;
|
|
918
|
+
}
|
|
919
|
+
else if (el instanceof HTMLOptionElement) {
|
|
920
|
+
selectedState = el.selected;
|
|
921
|
+
value = el.value;
|
|
922
|
+
}
|
|
923
|
+
else if (isEditingHost(el)) {
|
|
924
|
+
// An editor's document is its value, as a textarea's text is: kept as
|
|
925
|
+
// rendered, spaces and newlines included. An empty editor renders
|
|
926
|
+
// `<p><br></p>`, which innerText reads as a newline, so a document with
|
|
927
|
+
// no text nodes, or with only markup whitespace that renders as that one
|
|
928
|
+
// newline, is an empty value; typed spaces are text nodes and stay. The
|
|
929
|
+
// zero-width no-break space Slate and Quill pad an empty line with
|
|
930
|
+
// renders nothing, so it is no text either. Cut like text in the
|
|
931
|
+
// model-bound projection.
|
|
932
|
+
const rendered = textOf(el).replace(//g, '');
|
|
933
|
+
const text = (el.textContent ?? '').replace(//g, '');
|
|
934
|
+
value = text === '' || (text.trim() === '' && rendered === '\n') ? '' : rendered;
|
|
935
|
+
if (projection.textLimit !== null)
|
|
936
|
+
value = value.slice(0, projection.textLimit);
|
|
937
|
+
}
|
|
938
|
+
// A native control's state is the control's, as Playwright reads it: a
|
|
939
|
+
// stale aria-checked on a checkbox or aria-selected on an option says
|
|
940
|
+
// nothing about what the browser will submit. ARIA fills in only where the
|
|
941
|
+
// element has no native state.
|
|
942
|
+
if (checked === null) {
|
|
943
|
+
const ariaChecked = el.getAttribute('aria-checked');
|
|
944
|
+
if (ariaChecked !== null)
|
|
945
|
+
checked = ariaChecked === 'true';
|
|
946
|
+
}
|
|
947
|
+
if (selectedState === null) {
|
|
948
|
+
const ariaSelected = el.getAttribute('aria-selected');
|
|
949
|
+
if (ariaSelected !== null)
|
|
950
|
+
selectedState = ariaSelected === 'true';
|
|
951
|
+
}
|
|
952
|
+
const disabled = isDisabled(el);
|
|
953
|
+
const ariaExpanded = el.getAttribute('aria-expanded');
|
|
954
|
+
const ariaPressed = el.getAttribute('aria-pressed');
|
|
955
|
+
// Matched against the shared selector rather than re-derived from tag and
|
|
956
|
+
// type, so this node's `secure` flag and the screenshot mask agree by
|
|
957
|
+
// construction.
|
|
958
|
+
const secure = el.matches(options.secureFieldSelector);
|
|
959
|
+
let selection = null;
|
|
960
|
+
if (focusedElement === el && !secure) {
|
|
961
|
+
selection = selectedTextOf(el);
|
|
962
|
+
if (selection !== null && projection.textLimit !== null)
|
|
963
|
+
selection = selection.slice(0, projection.textLimit);
|
|
964
|
+
}
|
|
965
|
+
let inputPurpose = 'none';
|
|
966
|
+
if (secure)
|
|
967
|
+
inputPurpose = 'password';
|
|
968
|
+
else if (autocomplete === 'username')
|
|
969
|
+
inputPurpose = 'username';
|
|
970
|
+
else if (autocomplete === 'current-password' || autocomplete === 'new-password') {
|
|
971
|
+
inputPurpose = 'password';
|
|
972
|
+
}
|
|
973
|
+
else if (autocomplete === 'one-time-code')
|
|
974
|
+
inputPurpose = 'one-time-code';
|
|
975
|
+
const attributes = Object.create(null);
|
|
976
|
+
for (const attribute of Array.from(el.attributes)) {
|
|
977
|
+
if (projection.attributes === null ||
|
|
978
|
+
projection.attributes.indexOf(attribute.name) !== -1 ||
|
|
979
|
+
attribute.name.startsWith('aria-')) {
|
|
980
|
+
if (secure && attribute.name === 'value')
|
|
981
|
+
continue;
|
|
982
|
+
// Observations expose href origin and path only: query strings and
|
|
983
|
+
// fragments routinely carry tokens.
|
|
984
|
+
if (projection.redactHref && attribute.name === 'href') {
|
|
985
|
+
attributes[attribute.name] = originAndPath(attribute.value, el.ownerDocument.baseURI);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
attributes[attribute.name] = attribute.value;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
let text;
|
|
992
|
+
if (secure)
|
|
993
|
+
text = '';
|
|
994
|
+
else if (projection.directTextOnly)
|
|
995
|
+
text = directTextOf(el);
|
|
996
|
+
else
|
|
997
|
+
text = textOf(el);
|
|
998
|
+
if (projection.textLimit !== null)
|
|
999
|
+
text = text.slice(0, projection.textLimit);
|
|
1000
|
+
let name = accessibleName(el);
|
|
1001
|
+
if (projection.nameLimit !== null && name !== null)
|
|
1002
|
+
name = name.slice(0, projection.nameLimit);
|
|
1003
|
+
// A secure field withholds its value, never its labels: a password field is still found by its label.
|
|
1004
|
+
const labels = labelsOf(el);
|
|
1005
|
+
const isDocumentRoot = projection.documentRoot && tag === 'html';
|
|
1006
|
+
if (isDocumentRoot)
|
|
1007
|
+
name = el.ownerDocument.title;
|
|
1008
|
+
const rect = el.getBoundingClientRect();
|
|
1009
|
+
const role = isDocumentRoot ? 'document' : roleOf(el, tag);
|
|
1010
|
+
let level = null;
|
|
1011
|
+
if (role === 'heading') {
|
|
1012
|
+
const ariaLevel = Number.parseInt(el.getAttribute('aria-level') ?? '', 10);
|
|
1013
|
+
if (Number.isInteger(ariaLevel) && ariaLevel > 0)
|
|
1014
|
+
level = ariaLevel;
|
|
1015
|
+
else if (/^h[1-6]$/.test(tag))
|
|
1016
|
+
level = Number(tag.slice(1));
|
|
1017
|
+
// HTML-AAM: a role="heading" element with no aria-level is level 2.
|
|
1018
|
+
else
|
|
1019
|
+
level = 2;
|
|
1020
|
+
}
|
|
1021
|
+
return {
|
|
1022
|
+
role,
|
|
1023
|
+
name,
|
|
1024
|
+
labels,
|
|
1025
|
+
text,
|
|
1026
|
+
value: secure ? null : value,
|
|
1027
|
+
selection,
|
|
1028
|
+
inputPurpose,
|
|
1029
|
+
states: {
|
|
1030
|
+
checked,
|
|
1031
|
+
disabled,
|
|
1032
|
+
selected: selectedState,
|
|
1033
|
+
expanded: ariaExpanded === null ? null : ariaExpanded === 'true',
|
|
1034
|
+
pressed: ariaPressed === null ? null : ariaPressed === 'true',
|
|
1035
|
+
focused: focusedElement === el,
|
|
1036
|
+
hidden: isHidden(el, style),
|
|
1037
|
+
secure,
|
|
1038
|
+
},
|
|
1039
|
+
level,
|
|
1040
|
+
attributes,
|
|
1041
|
+
testId: el.getAttribute(options.testIdAttribute),
|
|
1042
|
+
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
1043
|
+
};
|
|
1044
|
+
};
|
|
1045
|
+
// The conditional return type resolves per call site; inside the body the
|
|
1046
|
+
// discriminant narrows the value but not the generic, hence the two casts.
|
|
1047
|
+
if (options.mode.kind === 'node') {
|
|
1048
|
+
return (element.isConnected ? describe(element) : { detached: true, value: describe(element).value });
|
|
1049
|
+
}
|
|
1050
|
+
const maxNodes = options.mode.maxNodes;
|
|
1051
|
+
const nodes = [];
|
|
1052
|
+
const elements = [];
|
|
1053
|
+
const ids = [];
|
|
1054
|
+
let nextId = options.mode.idSeed;
|
|
1055
|
+
let truncated = false;
|
|
1056
|
+
// The id lives on the element itself under a registry symbol: invisible to
|
|
1057
|
+
// application code (no attribute, no enumerable property), gone with the
|
|
1058
|
+
// element, and readable by every later observation of the same document.
|
|
1059
|
+
const REF_KEY = Symbol.for('e2e.observation.ref');
|
|
1060
|
+
const stamp = (el) => {
|
|
1061
|
+
const carrier = el;
|
|
1062
|
+
let id = carrier[REF_KEY];
|
|
1063
|
+
if (id === undefined) {
|
|
1064
|
+
id = `n${String(nextId)}`;
|
|
1065
|
+
nextId += 1;
|
|
1066
|
+
carrier[REF_KEY] = id;
|
|
1067
|
+
}
|
|
1068
|
+
return id;
|
|
1069
|
+
};
|
|
1070
|
+
const include = (el, parent, style = styleOf(el)) => {
|
|
1071
|
+
const data = describe(el, style);
|
|
1072
|
+
nodes.push({ ...data, parent });
|
|
1073
|
+
elements.push(el);
|
|
1074
|
+
ids.push(stamp(el));
|
|
1075
|
+
return nodes.length - 1;
|
|
1076
|
+
};
|
|
1077
|
+
/** True when a node carries semantics worth sending to a model. */
|
|
1078
|
+
const isInteresting = (el) => {
|
|
1079
|
+
if (el.hasAttribute(options.testIdAttribute))
|
|
1080
|
+
return true;
|
|
1081
|
+
const role = implicitRole(el);
|
|
1082
|
+
if (role !== null && role !== 'presentation' && role !== 'none')
|
|
1083
|
+
return true;
|
|
1084
|
+
if (accessibleName(el) !== null)
|
|
1085
|
+
return true;
|
|
1086
|
+
return directTextOf(el) !== '';
|
|
1087
|
+
};
|
|
1088
|
+
const walk = (el, parent) => {
|
|
1089
|
+
if (truncated)
|
|
1090
|
+
return;
|
|
1091
|
+
const tag = el.tagName.toLowerCase();
|
|
1092
|
+
if (SKIP_TAGS.indexOf(tag) !== -1)
|
|
1093
|
+
return;
|
|
1094
|
+
// Read once and share: `isHidden` and the empty-box test both need it, and
|
|
1095
|
+
// this walk already pays one `getComputedStyle` per node.
|
|
1096
|
+
const style = styleOf(el);
|
|
1097
|
+
if (hidesSubtree(el, style))
|
|
1098
|
+
return;
|
|
1099
|
+
const hidden = isHidden(el, style);
|
|
1100
|
+
// The two elements listed by a rule of their own carry nothing a person
|
|
1101
|
+
// sees past their edges: a frame with no box shows none of its document,
|
|
1102
|
+
// and a select with none offers no options.
|
|
1103
|
+
if (hidden && (tag === 'iframe' || tag === 'select'))
|
|
1104
|
+
return;
|
|
1105
|
+
// Iframes are emitted as boundary nodes and never entered: their content
|
|
1106
|
+
// lives in another document, which the engine captures per frame and
|
|
1107
|
+
// stitches under this node.
|
|
1108
|
+
if (tag === 'iframe') {
|
|
1109
|
+
if (nodes.length >= maxNodes) {
|
|
1110
|
+
truncated = true;
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const index = include(el, parent, style);
|
|
1114
|
+
const node = nodes[index];
|
|
1115
|
+
node.frameSelector = frameSelectorOf(el);
|
|
1116
|
+
if (node.name === null) {
|
|
1117
|
+
const title = el.getAttribute('title');
|
|
1118
|
+
if (title !== null && title.trim() !== '')
|
|
1119
|
+
node.name = title.trim();
|
|
1120
|
+
}
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
let nextParent = parent;
|
|
1124
|
+
// An empty painted rectangle carries no semantics to be "interesting" by and
|
|
1125
|
+
// is still something a person sees and aims at; `roleOf` names it `box`.
|
|
1126
|
+
if (!hidden && (isInteresting(el) || isVisibleEmptyBox(el))) {
|
|
1127
|
+
if (nodes.length >= maxNodes) {
|
|
1128
|
+
truncated = true;
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
nextParent = include(el, parent, style);
|
|
1132
|
+
}
|
|
1133
|
+
// A closed select paints none of its options, so the hidden test would
|
|
1134
|
+
// drop every one of them — and a model shown `combobox "Category"` alone
|
|
1135
|
+
// has to guess the labels it may pick. The options are what the control
|
|
1136
|
+
// offers, so they are listed under it, bounded like any long list.
|
|
1137
|
+
if (tag === 'select') {
|
|
1138
|
+
const choices = Array.from(el.options).slice(0, MAX_SELECT_OPTIONS);
|
|
1139
|
+
for (const option of choices) {
|
|
1140
|
+
if (nodes.length >= maxNodes) {
|
|
1141
|
+
truncated = true;
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const index = include(option, nextParent, styleOf(option));
|
|
1145
|
+
const node = nodes[index];
|
|
1146
|
+
// Listed on purpose, so not "hidden"; the label is the name and the
|
|
1147
|
+
// value attribute is the app's internal token, not something to show.
|
|
1148
|
+
node.states.hidden = false;
|
|
1149
|
+
node.value = null;
|
|
1150
|
+
}
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
if (OPAQUE_TAGS.indexOf(tag) !== -1)
|
|
1154
|
+
return;
|
|
1155
|
+
for (const child of Array.from(el.children))
|
|
1156
|
+
walk(child, nextParent);
|
|
1157
|
+
// A shadow root is part of what the user sees, so it is part of what the
|
|
1158
|
+
// model is shown. Walking the host's light children and its shadow tree
|
|
1159
|
+
// double-counts nothing: slotted elements are light children, and the shadow
|
|
1160
|
+
// tree holds the `<slot>` placeholders rather than copies of them. A closed
|
|
1161
|
+
// root is unreachable from `shadowRoot`, so it comes from the record the
|
|
1162
|
+
// context's init script kept when the page attached it.
|
|
1163
|
+
const shadow = shadowRootOf(el);
|
|
1164
|
+
if (shadow !== null) {
|
|
1165
|
+
for (const child of Array.from(shadow.children))
|
|
1166
|
+
walk(child, nextParent);
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
// The root is the document the screen shows, listed whatever its own box: a
|
|
1170
|
+
// page of fixed controls leaves `<html>` with no height of its own.
|
|
1171
|
+
nodes[include(element, -1)].states.hidden = false;
|
|
1172
|
+
if (isInert(element, styleOf(element)))
|
|
1173
|
+
return { nodes, elements, ids, truncated };
|
|
1174
|
+
for (const child of Array.from(element.children))
|
|
1175
|
+
walk(child, 0);
|
|
1176
|
+
return { nodes, elements, ids, truncated };
|
|
1177
|
+
};
|
|
1178
|
+
//# sourceMappingURL=read-semantics.js.map
|