explorbot 0.1.12 → 0.1.15
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/bin/explorbot-cli.ts +21 -21
- package/dist/bin/explorbot-cli.js +3 -3
- package/dist/package.json +4 -2
- package/dist/rules/researcher/container-rules.md +2 -0
- package/dist/src/action-result.js +2 -1
- package/dist/src/action.js +3 -8
- package/dist/src/ai/captain.js +0 -2
- package/dist/src/ai/conversation.js +20 -4
- package/dist/src/ai/driller.js +1108 -0
- package/dist/src/ai/historian/utils.js +8 -1
- package/dist/src/ai/pilot.js +214 -267
- package/dist/src/ai/provider.js +25 -12
- package/dist/src/ai/quartermaster.js +2 -2
- package/dist/src/ai/rules.js +5 -5
- package/dist/src/ai/session-analyst.js +122 -0
- package/dist/src/ai/tester.js +69 -22
- package/dist/src/ai/tools.js +19 -4
- package/dist/src/commands/base-command.js +6 -6
- package/dist/src/commands/drill-command.js +3 -2
- package/dist/src/commands/exit-command.js +1 -0
- package/dist/src/commands/explore-command.js +9 -2
- package/dist/src/components/AddRule.js +1 -1
- package/dist/src/components/StatusPane.js +6 -1
- package/dist/src/experience-tracker.js +9 -0
- package/dist/src/explorbot.js +48 -8
- package/dist/src/explorer.js +11 -13
- package/dist/src/reporter.js +105 -4
- package/dist/src/state-manager.js +4 -3
- package/dist/src/stats.js +7 -1
- package/dist/src/test-plan.js +47 -3
- package/dist/src/utils/aria.js +354 -529
- package/dist/src/utils/hooks-runner.js +2 -8
- package/dist/src/utils/html.js +371 -0
- package/dist/src/utils/unique-names.js +12 -1
- package/dist/src/utils/url-matcher.js +6 -1
- package/dist/src/utils/web-element.js +27 -24
- package/dist/src/utils/xpath.js +1 -1
- package/package.json +4 -2
- package/rules/researcher/container-rules.md +2 -0
- package/src/action-result.ts +2 -1
- package/src/action.ts +3 -10
- package/src/ai/captain.ts +0 -2
- package/src/ai/conversation.ts +21 -4
- package/src/ai/driller.ts +1194 -0
- package/src/ai/historian/utils.ts +8 -1
- package/src/ai/pilot.ts +215 -265
- package/src/ai/provider.ts +24 -12
- package/src/ai/quartermaster.ts +2 -2
- package/src/ai/rules.ts +5 -5
- package/src/ai/session-analyst.ts +139 -0
- package/src/ai/tester.ts +63 -20
- package/src/ai/tools.ts +18 -4
- package/src/commands/base-command.ts +6 -6
- package/src/commands/drill-command.ts +3 -2
- package/src/commands/exit-command.ts +1 -0
- package/src/commands/explore-command.ts +10 -2
- package/src/components/AddRule.tsx +1 -1
- package/src/components/StatusPane.tsx +6 -3
- package/src/config.ts +4 -0
- package/src/experience-tracker.ts +9 -0
- package/src/explorbot.ts +55 -10
- package/src/explorer.ts +10 -12
- package/src/reporter.ts +108 -4
- package/src/state-manager.ts +4 -3
- package/src/stats.ts +10 -1
- package/src/test-plan.ts +62 -3
- package/src/utils/aria.ts +367 -537
- package/src/utils/hooks-runner.ts +2 -6
- package/src/utils/html.ts +381 -0
- package/src/utils/unique-names.ts +13 -0
- package/src/utils/url-matcher.ts +5 -1
- package/src/utils/web-element.ts +31 -28
- package/src/utils/xpath.ts +1 -1
- package/dist/src/ai/bosun.js +0 -456
- package/src/ai/bosun.ts +0 -571
package/dist/src/utils/aria.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { parse as parseYaml } from 'yaml';
|
|
2
|
+
// ─────────────────────────────────────────────────────────────────
|
|
3
|
+
// Roles
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1
5
|
const INTERACTIVE_ROLES = new Set([
|
|
2
6
|
'button',
|
|
3
7
|
'link',
|
|
@@ -34,304 +38,329 @@ const INTERACTIVE_ROLES = new Set([
|
|
|
34
38
|
'comboboxbutton',
|
|
35
39
|
'gridcellbutton',
|
|
36
40
|
].map((role) => role.toLowerCase()));
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (nested)
|
|
47
|
-
part += ` > ${nested}`;
|
|
48
|
-
parts.push(part);
|
|
49
|
-
}
|
|
50
|
-
return parts.join(', ');
|
|
51
|
-
};
|
|
52
|
-
const buildInteractiveEntry = (node) => {
|
|
53
|
-
if (!INTERACTIVE_ROLES.has(node.role)) {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
const entry = { role: node.role };
|
|
57
|
-
if (node.name && node.name.trim() !== '') {
|
|
58
|
-
entry.name = node.name.trim();
|
|
59
|
-
}
|
|
60
|
-
if (node.value !== undefined && node.value !== null) {
|
|
61
|
-
const valueText = `${node.value}`.trim();
|
|
62
|
-
if (valueText !== '') {
|
|
63
|
-
entry.value = node.value;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
for (const [key, value] of Object.entries(node.attributes)) {
|
|
67
|
-
if (value === undefined || value === null) {
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
if (value === '') {
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
entry[key] = value;
|
|
74
|
-
}
|
|
75
|
-
const hasData = Object.keys(entry).some((key) => key !== 'role');
|
|
76
|
-
const entryName = typeof entry.name === 'string' ? entry.name : '';
|
|
77
|
-
const hasValue = Object.prototype.hasOwnProperty.call(entry, 'value');
|
|
78
|
-
const isButtonOrLink = node.role === 'button' || node.role === 'link';
|
|
79
|
-
let shouldInclude = hasData;
|
|
80
|
-
if (!shouldInclude && hasValue) {
|
|
81
|
-
shouldInclude = true;
|
|
82
|
-
}
|
|
83
|
-
if (isButtonOrLink && !entryName && !hasValue) {
|
|
84
|
-
const resolved = resolveDisplayName(node);
|
|
85
|
-
if (resolved) {
|
|
86
|
-
entry.name = resolved;
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
entry.unnamed = true;
|
|
90
|
-
}
|
|
91
|
-
shouldInclude = true;
|
|
92
|
-
}
|
|
93
|
-
if (!shouldInclude) {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
return entry;
|
|
97
|
-
};
|
|
41
|
+
const IGNORED_ROLES = new Set(['navigation']);
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────
|
|
43
|
+
// Tunables (knobs that change pipeline behavior)
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────
|
|
45
|
+
const SIBLING_COLLAPSE_THRESHOLD = 50;
|
|
46
|
+
const SIBLING_COLLAPSE_KEEP_EACH_SIDE = 5;
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────
|
|
48
|
+
// STEP 1 · Parse: YAML text → AriaNode[]
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────
|
|
98
50
|
const normalizeScalar = (input) => {
|
|
99
51
|
let value = input.trim();
|
|
100
52
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
101
53
|
value = value.slice(1, -1);
|
|
102
54
|
}
|
|
103
55
|
const lower = value.toLowerCase();
|
|
104
|
-
if (lower === 'true')
|
|
56
|
+
if (lower === 'true')
|
|
105
57
|
return true;
|
|
106
|
-
|
|
107
|
-
if (lower === 'false') {
|
|
58
|
+
if (lower === 'false')
|
|
108
59
|
return false;
|
|
109
|
-
|
|
110
|
-
if (lower === 'null') {
|
|
60
|
+
if (lower === 'null')
|
|
111
61
|
return null;
|
|
112
|
-
}
|
|
113
62
|
return value;
|
|
114
63
|
};
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
current += char;
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
if (!inQuotes && (char === ' ' || char === ',')) {
|
|
135
|
-
if (current.trim() !== '') {
|
|
136
|
-
tokens.push(current.trim());
|
|
137
|
-
}
|
|
138
|
-
current = '';
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
current += char;
|
|
142
|
-
}
|
|
143
|
-
if (current.trim() !== '') {
|
|
144
|
-
tokens.push(current.trim());
|
|
64
|
+
// Parse one YAML node label like: `button "Save"`, `textbox "Email" [focused]`, `heading "Title" [level=2]`
|
|
65
|
+
const parseLabel = (label) => {
|
|
66
|
+
if (!label)
|
|
67
|
+
return null;
|
|
68
|
+
const trimmed = label.trim();
|
|
69
|
+
const roleMatch = trimmed.match(/^(\w+)/);
|
|
70
|
+
if (!roleMatch)
|
|
71
|
+
return null;
|
|
72
|
+
const role = roleMatch[1].toLowerCase();
|
|
73
|
+
let rest = trimmed.slice(roleMatch[0].length);
|
|
74
|
+
let name;
|
|
75
|
+
const nameMatch = rest.match(/^\s*"((?:[^"\\]|\\.)*)"/) || rest.match(/^\s*'((?:[^'\\]|\\.)*)'/);
|
|
76
|
+
if (nameMatch) {
|
|
77
|
+
name = nameMatch[1];
|
|
78
|
+
rest = rest.slice(nameMatch[0].length);
|
|
145
79
|
}
|
|
146
|
-
return tokens;
|
|
147
|
-
};
|
|
148
|
-
const parseAttributes = (input) => {
|
|
149
80
|
const attributes = {};
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
81
|
+
const attrMatch = rest.match(/\[([^\]]*)\]/);
|
|
82
|
+
if (attrMatch) {
|
|
83
|
+
for (const tok of attrMatch[1].split(/[\s,]+/).filter(Boolean)) {
|
|
84
|
+
const eq = tok.indexOf('=');
|
|
85
|
+
if (eq === -1) {
|
|
86
|
+
attributes[tok.toLowerCase()] = true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
attributes[tok.slice(0, eq).trim().toLowerCase()] = normalizeScalar(tok.slice(eq + 1));
|
|
159
90
|
}
|
|
160
|
-
const key = token.slice(0, separatorIndex).trim().toLowerCase();
|
|
161
|
-
const valueRaw = token.slice(separatorIndex + 1).trim();
|
|
162
|
-
attributes[key] = normalizeScalar(valueRaw);
|
|
163
91
|
}
|
|
164
|
-
return attributes;
|
|
92
|
+
return { role, name, attributes };
|
|
165
93
|
};
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
94
|
+
const yamlItemToNode = (item) => {
|
|
95
|
+
if (typeof item === 'string') {
|
|
96
|
+
const label = parseLabel(item);
|
|
97
|
+
if (!label)
|
|
98
|
+
return null;
|
|
99
|
+
const node = { role: label.role, attributes: label.attributes, children: [] };
|
|
100
|
+
if (label.name && label.name.trim() !== '')
|
|
101
|
+
node.name = label.name.trim();
|
|
102
|
+
return node;
|
|
103
|
+
}
|
|
104
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
105
|
+
return null;
|
|
106
|
+
const entries = Object.entries(item);
|
|
107
|
+
if (entries.length === 0)
|
|
168
108
|
return null;
|
|
109
|
+
const [key, value] = entries[0];
|
|
110
|
+
const label = parseLabel(key);
|
|
111
|
+
if (!label)
|
|
112
|
+
return null;
|
|
113
|
+
const node = { role: label.role, attributes: label.attributes, children: [] };
|
|
114
|
+
if (label.name && label.name.trim() !== '')
|
|
115
|
+
node.name = label.name.trim();
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
node.children = value.map(yamlItemToNode).filter((n) => n !== null);
|
|
118
|
+
return node;
|
|
119
|
+
}
|
|
120
|
+
if (value === null || value === undefined)
|
|
121
|
+
return node;
|
|
122
|
+
const normalized = normalizeScalar(String(value));
|
|
123
|
+
if (normalized !== '' && normalized !== undefined)
|
|
124
|
+
node.value = normalized;
|
|
125
|
+
return node;
|
|
126
|
+
};
|
|
127
|
+
const parseSnapshot = (snapshot) => {
|
|
128
|
+
if (!snapshot)
|
|
129
|
+
return [];
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = parseYaml(snapshot);
|
|
169
133
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
while (index < length && header[index] === ' ') {
|
|
173
|
-
index += 1;
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
174
136
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
137
|
+
if (!Array.isArray(parsed))
|
|
138
|
+
return [];
|
|
139
|
+
return parsed.map(yamlItemToNode).filter((n) => n !== null);
|
|
140
|
+
};
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────
|
|
142
|
+
// STEP 2 · Transforms: AriaNode[] → AriaNode[]
|
|
143
|
+
// Each is a pure function. Compose by stacking calls in the public API.
|
|
144
|
+
// ─────────────────────────────────────────────────────────────────
|
|
145
|
+
// Dissolve <navigation> wrappers into their children.
|
|
146
|
+
const unwrapIgnored = (nodes) => nodes.flatMap((node) => {
|
|
147
|
+
const children = unwrapIgnored(node.children);
|
|
148
|
+
if (IGNORED_ROLES.has(node.role))
|
|
149
|
+
return children;
|
|
150
|
+
return [{ ...node, children }];
|
|
151
|
+
});
|
|
152
|
+
// Walk children to produce a synthetic label for naming icon-only buttons.
|
|
153
|
+
const summarizeChildren = (children) => children
|
|
154
|
+
.map((child) => {
|
|
155
|
+
let part = child.role;
|
|
156
|
+
if (child.name)
|
|
157
|
+
part += ` "${child.name}"`;
|
|
158
|
+
const nested = summarizeChildren(child.children);
|
|
159
|
+
if (nested)
|
|
160
|
+
part += ` > ${nested}`;
|
|
161
|
+
return part;
|
|
162
|
+
})
|
|
163
|
+
.join(', ');
|
|
164
|
+
// Set node.name = "{img "icon"}" for buttons/links that have no name but do have children.
|
|
165
|
+
// Recurses so nested buttons get named too. Uses ORIGINAL children for the summary, before pruning.
|
|
166
|
+
const nameIconButtons = (nodes) => nodes.map((node) => {
|
|
167
|
+
const namedChildren = nameIconButtons(node.children);
|
|
168
|
+
if (node.name)
|
|
169
|
+
return { ...node, children: namedChildren };
|
|
170
|
+
if (node.role !== 'button' && node.role !== 'link')
|
|
171
|
+
return { ...node, children: namedChildren };
|
|
172
|
+
if (node.children.length === 0)
|
|
173
|
+
return { ...node, children: namedChildren };
|
|
174
|
+
return { ...node, name: `{${summarizeChildren(node.children)}}`, children: namedChildren };
|
|
175
|
+
});
|
|
176
|
+
// Drop containers that contribute nothing.
|
|
177
|
+
// keepNamed=true → also keep named non-interactive nodes (e.g. headings, named text).
|
|
178
|
+
const dropEmpty = (nodes, opts = {}) => nodes.flatMap((node) => {
|
|
179
|
+
const children = dropEmpty(node.children, opts);
|
|
180
|
+
if (INTERACTIVE_ROLES.has(node.role))
|
|
181
|
+
return [{ ...node, children }];
|
|
182
|
+
if (children.length > 0)
|
|
183
|
+
return [{ ...node, children }];
|
|
184
|
+
if (opts.keepNamed && (node.name || node.value !== undefined))
|
|
185
|
+
return [{ ...node, children }];
|
|
186
|
+
return [];
|
|
187
|
+
});
|
|
188
|
+
// ─────────────────────────────────────────────────────────────────
|
|
189
|
+
// STEP 3 · Render: AriaNode[] → text or flat entries
|
|
190
|
+
// ─────────────────────────────────────────────────────────────────
|
|
191
|
+
// One-line representation of a node. Stable attr order so diff comparisons are deterministic.
|
|
192
|
+
const formatNode = (node) => {
|
|
193
|
+
let line = node.role;
|
|
194
|
+
if (node.name?.trim())
|
|
195
|
+
line += ` "${node.name.trim()}"`;
|
|
196
|
+
const attrStr = Object.keys(node.attributes)
|
|
197
|
+
.sort()
|
|
198
|
+
.map((k) => {
|
|
199
|
+
const v = node.attributes[k];
|
|
200
|
+
if (v === undefined || v === null || v === '')
|
|
201
|
+
return '';
|
|
202
|
+
if (v === true)
|
|
203
|
+
return k;
|
|
204
|
+
return `${k}=${v}`;
|
|
205
|
+
})
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.join(' ');
|
|
208
|
+
if (attrStr)
|
|
209
|
+
line += ` [${attrStr}]`;
|
|
210
|
+
if (node.value !== undefined && node.value !== null) {
|
|
211
|
+
const text = String(node.value).trim();
|
|
212
|
+
if (text)
|
|
213
|
+
line += `: ${text}`;
|
|
182
214
|
}
|
|
183
|
-
|
|
184
|
-
|
|
215
|
+
return line;
|
|
216
|
+
};
|
|
217
|
+
// Group consecutive same-role siblings. [a,a,b,a,a,a] → [[a,a],[b],[a,a,a]]
|
|
218
|
+
const groupByConsecutiveRole = (nodes) => nodes.reduce((groups, node) => {
|
|
219
|
+
const last = groups[groups.length - 1];
|
|
220
|
+
if (last && last[0].role === node.role) {
|
|
221
|
+
last.push(node);
|
|
222
|
+
return groups;
|
|
223
|
+
}
|
|
224
|
+
groups.push([node]);
|
|
225
|
+
return groups;
|
|
226
|
+
}, []);
|
|
227
|
+
// Large group of same-role siblings → first N + "...M omitted..." marker + last N.
|
|
228
|
+
const collapseGroup = (group, depth) => {
|
|
229
|
+
if (group.length <= SIBLING_COLLAPSE_THRESHOLD) {
|
|
230
|
+
return group.map((node) => ({ node }));
|
|
231
|
+
}
|
|
232
|
+
const keep = SIBLING_COLLAPSE_KEEP_EACH_SIDE;
|
|
233
|
+
const omitted = group.length - keep * 2;
|
|
234
|
+
const indent = ' '.repeat(depth);
|
|
235
|
+
return [...group.slice(0, keep).map((node) => ({ node })), { placeholder: `${indent}- ...${omitted} similar "${group[0].role}" items omitted...` }, ...group.slice(-keep).map((node) => ({ node }))];
|
|
236
|
+
};
|
|
237
|
+
const collapseSiblingGroups = (nodes, depth) => groupByConsecutiveRole(nodes).flatMap((group) => collapseGroup(group, depth));
|
|
238
|
+
// Tree → indented YAML text.
|
|
239
|
+
const renderTree = (nodes, depth = 0) => collapseSiblingGroups(nodes, depth)
|
|
240
|
+
.map((entry) => {
|
|
241
|
+
if ('placeholder' in entry)
|
|
242
|
+
return entry.placeholder;
|
|
243
|
+
const { node } = entry;
|
|
244
|
+
const indent = ' '.repeat(depth);
|
|
245
|
+
const head = `${indent}- ${formatNode(node)}`;
|
|
246
|
+
if (node.children.length === 0)
|
|
247
|
+
return head;
|
|
248
|
+
return `${head}:\n${renderTree(node.children, depth + 1)}`;
|
|
249
|
+
})
|
|
250
|
+
.join('\n');
|
|
251
|
+
// Build the structured "entry" object for an interactive node, or null if not worth keeping.
|
|
252
|
+
const nodeToEntry = (node) => {
|
|
253
|
+
if (!INTERACTIVE_ROLES.has(node.role))
|
|
185
254
|
return null;
|
|
255
|
+
const entry = { role: node.role };
|
|
256
|
+
if (node.name?.trim())
|
|
257
|
+
entry.name = node.name.trim();
|
|
258
|
+
if (node.value !== undefined && node.value !== null) {
|
|
259
|
+
const text = String(node.value).trim();
|
|
260
|
+
if (text)
|
|
261
|
+
entry.value = node.value;
|
|
186
262
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
index = roleEnd;
|
|
190
|
-
while (index < length) {
|
|
191
|
-
const char = header[index];
|
|
192
|
-
if (char === ' ') {
|
|
193
|
-
index += 1;
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
if (char === '"' || char === "'") {
|
|
197
|
-
const quoteChar = char;
|
|
198
|
-
index += 1;
|
|
199
|
-
let value = '';
|
|
200
|
-
while (index < length) {
|
|
201
|
-
const currentChar = header[index];
|
|
202
|
-
if (currentChar === quoteChar && header[index - 1] !== '\\') {
|
|
203
|
-
index += 1;
|
|
204
|
-
break;
|
|
205
|
-
}
|
|
206
|
-
value += currentChar;
|
|
207
|
-
index += 1;
|
|
208
|
-
}
|
|
209
|
-
if (!name) {
|
|
210
|
-
name = value;
|
|
211
|
-
}
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
if (char === '[') {
|
|
215
|
-
const end = header.indexOf(']', index);
|
|
216
|
-
const content = end === -1 ? header.slice(index + 1) : header.slice(index + 1, end);
|
|
217
|
-
const parsed = parseAttributes(content);
|
|
218
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
219
|
-
attributes[key] = value;
|
|
220
|
-
}
|
|
221
|
-
index = end === -1 ? length : end + 1;
|
|
263
|
+
for (const [key, value] of Object.entries(node.attributes)) {
|
|
264
|
+
if (value === undefined || value === null || value === '')
|
|
222
265
|
continue;
|
|
223
|
-
|
|
224
|
-
break;
|
|
266
|
+
entry[key] = value;
|
|
225
267
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
for (let i = 0; i < content.length; i += 1) {
|
|
232
|
-
const char = content[i];
|
|
233
|
-
if ((char === '"' || char === "'") && content[i - 1] !== '\\') {
|
|
234
|
-
if (activeQuote === char) {
|
|
235
|
-
activeQuote = null;
|
|
236
|
-
}
|
|
237
|
-
else if (!activeQuote) {
|
|
238
|
-
activeQuote = char;
|
|
239
|
-
}
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
if (!activeQuote) {
|
|
243
|
-
if (char === '[') {
|
|
244
|
-
bracketDepth += 1;
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
if (char === ']') {
|
|
248
|
-
if (bracketDepth > 0) {
|
|
249
|
-
bracketDepth -= 1;
|
|
250
|
-
}
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
if (char === ':') {
|
|
254
|
-
if (bracketDepth === 0) {
|
|
255
|
-
const header = content.slice(0, i).trimEnd();
|
|
256
|
-
const value = content.slice(i + 1).trimStart();
|
|
257
|
-
return { header, value: value === '' ? null : value };
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
268
|
+
const isButtonOrLink = node.role === 'button' || node.role === 'link';
|
|
269
|
+
const hasContent = Object.keys(entry).length > 1;
|
|
270
|
+
if (isButtonOrLink && !hasContent) {
|
|
271
|
+
entry.unnamed = true;
|
|
272
|
+
return entry;
|
|
261
273
|
}
|
|
262
|
-
|
|
274
|
+
if (!hasContent)
|
|
275
|
+
return null;
|
|
276
|
+
return entry;
|
|
263
277
|
};
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (!interactive && children.length === 0) {
|
|
274
|
-
if (!keepNamed || (!node.name && node.value === undefined)) {
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
result.push({ ...node, children, rawChildren: node.children });
|
|
279
|
-
}
|
|
280
|
-
return result;
|
|
278
|
+
// Walk tree, emit one FlatEntry per interactive node. Path is dotted index from root.
|
|
279
|
+
const flatten = (nodes) => {
|
|
280
|
+
const collect = (node, path) => {
|
|
281
|
+
const entry = nodeToEntry(node);
|
|
282
|
+
const here = entry ? [{ path, summary: formatNode(node), entry }] : [];
|
|
283
|
+
const fromChildren = node.children.flatMap((child, i) => collect(child, `${path}.${i}`));
|
|
284
|
+
return [...here, ...fromChildren];
|
|
285
|
+
};
|
|
286
|
+
return nodes.flatMap((node, i) => collect(node, String(i)));
|
|
281
287
|
};
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
288
|
+
// ─────────────────────────────────────────────────────────────────
|
|
289
|
+
// STEP 4 · Diff: FlatEntry[] × FlatEntry[] → text
|
|
290
|
+
// ─────────────────────────────────────────────────────────────────
|
|
291
|
+
const countBy = (items) => items.reduce((map, item) => {
|
|
292
|
+
if (item === '')
|
|
293
|
+
return map;
|
|
294
|
+
map.set(item, (map.get(item) ?? 0) + 1);
|
|
295
|
+
return map;
|
|
296
|
+
}, new Map());
|
|
297
|
+
// Bag-style diff: any summary appearing more in one bag than the other becomes added/removed.
|
|
298
|
+
const diffByCount = (before, after) => {
|
|
299
|
+
const added = [];
|
|
300
|
+
const removed = [];
|
|
301
|
+
const all = new Set([...before.keys(), ...after.keys()]);
|
|
302
|
+
for (const summary of all) {
|
|
303
|
+
const b = before.get(summary) ?? 0;
|
|
304
|
+
const a = after.get(summary) ?? 0;
|
|
305
|
+
for (let i = 0; i < a - b; i += 1)
|
|
306
|
+
added.push(summary);
|
|
307
|
+
for (let i = 0; i < b - a; i += 1)
|
|
308
|
+
removed.push(summary);
|
|
309
|
+
}
|
|
310
|
+
return { added, removed };
|
|
311
|
+
};
|
|
312
|
+
// When the same path has a different summary AND the per-summary totals haven't shifted,
|
|
313
|
+
// treat it as a rename (one add + one remove). Catches "button text changed" cases that
|
|
314
|
+
// the count-based diff would miss.
|
|
315
|
+
const detectRenames = (prev, curr, prevTotals, currTotals) => {
|
|
316
|
+
const added = [];
|
|
317
|
+
const removed = [];
|
|
318
|
+
const prevByPath = new Map(prev.map((e) => [e.path, e.summary]));
|
|
319
|
+
const currByPath = new Map(curr.map((e) => [e.path, e.summary]));
|
|
320
|
+
for (const [path, beforeSummary] of prevByPath) {
|
|
321
|
+
const afterSummary = currByPath.get(path);
|
|
322
|
+
if (!afterSummary || afterSummary === beforeSummary)
|
|
297
323
|
continue;
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
if (
|
|
324
|
+
const totalsAfter = (currTotals.get(afterSummary) ?? 0) === (prevTotals.get(afterSummary) ?? 0);
|
|
325
|
+
const totalsBefore = (currTotals.get(beforeSummary) ?? 0) === (prevTotals.get(beforeSummary) ?? 0);
|
|
326
|
+
if (!totalsAfter || !totalsBefore)
|
|
301
327
|
continue;
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
if (!parsedHeader) {
|
|
328
|
+
const beforeElsewhere = curr.some((e) => e.path !== path && e.summary === beforeSummary);
|
|
329
|
+
const afterElsewhere = prev.some((e) => e.path !== path && e.summary === afterSummary);
|
|
330
|
+
if (beforeElsewhere && afterElsewhere)
|
|
306
331
|
continue;
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
role: parsedHeader.role,
|
|
310
|
-
attributes: { ...parsedHeader.attributes },
|
|
311
|
-
children: [],
|
|
312
|
-
};
|
|
313
|
-
if (parsedHeader.name && parsedHeader.name.trim() !== '') {
|
|
314
|
-
node.name = parsedHeader.name.trim();
|
|
315
|
-
}
|
|
316
|
-
if (value !== null) {
|
|
317
|
-
const normalizedValue = normalizeScalar(value);
|
|
318
|
-
if (normalizedValue !== '' && normalizedValue !== undefined) {
|
|
319
|
-
node.value = normalizedValue;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
const depth = Math.floor(indent / 2);
|
|
323
|
-
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
|
|
324
|
-
stack.pop();
|
|
325
|
-
}
|
|
326
|
-
if (stack.length === 0) {
|
|
327
|
-
roots.push(node);
|
|
328
|
-
}
|
|
329
|
-
else {
|
|
330
|
-
stack[stack.length - 1].node.children.push(node);
|
|
331
|
-
}
|
|
332
|
-
stack.push({ depth, node });
|
|
332
|
+
added.push(afterSummary);
|
|
333
|
+
removed.push(beforeSummary);
|
|
333
334
|
}
|
|
334
|
-
return
|
|
335
|
+
return { added, removed };
|
|
336
|
+
};
|
|
337
|
+
const TOP_DIFF_ITEMS = 10;
|
|
338
|
+
const formatDiffSection = (label, items) => {
|
|
339
|
+
const summary = countBy(items);
|
|
340
|
+
if (summary.size === 0)
|
|
341
|
+
return [` ${label}: []`];
|
|
342
|
+
const sorted = Array.from(summary.entries()).sort(([aItem, aCount], [bItem, bCount]) => bCount - aCount || aItem.localeCompare(bItem));
|
|
343
|
+
const top = sorted.slice(0, TOP_DIFF_ITEMS);
|
|
344
|
+
const rest = sorted.slice(TOP_DIFF_ITEMS);
|
|
345
|
+
const lines = [` ${label}:`];
|
|
346
|
+
for (const [item, count] of top) {
|
|
347
|
+
let suffix = '';
|
|
348
|
+
if (count > 1)
|
|
349
|
+
suffix = ` (x${count})`;
|
|
350
|
+
lines.push(` - ${item}${suffix}`);
|
|
351
|
+
}
|
|
352
|
+
if (rest.length > 0) {
|
|
353
|
+
let restTotal = 0;
|
|
354
|
+
for (const [, count] of rest)
|
|
355
|
+
restTotal += count;
|
|
356
|
+
lines.push(` + ${restTotal} more interactive elements`);
|
|
357
|
+
}
|
|
358
|
+
return lines;
|
|
359
|
+
};
|
|
360
|
+
const formatDiff = (added, removed) => {
|
|
361
|
+
if (added.length === 0 && removed.length === 0)
|
|
362
|
+
return null;
|
|
363
|
+
return ['ariaDiff:', ...formatDiffSection('added', added), ...formatDiffSection('removed', removed)].join('\n');
|
|
335
364
|
};
|
|
336
365
|
const CLOSE_OVERLAY_BUTTON_RE = /^close\s+(modal|dialog|popup|drawer|panel|sheet)\b/i;
|
|
337
366
|
const findOverlayByCloseButton = (nodeList) => {
|
|
@@ -352,11 +381,7 @@ const findOverlayByCloseButton = (nodeList) => {
|
|
|
352
381
|
}
|
|
353
382
|
}
|
|
354
383
|
}
|
|
355
|
-
return {
|
|
356
|
-
detected: true,
|
|
357
|
-
type: 'dialog',
|
|
358
|
-
name: heading?.name || null,
|
|
359
|
-
};
|
|
384
|
+
return { detected: true, type: 'dialog', name: heading?.name || null };
|
|
360
385
|
}
|
|
361
386
|
for (const node of nodeList) {
|
|
362
387
|
const inner = findOverlayByCloseButton(node.children);
|
|
@@ -365,126 +390,66 @@ const findOverlayByCloseButton = (nodeList) => {
|
|
|
365
390
|
}
|
|
366
391
|
return null;
|
|
367
392
|
};
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (node.role === 'dialog' || node.role === 'alertdialog') {
|
|
373
|
-
return {
|
|
374
|
-
detected: true,
|
|
375
|
-
type: 'dialog',
|
|
376
|
-
name: node.name || null,
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
if (node.attributes.modal === true || node.attributes.modal === 'true') {
|
|
380
|
-
return {
|
|
381
|
-
detected: true,
|
|
382
|
-
type: 'modal',
|
|
383
|
-
name: node.name || null,
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
const childResult = findFocusArea(node.children);
|
|
387
|
-
if (childResult) {
|
|
388
|
-
return childResult;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
return null;
|
|
392
|
-
};
|
|
393
|
-
const result = findFocusArea(nodes);
|
|
394
|
-
if (result)
|
|
395
|
-
return result;
|
|
396
|
-
const fallback = findOverlayByCloseButton(nodes);
|
|
397
|
-
if (fallback?.name)
|
|
398
|
-
return fallback;
|
|
399
|
-
return { detected: false, type: null, name: null };
|
|
400
|
-
};
|
|
401
|
-
export const collectInteractiveNodes = (snapshot) => {
|
|
402
|
-
const nodes = parseAriaSnapshot(snapshot);
|
|
403
|
-
const result = [];
|
|
404
|
-
const visit = (node) => {
|
|
405
|
-
if (IGNORED_CONTAINER_ROLES.has(node.role)) {
|
|
406
|
-
node.children.forEach(visit);
|
|
407
|
-
return;
|
|
393
|
+
const findDialogOrModal = (nodes) => {
|
|
394
|
+
for (const node of nodes) {
|
|
395
|
+
if (node.role === 'dialog' || node.role === 'alertdialog') {
|
|
396
|
+
return { detected: true, type: 'dialog', name: node.name || null };
|
|
408
397
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
result.push(entry);
|
|
398
|
+
if (node.attributes.modal === true || node.attributes.modal === 'true') {
|
|
399
|
+
return { detected: true, type: 'modal', name: node.name || null };
|
|
412
400
|
}
|
|
413
|
-
node.children
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
return result;
|
|
417
|
-
};
|
|
418
|
-
const renderNodeLine = (role, name, attrs, value) => {
|
|
419
|
-
let line = role;
|
|
420
|
-
const displayName = name?.trim();
|
|
421
|
-
if (displayName)
|
|
422
|
-
line += ` "${displayName}"`;
|
|
423
|
-
const attrStr = Object.entries(attrs)
|
|
424
|
-
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
425
|
-
.map(([k, v]) => (v === true ? k : `${k}=${v}`))
|
|
426
|
-
.join(' ');
|
|
427
|
-
if (attrStr)
|
|
428
|
-
line += ` [${attrStr}]`;
|
|
429
|
-
if (value !== undefined && value !== null) {
|
|
430
|
-
const text = `${value}`.trim();
|
|
431
|
-
if (text)
|
|
432
|
-
line += `: ${text}`;
|
|
401
|
+
const child = findDialogOrModal(node.children);
|
|
402
|
+
if (child)
|
|
403
|
+
return child;
|
|
433
404
|
}
|
|
434
|
-
return
|
|
405
|
+
return null;
|
|
435
406
|
};
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
407
|
+
// ─────────────────────────────────────────────────────────────────
|
|
408
|
+
// Public API — pipelines composed visibly, top-to-bottom
|
|
409
|
+
// ─────────────────────────────────────────────────────────────────
|
|
410
|
+
export const compactAriaSnapshot = (snapshot, keepNamed = false) => {
|
|
411
|
+
if (!snapshot)
|
|
439
412
|
return '';
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
attrs[key] = node[key];
|
|
446
|
-
}
|
|
447
|
-
const value = Object.prototype.hasOwnProperty.call(node, 'value') ? node.value : undefined;
|
|
448
|
-
return renderNodeLine(role, name, attrs, value);
|
|
413
|
+
let tree = parseSnapshot(snapshot);
|
|
414
|
+
tree = unwrapIgnored(tree);
|
|
415
|
+
tree = nameIconButtons(tree);
|
|
416
|
+
tree = dropEmpty(tree, { keepNamed });
|
|
417
|
+
return renderTree(tree);
|
|
449
418
|
};
|
|
450
|
-
const
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const summary = formatSummary(entry);
|
|
458
|
-
if (summary !== '') {
|
|
459
|
-
result.push({ path, summary });
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
node.children.forEach((child, index) => {
|
|
464
|
-
const childPath = path === '' ? `${index}` : `${path}.${index}`;
|
|
465
|
-
visit(child, childPath);
|
|
466
|
-
});
|
|
419
|
+
export const diffAriaSnapshots = (previous, current) => {
|
|
420
|
+
const flat = (snap) => {
|
|
421
|
+
let tree = parseSnapshot(snap);
|
|
422
|
+
tree = unwrapIgnored(tree);
|
|
423
|
+
tree = nameIconButtons(tree);
|
|
424
|
+
tree = dropEmpty(tree);
|
|
425
|
+
return flatten(tree);
|
|
467
426
|
};
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
427
|
+
const prev = flat(previous);
|
|
428
|
+
const curr = flat(current);
|
|
429
|
+
const prevTotals = countBy(prev.map((e) => e.summary));
|
|
430
|
+
const currTotals = countBy(curr.map((e) => e.summary));
|
|
431
|
+
const byCount = diffByCount(prevTotals, currTotals);
|
|
432
|
+
const renames = detectRenames(prev, curr, prevTotals, currTotals);
|
|
433
|
+
return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed]);
|
|
472
434
|
};
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
435
|
+
export const detectFocusArea = (snapshot) => {
|
|
436
|
+
let tree = parseSnapshot(snapshot);
|
|
437
|
+
tree = unwrapIgnored(tree);
|
|
438
|
+
tree = dropEmpty(tree, { keepNamed: true });
|
|
439
|
+
const direct = findDialogOrModal(tree);
|
|
440
|
+
if (direct)
|
|
441
|
+
return direct;
|
|
442
|
+
const fallback = findOverlayByCloseButton(tree);
|
|
443
|
+
if (fallback?.name)
|
|
444
|
+
return fallback;
|
|
445
|
+
return { detected: false, type: null, name: null };
|
|
482
446
|
};
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
447
|
+
export const collectInteractiveNodes = (snapshot) => {
|
|
448
|
+
let tree = parseSnapshot(snapshot);
|
|
449
|
+
tree = unwrapIgnored(tree);
|
|
450
|
+
tree = nameIconButtons(tree);
|
|
451
|
+
tree = dropEmpty(tree);
|
|
452
|
+
return flatten(tree).map((e) => e.entry);
|
|
488
453
|
};
|
|
489
454
|
export function extractFocusedElement(ariaSnapshot) {
|
|
490
455
|
if (!ariaSnapshot)
|
|
@@ -502,12 +467,12 @@ export function extractFocusedElement(ariaSnapshot) {
|
|
|
502
467
|
}
|
|
503
468
|
}
|
|
504
469
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
470
|
+
const result = { role, name };
|
|
471
|
+
if (value)
|
|
472
|
+
result.value = value.trim();
|
|
473
|
+
if (attributes.length > 0)
|
|
474
|
+
result.attributes = attributes;
|
|
475
|
+
return result;
|
|
511
476
|
}
|
|
512
477
|
export function parseAriaLocator(ariaStr) {
|
|
513
478
|
const trimmed = ariaStr.trim();
|
|
@@ -518,143 +483,3 @@ export function parseAriaLocator(ariaStr) {
|
|
|
518
483
|
return null;
|
|
519
484
|
return { role: match[1], text: match[2] };
|
|
520
485
|
}
|
|
521
|
-
export const diffAriaSnapshots = (previous, current) => {
|
|
522
|
-
const previousEntries = flattenInteractiveNodes(previous);
|
|
523
|
-
const currentEntries = flattenInteractiveNodes(current);
|
|
524
|
-
const previousTotals = buildCountMap(previousEntries.map((entry) => entry.summary));
|
|
525
|
-
const currentTotals = buildCountMap(currentEntries.map((entry) => entry.summary));
|
|
526
|
-
const added = [];
|
|
527
|
-
const removed = [];
|
|
528
|
-
const allSummaries = new Set([...previousTotals.keys(), ...currentTotals.keys()]);
|
|
529
|
-
for (const summary of allSummaries) {
|
|
530
|
-
const before = previousTotals.get(summary) ?? 0;
|
|
531
|
-
const after = currentTotals.get(summary) ?? 0;
|
|
532
|
-
if (after > before) {
|
|
533
|
-
for (let i = 0; i < after - before; i += 1) {
|
|
534
|
-
added.push(summary);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
if (before > after) {
|
|
538
|
-
for (let i = 0; i < before - after; i += 1) {
|
|
539
|
-
removed.push(summary);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
const previousByPath = new Map();
|
|
544
|
-
for (const entry of previousEntries) {
|
|
545
|
-
previousByPath.set(entry.path, entry.summary);
|
|
546
|
-
}
|
|
547
|
-
const currentByPath = new Map();
|
|
548
|
-
for (const entry of currentEntries) {
|
|
549
|
-
currentByPath.set(entry.path, entry.summary);
|
|
550
|
-
}
|
|
551
|
-
for (const [path, beforeSummary] of previousByPath.entries()) {
|
|
552
|
-
const afterSummary = currentByPath.get(path);
|
|
553
|
-
if (!afterSummary || afterSummary === beforeSummary) {
|
|
554
|
-
continue;
|
|
555
|
-
}
|
|
556
|
-
const totalsEqualAfter = (currentTotals.get(afterSummary) ?? 0) === (previousTotals.get(afterSummary) ?? 0);
|
|
557
|
-
const totalsEqualBefore = (currentTotals.get(beforeSummary) ?? 0) === (previousTotals.get(beforeSummary) ?? 0);
|
|
558
|
-
if (!totalsEqualAfter || !totalsEqualBefore) {
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
const beforeExistsElsewhere = currentEntries.some((entry) => entry.path !== path && entry.summary === beforeSummary);
|
|
562
|
-
const afterExistsElsewhere = previousEntries.some((entry) => entry.path !== path && entry.summary === afterSummary);
|
|
563
|
-
if (beforeExistsElsewhere && afterExistsElsewhere) {
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
added.push(afterSummary);
|
|
567
|
-
removed.push(beforeSummary);
|
|
568
|
-
}
|
|
569
|
-
if (added.length === 0 && removed.length === 0) {
|
|
570
|
-
return null;
|
|
571
|
-
}
|
|
572
|
-
const lines = ['ariaDiff:'];
|
|
573
|
-
const addedSummary = buildCountMap(added);
|
|
574
|
-
if (addedSummary.size === 0) {
|
|
575
|
-
lines.push(' added: []');
|
|
576
|
-
}
|
|
577
|
-
else {
|
|
578
|
-
lines.push(' added:');
|
|
579
|
-
Array.from(addedSummary.entries())
|
|
580
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
581
|
-
.forEach(([item, count]) => {
|
|
582
|
-
lines.push(` - ${formatDiffItem(item, count)}`);
|
|
583
|
-
});
|
|
584
|
-
}
|
|
585
|
-
if (removed.length === 0) {
|
|
586
|
-
lines.push(' removed: []');
|
|
587
|
-
}
|
|
588
|
-
else {
|
|
589
|
-
lines.push(` removed: ${removed.length} interactive elements`);
|
|
590
|
-
}
|
|
591
|
-
return lines.join('\n');
|
|
592
|
-
};
|
|
593
|
-
const resolveDisplayName = (node) => {
|
|
594
|
-
if (node.name)
|
|
595
|
-
return node.name;
|
|
596
|
-
const isButtonOrLink = node.role === 'button' || node.role === 'link';
|
|
597
|
-
if (!isButtonOrLink)
|
|
598
|
-
return undefined;
|
|
599
|
-
const childContent = serializeChildContent(node);
|
|
600
|
-
if (childContent)
|
|
601
|
-
return `{${childContent}}`;
|
|
602
|
-
return undefined;
|
|
603
|
-
};
|
|
604
|
-
const SIBLING_COLLAPSE_THRESHOLD = 50;
|
|
605
|
-
const SIBLING_COLLAPSE_KEEP_EACH_SIDE = 5;
|
|
606
|
-
const serializeAriaNodes = (nodes, depth = 0) => {
|
|
607
|
-
const lines = [];
|
|
608
|
-
const collapsed = collapseSimilarSiblingRuns(nodes, depth);
|
|
609
|
-
for (const entry of collapsed) {
|
|
610
|
-
if (entry.placeholder) {
|
|
611
|
-
lines.push(entry.placeholder);
|
|
612
|
-
continue;
|
|
613
|
-
}
|
|
614
|
-
const node = entry.node;
|
|
615
|
-
const indent = ' '.repeat(depth);
|
|
616
|
-
let line = `${indent}- ${renderNodeLine(node.role, resolveDisplayName(node), node.attributes, node.value)}`;
|
|
617
|
-
if (node.children.length > 0) {
|
|
618
|
-
line += ':';
|
|
619
|
-
}
|
|
620
|
-
lines.push(line);
|
|
621
|
-
if (node.children.length > 0) {
|
|
622
|
-
lines.push(serializeAriaNodes(node.children, depth + 1));
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
return lines.join('\n');
|
|
626
|
-
};
|
|
627
|
-
const collapseSimilarSiblingRuns = (nodes, depth) => {
|
|
628
|
-
const result = [];
|
|
629
|
-
let i = 0;
|
|
630
|
-
while (i < nodes.length) {
|
|
631
|
-
const role = nodes[i].role;
|
|
632
|
-
let j = i;
|
|
633
|
-
while (j < nodes.length && nodes[j].role === role)
|
|
634
|
-
j++;
|
|
635
|
-
const runLength = j - i;
|
|
636
|
-
if (runLength > SIBLING_COLLAPSE_THRESHOLD) {
|
|
637
|
-
for (let k = i; k < i + SIBLING_COLLAPSE_KEEP_EACH_SIDE; k++) {
|
|
638
|
-
result.push({ node: nodes[k] });
|
|
639
|
-
}
|
|
640
|
-
const omitted = runLength - SIBLING_COLLAPSE_KEEP_EACH_SIDE * 2;
|
|
641
|
-
const indent = ' '.repeat(depth);
|
|
642
|
-
result.push({ placeholder: `${indent}- ...${omitted} similar "${role}" items omitted...` });
|
|
643
|
-
for (let k = j - SIBLING_COLLAPSE_KEEP_EACH_SIDE; k < j; k++) {
|
|
644
|
-
result.push({ node: nodes[k] });
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
else {
|
|
648
|
-
for (let k = i; k < j; k++)
|
|
649
|
-
result.push({ node: nodes[k] });
|
|
650
|
-
}
|
|
651
|
-
i = j;
|
|
652
|
-
}
|
|
653
|
-
return result;
|
|
654
|
-
};
|
|
655
|
-
export const compactAriaSnapshot = (snapshot, keepNamed = false) => {
|
|
656
|
-
if (!snapshot)
|
|
657
|
-
return '';
|
|
658
|
-
const nodes = parseAriaSnapshot(snapshot, keepNamed);
|
|
659
|
-
return serializeAriaNodes(nodes);
|
|
660
|
-
};
|