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.
Files changed (47) hide show
  1. package/README.md +80 -35
  2. package/SKILL.md +151 -239
  3. package/install.js +1 -0
  4. package/package.json +1 -1
  5. package/src/aria/index.js +8 -0
  6. package/src/aria/output-processor.js +173 -0
  7. package/src/aria/role-query.js +1229 -0
  8. package/src/aria/snapshot.js +459 -0
  9. package/src/aria.js +237 -43
  10. package/src/cdp/browser.js +22 -4
  11. package/src/cdp-skill.js +245 -69
  12. package/src/dom/click-executor.js +240 -76
  13. package/src/dom/element-locator.js +34 -25
  14. package/src/dom/fill-executor.js +55 -27
  15. package/src/page/dialog-handler.js +119 -0
  16. package/src/page/page-controller.js +190 -3
  17. package/src/runner/context-helpers.js +33 -55
  18. package/src/runner/execute-dynamic.js +8 -7
  19. package/src/runner/execute-form.js +11 -11
  20. package/src/runner/execute-input.js +2 -2
  21. package/src/runner/execute-interaction.js +99 -120
  22. package/src/runner/execute-navigation.js +11 -26
  23. package/src/runner/execute-query.js +8 -5
  24. package/src/runner/step-executors.js +225 -84
  25. package/src/runner/step-registry.js +1064 -0
  26. package/src/runner/step-validator.js +16 -754
  27. package/src/tests/Aria.test.js +1025 -0
  28. package/src/tests/ContextHelpers.test.js +39 -28
  29. package/src/tests/ExecuteBrowser.test.js +572 -0
  30. package/src/tests/ExecuteDynamic.test.js +2 -457
  31. package/src/tests/ExecuteForm.test.js +700 -0
  32. package/src/tests/ExecuteInput.test.js +540 -0
  33. package/src/tests/ExecuteInteraction.test.js +319 -0
  34. package/src/tests/ExecuteQuery.test.js +820 -0
  35. package/src/tests/FillExecutor.test.js +2 -2
  36. package/src/tests/StepValidator.test.js +222 -76
  37. package/src/tests/TestRunner.test.js +36 -25
  38. package/src/tests/integration.test.js +2 -1
  39. package/src/types.js +9 -9
  40. package/src/utils/backoff.js +118 -0
  41. package/src/utils/cdp-helpers.js +130 -0
  42. package/src/utils/devices.js +140 -0
  43. package/src/utils/errors.js +242 -0
  44. package/src/utils/index.js +65 -0
  45. package/src/utils/temp.js +75 -0
  46. package/src/utils/validators.js +433 -0
  47. 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
+ */