cdp-skill 1.0.0

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