cdp-skill 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +543 -0
- package/install.js +92 -0
- package/package.json +47 -0
- package/src/aria.js +1302 -0
- package/src/capture.js +1359 -0
- package/src/cdp.js +905 -0
- package/src/cli.js +244 -0
- package/src/dom.js +3525 -0
- package/src/index.js +155 -0
- package/src/page.js +1720 -0
- package/src/runner.js +2111 -0
- package/src/tests/BrowserClient.test.js +588 -0
- package/src/tests/CDPConnection.test.js +598 -0
- package/src/tests/ChromeDiscovery.test.js +181 -0
- package/src/tests/ConsoleCapture.test.js +302 -0
- package/src/tests/ElementHandle.test.js +586 -0
- package/src/tests/ElementLocator.test.js +586 -0
- package/src/tests/ErrorAggregator.test.js +327 -0
- package/src/tests/InputEmulator.test.js +641 -0
- package/src/tests/NetworkErrorCapture.test.js +458 -0
- package/src/tests/PageController.test.js +822 -0
- package/src/tests/ScreenshotCapture.test.js +356 -0
- package/src/tests/SessionRegistry.test.js +257 -0
- package/src/tests/TargetManager.test.js +274 -0
- package/src/tests/TestRunner.test.js +1529 -0
- package/src/tests/WaitStrategy.test.js +406 -0
- package/src/tests/integration.test.js +431 -0
- package/src/utils.js +1034 -0
- package/uninstall.js +44 -0
package/src/aria.js
ADDED
|
@@ -0,0 +1,1302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARIA - Accessibility tree generation and role-based queries for AI agents
|
|
3
|
+
*
|
|
4
|
+
* Consolidated module containing:
|
|
5
|
+
* - AriaSnapshot: Generates semantic tree representation based on ARIA roles
|
|
6
|
+
* - RoleQueryExecutor: Advanced role-based queries with filtering
|
|
7
|
+
* - QueryOutputProcessor: Output formatting and attribute extraction
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Query Output Processor (from QueryOutputProcessor.js)
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a query output processor for handling multiple output modes
|
|
16
|
+
* @param {Object} session - CDP session
|
|
17
|
+
* @returns {Object} Query output processor interface
|
|
18
|
+
*/
|
|
19
|
+
export function createQueryOutputProcessor(session) {
|
|
20
|
+
/**
|
|
21
|
+
* Get a single output value by mode
|
|
22
|
+
* @param {Object} elementHandle - Element handle
|
|
23
|
+
* @param {string} mode - Output mode
|
|
24
|
+
* @param {boolean} clean - Whether to trim whitespace
|
|
25
|
+
* @returns {Promise<string>}
|
|
26
|
+
*/
|
|
27
|
+
async function getSingleOutput(elementHandle, mode, clean) {
|
|
28
|
+
let value;
|
|
29
|
+
|
|
30
|
+
switch (mode) {
|
|
31
|
+
case 'text':
|
|
32
|
+
value = await elementHandle.evaluate(`function() {
|
|
33
|
+
return this.textContent ? this.textContent.substring(0, 100) : '';
|
|
34
|
+
}`);
|
|
35
|
+
break;
|
|
36
|
+
|
|
37
|
+
case 'html':
|
|
38
|
+
value = await elementHandle.evaluate(`function() {
|
|
39
|
+
return this.outerHTML ? this.outerHTML.substring(0, 200) : '';
|
|
40
|
+
}`);
|
|
41
|
+
break;
|
|
42
|
+
|
|
43
|
+
case 'href':
|
|
44
|
+
value = await elementHandle.evaluate(`function() {
|
|
45
|
+
return this.href || this.getAttribute('href') || '';
|
|
46
|
+
}`);
|
|
47
|
+
break;
|
|
48
|
+
|
|
49
|
+
case 'value':
|
|
50
|
+
value = await elementHandle.evaluate(`function() {
|
|
51
|
+
return this.value || '';
|
|
52
|
+
}`);
|
|
53
|
+
break;
|
|
54
|
+
|
|
55
|
+
case 'tag':
|
|
56
|
+
value = await elementHandle.evaluate(`function() {
|
|
57
|
+
return this.tagName ? this.tagName.toLowerCase() : '';
|
|
58
|
+
}`);
|
|
59
|
+
break;
|
|
60
|
+
|
|
61
|
+
default:
|
|
62
|
+
value = await elementHandle.evaluate(`function() {
|
|
63
|
+
return this.textContent ? this.textContent.substring(0, 100) : '';
|
|
64
|
+
}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Apply text cleanup
|
|
68
|
+
if (clean && typeof value === 'string') {
|
|
69
|
+
value = value.trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return value || '';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get an attribute value from element
|
|
77
|
+
* @param {Object} elementHandle - Element handle
|
|
78
|
+
* @param {string} attributeName - Attribute name to retrieve
|
|
79
|
+
* @param {boolean} clean - Whether to trim whitespace
|
|
80
|
+
* @returns {Promise<string|null>}
|
|
81
|
+
*/
|
|
82
|
+
async function getAttribute(elementHandle, attributeName, clean) {
|
|
83
|
+
const value = await elementHandle.evaluate(`function() {
|
|
84
|
+
return this.getAttribute(${JSON.stringify(attributeName)});
|
|
85
|
+
}`);
|
|
86
|
+
|
|
87
|
+
if (clean && typeof value === 'string') {
|
|
88
|
+
return value.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Process output for an element based on output specification
|
|
96
|
+
* @param {Object} elementHandle - Element handle with evaluate method
|
|
97
|
+
* @param {string|string[]|Object} output - Output specification
|
|
98
|
+
* @param {Object} options - Additional options
|
|
99
|
+
* @param {boolean} options.clean - Whether to trim whitespace
|
|
100
|
+
* @returns {Promise<*>} Processed output value
|
|
101
|
+
*/
|
|
102
|
+
async function processOutput(elementHandle, output, options = {}) {
|
|
103
|
+
const clean = options.clean === true;
|
|
104
|
+
|
|
105
|
+
// Handle multiple output modes
|
|
106
|
+
if (Array.isArray(output)) {
|
|
107
|
+
const result = {};
|
|
108
|
+
for (const mode of output) {
|
|
109
|
+
result[mode] = await getSingleOutput(elementHandle, mode, clean);
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Handle attribute output
|
|
115
|
+
if (typeof output === 'object' && output !== null) {
|
|
116
|
+
if (output.attribute) {
|
|
117
|
+
return getAttribute(elementHandle, output.attribute, clean);
|
|
118
|
+
}
|
|
119
|
+
// Default to text if object doesn't specify attribute
|
|
120
|
+
return getSingleOutput(elementHandle, 'text', clean);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Handle single output mode
|
|
124
|
+
return getSingleOutput(elementHandle, output || 'text', clean);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get element metadata
|
|
129
|
+
* @param {Object} elementHandle - Element handle
|
|
130
|
+
* @returns {Promise<Object>} Element metadata
|
|
131
|
+
*/
|
|
132
|
+
async function getElementMetadata(elementHandle) {
|
|
133
|
+
return elementHandle.evaluate(`function() {
|
|
134
|
+
const el = this;
|
|
135
|
+
|
|
136
|
+
// Build selector path
|
|
137
|
+
const getSelectorPath = (element) => {
|
|
138
|
+
const path = [];
|
|
139
|
+
let current = element;
|
|
140
|
+
while (current && current !== document.body && path.length < 5) {
|
|
141
|
+
let selector = current.tagName.toLowerCase();
|
|
142
|
+
if (current.id) {
|
|
143
|
+
selector += '#' + current.id;
|
|
144
|
+
path.unshift(selector);
|
|
145
|
+
break; // ID is unique, stop here
|
|
146
|
+
}
|
|
147
|
+
if (current.className && typeof current.className === 'string') {
|
|
148
|
+
const classes = current.className.trim().split(/\\s+/).slice(0, 2);
|
|
149
|
+
if (classes.length > 0 && classes[0]) {
|
|
150
|
+
selector += '.' + classes.join('.');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
path.unshift(selector);
|
|
154
|
+
current = current.parentElement;
|
|
155
|
+
}
|
|
156
|
+
return path.join(' > ');
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
tag: el.tagName ? el.tagName.toLowerCase() : null,
|
|
161
|
+
classes: el.className && typeof el.className === 'string'
|
|
162
|
+
? el.className.trim().split(/\\s+/).filter(c => c)
|
|
163
|
+
: [],
|
|
164
|
+
selectorPath: getSelectorPath(el)
|
|
165
|
+
};
|
|
166
|
+
}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
processOutput,
|
|
171
|
+
getSingleOutput,
|
|
172
|
+
getAttribute,
|
|
173
|
+
getElementMetadata
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// Role Query Executor (from RoleQueryExecutor.js)
|
|
179
|
+
// ============================================================================
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Create a role query executor for advanced role-based queries
|
|
183
|
+
* @param {Object} session - CDP session
|
|
184
|
+
* @param {Object} elementLocator - Element locator instance
|
|
185
|
+
* @returns {Object} Role query executor interface
|
|
186
|
+
*/
|
|
187
|
+
export function createRoleQueryExecutor(session, elementLocator) {
|
|
188
|
+
const outputProcessor = createQueryOutputProcessor(session);
|
|
189
|
+
|
|
190
|
+
async function releaseObject(objectId) {
|
|
191
|
+
try {
|
|
192
|
+
await session.send('Runtime.releaseObject', { objectId });
|
|
193
|
+
} catch {
|
|
194
|
+
// Ignore
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Query elements by one or more roles
|
|
200
|
+
* @param {string[]} roles - Array of roles to query
|
|
201
|
+
* @param {Object} filters - Filter options
|
|
202
|
+
* @returns {Promise<Object[]>} Array of element handles
|
|
203
|
+
*/
|
|
204
|
+
async function queryByRoles(roles, filters) {
|
|
205
|
+
const { name, nameExact, nameRegex, checked, disabled, level } = filters;
|
|
206
|
+
|
|
207
|
+
// Map ARIA roles to common HTML element selectors
|
|
208
|
+
const ROLE_SELECTORS = {
|
|
209
|
+
button: ['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]', '[role="button"]'],
|
|
210
|
+
textbox: ['input:not([type])', 'input[type="text"]', 'input[type="email"]', 'input[type="password"]', 'input[type="search"]', 'input[type="tel"]', 'input[type="url"]', 'textarea', '[role="textbox"]'],
|
|
211
|
+
checkbox: ['input[type="checkbox"]', '[role="checkbox"]'],
|
|
212
|
+
link: ['a[href]', '[role="link"]'],
|
|
213
|
+
heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', '[role="heading"]'],
|
|
214
|
+
listitem: ['li', '[role="listitem"]'],
|
|
215
|
+
option: ['option', '[role="option"]'],
|
|
216
|
+
combobox: ['select', '[role="combobox"]'],
|
|
217
|
+
radio: ['input[type="radio"]', '[role="radio"]'],
|
|
218
|
+
img: ['img[alt]', '[role="img"]'],
|
|
219
|
+
tab: ['[role="tab"]'],
|
|
220
|
+
tabpanel: ['[role="tabpanel"]'],
|
|
221
|
+
menu: ['[role="menu"]'],
|
|
222
|
+
menuitem: ['[role="menuitem"]'],
|
|
223
|
+
dialog: ['dialog', '[role="dialog"]'],
|
|
224
|
+
alert: ['[role="alert"]'],
|
|
225
|
+
navigation: ['nav', '[role="navigation"]'],
|
|
226
|
+
main: ['main', '[role="main"]'],
|
|
227
|
+
search: ['[role="search"]'],
|
|
228
|
+
form: ['form', '[role="form"]']
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Build selectors for all requested roles
|
|
232
|
+
const allSelectors = [];
|
|
233
|
+
for (const r of roles) {
|
|
234
|
+
const selectors = ROLE_SELECTORS[r] || [`[role="${r}"]`];
|
|
235
|
+
allSelectors.push(...selectors);
|
|
236
|
+
}
|
|
237
|
+
const selectorString = allSelectors.join(', ');
|
|
238
|
+
|
|
239
|
+
// Build filter conditions
|
|
240
|
+
const nameFilter = (name !== undefined && name !== null) ? JSON.stringify(name) : null;
|
|
241
|
+
const nameExactFlag = nameExact === true;
|
|
242
|
+
const nameRegexPattern = nameRegex ? JSON.stringify(nameRegex) : null;
|
|
243
|
+
const checkedFilter = checked !== undefined ? checked : null;
|
|
244
|
+
const disabledFilter = disabled !== undefined ? disabled : null;
|
|
245
|
+
const levelFilter = level !== undefined ? level : null;
|
|
246
|
+
const rolesForLevel = roles; // For heading level detection
|
|
247
|
+
|
|
248
|
+
const expression = `
|
|
249
|
+
(function() {
|
|
250
|
+
const selectors = ${JSON.stringify(selectorString)};
|
|
251
|
+
const nameFilter = ${nameFilter};
|
|
252
|
+
const nameExact = ${nameExactFlag};
|
|
253
|
+
const nameRegex = ${nameRegexPattern};
|
|
254
|
+
const checkedFilter = ${checkedFilter !== null ? checkedFilter : 'null'};
|
|
255
|
+
const disabledFilter = ${disabledFilter !== null ? disabledFilter : 'null'};
|
|
256
|
+
const levelFilter = ${levelFilter !== null ? levelFilter : 'null'};
|
|
257
|
+
const rolesForLevel = ${JSON.stringify(rolesForLevel)};
|
|
258
|
+
|
|
259
|
+
const elements = Array.from(document.querySelectorAll(selectors));
|
|
260
|
+
|
|
261
|
+
return elements.filter(el => {
|
|
262
|
+
// Filter by accessible name if specified
|
|
263
|
+
if (nameFilter !== null || nameRegex !== null) {
|
|
264
|
+
const accessibleName = (
|
|
265
|
+
el.getAttribute('aria-label') ||
|
|
266
|
+
el.textContent?.trim() ||
|
|
267
|
+
el.getAttribute('title') ||
|
|
268
|
+
el.getAttribute('placeholder') ||
|
|
269
|
+
el.value ||
|
|
270
|
+
''
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if (nameFilter !== null) {
|
|
274
|
+
if (nameExact) {
|
|
275
|
+
// Exact match
|
|
276
|
+
if (accessibleName !== nameFilter) return false;
|
|
277
|
+
} else {
|
|
278
|
+
// Contains match (case-insensitive)
|
|
279
|
+
if (!accessibleName.toLowerCase().includes(nameFilter.toLowerCase())) return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (nameRegex !== null) {
|
|
284
|
+
// Regex match
|
|
285
|
+
try {
|
|
286
|
+
const regex = new RegExp(nameRegex);
|
|
287
|
+
if (!regex.test(accessibleName)) return false;
|
|
288
|
+
} catch (e) {
|
|
289
|
+
// Invalid regex, skip filter
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Filter by checked state if specified
|
|
295
|
+
if (checkedFilter !== null) {
|
|
296
|
+
const isChecked = el.checked === true || el.getAttribute('aria-checked') === 'true';
|
|
297
|
+
if (isChecked !== checkedFilter) return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Filter by disabled state if specified
|
|
301
|
+
if (disabledFilter !== null) {
|
|
302
|
+
const isDisabled = el.disabled === true || el.getAttribute('aria-disabled') === 'true';
|
|
303
|
+
if (isDisabled !== disabledFilter) return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Filter by heading level if specified
|
|
307
|
+
if (levelFilter !== null && rolesForLevel.includes('heading')) {
|
|
308
|
+
const tagName = el.tagName.toLowerCase();
|
|
309
|
+
let headingLevel = null;
|
|
310
|
+
|
|
311
|
+
// Check aria-level first
|
|
312
|
+
const ariaLevel = el.getAttribute('aria-level');
|
|
313
|
+
if (ariaLevel) {
|
|
314
|
+
headingLevel = parseInt(ariaLevel, 10);
|
|
315
|
+
} else if (tagName.match(/^h[1-6]$/)) {
|
|
316
|
+
// Extract level from h1-h6 tag
|
|
317
|
+
headingLevel = parseInt(tagName.charAt(1), 10);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (headingLevel !== levelFilter) return false;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return true;
|
|
324
|
+
});
|
|
325
|
+
})()
|
|
326
|
+
`;
|
|
327
|
+
|
|
328
|
+
let result;
|
|
329
|
+
try {
|
|
330
|
+
result = await session.send('Runtime.evaluate', {
|
|
331
|
+
expression,
|
|
332
|
+
returnByValue: false
|
|
333
|
+
});
|
|
334
|
+
} catch (error) {
|
|
335
|
+
throw new Error(`Role query error: ${error.message}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (result.exceptionDetails) {
|
|
339
|
+
throw new Error(`Role query error: ${result.exceptionDetails.text}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!result.result.objectId) return [];
|
|
343
|
+
|
|
344
|
+
const arrayObjectId = result.result.objectId;
|
|
345
|
+
let props;
|
|
346
|
+
try {
|
|
347
|
+
props = await session.send('Runtime.getProperties', {
|
|
348
|
+
objectId: arrayObjectId,
|
|
349
|
+
ownProperties: true
|
|
350
|
+
});
|
|
351
|
+
} catch (error) {
|
|
352
|
+
await releaseObject(arrayObjectId);
|
|
353
|
+
throw new Error(`Role query error: ${error.message}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const { createElementHandle } = await import('./dom.js');
|
|
357
|
+
const elements = props.result
|
|
358
|
+
.filter(p => /^\d+$/.test(p.name) && p.value && p.value.objectId)
|
|
359
|
+
.map(p => createElementHandle(session, p.value.objectId, {
|
|
360
|
+
selector: `[role="${roles.join('|')}"]`
|
|
361
|
+
}));
|
|
362
|
+
|
|
363
|
+
await releaseObject(arrayObjectId);
|
|
364
|
+
return elements;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Execute a role-based query with advanced options
|
|
369
|
+
* @param {Object} params - Query parameters
|
|
370
|
+
* @returns {Promise<Object>} Query results
|
|
371
|
+
*/
|
|
372
|
+
async function execute(params) {
|
|
373
|
+
const {
|
|
374
|
+
role,
|
|
375
|
+
name,
|
|
376
|
+
nameExact,
|
|
377
|
+
nameRegex,
|
|
378
|
+
checked,
|
|
379
|
+
disabled,
|
|
380
|
+
level,
|
|
381
|
+
limit = 10,
|
|
382
|
+
output = 'text',
|
|
383
|
+
clean = false,
|
|
384
|
+
metadata = false,
|
|
385
|
+
countOnly = false,
|
|
386
|
+
refs = false
|
|
387
|
+
} = params;
|
|
388
|
+
|
|
389
|
+
// Handle compound roles
|
|
390
|
+
const roles = Array.isArray(role) ? role : [role];
|
|
391
|
+
|
|
392
|
+
// Build query expression
|
|
393
|
+
const elements = await queryByRoles(roles, {
|
|
394
|
+
name,
|
|
395
|
+
nameExact,
|
|
396
|
+
nameRegex,
|
|
397
|
+
checked,
|
|
398
|
+
disabled,
|
|
399
|
+
level
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Count-only mode
|
|
403
|
+
if (countOnly) {
|
|
404
|
+
// Dispose all elements
|
|
405
|
+
for (const el of elements) {
|
|
406
|
+
try { await el.dispose(); } catch { /* ignore */ }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
role: roles.length === 1 ? roles[0] : roles,
|
|
411
|
+
total: elements.length,
|
|
412
|
+
countOnly: true
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const results = [];
|
|
417
|
+
const count = Math.min(elements.length, limit);
|
|
418
|
+
|
|
419
|
+
for (let i = 0; i < count; i++) {
|
|
420
|
+
const el = elements[i];
|
|
421
|
+
try {
|
|
422
|
+
const resultItem = {
|
|
423
|
+
index: i + 1,
|
|
424
|
+
value: await outputProcessor.processOutput(el, output, { clean })
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// Add element metadata if requested
|
|
428
|
+
if (metadata) {
|
|
429
|
+
resultItem.metadata = await outputProcessor.getElementMetadata(el);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Add element ref if requested
|
|
433
|
+
if (refs) {
|
|
434
|
+
resultItem.ref = el.objectId;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
results.push(resultItem);
|
|
438
|
+
} catch (e) {
|
|
439
|
+
results.push({ index: i + 1, value: null, error: e.message });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Dispose all elements
|
|
444
|
+
for (const el of elements) {
|
|
445
|
+
try { await el.dispose(); } catch { /* ignore */ }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
role: roles.length === 1 ? roles[0] : roles,
|
|
450
|
+
name: name || null,
|
|
451
|
+
nameExact: nameExact || false,
|
|
452
|
+
nameRegex: nameRegex || null,
|
|
453
|
+
checked: checked !== undefined ? checked : null,
|
|
454
|
+
disabled: disabled !== undefined ? disabled : null,
|
|
455
|
+
level: level !== undefined ? level : null,
|
|
456
|
+
total: elements.length,
|
|
457
|
+
showing: count,
|
|
458
|
+
results
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
execute,
|
|
464
|
+
queryByRoles
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ============================================================================
|
|
469
|
+
// Aria Snapshot (from AriaSnapshot.js)
|
|
470
|
+
// ============================================================================
|
|
471
|
+
|
|
472
|
+
// The snapshot script runs entirely in the browser context
|
|
473
|
+
const SNAPSHOT_SCRIPT = `
|
|
474
|
+
(function generateAriaSnapshot(rootSelector, options) {
|
|
475
|
+
const { mode = 'ai', maxDepth = 50, maxElements = 0, includeText = false, includeFrames = false } = options || {};
|
|
476
|
+
|
|
477
|
+
// Element counter for maxElements limit
|
|
478
|
+
let elementCount = 0;
|
|
479
|
+
let limitReached = false;
|
|
480
|
+
|
|
481
|
+
// Role mappings from HTML elements to ARIA roles
|
|
482
|
+
const IMPLICIT_ROLES = {
|
|
483
|
+
'A': (el) => el.hasAttribute('href') ? 'link' : null,
|
|
484
|
+
'AREA': (el) => el.hasAttribute('href') ? 'link' : null,
|
|
485
|
+
'ARTICLE': () => 'article',
|
|
486
|
+
'ASIDE': () => 'complementary',
|
|
487
|
+
'BUTTON': () => 'button',
|
|
488
|
+
'DATALIST': () => 'listbox',
|
|
489
|
+
'DETAILS': () => 'group',
|
|
490
|
+
'DIALOG': () => 'dialog',
|
|
491
|
+
'FIELDSET': () => 'group',
|
|
492
|
+
'FIGURE': () => 'figure',
|
|
493
|
+
'FOOTER': () => 'contentinfo',
|
|
494
|
+
'FORM': (el) => hasAccessibleName(el) ? 'form' : null,
|
|
495
|
+
'H1': () => 'heading',
|
|
496
|
+
'H2': () => 'heading',
|
|
497
|
+
'H3': () => 'heading',
|
|
498
|
+
'H4': () => 'heading',
|
|
499
|
+
'H5': () => 'heading',
|
|
500
|
+
'H6': () => 'heading',
|
|
501
|
+
'HEADER': () => 'banner',
|
|
502
|
+
'HR': () => 'separator',
|
|
503
|
+
'IMG': (el) => el.getAttribute('alt') === '' ? 'presentation' : 'img',
|
|
504
|
+
'INPUT': (el) => {
|
|
505
|
+
const type = (el.type || 'text').toLowerCase();
|
|
506
|
+
const typeRoles = {
|
|
507
|
+
'button': 'button',
|
|
508
|
+
'checkbox': 'checkbox',
|
|
509
|
+
'radio': 'radio',
|
|
510
|
+
'range': 'slider',
|
|
511
|
+
'number': 'spinbutton',
|
|
512
|
+
'search': 'searchbox',
|
|
513
|
+
'email': 'textbox',
|
|
514
|
+
'tel': 'textbox',
|
|
515
|
+
'text': 'textbox',
|
|
516
|
+
'url': 'textbox',
|
|
517
|
+
'password': 'textbox',
|
|
518
|
+
'submit': 'button',
|
|
519
|
+
'reset': 'button',
|
|
520
|
+
'image': 'button'
|
|
521
|
+
};
|
|
522
|
+
if (el.hasAttribute('list')) return 'combobox';
|
|
523
|
+
return typeRoles[type] || 'textbox';
|
|
524
|
+
},
|
|
525
|
+
'LI': () => 'listitem',
|
|
526
|
+
'MAIN': () => 'main',
|
|
527
|
+
'MATH': () => 'math',
|
|
528
|
+
'MENU': () => 'list',
|
|
529
|
+
'NAV': () => 'navigation',
|
|
530
|
+
'OL': () => 'list',
|
|
531
|
+
'OPTGROUP': () => 'group',
|
|
532
|
+
'OPTION': () => 'option',
|
|
533
|
+
'OUTPUT': () => 'status',
|
|
534
|
+
'P': () => 'paragraph',
|
|
535
|
+
'PROGRESS': () => 'progressbar',
|
|
536
|
+
'SECTION': (el) => hasAccessibleName(el) ? 'region' : null,
|
|
537
|
+
'SELECT': (el) => el.multiple ? 'listbox' : 'combobox',
|
|
538
|
+
'SPAN': () => null,
|
|
539
|
+
'SUMMARY': () => 'button',
|
|
540
|
+
'TABLE': () => 'table',
|
|
541
|
+
'TBODY': () => 'rowgroup',
|
|
542
|
+
'TD': () => 'cell',
|
|
543
|
+
'TEXTAREA': () => 'textbox',
|
|
544
|
+
'TFOOT': () => 'rowgroup',
|
|
545
|
+
'TH': () => 'columnheader',
|
|
546
|
+
'THEAD': () => 'rowgroup',
|
|
547
|
+
'TR': () => 'row',
|
|
548
|
+
'UL': () => 'list'
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// Roles that support checked state
|
|
552
|
+
const CHECKED_ROLES = ['checkbox', 'radio', 'menuitemcheckbox', 'menuitemradio', 'option', 'switch'];
|
|
553
|
+
|
|
554
|
+
// Roles that support disabled state
|
|
555
|
+
const DISABLED_ROLES = ['button', 'checkbox', 'combobox', 'gridcell', 'link', 'listbox', 'menuitem',
|
|
556
|
+
'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider',
|
|
557
|
+
'spinbutton', 'switch', 'tab', 'textbox', 'treeitem'];
|
|
558
|
+
|
|
559
|
+
// Roles that support expanded state
|
|
560
|
+
const EXPANDED_ROLES = ['application', 'button', 'checkbox', 'combobox', 'gridcell', 'link',
|
|
561
|
+
'listbox', 'menuitem', 'row', 'rowheader', 'tab', 'treeitem'];
|
|
562
|
+
|
|
563
|
+
// Roles that support pressed state
|
|
564
|
+
const PRESSED_ROLES = ['button'];
|
|
565
|
+
|
|
566
|
+
// Roles that support selected state
|
|
567
|
+
const SELECTED_ROLES = ['gridcell', 'option', 'row', 'tab', 'treeitem'];
|
|
568
|
+
|
|
569
|
+
// Roles that support required state
|
|
570
|
+
const REQUIRED_ROLES = ['checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup',
|
|
571
|
+
'searchbox', 'spinbutton', 'textbox', 'tree'];
|
|
572
|
+
|
|
573
|
+
// Roles that support invalid state
|
|
574
|
+
const INVALID_ROLES = ['checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup',
|
|
575
|
+
'searchbox', 'slider', 'spinbutton', 'textbox', 'tree'];
|
|
576
|
+
|
|
577
|
+
// Interactable roles for AI mode
|
|
578
|
+
const INTERACTABLE_ROLES = ['button', 'checkbox', 'combobox', 'link', 'listbox', 'menuitem',
|
|
579
|
+
'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'searchbox', 'slider', 'spinbutton',
|
|
580
|
+
'switch', 'tab', 'textbox', 'treeitem'];
|
|
581
|
+
|
|
582
|
+
// Roles where text content is important to display (status messages, alerts, etc.)
|
|
583
|
+
const TEXT_CONTENT_ROLES = ['alert', 'alertdialog', 'status', 'log', 'marquee', 'timer', 'paragraph'];
|
|
584
|
+
|
|
585
|
+
let refCounter = 0;
|
|
586
|
+
const elementRefs = new Map();
|
|
587
|
+
const refElements = new Map();
|
|
588
|
+
|
|
589
|
+
function hasAccessibleName(el) {
|
|
590
|
+
return el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby') || el.hasAttribute('title');
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function isHiddenForAria(el) {
|
|
594
|
+
if (el.hasAttribute('aria-hidden') && el.getAttribute('aria-hidden') !== 'false') return true;
|
|
595
|
+
if (el.hidden) return true;
|
|
596
|
+
const style = window.getComputedStyle(el);
|
|
597
|
+
if (style.display === 'none' || style.visibility === 'hidden') return true;
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function isVisible(el) {
|
|
602
|
+
if (isHiddenForAria(el)) return false;
|
|
603
|
+
const rect = el.getBoundingClientRect();
|
|
604
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function getAriaRole(el) {
|
|
609
|
+
// Explicit role takes precedence
|
|
610
|
+
const explicitRole = el.getAttribute('role');
|
|
611
|
+
if (explicitRole) {
|
|
612
|
+
const roles = explicitRole.split(/\\s+/).filter(r => r);
|
|
613
|
+
if (roles.length > 0 && roles[0] !== 'presentation' && roles[0] !== 'none') {
|
|
614
|
+
return roles[0];
|
|
615
|
+
}
|
|
616
|
+
if (roles[0] === 'presentation' || roles[0] === 'none') {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Implicit role from element type
|
|
622
|
+
const tagName = el.tagName.toUpperCase();
|
|
623
|
+
const roleFunc = IMPLICIT_ROLES[tagName];
|
|
624
|
+
if (roleFunc) {
|
|
625
|
+
return roleFunc(el);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function getAccessibleName(el) {
|
|
632
|
+
// aria-labelledby takes precedence
|
|
633
|
+
if (el.hasAttribute('aria-labelledby')) {
|
|
634
|
+
const ids = el.getAttribute('aria-labelledby').split(/\\s+/);
|
|
635
|
+
const texts = ids.map(id => {
|
|
636
|
+
const labelEl = document.getElementById(id);
|
|
637
|
+
return labelEl ? labelEl.textContent : '';
|
|
638
|
+
}).filter(t => t);
|
|
639
|
+
if (texts.length > 0) return normalizeWhitespace(texts.join(' '));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// aria-label
|
|
643
|
+
if (el.hasAttribute('aria-label')) {
|
|
644
|
+
return normalizeWhitespace(el.getAttribute('aria-label'));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Labels for form elements
|
|
648
|
+
if (el.id && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')) {
|
|
649
|
+
const label = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
|
|
650
|
+
if (label) return normalizeWhitespace(label.textContent);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Wrapped in label
|
|
654
|
+
const parentLabel = el.closest('label');
|
|
655
|
+
if (parentLabel && parentLabel !== el) {
|
|
656
|
+
// Get label text excluding the input itself
|
|
657
|
+
const clone = parentLabel.cloneNode(true);
|
|
658
|
+
const inputs = clone.querySelectorAll('input, select, textarea');
|
|
659
|
+
inputs.forEach(i => i.remove());
|
|
660
|
+
const text = normalizeWhitespace(clone.textContent);
|
|
661
|
+
if (text) return text;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Title attribute
|
|
665
|
+
if (el.hasAttribute('title')) {
|
|
666
|
+
return normalizeWhitespace(el.getAttribute('title'));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Placeholder for inputs
|
|
670
|
+
if (el.hasAttribute('placeholder') && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
|
|
671
|
+
return normalizeWhitespace(el.getAttribute('placeholder'));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Alt text for images
|
|
675
|
+
if (el.tagName === 'IMG' && el.hasAttribute('alt')) {
|
|
676
|
+
return normalizeWhitespace(el.getAttribute('alt'));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Text content for buttons, links, etc.
|
|
680
|
+
const role = getAriaRole(el);
|
|
681
|
+
if (['button', 'link', 'menuitem', 'option', 'tab', 'treeitem', 'heading'].includes(role)) {
|
|
682
|
+
return normalizeWhitespace(el.textContent);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return '';
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function normalizeWhitespace(text) {
|
|
689
|
+
if (!text) return '';
|
|
690
|
+
return text.replace(/\\s+/g, ' ').trim();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function getCheckedState(el, role) {
|
|
694
|
+
if (!CHECKED_ROLES.includes(role)) return undefined;
|
|
695
|
+
|
|
696
|
+
const ariaChecked = el.getAttribute('aria-checked');
|
|
697
|
+
if (ariaChecked === 'mixed') return 'mixed';
|
|
698
|
+
if (ariaChecked === 'true') return true;
|
|
699
|
+
if (ariaChecked === 'false') return false;
|
|
700
|
+
|
|
701
|
+
if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
|
|
702
|
+
return el.checked;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return undefined;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function getDisabledState(el, role) {
|
|
709
|
+
if (!DISABLED_ROLES.includes(role)) return undefined;
|
|
710
|
+
|
|
711
|
+
if (el.hasAttribute('aria-disabled')) {
|
|
712
|
+
return el.getAttribute('aria-disabled') === 'true';
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (el.disabled !== undefined) {
|
|
716
|
+
return el.disabled;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return undefined;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function getExpandedState(el, role) {
|
|
723
|
+
if (!EXPANDED_ROLES.includes(role)) return undefined;
|
|
724
|
+
|
|
725
|
+
if (el.hasAttribute('aria-expanded')) {
|
|
726
|
+
return el.getAttribute('aria-expanded') === 'true';
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (el.tagName === 'DETAILS') {
|
|
730
|
+
return el.open;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function getPressedState(el, role) {
|
|
737
|
+
if (!PRESSED_ROLES.includes(role)) return undefined;
|
|
738
|
+
|
|
739
|
+
const ariaPressed = el.getAttribute('aria-pressed');
|
|
740
|
+
if (ariaPressed === 'mixed') return 'mixed';
|
|
741
|
+
if (ariaPressed === 'true') return true;
|
|
742
|
+
if (ariaPressed === 'false') return false;
|
|
743
|
+
|
|
744
|
+
return undefined;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function getSelectedState(el, role) {
|
|
748
|
+
if (!SELECTED_ROLES.includes(role)) return undefined;
|
|
749
|
+
|
|
750
|
+
if (el.hasAttribute('aria-selected')) {
|
|
751
|
+
return el.getAttribute('aria-selected') === 'true';
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (el.tagName === 'OPTION') {
|
|
755
|
+
return el.selected;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return undefined;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function getLevel(el, role) {
|
|
762
|
+
if (role !== 'heading') return undefined;
|
|
763
|
+
|
|
764
|
+
if (el.hasAttribute('aria-level')) {
|
|
765
|
+
return parseInt(el.getAttribute('aria-level'), 10);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const match = el.tagName.match(/^H(\\d)$/);
|
|
769
|
+
if (match) {
|
|
770
|
+
return parseInt(match[1], 10);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return undefined;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function getInvalidState(el, role) {
|
|
777
|
+
if (!INVALID_ROLES.includes(role)) return undefined;
|
|
778
|
+
|
|
779
|
+
// Check aria-invalid attribute
|
|
780
|
+
if (el.hasAttribute('aria-invalid')) {
|
|
781
|
+
const value = el.getAttribute('aria-invalid');
|
|
782
|
+
if (value === 'true') return true;
|
|
783
|
+
if (value === 'grammar') return 'grammar';
|
|
784
|
+
if (value === 'spelling') return 'spelling';
|
|
785
|
+
if (value === 'false') return false;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Check HTML5 validation state for form elements
|
|
789
|
+
if (el.validity && typeof el.validity === 'object') {
|
|
790
|
+
// Only report invalid if the field has been interacted with
|
|
791
|
+
// or has a value (to avoid showing all empty required fields as invalid)
|
|
792
|
+
if (!el.validity.valid && (el.value || el.classList.contains('touched') || el.dataset.touched)) {
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return undefined;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function getRequiredState(el, role) {
|
|
801
|
+
if (!REQUIRED_ROLES.includes(role)) return undefined;
|
|
802
|
+
|
|
803
|
+
// Check aria-required attribute
|
|
804
|
+
if (el.hasAttribute('aria-required')) {
|
|
805
|
+
return el.getAttribute('aria-required') === 'true';
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Check HTML5 required attribute
|
|
809
|
+
if (el.required !== undefined) {
|
|
810
|
+
return el.required;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return undefined;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function getNameAttribute(el, role) {
|
|
817
|
+
// Only include name attribute for form-related roles
|
|
818
|
+
const FORM_ROLES = ['textbox', 'searchbox', 'checkbox', 'radio', 'combobox',
|
|
819
|
+
'listbox', 'spinbutton', 'slider', 'switch'];
|
|
820
|
+
if (!FORM_ROLES.includes(role)) return undefined;
|
|
821
|
+
|
|
822
|
+
const name = el.getAttribute('name');
|
|
823
|
+
if (name && name.trim()) {
|
|
824
|
+
return name.trim();
|
|
825
|
+
}
|
|
826
|
+
return undefined;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function getBoundingBox(el) {
|
|
830
|
+
const rect = el.getBoundingClientRect();
|
|
831
|
+
return {
|
|
832
|
+
x: Math.round(rect.x),
|
|
833
|
+
y: Math.round(rect.y),
|
|
834
|
+
width: Math.round(rect.width),
|
|
835
|
+
height: Math.round(rect.height)
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function hasPointerCursor(el) {
|
|
840
|
+
const style = window.getComputedStyle(el);
|
|
841
|
+
return style.cursor === 'pointer';
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function isInteractable(el, role) {
|
|
845
|
+
if (!role) return false;
|
|
846
|
+
if (INTERACTABLE_ROLES.includes(role)) return true;
|
|
847
|
+
if (hasPointerCursor(el)) return true;
|
|
848
|
+
if (el.onclick || el.hasAttribute('onclick')) return true;
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function generateRef(el, role) {
|
|
853
|
+
if (elementRefs.has(el)) return elementRefs.get(el);
|
|
854
|
+
|
|
855
|
+
refCounter++;
|
|
856
|
+
const ref = 'e' + refCounter;
|
|
857
|
+
elementRefs.set(el, ref);
|
|
858
|
+
refElements.set(ref, el);
|
|
859
|
+
return ref;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function shouldIncludeTextContent(role) {
|
|
863
|
+
// Always include text for roles that typically contain important messages
|
|
864
|
+
return TEXT_CONTENT_ROLES.includes(role);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function buildAriaNode(el, depth, parentRole) {
|
|
868
|
+
// Check maxElements limit
|
|
869
|
+
if (maxElements > 0 && elementCount >= maxElements) {
|
|
870
|
+
limitReached = true;
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (depth > maxDepth) return null;
|
|
875
|
+
if (!el || el.nodeType !== Node.ELEMENT_NODE) return null;
|
|
876
|
+
|
|
877
|
+
// Handle iframes specially if includeFrames is enabled
|
|
878
|
+
if (includeFrames && (el.tagName === 'IFRAME' || el.tagName === 'FRAME')) {
|
|
879
|
+
elementCount++;
|
|
880
|
+
try {
|
|
881
|
+
const frameDoc = el.contentDocument;
|
|
882
|
+
if (frameDoc && frameDoc.body) {
|
|
883
|
+
const frameNode = {
|
|
884
|
+
role: 'document',
|
|
885
|
+
name: el.title || el.name || 'iframe',
|
|
886
|
+
isFrame: true,
|
|
887
|
+
frameUrl: el.src || '',
|
|
888
|
+
children: []
|
|
889
|
+
};
|
|
890
|
+
for (const child of frameDoc.body.childNodes) {
|
|
891
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
892
|
+
const node = buildAriaNode(child, depth + 1, 'document');
|
|
893
|
+
if (node) frameNode.children.push(node);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return frameNode.children.length > 0 ? frameNode : null;
|
|
897
|
+
}
|
|
898
|
+
} catch (e) {
|
|
899
|
+
// Cross-origin iframe - can't access content
|
|
900
|
+
return {
|
|
901
|
+
role: 'document',
|
|
902
|
+
name: el.title || el.name || 'iframe (cross-origin)',
|
|
903
|
+
isFrame: true,
|
|
904
|
+
frameUrl: el.src || '',
|
|
905
|
+
crossOrigin: true
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
return null;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const visible = isVisible(el);
|
|
912
|
+
if (mode === 'ai' && !visible) return null;
|
|
913
|
+
|
|
914
|
+
const role = getAriaRole(el);
|
|
915
|
+
const name = getAccessibleName(el);
|
|
916
|
+
|
|
917
|
+
// Skip elements without semantic meaning
|
|
918
|
+
if (!role && mode === 'ai') {
|
|
919
|
+
// Still process children
|
|
920
|
+
const children = buildChildren(el, depth, null);
|
|
921
|
+
if (children.length === 0) return null;
|
|
922
|
+
if (children.length === 1 && typeof children[0] !== 'string') return children[0];
|
|
923
|
+
return { role: 'generic', name: '', children };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (!role) return null;
|
|
927
|
+
|
|
928
|
+
// Increment element count
|
|
929
|
+
elementCount++;
|
|
930
|
+
|
|
931
|
+
const node = { role, name };
|
|
932
|
+
|
|
933
|
+
// Add states
|
|
934
|
+
const checked = getCheckedState(el, role);
|
|
935
|
+
if (checked !== undefined) node.checked = checked;
|
|
936
|
+
|
|
937
|
+
const disabled = getDisabledState(el, role);
|
|
938
|
+
if (disabled === true) node.disabled = true;
|
|
939
|
+
|
|
940
|
+
const expanded = getExpandedState(el, role);
|
|
941
|
+
if (expanded !== undefined) node.expanded = expanded;
|
|
942
|
+
|
|
943
|
+
const pressed = getPressedState(el, role);
|
|
944
|
+
if (pressed !== undefined) node.pressed = pressed;
|
|
945
|
+
|
|
946
|
+
const selected = getSelectedState(el, role);
|
|
947
|
+
if (selected === true) node.selected = true;
|
|
948
|
+
|
|
949
|
+
const level = getLevel(el, role);
|
|
950
|
+
if (level !== undefined) node.level = level;
|
|
951
|
+
|
|
952
|
+
// Add invalid state
|
|
953
|
+
const invalid = getInvalidState(el, role);
|
|
954
|
+
if (invalid === true) node.invalid = true;
|
|
955
|
+
else if (invalid === 'grammar' || invalid === 'spelling') node.invalid = invalid;
|
|
956
|
+
|
|
957
|
+
// Add required state
|
|
958
|
+
const required = getRequiredState(el, role);
|
|
959
|
+
if (required === true) node.required = true;
|
|
960
|
+
|
|
961
|
+
// Add ref for interactable elements in AI mode
|
|
962
|
+
if (mode === 'ai' && visible && isInteractable(el, role)) {
|
|
963
|
+
node.ref = generateRef(el, role);
|
|
964
|
+
node.box = getBoundingBox(el);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Add name attribute for form elements
|
|
968
|
+
const nameAttr = getNameAttribute(el, role);
|
|
969
|
+
if (nameAttr) node.nameAttr = nameAttr;
|
|
970
|
+
|
|
971
|
+
// Add value for inputs
|
|
972
|
+
if (role === 'textbox' || role === 'searchbox' || role === 'spinbutton') {
|
|
973
|
+
const value = el.value || '';
|
|
974
|
+
if (value) node.value = value;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Add URL for links
|
|
978
|
+
if (role === 'link' && el.href) {
|
|
979
|
+
node.url = el.href;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Build children - pass the role so text nodes can be included for certain roles
|
|
983
|
+
const children = buildChildren(el, depth, role);
|
|
984
|
+
if (children.length > 0) {
|
|
985
|
+
node.children = children;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
return node;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function buildChildren(el, depth, parentRole) {
|
|
992
|
+
const children = [];
|
|
993
|
+
|
|
994
|
+
// Determine if we should include text nodes for this parent
|
|
995
|
+
const shouldIncludeText = includeText || shouldIncludeTextContent(parentRole);
|
|
996
|
+
|
|
997
|
+
for (const child of el.childNodes) {
|
|
998
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
999
|
+
const text = normalizeWhitespace(child.textContent);
|
|
1000
|
+
// Include text nodes in full mode, or when includeText option is set,
|
|
1001
|
+
// or when parent role typically contains important text content
|
|
1002
|
+
if (text && (mode !== 'ai' || shouldIncludeText)) {
|
|
1003
|
+
children.push({ role: 'staticText', name: text });
|
|
1004
|
+
}
|
|
1005
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
1006
|
+
const node = buildAriaNode(child, depth + 1, parentRole);
|
|
1007
|
+
if (node) {
|
|
1008
|
+
if (node.role === 'generic' && node.children) {
|
|
1009
|
+
// Flatten generic nodes
|
|
1010
|
+
children.push(...node.children);
|
|
1011
|
+
} else {
|
|
1012
|
+
children.push(node);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Handle shadow DOM
|
|
1019
|
+
if (el.shadowRoot) {
|
|
1020
|
+
for (const child of el.shadowRoot.childNodes) {
|
|
1021
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
1022
|
+
const node = buildAriaNode(child, depth + 1, parentRole);
|
|
1023
|
+
if (node) children.push(node);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
return children;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function renderYaml(node, indent = '') {
|
|
1032
|
+
if (typeof node === 'string') {
|
|
1033
|
+
return indent + '- text: ' + JSON.stringify(node);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Handle staticText nodes
|
|
1037
|
+
if (node.role === 'staticText') {
|
|
1038
|
+
return indent + '- text ' + JSON.stringify(node.name);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
let key = node.role;
|
|
1042
|
+
if (node.name) {
|
|
1043
|
+
key += ' ' + JSON.stringify(node.name);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Add states
|
|
1047
|
+
if (node.checked === 'mixed') key += ' [checked=mixed]';
|
|
1048
|
+
else if (node.checked === true) key += ' [checked]';
|
|
1049
|
+
if (node.disabled) key += ' [disabled]';
|
|
1050
|
+
if (node.expanded === true) key += ' [expanded]';
|
|
1051
|
+
else if (node.expanded === false) key += ' [collapsed]';
|
|
1052
|
+
if (node.pressed === 'mixed') key += ' [pressed=mixed]';
|
|
1053
|
+
else if (node.pressed === true) key += ' [pressed]';
|
|
1054
|
+
if (node.selected) key += ' [selected]';
|
|
1055
|
+
if (node.required) key += ' [required]';
|
|
1056
|
+
if (node.invalid === true) key += ' [invalid]';
|
|
1057
|
+
else if (node.invalid === 'grammar') key += ' [invalid=grammar]';
|
|
1058
|
+
else if (node.invalid === 'spelling') key += ' [invalid=spelling]';
|
|
1059
|
+
if (node.level) key += ' [level=' + node.level + ']';
|
|
1060
|
+
if (node.nameAttr) key += ' [name=' + node.nameAttr + ']';
|
|
1061
|
+
if (node.ref) key += ' [ref=' + node.ref + ']';
|
|
1062
|
+
|
|
1063
|
+
const lines = [];
|
|
1064
|
+
|
|
1065
|
+
if (!node.children || node.children.length === 0) {
|
|
1066
|
+
// Leaf node
|
|
1067
|
+
if (node.value !== undefined) {
|
|
1068
|
+
lines.push(indent + '- ' + key + ': ' + JSON.stringify(node.value));
|
|
1069
|
+
} else {
|
|
1070
|
+
lines.push(indent + '- ' + key);
|
|
1071
|
+
}
|
|
1072
|
+
} else if (node.children.length === 1 && node.children[0].role === 'staticText') {
|
|
1073
|
+
// Single static text child - inline it
|
|
1074
|
+
lines.push(indent + '- ' + key + ': ' + JSON.stringify(node.children[0].name));
|
|
1075
|
+
} else {
|
|
1076
|
+
// Node with children
|
|
1077
|
+
lines.push(indent + '- ' + key + ':');
|
|
1078
|
+
for (const child of node.children) {
|
|
1079
|
+
lines.push(renderYaml(child, indent + ' '));
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return lines.join('\\n');
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Parse rootSelector - support both CSS selectors and role= syntax
|
|
1087
|
+
function resolveRoot(selector) {
|
|
1088
|
+
if (!selector) return document.body;
|
|
1089
|
+
|
|
1090
|
+
// Check for role= syntax (e.g., "role=main", "role=navigation")
|
|
1091
|
+
const roleMatch = selector.match(/^role=(.+)$/i);
|
|
1092
|
+
if (roleMatch) {
|
|
1093
|
+
const targetRole = roleMatch[1].toLowerCase();
|
|
1094
|
+
|
|
1095
|
+
// First, try explicit role attribute
|
|
1096
|
+
const explicitRoleEl = document.querySelector('[role="' + targetRole + '"]');
|
|
1097
|
+
if (explicitRoleEl) return explicitRoleEl;
|
|
1098
|
+
|
|
1099
|
+
// Then try implicit roles from HTML elements
|
|
1100
|
+
const implicitMappings = {
|
|
1101
|
+
'main': 'main',
|
|
1102
|
+
'navigation': 'nav',
|
|
1103
|
+
'banner': 'header',
|
|
1104
|
+
'contentinfo': 'footer',
|
|
1105
|
+
'complementary': 'aside',
|
|
1106
|
+
'article': 'article',
|
|
1107
|
+
'form': 'form',
|
|
1108
|
+
'region': 'section',
|
|
1109
|
+
'list': 'ul, ol, menu',
|
|
1110
|
+
'listitem': 'li',
|
|
1111
|
+
'heading': 'h1, h2, h3, h4, h5, h6',
|
|
1112
|
+
'link': 'a[href]',
|
|
1113
|
+
'button': 'button, input[type="button"], input[type="submit"], input[type="reset"]',
|
|
1114
|
+
'textbox': 'input:not([type]), input[type="text"], input[type="email"], input[type="tel"], input[type="url"], input[type="password"], textarea',
|
|
1115
|
+
'checkbox': 'input[type="checkbox"]',
|
|
1116
|
+
'radio': 'input[type="radio"]',
|
|
1117
|
+
'combobox': 'select',
|
|
1118
|
+
'table': 'table',
|
|
1119
|
+
'row': 'tr',
|
|
1120
|
+
'cell': 'td',
|
|
1121
|
+
'columnheader': 'th',
|
|
1122
|
+
'img': 'img[alt]:not([alt=""])',
|
|
1123
|
+
'separator': 'hr',
|
|
1124
|
+
'dialog': 'dialog'
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
const implicitSelector = implicitMappings[targetRole];
|
|
1128
|
+
if (implicitSelector) {
|
|
1129
|
+
const el = document.querySelector(implicitSelector);
|
|
1130
|
+
if (el) return el;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
return null; // Role not found
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Regular CSS selector
|
|
1137
|
+
return document.querySelector(selector);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Main execution
|
|
1141
|
+
const root = resolveRoot(rootSelector);
|
|
1142
|
+
if (!root) {
|
|
1143
|
+
// Provide helpful error message based on selector type
|
|
1144
|
+
const roleMatch = rootSelector && rootSelector.match(/^role=(.+)$/i);
|
|
1145
|
+
if (roleMatch) {
|
|
1146
|
+
return { error: 'Root element not found for role: ' + roleMatch[1] + '. Use CSS selector (e.g., "main", "#container") or check that an element with this role exists.' };
|
|
1147
|
+
}
|
|
1148
|
+
return { error: 'Root element not found: ' + rootSelector + '. Note: for ARIA roles, use "role=main" syntax instead of just "main".' };
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const tree = buildAriaNode(root, 0, null);
|
|
1152
|
+
if (!tree) {
|
|
1153
|
+
return { tree: null, yaml: '', refs: {} };
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Build refs map for output
|
|
1157
|
+
const refs = {};
|
|
1158
|
+
for (const [ref, el] of refElements) {
|
|
1159
|
+
const rect = el.getBoundingClientRect();
|
|
1160
|
+
refs[ref] = {
|
|
1161
|
+
selector: generateSelector(el),
|
|
1162
|
+
box: {
|
|
1163
|
+
x: Math.round(rect.x),
|
|
1164
|
+
y: Math.round(rect.y),
|
|
1165
|
+
width: Math.round(rect.width),
|
|
1166
|
+
height: Math.round(rect.height)
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function generateSelector(el) {
|
|
1172
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
1173
|
+
|
|
1174
|
+
// Try unique attributes
|
|
1175
|
+
for (const attr of ['data-testid', 'data-test-id', 'data-cy', 'name']) {
|
|
1176
|
+
if (el.hasAttribute(attr)) {
|
|
1177
|
+
const value = el.getAttribute(attr);
|
|
1178
|
+
const selector = '[' + attr + '=' + JSON.stringify(value) + ']';
|
|
1179
|
+
if (document.querySelectorAll(selector).length === 1) return selector;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Build path
|
|
1184
|
+
const path = [];
|
|
1185
|
+
let current = el;
|
|
1186
|
+
while (current && current !== document.body) {
|
|
1187
|
+
let selector = current.tagName.toLowerCase();
|
|
1188
|
+
if (current.id) {
|
|
1189
|
+
selector = '#' + CSS.escape(current.id);
|
|
1190
|
+
path.unshift(selector);
|
|
1191
|
+
break;
|
|
1192
|
+
}
|
|
1193
|
+
const parent = current.parentElement;
|
|
1194
|
+
if (parent) {
|
|
1195
|
+
const siblings = Array.from(parent.children).filter(c => c.tagName === current.tagName);
|
|
1196
|
+
if (siblings.length > 1) {
|
|
1197
|
+
const index = siblings.indexOf(current) + 1;
|
|
1198
|
+
selector += ':nth-of-type(' + index + ')';
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
path.unshift(selector);
|
|
1202
|
+
current = parent;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return path.join(' > ');
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const yaml = tree.children ? tree.children.map(c => renderYaml(c, '')).join('\\n') : renderYaml(tree, '');
|
|
1209
|
+
|
|
1210
|
+
// Store refs globally for later use (e.g., click by ref)
|
|
1211
|
+
window.__ariaRefs = refElements;
|
|
1212
|
+
|
|
1213
|
+
return {
|
|
1214
|
+
tree,
|
|
1215
|
+
yaml,
|
|
1216
|
+
refs,
|
|
1217
|
+
stats: {
|
|
1218
|
+
totalRefs: refCounter,
|
|
1219
|
+
totalElements: elementCount,
|
|
1220
|
+
maxDepth: maxDepth,
|
|
1221
|
+
maxElements: maxElements,
|
|
1222
|
+
limitReached: limitReached
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
})
|
|
1226
|
+
`;
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Create an ARIA snapshot generator for accessibility tree generation
|
|
1230
|
+
* @param {Object} session - CDP session
|
|
1231
|
+
* @returns {Object} ARIA snapshot interface
|
|
1232
|
+
*/
|
|
1233
|
+
export function createAriaSnapshot(session) {
|
|
1234
|
+
/**
|
|
1235
|
+
* Generate accessibility snapshot of the page
|
|
1236
|
+
* @param {Object} options - Snapshot options
|
|
1237
|
+
* @param {string} options.root - CSS selector or role selector (e.g., "role=main") for root element
|
|
1238
|
+
* @param {string} options.mode - 'ai' for agent-friendly output, 'full' for complete tree
|
|
1239
|
+
* @param {number} options.maxDepth - Maximum tree depth (default: 50)
|
|
1240
|
+
* @param {number} options.maxElements - Maximum elements to include (default: unlimited)
|
|
1241
|
+
* @param {boolean} options.includeText - Include static text nodes in output (default: false for ai mode)
|
|
1242
|
+
* @param {boolean} options.includeFrames - Include same-origin iframe content (default: false)
|
|
1243
|
+
* @returns {Promise<Object>} Snapshot result with tree, yaml, and refs
|
|
1244
|
+
*/
|
|
1245
|
+
async function generate(options = {}) {
|
|
1246
|
+
const { root = null, mode = 'ai', maxDepth = 50, maxElements = 0, includeText = false, includeFrames = false } = options;
|
|
1247
|
+
|
|
1248
|
+
const result = await session.send('Runtime.evaluate', {
|
|
1249
|
+
expression: `(${SNAPSHOT_SCRIPT})(${JSON.stringify(root)}, ${JSON.stringify({ mode, maxDepth, maxElements, includeText, includeFrames })})`,
|
|
1250
|
+
returnByValue: true,
|
|
1251
|
+
awaitPromise: false
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
if (result.exceptionDetails) {
|
|
1255
|
+
throw new Error(`Snapshot generation failed: ${result.exceptionDetails.text}`);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
return result.result.value;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Find element by ref and return its selector
|
|
1263
|
+
* @param {string} ref - Element reference (e.g., 'e1')
|
|
1264
|
+
* @returns {Promise<Object>} Element info with selector, box, and connection status
|
|
1265
|
+
*/
|
|
1266
|
+
async function getElementByRef(ref) {
|
|
1267
|
+
const result = await session.send('Runtime.evaluate', {
|
|
1268
|
+
expression: `(function() {
|
|
1269
|
+
const el = window.__ariaRefs && window.__ariaRefs.get('${ref}');
|
|
1270
|
+
if (!el) return null;
|
|
1271
|
+
|
|
1272
|
+
// Check if element is still connected to DOM
|
|
1273
|
+
const isConnected = el.isConnected;
|
|
1274
|
+
if (!isConnected) {
|
|
1275
|
+
return { stale: true, ref: '${ref}' };
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Check visibility
|
|
1279
|
+
const style = window.getComputedStyle(el);
|
|
1280
|
+
const isVisible = style.display !== 'none' &&
|
|
1281
|
+
style.visibility !== 'hidden' &&
|
|
1282
|
+
style.opacity !== '0';
|
|
1283
|
+
|
|
1284
|
+
const rect = el.getBoundingClientRect();
|
|
1285
|
+
return {
|
|
1286
|
+
selector: el.id ? '#' + el.id : null,
|
|
1287
|
+
box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
1288
|
+
isConnected: true,
|
|
1289
|
+
isVisible: isVisible && rect.width > 0 && rect.height > 0
|
|
1290
|
+
};
|
|
1291
|
+
})()`,
|
|
1292
|
+
returnByValue: true
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
return result.result.value;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
return {
|
|
1299
|
+
generate,
|
|
1300
|
+
getElementByRef
|
|
1301
|
+
};
|
|
1302
|
+
}
|