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