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