explorbot 0.1.12 → 0.1.13

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 (59) hide show
  1. package/bin/explorbot-cli.ts +21 -21
  2. package/dist/bin/explorbot-cli.js +3 -3
  3. package/dist/package.json +3 -2
  4. package/dist/rules/researcher/container-rules.md +2 -0
  5. package/dist/src/action-result.js +2 -1
  6. package/dist/src/action.js +0 -6
  7. package/dist/src/ai/captain.js +0 -2
  8. package/dist/src/ai/driller.js +1108 -0
  9. package/dist/src/ai/pilot.js +31 -22
  10. package/dist/src/ai/rules.js +3 -5
  11. package/dist/src/ai/session-analyst.js +117 -0
  12. package/dist/src/ai/tester.js +13 -2
  13. package/dist/src/commands/base-command.js +6 -6
  14. package/dist/src/commands/drill-command.js +3 -2
  15. package/dist/src/commands/exit-command.js +1 -0
  16. package/dist/src/commands/explore-command.js +1 -0
  17. package/dist/src/components/AddRule.js +1 -1
  18. package/dist/src/explorbot.js +48 -8
  19. package/dist/src/explorer.js +9 -8
  20. package/dist/src/reporter.js +64 -3
  21. package/dist/src/state-manager.js +4 -3
  22. package/dist/src/stats.js +5 -0
  23. package/dist/src/utils/aria.js +354 -529
  24. package/dist/src/utils/hooks-runner.js +2 -8
  25. package/dist/src/utils/html.js +371 -0
  26. package/dist/src/utils/unique-names.js +12 -1
  27. package/dist/src/utils/url-matcher.js +6 -1
  28. package/dist/src/utils/web-element.js +27 -24
  29. package/dist/src/utils/xpath.js +1 -1
  30. package/package.json +3 -2
  31. package/rules/researcher/container-rules.md +2 -0
  32. package/src/action-result.ts +2 -1
  33. package/src/action.ts +0 -8
  34. package/src/ai/captain.ts +0 -2
  35. package/src/ai/driller.ts +1194 -0
  36. package/src/ai/pilot.ts +31 -21
  37. package/src/ai/rules.ts +3 -5
  38. package/src/ai/session-analyst.ts +133 -0
  39. package/src/ai/tester.ts +15 -2
  40. package/src/commands/base-command.ts +6 -6
  41. package/src/commands/drill-command.ts +3 -2
  42. package/src/commands/exit-command.ts +1 -0
  43. package/src/commands/explore-command.ts +1 -0
  44. package/src/components/AddRule.tsx +1 -1
  45. package/src/config.ts +4 -0
  46. package/src/explorbot.ts +55 -10
  47. package/src/explorer.ts +9 -8
  48. package/src/reporter.ts +64 -3
  49. package/src/state-manager.ts +4 -3
  50. package/src/stats.ts +7 -0
  51. package/src/utils/aria.ts +367 -537
  52. package/src/utils/hooks-runner.ts +2 -6
  53. package/src/utils/html.ts +381 -0
  54. package/src/utils/unique-names.ts +13 -0
  55. package/src/utils/url-matcher.ts +5 -1
  56. package/src/utils/web-element.ts +31 -28
  57. package/src/utils/xpath.ts +1 -1
  58. package/dist/src/ai/bosun.js +0 -456
  59. package/src/ai/bosun.ts +0 -571
@@ -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 IGNORED_CONTAINER_ROLES = new Set(['navigation']);
38
- const serializeChildContent = (node) => {
39
- const children = node.rawChildren || node.children;
40
- const parts = [];
41
- for (const child of children) {
42
- let part = child.role;
43
- if (child.name)
44
- part += ` "${child.name}"`;
45
- const nested = serializeChildContent(child);
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
- const tokenizeAttributes = (input) => {
116
- const tokens = [];
117
- let current = '';
118
- let inQuotes = false;
119
- let quoteChar = '';
120
- for (let i = 0; i < input.length; i += 1) {
121
- const char = input[i];
122
- if ((char === '"' || char === "'") && input[i - 1] !== '\\') {
123
- if (inQuotes && quoteChar === char) {
124
- inQuotes = false;
125
- quoteChar = '';
126
- }
127
- else if (!inQuotes) {
128
- inQuotes = true;
129
- quoteChar = char;
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 tokens = tokenizeAttributes(input);
151
- for (const token of tokens) {
152
- if (token === '') {
153
- continue;
154
- }
155
- const separatorIndex = token.indexOf('=');
156
- if (separatorIndex === -1) {
157
- attributes[token.toLowerCase()] = true;
158
- continue;
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 parseHeader = (header) => {
167
- if (!header) {
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
- let index = 0;
171
- const length = header.length;
172
- while (index < length && header[index] === ' ') {
173
- index += 1;
134
+ catch {
135
+ return [];
174
136
  }
175
- let roleEnd = index;
176
- while (roleEnd < length) {
177
- const char = header[roleEnd];
178
- if (char === ' ' || char === '[' || char === '"' || char === "'") {
179
- break;
180
- }
181
- roleEnd += 1;
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
- const role = header.slice(index, roleEnd).trim().toLowerCase();
184
- if (!role) {
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
- let name;
188
- const attributes = {};
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
- return { role, name, attributes };
227
- };
228
- const splitHeaderValue = (content) => {
229
- let activeQuote = null;
230
- let bracketDepth = 0;
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
- return { header: content.trim(), value: null };
274
+ if (!hasContent)
275
+ return null;
276
+ return entry;
263
277
  };
264
- const pruneNodes = (nodes, keepNamed = false) => {
265
- const result = [];
266
- for (const node of nodes) {
267
- const children = pruneNodes(node.children, keepNamed);
268
- if (IGNORED_CONTAINER_ROLES.has(node.role)) {
269
- result.push(...children);
270
- continue;
271
- }
272
- const interactive = INTERACTIVE_ROLES.has(node.role);
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
- const parseAriaSnapshot = (snapshot, keepNamed = false) => {
283
- if (!snapshot) {
284
- return [];
285
- }
286
- const roots = [];
287
- const stack = [];
288
- const lines = snapshot.split(/\r?\n/);
289
- for (const line of lines) {
290
- if (!line.trim()) {
291
- continue;
292
- }
293
- const indentMatch = line.match(/^\s*/);
294
- const indent = indentMatch ? indentMatch[0].length : 0;
295
- const trimmed = line.slice(indent);
296
- if (!trimmed.startsWith('-')) {
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 content = trimmed.slice(1).trim();
300
- if (content === '') {
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 { header, value } = splitHeaderValue(content);
304
- const parsedHeader = parseHeader(header);
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
- const node = {
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 pruneNodes(roots, keepNamed);
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
- export const detectFocusArea = (snapshot) => {
369
- const nodes = parseAriaSnapshot(snapshot, true);
370
- const findFocusArea = (nodeList) => {
371
- for (const node of nodeList) {
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
- const entry = buildInteractiveEntry(node);
410
- if (entry) {
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.forEach(visit);
414
- };
415
- nodes.forEach(visit);
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 line;
405
+ return null;
435
406
  };
436
- const formatSummary = (node) => {
437
- const role = typeof node.role === 'string' ? node.role : '';
438
- if (!role)
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
- const name = typeof node.name === 'string' ? node.name : undefined;
441
- const attrs = {};
442
- for (const key of Object.keys(node)
443
- .filter((k) => k !== 'role' && k !== 'name' && k !== 'value')
444
- .sort()) {
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 flattenInteractiveNodes = (snapshot) => {
451
- const nodes = parseAriaSnapshot(snapshot);
452
- const result = [];
453
- const visit = (node, path) => {
454
- if (!IGNORED_CONTAINER_ROLES.has(node.role)) {
455
- const entry = buildInteractiveEntry(node);
456
- if (entry) {
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
- nodes.forEach((node, index) => {
469
- visit(node, `${index}`);
470
- });
471
- return result;
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 buildCountMap = (items) => {
474
- const map = new Map();
475
- for (const item of items) {
476
- if (item === '') {
477
- continue;
478
- }
479
- map.set(item, (map.get(item) ?? 0) + 1);
480
- }
481
- return map;
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 formatDiffItem = (item, count) => {
484
- if (count > 1) {
485
- return `${item} (x${count})`;
486
- }
487
- return item;
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
- return {
506
- role,
507
- name,
508
- ...(value && { value: value.trim() }),
509
- ...(attributes.length > 0 && { attributes }),
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
- };