runline 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/dist/plugins/shiftOcr/src/index.js +13 -0
  2. package/dist/plugins/shiftPages/src/pages.js +72 -15
  3. package/dist/plugins/shiftPages/src/shared.js +5 -0
  4. package/dist/plugins/shiftTranscription/src/index.js +1 -1
  5. package/dist/plugins/shiftTranscription/src/transcription.js +21 -5
  6. package/dist/plugins/shiftWork/src/issues.js +119 -33
  7. package/dist/plugins/steel/src/browser.js +11 -79
  8. package/dist/plugins/steel/src/index.js +2 -0
  9. package/dist/plugins/steel/src/page/bundle.generated.js +11 -0
  10. package/dist/plugins/steel/src/page/cdp.js +232 -0
  11. package/dist/plugins/steel/src/page/driver.js +286 -0
  12. package/dist/plugins/steel/src/page/entry.js +91 -0
  13. package/dist/plugins/steel/src/page/install.js +3 -0
  14. package/dist/plugins/steel/src/page/page-snapshot.js +330 -0
  15. package/dist/plugins/steel/src/page/tools.js +649 -0
  16. package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaSnapshot.js +411 -0
  17. package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaSnapshotDistiller.js +207 -0
  18. package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaTypes.js +62 -0
  19. package/dist/plugins/steel/src/page/vendor/aria-snapshot/cssTokenizer.js +949 -0
  20. package/dist/plugins/steel/src/page/vendor/aria-snapshot/domUtils.js +157 -0
  21. package/dist/plugins/steel/src/page/vendor/aria-snapshot/roleUtils.js +1193 -0
  22. package/dist/plugins/steel/src/page/vendor/aria-snapshot/stringUtils.js +194 -0
  23. package/dist/plugins/steel/src/page/vendor/aria-snapshot/yaml.js +80 -0
  24. package/dist/plugins/steel/src/page/wait-for.js +30 -0
  25. package/dist/plugins/steel/src/sessions.js +2 -7
  26. package/dist/plugins/steel/src/shared.js +12 -0
  27. package/package.json +1 -1
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Copyright (c) Microsoft Corporation.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ // Trimmed from playwright packages/injected/src/ariaSnapshot.ts - see README.md.
17
+ // Dropped: the assertion template matchers (matchesExpectAriaTemplate and friends).
18
+ import * as aria from './ariaTypes.js';
19
+ import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace, truncateDataUrl } from './stringUtils.js';
20
+ import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml.js';
21
+ import { distillAriaSnapshot } from './ariaSnapshotDistiller.js';
22
+ import { computeBox, getElementComputedStyle, isElementVisible } from './domUtils.js';
23
+ import * as roleUtils from './roleUtils.js';
24
+ export const SENSITIVE_VALUE_MARKER = '[REDACTED]';
25
+ let lastRef = 0;
26
+ function toInternalOptions(options) {
27
+ const renderBoxes = options.boxes;
28
+ if (options.mode === 'ai') {
29
+ // For AI consumption.
30
+ return {
31
+ visibility: 'ariaOrVisible',
32
+ refs: 'interactable',
33
+ refPrefix: options.refPrefix,
34
+ includeGenericRole: true,
35
+ renderActive: !options.doNotRenderActive,
36
+ renderCursorPointer: true,
37
+ renderBoxes,
38
+ };
39
+ }
40
+ if (options.mode === 'autoexpect') {
41
+ // To auto-generate assertions on visible elements.
42
+ return { visibility: 'ariaAndVisible', refs: 'none', renderBoxes };
43
+ }
44
+ if (options.mode === 'codegen') {
45
+ // To generate aria assertion with regex heurisitcs.
46
+ return { visibility: 'aria', refs: 'none', renderStringsAsRegex: true, renderBoxes };
47
+ }
48
+ // To match aria snapshot.
49
+ return { visibility: 'aria', refs: 'none', renderBoxes };
50
+ }
51
+ export function generateAriaTree(rootElement, publicOptions) {
52
+ const options = toInternalOptions(publicOptions);
53
+ const visited = new Set();
54
+ // For each node, the elements that contributed to its accessible name.
55
+ const nameSourceElements = new Map();
56
+ const snapshot = {
57
+ root: { role: 'fragment', name: '', children: [], props: {}, box: computeBox(rootElement), receivesPointerEvents: true },
58
+ info: new Map(),
59
+ refs: new Map(),
60
+ iframeRefs: [],
61
+ };
62
+ setAriaNodeElement(snapshot.root, rootElement);
63
+ const visit = (ariaNode, node, parentElementVisible) => {
64
+ if (visited.has(node))
65
+ return;
66
+ visited.add(node);
67
+ if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
68
+ if (!parentElementVisible)
69
+ return;
70
+ const text = node.nodeValue;
71
+ // <textarea>AAA</textarea> should not report AAA as a child of the textarea.
72
+ if (ariaNode.role !== 'textbox' && text)
73
+ ariaNode.children.push(node.nodeValue || '');
74
+ return;
75
+ }
76
+ if (node.nodeType !== Node.ELEMENT_NODE)
77
+ return;
78
+ const element = node;
79
+ const isElementVisibleForAria = !roleUtils.isElementHiddenForAria(element);
80
+ let visible = isElementVisibleForAria;
81
+ if (options.visibility === 'ariaOrVisible')
82
+ visible = isElementVisibleForAria || isElementVisible(element);
83
+ if (options.visibility === 'ariaAndVisible')
84
+ visible = isElementVisibleForAria && isElementVisible(element);
85
+ // Optimization: if we only consider aria visibility, we can skip child elements because
86
+ // they will not be visible for aria as well.
87
+ if (options.visibility === 'aria' && !visible)
88
+ return;
89
+ const ariaChildren = [];
90
+ if (element.hasAttribute('aria-owns')) {
91
+ const ids = element.getAttribute('aria-owns').split(/\s+/);
92
+ for (const id of ids) {
93
+ const ownedElement = rootElement.ownerDocument.getElementById(id);
94
+ if (ownedElement)
95
+ ariaChildren.push(ownedElement);
96
+ }
97
+ }
98
+ const childAriaNode = visible ? toAriaNode(element, options, nameSourceElements) : null;
99
+ let elementInfo;
100
+ if (childAriaNode) {
101
+ if (childAriaNode.ref) {
102
+ elementInfo = { element, nameFromContentRefs: [] };
103
+ snapshot.info.set(childAriaNode.ref, elementInfo);
104
+ snapshot.refs.set(element, childAriaNode.ref);
105
+ if (childAriaNode.role === 'iframe')
106
+ snapshot.iframeRefs.push(childAriaNode.ref);
107
+ }
108
+ ariaNode.children.push(childAriaNode);
109
+ }
110
+ processElement(childAriaNode || ariaNode, element, ariaChildren, visible);
111
+ // Now that the subtree is processed, every descendant that contributed to this node's
112
+ // accessible name has its ref assigned, so we can resolve those refs as the name's origins.
113
+ if (elementInfo) {
114
+ for (const contributor of nameSourceElements.get(childAriaNode) || []) {
115
+ const ref = snapshot.refs.get(contributor);
116
+ if (ref && ref !== childAriaNode.ref)
117
+ elementInfo.nameFromContentRefs.push(ref);
118
+ }
119
+ }
120
+ };
121
+ function processElement(ariaNode, element, ariaChildren, parentElementVisible) {
122
+ // Surround every element with spaces for the sake of concatenated text nodes.
123
+ const display = getElementComputedStyle(element)?.display || 'inline';
124
+ const treatAsBlock = (display !== 'inline' || element.nodeName === 'BR') ? ' ' : '';
125
+ if (treatAsBlock)
126
+ ariaNode.children.push(treatAsBlock);
127
+ ariaNode.children.push(roleUtils.getCSSContent(element, '::before') || '');
128
+ const assignedNodes = element.nodeName === 'SLOT' ? element.assignedNodes() : [];
129
+ if (assignedNodes.length) {
130
+ for (const child of assignedNodes)
131
+ visit(ariaNode, child, parentElementVisible);
132
+ }
133
+ else {
134
+ for (let child = element.firstChild; child; child = child.nextSibling) {
135
+ if (!child.assignedSlot)
136
+ visit(ariaNode, child, parentElementVisible);
137
+ }
138
+ if (element.shadowRoot) {
139
+ for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
140
+ visit(ariaNode, child, parentElementVisible);
141
+ }
142
+ }
143
+ for (const child of ariaChildren)
144
+ visit(ariaNode, child, parentElementVisible);
145
+ ariaNode.children.push(roleUtils.getCSSContent(element, '::after') || '');
146
+ if (treatAsBlock)
147
+ ariaNode.children.push(treatAsBlock);
148
+ if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0])
149
+ ariaNode.children = [];
150
+ if (ariaNode.role === 'link' && element.hasAttribute('href')) {
151
+ const href = element.getAttribute('href');
152
+ ariaNode.props['url'] = truncateDataUrl(href);
153
+ }
154
+ if (ariaNode.role === 'textbox' && element.hasAttribute('placeholder') && element.getAttribute('placeholder') !== ariaNode.name) {
155
+ const placeholder = element.getAttribute('placeholder');
156
+ ariaNode.props['placeholder'] = placeholder;
157
+ }
158
+ }
159
+ roleUtils.beginAriaCaches();
160
+ try {
161
+ visit(snapshot.root, rootElement, true);
162
+ }
163
+ finally {
164
+ roleUtils.endAriaCaches();
165
+ }
166
+ distillAriaSnapshot(snapshot, publicOptions);
167
+ return snapshot;
168
+ }
169
+ function computeAriaRef(ariaNode, options) {
170
+ if (options.refs === 'none')
171
+ return;
172
+ if (options.refs === 'interactable' && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents))
173
+ return;
174
+ const element = ariaNodeElement(ariaNode);
175
+ let ariaRef = element._ariaRef;
176
+ if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) {
177
+ ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: (options.refPrefix ?? '') + 'e' + (++lastRef) };
178
+ element._ariaRef = ariaRef;
179
+ }
180
+ ariaNode.ref = ariaRef.ref;
181
+ }
182
+ function toAriaNode(element, options, nameSourceElements) {
183
+ const active = element.ownerDocument.activeElement === element && element.ownerDocument.hasFocus();
184
+ if (element.nodeName === 'IFRAME') {
185
+ const ariaNode = {
186
+ role: 'iframe',
187
+ name: '',
188
+ children: [],
189
+ props: {},
190
+ box: computeBox(element),
191
+ receivesPointerEvents: true,
192
+ active
193
+ };
194
+ setAriaNodeElement(ariaNode, element);
195
+ computeAriaRef(ariaNode, options);
196
+ return ariaNode;
197
+ }
198
+ const defaultRole = options.includeGenericRole ? 'generic' : null;
199
+ const role = roleUtils.getAriaRole(element) ?? defaultRole;
200
+ if (!role || role === 'presentation' || role === 'none')
201
+ return null;
202
+ const name = roleUtils.getElementAccessibleName(element, false);
203
+ const receivesPointerEvents = roleUtils.receivesPointerEvents(element);
204
+ const box = computeBox(element);
205
+ if (role === 'generic' && box.inline && element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE)
206
+ return null;
207
+ const result = {
208
+ role,
209
+ name: normalizeWhiteSpace(name.text),
210
+ children: [],
211
+ props: {},
212
+ box,
213
+ receivesPointerEvents,
214
+ active
215
+ };
216
+ setAriaNodeElement(result, element);
217
+ nameSourceElements.set(result, name.elements);
218
+ computeAriaRef(result, options);
219
+ if (roleUtils.kAriaCheckedRoles.includes(role))
220
+ result.checked = roleUtils.getAriaChecked(element);
221
+ if (roleUtils.kAriaDisabledRoles.includes(role))
222
+ result.disabled = roleUtils.getAriaDisabled(element);
223
+ if (roleUtils.kAriaExpandedRoles.includes(role))
224
+ result.expanded = roleUtils.getAriaExpanded(element);
225
+ if (roleUtils.kAriaInvalidRoles.includes(role)) {
226
+ const invalid = roleUtils.getAriaInvalid(element);
227
+ result.invalid = invalid === 'false' ? false : invalid === 'true' ? true : invalid;
228
+ }
229
+ if (roleUtils.kAriaLevelRoles.includes(role))
230
+ result.level = roleUtils.getAriaLevel(element);
231
+ if (roleUtils.kAriaPressedRoles.includes(role))
232
+ result.pressed = roleUtils.getAriaPressed(element);
233
+ if (roleUtils.kAriaSelectedRoles.includes(role))
234
+ result.selected = roleUtils.getAriaSelected(element);
235
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
236
+ if (element.type !== 'checkbox' && element.type !== 'radio' && element.type !== 'file')
237
+ result.children = [element instanceof HTMLInputElement && element.type === 'password' ? SENSITIVE_VALUE_MARKER : element.value];
238
+ }
239
+ return result;
240
+ }
241
+ function indent(depth) {
242
+ return ' '.repeat(depth);
243
+ }
244
+ export function renderAriaTree(ariaSnapshot, publicOptions) {
245
+ const options = toInternalOptions(publicOptions);
246
+ const lines = [];
247
+ const iframeDepths = {};
248
+ const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true;
249
+ const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str) => str;
250
+ // Do not render the root fragment, just its children.
251
+ const nodesToRender = ariaSnapshot.root.role === 'fragment' ? ariaSnapshot.root.children : [ariaSnapshot.root];
252
+ const visitText = (text, depth) => {
253
+ if (publicOptions.depth && depth > publicOptions.depth)
254
+ return;
255
+ const escaped = yamlEscapeValueIfNeeded(renderString(text));
256
+ if (escaped)
257
+ lines.push(indent(depth) + '- text: ' + escaped);
258
+ };
259
+ const createKey = (ariaNode, renderCursorPointer) => {
260
+ let key = ariaNode.role;
261
+ // Yaml has a limit of 1024 characters per key, and we leave some space for role and attributes.
262
+ if (ariaNode.name && ariaNode.name.length <= 900) {
263
+ const name = renderString(ariaNode.name);
264
+ if (name) {
265
+ const stringifiedName = name.startsWith('/') && name.endsWith('/') ? name : JSON.stringify(name);
266
+ key += ' ' + stringifiedName;
267
+ }
268
+ }
269
+ if (ariaNode.checked === 'mixed')
270
+ key += ` [checked=mixed]`;
271
+ if (ariaNode.checked === true)
272
+ key += ` [checked]`;
273
+ if (ariaNode.disabled)
274
+ key += ` [disabled]`;
275
+ if (ariaNode.expanded)
276
+ key += ` [expanded]`;
277
+ if (ariaNode.active && options.renderActive)
278
+ key += ` [active]`;
279
+ if (ariaNode.invalid === 'grammar' || ariaNode.invalid === 'spelling')
280
+ key += ` [invalid=${ariaNode.invalid}]`;
281
+ if (ariaNode.invalid === true)
282
+ key += ` [invalid]`;
283
+ if (ariaNode.level)
284
+ key += ` [level=${ariaNode.level}]`;
285
+ if (ariaNode.pressed === 'mixed')
286
+ key += ` [pressed=mixed]`;
287
+ if (ariaNode.pressed === true)
288
+ key += ` [pressed]`;
289
+ if (ariaNode.selected === true)
290
+ key += ` [selected]`;
291
+ if (ariaNode.ref) {
292
+ key += ` [ref=${ariaNode.ref}]`;
293
+ if (renderCursorPointer && aria.hasPointerCursor(ariaNode))
294
+ key += ' [cursor=pointer]';
295
+ }
296
+ if (options.renderBoxes) {
297
+ const element = ariaNodeElement(ariaNode);
298
+ if (element) {
299
+ const r = element.getBoundingClientRect();
300
+ key += ` [box=${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)},${Math.round(r.height)}]`;
301
+ }
302
+ }
303
+ return key;
304
+ };
305
+ const getSingleTextChild = (ariaNode) => {
306
+ return ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined;
307
+ };
308
+ const visit = (ariaNode, depth, renderCursorPointer) => {
309
+ if (publicOptions.depth && depth > publicOptions.depth)
310
+ return;
311
+ if (ariaNode.role === 'iframe' && ariaNode.ref)
312
+ iframeDepths[ariaNode.ref] = depth;
313
+ const escapedKey = indent(depth) + '- ' + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer));
314
+ const singleTextChild = getSingleTextChild(ariaNode);
315
+ const isAtDepthLimit = !!publicOptions.depth && depth === publicOptions.depth;
316
+ const hasNoChildren = !singleTextChild && (!ariaNode.children.length || isAtDepthLimit);
317
+ if (hasNoChildren && !Object.keys(ariaNode.props).length) {
318
+ // Leaf node without children.
319
+ lines.push(escapedKey);
320
+ }
321
+ else if (singleTextChild !== undefined) {
322
+ // Leaf node with just some text inside.
323
+ const shouldInclude = includeText(ariaNode, singleTextChild);
324
+ if (shouldInclude)
325
+ lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(renderString(singleTextChild)));
326
+ else
327
+ lines.push(escapedKey);
328
+ }
329
+ else {
330
+ // Node with (optional) props and some children.
331
+ lines.push(escapedKey + ':');
332
+ for (const [name, value] of Object.entries(ariaNode.props))
333
+ lines.push(indent(depth + 1) + '- /' + name + ': ' + yamlEscapeValueIfNeeded(value));
334
+ const inCursorPointer = !!ariaNode.ref && renderCursorPointer && aria.hasPointerCursor(ariaNode);
335
+ for (const child of ariaNode.children) {
336
+ if (typeof child === 'string')
337
+ visitText(includeText(ariaNode, child) ? child : '', depth + 1);
338
+ else
339
+ visit(child, depth + 1, renderCursorPointer && !inCursorPointer);
340
+ }
341
+ }
342
+ };
343
+ for (const nodeToRender of nodesToRender) {
344
+ if (typeof nodeToRender === 'string')
345
+ visitText(nodeToRender, 0);
346
+ else
347
+ visit(nodeToRender, 0, !!options.renderCursorPointer);
348
+ }
349
+ return { text: lines.join('\n'), iframeDepths };
350
+ }
351
+ function convertToBestGuessRegex(text) {
352
+ const dynamicContent = [
353
+ // 550e8400-e29b-41d4-a716-446655440000
354
+ { regex: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/, replacement: '[0-9a-fA-F-]+' },
355
+ // 2mb
356
+ { regex: /\b[\d,.]+[bkmBKM]+\b/, replacement: '[\\d,.]+[bkmBKM]+' },
357
+ // 2ms, 20s
358
+ { regex: /\b\d+[hmsp]+\b/, replacement: '\\d+[hmsp]+' },
359
+ { regex: /\b[\d,.]+[hmsp]+\b/, replacement: '[\\d,.]+[hmsp]+' },
360
+ // Do not replace single digits with regex by default.
361
+ // 2+ digits: [Issue 22, 22.3, 2.33, 2,333]
362
+ { regex: /\b\d+,\d+\b/, replacement: '\\d+,\\d+' },
363
+ { regex: /\b\d+\.\d{2,}\b/, replacement: '\\d+\\.\\d+' },
364
+ { regex: /\b\d{2,}\.\d+\b/, replacement: '\\d+\\.\\d+' },
365
+ { regex: /\b\d{2,}\b/, replacement: '\\d+' },
366
+ ];
367
+ let pattern = '';
368
+ let lastIndex = 0;
369
+ const combinedRegex = new RegExp(dynamicContent.map(r => '(' + r.regex.source + ')').join('|'), 'g');
370
+ text.replace(combinedRegex, (match, ...args) => {
371
+ const offset = args[args.length - 2];
372
+ const groups = args.slice(0, -2);
373
+ pattern += escapeRegExp(text.slice(lastIndex, offset));
374
+ for (let i = 0; i < groups.length; i++) {
375
+ if (groups[i]) {
376
+ const { replacement } = dynamicContent[i];
377
+ pattern += replacement;
378
+ break;
379
+ }
380
+ }
381
+ lastIndex = offset + match.length;
382
+ return match;
383
+ });
384
+ if (!pattern)
385
+ return text;
386
+ pattern += escapeRegExp(text.slice(lastIndex));
387
+ return String(new RegExp(pattern));
388
+ }
389
+ function textContributesInfo(node, text) {
390
+ if (!text.length)
391
+ return false;
392
+ if (!node.name)
393
+ return true;
394
+ // Figure out if text adds any value. "longestCommonSubstring" is expensive, so limit strings length.
395
+ const substr = (text.length <= 200 && node.name.length <= 200) ? longestCommonSubstring(text, node.name) : '';
396
+ let filtered = text;
397
+ while (substr && filtered.includes(substr))
398
+ filtered = filtered.replace(substr, '');
399
+ return filtered.trim().length / text.length > 0.1;
400
+ }
401
+ const elementSymbol = Symbol('element');
402
+ function ariaNodeElement(ariaNode) {
403
+ return ariaNode[elementSymbol];
404
+ }
405
+ function setAriaNodeElement(ariaNode, element) {
406
+ ariaNode[elementSymbol] = element;
407
+ }
408
+ export function findNewElement(from, to) {
409
+ const node = aria.findNewNode(from, to);
410
+ return node ? ariaNodeElement(node) : undefined;
411
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Copyright (c) Microsoft Corporation.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import { normalizeWhiteSpace } from './stringUtils.js';
17
+ export function distillAriaSnapshot(snapshot, options) {
18
+ runPlugins(snapshot, options.mode === 'ai' ? aiPlugins : normalizePlugins, options);
19
+ }
20
+ function runPlugins(snapshot, plugins, options) {
21
+ const ctx = { snapshot, depth: -1, maxDepth: options.depth, ancestors: [], pendingContentRefs: new Set() };
22
+ const traverse = (node, depth) => {
23
+ const children = [];
24
+ const visitChild = (child) => {
25
+ if (typeof child === 'string') {
26
+ children.push(child);
27
+ return;
28
+ }
29
+ ctx.depth = depth + 1;
30
+ for (const plugin of plugins) {
31
+ const result = plugin.enter?.(child, ctx);
32
+ if (result === 'remove')
33
+ return;
34
+ if (result === 'unwrap') {
35
+ child.children.forEach(visitChild);
36
+ return;
37
+ }
38
+ }
39
+ traverse(child, depth + 1);
40
+ ctx.depth = depth + 1;
41
+ for (const plugin of plugins) {
42
+ const result = plugin.exit?.(child, ctx);
43
+ if (result === 'remove')
44
+ return;
45
+ if (result === 'unwrap') {
46
+ children.push(...child.children);
47
+ return;
48
+ }
49
+ }
50
+ children.push(child);
51
+ };
52
+ ctx.ancestors.push(node);
53
+ node.children.forEach(visitChild);
54
+ ctx.ancestors.pop();
55
+ node.children = children;
56
+ };
57
+ // Hooks run on the root as well, but the root cannot be removed or unwrapped.
58
+ for (const plugin of plugins)
59
+ plugin.enter?.(snapshot.root, ctx);
60
+ traverse(snapshot.root, -1);
61
+ ctx.depth = -1;
62
+ for (const plugin of plugins)
63
+ plugin.exit?.(snapshot.root, ctx);
64
+ }
65
+ // A generic node whose only content is text - it carries no structure of its own.
66
+ function isLeafGeneric(node) {
67
+ return node.role === 'generic' && node.children.every(child => typeof child === 'string');
68
+ }
69
+ // The tree builder emits raw text tokens - text nodes, CSS content, block spacing markers - as
70
+ // string children. Coalesce the adjacent ones, normalize whitespace and drop the empties, then
71
+ // drop a lone text child that merely repeats the node's accessible name. Runs on `exit`, so the
72
+ // merge sees the children in their final shape.
73
+ const mergeStringChildren = {
74
+ name: 'mergeStringChildren',
75
+ exit(node) {
76
+ const children = [];
77
+ const buffer = [];
78
+ const flush = () => {
79
+ if (!buffer.length)
80
+ return;
81
+ const text = normalizeWhiteSpace(buffer.join(''));
82
+ if (text)
83
+ children.push(text);
84
+ buffer.length = 0;
85
+ };
86
+ for (const child of node.children) {
87
+ if (typeof child === 'string') {
88
+ buffer.push(child);
89
+ }
90
+ else {
91
+ flush();
92
+ children.push(child);
93
+ }
94
+ }
95
+ flush();
96
+ node.children = children;
97
+ if (node.children.length === 1 && node.children[0] === node.name)
98
+ node.children = [];
99
+ },
100
+ };
101
+ // Only unwrap a generic that encloses at most one element, logical grouping still makes sense,
102
+ // even if it is not ref-able. The decision is made on `exit` - whether the node encloses a single
103
+ // ref-bearing child is only known after its own descendants were unwrapped - so nested wrappers
104
+ // collapse bottom-up.
105
+ const unwrapSingleChildGenerics = {
106
+ name: 'unwrapSingleChildGenerics',
107
+ exit(node) {
108
+ if (node.role === 'generic' && !node.name && node.children.length <= 1 && node.children.every(child => typeof child !== 'string' && !!child.ref))
109
+ return 'unwrap';
110
+ },
111
+ };
112
+ // A decorative image - role `img` with no accessible name and no content - carries no
113
+ // information. The decision is made on `exit` - whether the node has content is only known after
114
+ // `mergeStringChildren` dropped the empty text tokens.
115
+ const removeNamelessImages = {
116
+ name: 'removeNamelessImages',
117
+ exit(node) {
118
+ if (node.role === 'img' && !node.name && !node.children.length)
119
+ return 'remove';
120
+ },
121
+ };
122
+ // The node's accessible name is derived from content; when every node that contributed to it is
123
+ // represented in the output anyway, the name would just repeat that content and is dropped.
124
+ // Single-pass bookkeeping over the shared `pendingContentRefs` set: entering a node clears its
125
+ // own ref - it is now represented - except for leaf generics, which only exist to supply text
126
+ // and are dropped by `removeNameRepeatingChild` once a kept name shows it. On exit, either every
127
+ // contributor was cleared and the name goes, or the kept name now represents its contributors,
128
+ // so they are cleared for the benefit of the ancestors. A node removed on enter never clears its
129
+ // ref, and an unwrapped one does - matching what remains in the tree.
130
+ const removeRedundantNames = {
131
+ name: 'removeRedundantNames',
132
+ enter(node, ctx) {
133
+ if (!node.ref)
134
+ return;
135
+ for (const ref of ctx.snapshot.info.get(node.ref)?.nameFromContentRefs || [])
136
+ ctx.pendingContentRefs.add(ref);
137
+ const beyondDepth = !!ctx.maxDepth && ctx.depth > ctx.maxDepth;
138
+ if (!beyondDepth && !isLeafGeneric(node))
139
+ ctx.pendingContentRefs.delete(node.ref);
140
+ },
141
+ exit(node, ctx) {
142
+ if (!node.ref)
143
+ return;
144
+ const nameFromContentRefs = ctx.snapshot.info.get(node.ref)?.nameFromContentRefs;
145
+ if (!nameFromContentRefs?.length)
146
+ return;
147
+ if (nameFromContentRefs.every(ref => !ctx.pendingContentRefs.has(ref))) {
148
+ node.name = '';
149
+ }
150
+ else {
151
+ for (const ref of nameFromContentRefs)
152
+ ctx.pendingContentRefs.delete(ref);
153
+ }
154
+ },
155
+ };
156
+ // A generic whose whole content is a piece of text - a single text child, or just an accessible
157
+ // name - that repeats the parent's accessible name adds no information, so it removes itself.
158
+ // `inlineTextIntoGeneric` runs first, bubbling text up through nameless wrappers, so by the time
159
+ // a wrapper exits its text faces the real parent - no need to look further up the ancestor chain.
160
+ // Whenever the node is the source of that name, `removeRedundantNames` keeps the name - the node
161
+ // is a leaf generic - so the text is never lost.
162
+ const removeNameRepeatingChild = {
163
+ name: 'removeNameRepeatingChild',
164
+ exit(node, ctx) {
165
+ const parent = ctx.ancestors[ctx.ancestors.length - 1];
166
+ if (!parent?.name || node.role !== 'generic' || node.active || Object.keys(node.props).length)
167
+ return;
168
+ const singleTextChild = node.children.length === 1 && typeof node.children[0] === 'string' ? node.children[0] : undefined;
169
+ const text = node.name ? (node.children.length ? undefined : node.name) : singleTextChild;
170
+ if (text && text === parent.name)
171
+ return 'remove';
172
+ },
173
+ };
174
+ // A generic whose only child is a nameless leaf generic inlines that child's text:
175
+ // `generic: - generic: "text"` becomes `generic: "text"`. Runs post-order, so chains collapse
176
+ // bottom-up, and after the other plugins already removed or unwrapped the children.
177
+ const inlineTextIntoGeneric = {
178
+ name: 'inlineTextIntoGeneric',
179
+ exit(node) {
180
+ if (node.role !== 'generic' || Object.keys(node.props).length || node.children.length !== 1)
181
+ return;
182
+ const child = node.children[0];
183
+ if (typeof child === 'string')
184
+ return;
185
+ if (child.role !== 'generic' || child.name || child.active || Object.keys(child.props).length)
186
+ return;
187
+ if (child.children.length === 1 && typeof child.children[0] === 'string')
188
+ node.children = [child.children[0]];
189
+ },
190
+ };
191
+ // Structural normalization applies to all modes - it defines the canonical tree shape.
192
+ const normalizePlugins = [
193
+ mergeStringChildren,
194
+ unwrapSingleChildGenerics,
195
+ ];
196
+ // The ai preset compresses the snapshot on top of normalization. It runs as one traversal:
197
+ // `removeRedundantNames` bookkeeping must observe every node the tree retains, including the
198
+ // wrappers that `unwrapSingleChildGenerics` is about to unwrap. On exit, text is first inlined
199
+ // into the node, so that `removeNameRepeatingChild` faces the real parent when it compares.
200
+ const aiPlugins = [
201
+ mergeStringChildren,
202
+ removeNamelessImages,
203
+ removeRedundantNames,
204
+ inlineTextIntoGeneric,
205
+ removeNameRepeatingChild,
206
+ unwrapSingleChildGenerics,
207
+ ];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Copyright (c) Microsoft Corporation.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ export function hasPointerCursor(ariaNode) {
17
+ return ariaNode.box.cursor === 'pointer';
18
+ }
19
+ export function findNewNode(from, to) {
20
+ function fillMap(root, map, position) {
21
+ let size = 1;
22
+ let childPosition = position + size;
23
+ for (const child of root.children || []) {
24
+ if (typeof child === 'string') {
25
+ size++;
26
+ childPosition++;
27
+ }
28
+ else {
29
+ size += fillMap(child, map, childPosition);
30
+ childPosition += size;
31
+ }
32
+ }
33
+ if (!['none', 'presentation', 'fragment', 'iframe', 'generic'].includes(root.role) && root.name) {
34
+ let byRole = map.get(root.role);
35
+ if (!byRole) {
36
+ byRole = new Map();
37
+ map.set(root.role, byRole);
38
+ }
39
+ const existing = byRole.get(root.name);
40
+ // This heuristic prioritizes elements at the top of the page, even if somewhat smaller.
41
+ const sizeAndPosition = size * 100 - position;
42
+ if (!existing || existing.sizeAndPosition < sizeAndPosition)
43
+ byRole.set(root.name, { node: root, sizeAndPosition });
44
+ }
45
+ return size;
46
+ }
47
+ const fromMap = new Map();
48
+ if (from)
49
+ fillMap(from, fromMap, 0);
50
+ const toMap = new Map();
51
+ fillMap(to, toMap, 0);
52
+ const result = [];
53
+ for (const [role, byRole] of toMap) {
54
+ for (const [name, byName] of byRole) {
55
+ const inFrom = fromMap.get(role)?.get(name);
56
+ if (!inFrom)
57
+ result.push(byName);
58
+ }
59
+ }
60
+ result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition);
61
+ return result[0]?.node;
62
+ }