what-devtools-mcp 0.6.0 → 0.6.2

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.
@@ -1,35 +1,160 @@
1
1
  /**
2
2
  * Extended command handlers for the browser client.
3
- * Handles: eval, dom-inspect, get-route, navigate
3
+ * Handles: eval, dom-inspect, get-route, navigate, get-app-info, visual-inspect, page-map, get-signal-writers, component-screenshot
4
4
  *
5
5
  * Usage in client.js:
6
6
  * import { handleExtendedCommand } from './client-commands.js';
7
7
  *
8
8
  * // Inside handleCommand(), before the default case:
9
- * const extResult = handleExtendedCommand(command, args, devtools);
9
+ * const extResult = await handleExtendedCommand(command, args, devtools);
10
10
  * if (extResult !== null) { result = extResult; break; }
11
11
  */
12
12
 
13
+ // ---------------------------------------------------------------------------
14
+ // Helper: resolve the actual DOM Element for a component registry entry.
15
+ //
16
+ // The devtools stores `ctx._wrapper` which is a comment node (boundary marker,
17
+ // nodeType 8). Comment nodes don't have getBoundingClientRect, innerHTML,
18
+ // children, or any Element-level API. This helper walks from the stored node
19
+ // to find the nearest real Element.
20
+ // ---------------------------------------------------------------------------
21
+ function getComponentElement(entry) {
22
+ let el = entry.element;
23
+ if (!el) return null;
24
+
25
+ // Already a real Element — use it directly
26
+ if (el.nodeType === 1 && typeof el.getBoundingClientRect === 'function') return el;
27
+
28
+ // Comment node (component boundary marker) — find the next sibling element
29
+ if (el.nodeType === 8) {
30
+ let sibling = el.nextSibling;
31
+ while (sibling) {
32
+ if (sibling.nodeType === 1) return sibling;
33
+ sibling = sibling.nextSibling;
34
+ }
35
+ // No sibling element found — try parent
36
+ if (el.parentElement) return el.parentElement;
37
+ }
38
+
39
+ // Text node — use parent
40
+ if (el.nodeType === 3 && el.parentElement) return el.parentElement;
41
+
42
+ return null;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Module-level ring buffer for correlating signal writes with effect runs.
47
+ // Auto-initialized when initEventTracking() is called (early, not lazy).
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const MAX_WRITE_LOG = 200;
51
+ let _signalWriteLog = []; // { signalId, signalName, previousValue, newValue, timestamp, writerEffect }
52
+ let _lastRunningEffect = null; // { id, name, timestamp } — most recent effect:run event
53
+ let _trackingInitialized = false;
54
+ let _unsubTracker = null;
55
+
56
+ /**
57
+ * Initialize event tracking early so signal writes are always captured.
58
+ * Called from client.js on connection, not lazily on first tool call.
59
+ */
60
+ export function initEventTracking(devtools) {
61
+ if (_trackingInitialized || !devtools?.subscribe) return;
62
+ _trackingInitialized = true;
63
+
64
+ _unsubTracker = devtools.subscribe((event, data) => {
65
+ if (event === 'effect:run') {
66
+ _lastRunningEffect = {
67
+ id: data?.id,
68
+ name: data?.name,
69
+ timestamp: Date.now(),
70
+ };
71
+ }
72
+
73
+ if (event === 'signal:updated' && data?.id != null) {
74
+ // Try to get the previous value from the registry snapshot.
75
+ // The event fires after the value has already changed, so we
76
+ // cannot recover the true previous value retroactively.
77
+ // However, the emit payload from devtools includes `value` (new).
78
+ // We store what we can — the previous value will be the last
79
+ // known `newValue` for this signal in the log, or undefined.
80
+ let previousValue;
81
+ const priorEntry = findLastWrite(data.id);
82
+ if (priorEntry) {
83
+ previousValue = priorEntry.newValue;
84
+ }
85
+
86
+ const entry = {
87
+ signalId: data.id,
88
+ signalName: data.name || `signal_${data.id}`,
89
+ previousValue,
90
+ newValue: data.value,
91
+ timestamp: Date.now(),
92
+ writerEffect: _lastRunningEffect ? { ..._lastRunningEffect } : null,
93
+ };
94
+
95
+ _signalWriteLog.push(entry);
96
+ if (_signalWriteLog.length > MAX_WRITE_LOG) {
97
+ _signalWriteLog = _signalWriteLog.slice(-MAX_WRITE_LOG);
98
+ }
99
+ }
100
+ });
101
+ }
102
+
103
+ function findLastWrite(signalId) {
104
+ for (let i = _signalWriteLog.length - 1; i >= 0; i--) {
105
+ if (_signalWriteLog[i].signalId === signalId) return _signalWriteLog[i];
106
+ }
107
+ return null;
108
+ }
109
+
13
110
  /**
14
111
  * Handle extended commands sent from the MCP server via the bridge.
15
112
  *
16
113
  * @param {string} command - The command name
17
114
  * @param {object} args - Command arguments
18
115
  * @param {object|null} devtools - window.__WHAT_DEVTOOLS__ reference
19
- * @returns {object|null} Result object, or null if command not handled
116
+ * @returns {Promise<object|null>} Result object, or null if command not handled
20
117
  */
21
- export function handleExtendedCommand(command, args, devtools) {
118
+ export async function handleExtendedCommand(command, args, devtools) {
22
119
  switch (command) {
23
120
 
24
121
  // -------------------------------------------------------------------------
25
122
  // eval — Execute arbitrary JS in the browser context
123
+ // WARNING: This executes arbitrary code. The MCP server guards this behind
124
+ // the --unsafe-eval flag. The browser side also checks a global flag so
125
+ // that even if the command somehow reaches the client, it is rejected
126
+ // unless explicitly enabled.
26
127
  // -------------------------------------------------------------------------
27
128
  case 'eval': {
129
+ // Guard: only execute if explicitly enabled on the client side
130
+ const evalEnabled = typeof window !== 'undefined' &&
131
+ (window.__WHAT_UNSAFE_EVAL__ === true ||
132
+ devtools?._unsafeEvalEnabled === true);
133
+
134
+ // Allow safe read-only expressions without the unsafe flag.
135
+ // Uses a strict allowlist of specific property paths — no generic regex
136
+ // that could leak sensitive data (document.cookie, etc.).
137
+ const code = (args.code || '').trim();
138
+ const isSafeRead =
139
+ /^typeof\s+\w+$/.test(code) || // typeof checks
140
+ /^document\.(title|URL|readyState|visibilityState|characterSet|contentType)$/.test(code) ||
141
+ /^window\.(innerWidth|innerHeight|devicePixelRatio)$/.test(code) ||
142
+ /^window\.screen\.\w+$/.test(code) ||
143
+ /^navigator\.(userAgent|language|languages|onLine|hardwareConcurrency|platform)$/.test(code) ||
144
+ /^location\.(href|pathname|hostname|port|protocol|search|hash|origin)$/.test(code);
145
+
146
+ if (!evalEnabled && !isSafeRead) {
147
+ return {
148
+ error: 'Eval is disabled for arbitrary code. Safe read-only expressions (document.title, window.innerWidth, etc.) work without the flag. For full eval, set window.__WHAT_UNSAFE_EVAL__ = true or enable --unsafe-eval on the MCP server.',
149
+ };
150
+ }
151
+
28
152
  const start = performance.now();
29
153
  try {
30
- // Use Function constructor to execute in global scope
154
+ // Use Function constructor to execute in global scope.
155
+ // Use trimmed `code` (not raw `args.code`) to match the isSafeRead check.
31
156
  // eslint-disable-next-line no-new-func
32
- const fn = new Function(args.code);
157
+ const fn = new Function(code);
33
158
  const raw = fn();
34
159
  const elapsed = performance.now() - start;
35
160
  return {
@@ -61,7 +186,7 @@ export function handleExtendedCommand(command, args, devtools) {
61
186
  return { error: `Component ${componentId} not found` };
62
187
  }
63
188
 
64
- const el = entry.element;
189
+ const el = getComponentElement(entry);
65
190
  if (!el) {
66
191
  return { error: `Component "${entry.name}" (id: ${componentId}) has no DOM element` };
67
192
  }
@@ -197,6 +322,1385 @@ export function handleExtendedCommand(command, args, devtools) {
197
322
  }
198
323
  }
199
324
 
325
+ // -------------------------------------------------------------------------
326
+ // validate-code — Compile or statically analyse a code snippet
327
+ // -------------------------------------------------------------------------
328
+ case 'validate-code': {
329
+ const { code, format } = args || {};
330
+ if (!code) return { valid: false, errors: [{ message: 'No code provided' }], warnings: [] };
331
+
332
+ const errors = [];
333
+ const warnings = [];
334
+
335
+ // 1. Try the Babel/What compiler if available on window
336
+ if (typeof window !== 'undefined' && window.__WHAT_COMPILER__) {
337
+ try {
338
+ const result = window.__WHAT_COMPILER__.compile(code, { format: format || 'jsx' });
339
+ return {
340
+ valid: !result.errors || result.errors.length === 0,
341
+ output: result.output || result.code || null,
342
+ errors: result.errors || [],
343
+ warnings: result.warnings || [],
344
+ };
345
+ } catch (e) {
346
+ // Compiler threw — fall through to static analysis
347
+ errors.push({ message: `Compiler error: ${e.message}` });
348
+ }
349
+ }
350
+
351
+ // 2. Static analysis fallback
352
+
353
+ // --- Bracket/brace matching ---
354
+ const brackets = { '(': ')', '[': ']', '{': '}' };
355
+ const closers = new Set([')', ']', '}']);
356
+ const stack = [];
357
+ // Strip string literals and comments to avoid false positives
358
+ const stripped = code
359
+ .replace(/\/\/[^\n]*/g, '')
360
+ .replace(/\/\*[\s\S]*?\*\//g, '')
361
+ .replace(/(["'`])(?:\\[\s\S]|(?!\1)[^\\])*\1/g, '');
362
+ for (let i = 0; i < stripped.length; i++) {
363
+ const ch = stripped[i];
364
+ if (brackets[ch]) {
365
+ stack.push({ char: ch, pos: i });
366
+ } else if (closers.has(ch)) {
367
+ const last = stack.pop();
368
+ if (!last) {
369
+ errors.push({ message: `Unexpected '${ch}' at position ${i}`, pos: i });
370
+ } else if (brackets[last.char] !== ch) {
371
+ errors.push({ message: `Mismatched '${last.char}' at position ${last.pos} and '${ch}' at position ${i}`, pos: i });
372
+ }
373
+ }
374
+ }
375
+ if (stack.length > 0) {
376
+ for (const item of stack) {
377
+ errors.push({ message: `Unclosed '${item.char}' at position ${item.pos}`, pos: item.pos });
378
+ }
379
+ }
380
+
381
+ // --- Common import errors ---
382
+ const importFromWhat = code.match(/import\s*\{([^}]+)\}\s*from\s*['"]what['"]/g);
383
+ if (importFromWhat) {
384
+ warnings.push({ message: "Import from 'what' detected. Use 'what-framework' instead.", rule: 'import-path' });
385
+ }
386
+
387
+ // --- Lint patterns ---
388
+
389
+ // 1. Signal read without () — look for JSX expressions like {count} where count is likely a signal
390
+ const signalWithoutCall = stripped.match(/\{(\s*[a-z][a-zA-Z0-9_]*\s*)\}/g);
391
+ if (signalWithoutCall) {
392
+ for (const match of signalWithoutCall) {
393
+ const name = match.replace(/[{}\s]/g, '');
394
+ // Heuristic: if the same name appears with () elsewhere, it's a signal used without ()
395
+ if (stripped.includes(`${name}(`) && !match.includes('(')) {
396
+ warnings.push({ message: `Possible signal '${name}' used without () — renders as [Function]. Use {${name}()}.`, rule: 'signal-read-without-call' });
397
+ }
398
+ }
399
+ }
400
+
401
+ // 2. innerHTML without __html marker
402
+ if (/innerHTML\s*=/.test(stripped) && !/__html/.test(stripped)) {
403
+ warnings.push({ message: 'innerHTML set without __html safety marker. XSS risk. Use { __html: content }.', rule: 'unsafe-innerhtml' });
404
+ }
405
+
406
+ // 3. Effect cycle — effect that reads and writes the same signal
407
+ const effectBodies = code.matchAll(/effect\s*\(\s*\(\s*\)\s*=>\s*\{([^}]+)\}/g);
408
+ for (const m of effectBodies) {
409
+ const body = m[1];
410
+ // Find signal names that appear as both read sig() and write sig(value)
411
+ const reads = [...body.matchAll(/(\w+)\(\)/g)].map(r => r[1]);
412
+ const writes = [...body.matchAll(/(\w+)\([^)]+\)/g)].map(r => r[1]);
413
+ for (const name of reads) {
414
+ if (writes.includes(name) && name !== 'untrack') {
415
+ warnings.push({ message: `Potential effect cycle: '${name}' is both read and written inside effect. Use untrack() for the read.`, rule: 'effect-cycle' });
416
+ }
417
+ }
418
+ }
419
+
420
+ // 4. Missing cleanup — effect with addEventListener but no removeEventListener
421
+ if (/effect\s*\(/.test(code) && /addEventListener/.test(code) && !/removeEventListener/.test(code)) {
422
+ warnings.push({ message: 'Effect adds event listener but no cleanup detected (missing removeEventListener). Return a cleanup function.', rule: 'missing-cleanup' });
423
+ }
424
+
425
+ return {
426
+ valid: errors.length === 0,
427
+ output: null,
428
+ errors,
429
+ warnings,
430
+ };
431
+ }
432
+
433
+ // -------------------------------------------------------------------------
434
+ // get-app-info — Return app metadata for bootstrap
435
+ // -------------------------------------------------------------------------
436
+ case 'get-app-info': {
437
+ return {
438
+ url: window.location.href,
439
+ title: document.title,
440
+ viewport: { width: window.innerWidth, height: window.innerHeight },
441
+ // Try to detect framework version
442
+ version: window.__WHAT_CORE__?.version || window.__WHAT_DEVTOOLS__?.version || 'unknown',
443
+ // Get the entry point from Vite's module graph if available
444
+ entryPoint: document.querySelector('script[type="module"][src]')?.getAttribute('src') ||
445
+ document.querySelector('script[type="module"]')?.textContent?.match(/from ['"]([^'"]+)['"]/)?.[1] || 'unknown',
446
+ };
447
+ }
448
+
449
+ // -------------------------------------------------------------------------
450
+ // visual-inspect — Computed visual info about a component (no image)
451
+ // -------------------------------------------------------------------------
452
+ case 'visual-inspect': {
453
+ const { componentId } = args || {};
454
+ const registries = devtools?._registries;
455
+ if (!registries?.components) return { error: 'DevTools registries not available' };
456
+
457
+ const entry = registries.components.get(componentId);
458
+ if (!entry) return { error: `Component ${componentId} not found` };
459
+
460
+ const el = getComponentElement(entry);
461
+ if (!el) return { error: `Component "${entry.name}" has no DOM element` };
462
+
463
+ const rect = el.getBoundingClientRect();
464
+ const cs = window.getComputedStyle(el);
465
+
466
+ // Key computed styles
467
+ const styles = {
468
+ display: cs.display,
469
+ position: cs.position,
470
+ flexDirection: cs.flexDirection !== 'row' ? cs.flexDirection : undefined,
471
+ flexWrap: cs.flexWrap !== 'nowrap' ? cs.flexWrap : undefined,
472
+ gridTemplateColumns: cs.gridTemplateColumns !== 'none' ? cs.gridTemplateColumns : undefined,
473
+ gridTemplateRows: cs.gridTemplateRows !== 'none' ? cs.gridTemplateRows : undefined,
474
+ backgroundColor: cs.backgroundColor,
475
+ color: cs.color,
476
+ fontSize: cs.fontSize,
477
+ fontFamily: cs.fontFamily?.split(',')[0]?.trim(),
478
+ padding: cs.padding,
479
+ margin: cs.margin,
480
+ border: cs.border !== 'none' && cs.borderWidth !== '0px' ? cs.border : undefined,
481
+ borderRadius: cs.borderRadius !== '0px' ? cs.borderRadius : undefined,
482
+ zIndex: cs.zIndex !== 'auto' ? cs.zIndex : undefined,
483
+ opacity: cs.opacity !== '1' ? cs.opacity : undefined,
484
+ overflow: cs.overflow !== 'visible' ? cs.overflow : undefined,
485
+ visibility: cs.visibility !== 'visible' ? cs.visibility : undefined,
486
+ width: cs.width,
487
+ height: cs.height,
488
+ maxWidth: cs.maxWidth !== 'none' ? cs.maxWidth : undefined,
489
+ };
490
+ // Remove undefined values
491
+ Object.keys(styles).forEach(k => styles[k] === undefined && delete styles[k]);
492
+
493
+ // Text content preview
494
+ const textContent = (el.textContent || '').trim().substring(0, 200);
495
+
496
+ // Child element types
497
+ const childTypes = {};
498
+ const selectors = ['button', 'a', 'input', 'select', 'textarea', 'img', 'form', 'table', 'ul', 'ol', 'video', 'canvas', 'svg'];
499
+ for (const sel of selectors) {
500
+ const count = el.querySelectorAll(sel).length;
501
+ if (count > 0) childTypes[sel] = count;
502
+ }
503
+
504
+ // Accessibility info
505
+ const a11y = {};
506
+ const role = el.getAttribute('role');
507
+ const ariaLabel = el.getAttribute('aria-label');
508
+ const tabIndex = el.getAttribute('tabindex');
509
+ if (role) a11y.role = role;
510
+ if (ariaLabel) a11y.ariaLabel = ariaLabel;
511
+ if (tabIndex) a11y.tabIndex = tabIndex;
512
+
513
+ // Layout classification
514
+ let layout = styles.display || 'block';
515
+ const childCount = el.children.length;
516
+ if (cs.display === 'flex') {
517
+ layout = `flex ${cs.flexDirection === 'column' ? 'column' : 'row'} with ${childCount} children`;
518
+ } else if (cs.display === 'grid') {
519
+ const cols = cs.gridTemplateColumns.split(' ').length;
520
+ const rows = cs.gridTemplateRows.split(' ').length;
521
+ layout = `grid ${cols}×${rows} with ${childCount} children`;
522
+ } else if (cs.display === 'block' || cs.display === 'flow-root') {
523
+ layout = `block with ${childCount} children`;
524
+ } else if (cs.display === 'inline-flex' || cs.display === 'inline-block') {
525
+ layout = `${cs.display} with ${childCount} children`;
526
+ }
527
+
528
+ return {
529
+ componentName: entry.name,
530
+ componentId,
531
+ boundingRect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
532
+ styles,
533
+ textContent: textContent || '(empty)',
534
+ childElements: childTypes,
535
+ totalChildren: childCount,
536
+ accessibility: Object.keys(a11y).length > 0 ? a11y : undefined,
537
+ layout,
538
+ viewport: { width: window.innerWidth, height: window.innerHeight },
539
+ };
540
+ }
541
+
542
+ // -------------------------------------------------------------------------
543
+ // page-map — Structured map of the entire visible page
544
+ // -------------------------------------------------------------------------
545
+ case 'page-map': {
546
+ const maxElements = args?.maxElements || 200;
547
+ let count = 0;
548
+
549
+ // Landmarks
550
+ const landmarks = [];
551
+ const landmarkEls = document.querySelectorAll('[role], header, footer, nav, main, aside, section, article');
552
+ for (const el of landmarkEls) {
553
+ if (count >= maxElements) break;
554
+ const rect = el.getBoundingClientRect();
555
+ if (rect.width === 0 && rect.height === 0) continue;
556
+ landmarks.push({
557
+ tag: el.tagName.toLowerCase(),
558
+ role: el.getAttribute('role') || undefined,
559
+ id: el.id || undefined,
560
+ text: (el.textContent || '').trim().substring(0, 50),
561
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
562
+ });
563
+ count++;
564
+ }
565
+
566
+ // Interactive elements
567
+ const interactives = [];
568
+ const interactiveEls = document.querySelectorAll('button, a[href], input, select, textarea, [role=button], [role=link], [contenteditable]');
569
+ for (const el of interactiveEls) {
570
+ if (count >= maxElements) break;
571
+ const rect = el.getBoundingClientRect();
572
+ if (rect.width === 0 && rect.height === 0) continue;
573
+ const label = el.getAttribute('aria-label') || el.textContent?.trim().substring(0, 40) || el.getAttribute('placeholder') || el.getAttribute('name') || '';
574
+ interactives.push({
575
+ tag: el.tagName.toLowerCase(),
576
+ type: el.getAttribute('type') || undefined,
577
+ label: label || '(unlabeled)',
578
+ disabled: el.disabled || undefined,
579
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
580
+ });
581
+ count++;
582
+ }
583
+
584
+ // Headings
585
+ const headings = [];
586
+ const headingEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
587
+ for (const el of headingEls) {
588
+ if (count >= maxElements) break;
589
+ headings.push({
590
+ level: parseInt(el.tagName[1]),
591
+ text: (el.textContent || '').trim().substring(0, 80),
592
+ });
593
+ count++;
594
+ }
595
+
596
+ // WhatFW component boundaries
597
+ const components = [];
598
+ const registries = devtools?._registries;
599
+ if (registries?.components) {
600
+ for (const [id, entry] of registries.components) {
601
+ if (count >= maxElements) break;
602
+ const compEl = getComponentElement(entry);
603
+ if (!compEl) continue;
604
+ const rect = compEl.getBoundingClientRect();
605
+ if (rect.width === 0 && rect.height === 0) continue;
606
+ components.push({
607
+ id,
608
+ name: entry.name,
609
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
610
+ });
611
+ count++;
612
+ }
613
+ }
614
+
615
+ return {
616
+ viewport: { width: window.innerWidth, height: window.innerHeight },
617
+ landmarks,
618
+ interactives,
619
+ headings,
620
+ components,
621
+ totalElements: count,
622
+ };
623
+ }
624
+
625
+ // -------------------------------------------------------------------------
626
+ // get-signal-writers — Correlate signal writes with effect runs
627
+ // Uses the module-level ring buffer populated by initEventTracking().
628
+ // -------------------------------------------------------------------------
629
+ case 'get-signal-writers': {
630
+ const { signalId } = args || {};
631
+ const registries = devtools?._registries;
632
+
633
+ if (!registries?.signals) {
634
+ return { error: 'DevTools registries not available' };
635
+ }
636
+
637
+ const sigEntry = registries.signals.get(signalId);
638
+ if (!sigEntry) {
639
+ return { error: `Signal ${signalId} not found` };
640
+ }
641
+
642
+ // Lazily initialize event tracking on first call
643
+ initEventTracking(devtools);
644
+
645
+ // Filter the write log for this signal
646
+ const writes = _signalWriteLog
647
+ .filter(w => w.signalId === signalId)
648
+ .slice(-20); // Last 20 writes
649
+
650
+ const totalWrites = _signalWriteLog.filter(w => w.signalId === signalId).length;
651
+
652
+ return {
653
+ signalId,
654
+ signalName: sigEntry.name,
655
+ currentValue: devtools.safeSerialize
656
+ ? devtools.safeSerialize(sigEntry.ref.peek())
657
+ : sigEntry.ref.peek(),
658
+ recentWrites: writes,
659
+ totalWrites,
660
+ };
661
+ }
662
+
663
+ // -------------------------------------------------------------------------
664
+ // component-screenshot — Render component to base64 image via foreignObject
665
+ // -------------------------------------------------------------------------
666
+ case 'component-screenshot': {
667
+ const { componentId, maxWidth = 400, quality = 0.7, format = 'jpeg' } = args || {};
668
+ const registries = devtools?._registries;
669
+
670
+ if (!registries?.components) return { error: 'DevTools registries not available' };
671
+
672
+ const entry = registries.components.get(componentId);
673
+ if (!entry) return { error: `Component ${componentId} not found` };
674
+
675
+ const el = getComponentElement(entry);
676
+ if (!el) return { error: `Component "${entry.name}" has no DOM element` };
677
+
678
+ try {
679
+ const rect = el.getBoundingClientRect();
680
+ if (rect.width === 0 || rect.height === 0) {
681
+ return { error: `Component "${entry.name}" has zero dimensions (${rect.width}x${rect.height}). It may be hidden.` };
682
+ }
683
+
684
+ // Clone and inline styles so foreignObject renders correctly
685
+ const clone = el.cloneNode(true);
686
+
687
+ function inlineStyles(source, target) {
688
+ const cs = window.getComputedStyle(source);
689
+ const importantProps = [
690
+ 'display', 'position', 'top', 'left', 'right', 'bottom',
691
+ 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
692
+ 'margin', 'padding', 'border', 'border-radius',
693
+ 'background', 'background-color', 'background-image',
694
+ 'color', 'font-family', 'font-size', 'font-weight', 'line-height', 'text-align', 'text-decoration',
695
+ 'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'gap',
696
+ 'grid-template-columns', 'grid-template-rows',
697
+ 'overflow', 'opacity', 'visibility', 'z-index',
698
+ 'box-shadow', 'text-shadow', 'transform',
699
+ 'white-space', 'word-break', 'letter-spacing',
700
+ ];
701
+ for (const prop of importantProps) {
702
+ const val = cs.getPropertyValue(prop);
703
+ if (val && val !== '' && val !== 'none' && val !== 'normal' && val !== 'auto' && val !== '0px') {
704
+ target.style.setProperty(prop, val);
705
+ }
706
+ }
707
+ const sourceChildren = source.children;
708
+ const targetChildren = target.children;
709
+ const maxChildren = Math.min(sourceChildren.length, targetChildren.length, 100);
710
+ for (let i = 0; i < maxChildren; i++) {
711
+ inlineStyles(sourceChildren[i], targetChildren[i]);
712
+ }
713
+ }
714
+
715
+ inlineStyles(el, clone);
716
+
717
+ // Reset position so it renders at 0,0 inside the SVG
718
+ clone.style.position = 'static';
719
+ clone.style.margin = '0';
720
+
721
+ // Serialize to SVG foreignObject
722
+ const serialized = new XMLSerializer().serializeToString(clone);
723
+ const svgWidth = Math.ceil(rect.width);
724
+ const svgHeight = Math.ceil(rect.height);
725
+
726
+ const svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgWidth}" height="${svgHeight}">
727
+ <foreignObject width="100%" height="100%">
728
+ <div xmlns="http://www.w3.org/1999/xhtml" style="width:${svgWidth}px;height:${svgHeight}px;overflow:hidden;">
729
+ ${serialized}
730
+ </div>
731
+ </foreignObject>
732
+ </svg>`;
733
+
734
+ // Render SVG to canvas
735
+ const dpr = window.devicePixelRatio || 1;
736
+ const scale = Math.min(1, maxWidth / svgWidth);
737
+ const canvasWidth = Math.ceil(svgWidth * scale * dpr);
738
+ const canvasHeight = Math.ceil(svgHeight * scale * dpr);
739
+
740
+ const canvas = document.createElement('canvas');
741
+ canvas.width = canvasWidth;
742
+ canvas.height = canvasHeight;
743
+ const ctx = canvas.getContext('2d');
744
+ ctx.scale(scale * dpr, scale * dpr);
745
+
746
+ // Load SVG blob as image
747
+ const img = new Image();
748
+ const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
749
+ const url = URL.createObjectURL(blob);
750
+
751
+ await new Promise((resolve, reject) => {
752
+ img.onload = resolve;
753
+ img.onerror = () => reject(new Error('SVG rendering failed — component may contain cross-origin resources'));
754
+ img.src = url;
755
+ });
756
+
757
+ ctx.drawImage(img, 0, 0);
758
+ URL.revokeObjectURL(url);
759
+
760
+ // Export to base64
761
+ const mimeType = format === 'png' ? 'image/png' : 'image/jpeg';
762
+ let dataUrl = canvas.toDataURL(mimeType, format === 'png' ? undefined : quality);
763
+ let base64 = dataUrl.split(',')[1];
764
+ let sizeBytes = Math.ceil(base64.length * 3 / 4);
765
+
766
+ // Size safety: if over 100KB, reduce quality then dimensions
767
+ if (sizeBytes > 102400 && format !== 'png') {
768
+ dataUrl = canvas.toDataURL('image/jpeg', 0.3);
769
+ base64 = dataUrl.split(',')[1];
770
+ sizeBytes = Math.ceil(base64.length * 3 / 4);
771
+ }
772
+ if (sizeBytes > 102400) {
773
+ const smallCanvas = document.createElement('canvas');
774
+ smallCanvas.width = Math.ceil(canvasWidth / 2);
775
+ smallCanvas.height = Math.ceil(canvasHeight / 2);
776
+ const smallCtx = smallCanvas.getContext('2d');
777
+ smallCtx.drawImage(canvas, 0, 0, smallCanvas.width, smallCanvas.height);
778
+ dataUrl = smallCanvas.toDataURL('image/jpeg', 0.3);
779
+ base64 = dataUrl.split(',')[1];
780
+ sizeBytes = Math.ceil(base64.length * 3 / 4);
781
+ }
782
+ if (sizeBytes > 102400) {
783
+ return { error: 'Screenshot exceeds 100KB even after reduction. Use what_look for text-based visual info instead.' };
784
+ }
785
+
786
+ return {
787
+ base64,
788
+ format: format === 'png' ? 'png' : 'jpeg',
789
+ mimeType,
790
+ width: Math.round(svgWidth * scale),
791
+ height: Math.round(svgHeight * scale),
792
+ sizeBytes,
793
+ componentName: entry.name,
794
+ };
795
+ } catch (e) {
796
+ return {
797
+ error: `Screenshot failed: ${e.message}`,
798
+ fallback: 'Use what_look for text-based visual inspection without an image.',
799
+ };
800
+ }
801
+ }
802
+
803
+ // -------------------------------------------------------------------------
804
+ // click — Semantic clicking by text, componentId, role, testId, ariaLabel
805
+ // -------------------------------------------------------------------------
806
+ case 'click': {
807
+ const { text, componentId, role, testId, ariaLabel, index } = args || {};
808
+
809
+ // Find the target element using semantic selectors
810
+ let el = null;
811
+ let matchDescription = '';
812
+
813
+ // Scope to a component's DOM if componentId given
814
+ let scope = document;
815
+ if (componentId != null) {
816
+ const registries = devtools?._registries;
817
+ if (registries?.components) {
818
+ const entry = registries.components.get(componentId);
819
+ if (entry) {
820
+ const compEl = getComponentElement(entry);
821
+ if (compEl) scope = compEl;
822
+ else return { error: `Component ${componentId} has no DOM element` };
823
+ } else {
824
+ return { error: `Component ${componentId} not found` };
825
+ }
826
+ }
827
+ }
828
+
829
+ if (testId) {
830
+ el = scope.querySelector(`[data-testid="${CSS.escape(testId)}"]`);
831
+ matchDescription = `data-testid="${testId}"`;
832
+ } else if (ariaLabel) {
833
+ el = scope.querySelector(`[aria-label="${CSS.escape(ariaLabel)}"]`);
834
+ matchDescription = `aria-label="${ariaLabel}"`;
835
+ } else if (text) {
836
+ // Find interactive elements matching text content
837
+ const interactiveTags = 'button, a, [role=button], [role=link], input[type=submit], input[type=button], summary';
838
+ const candidates = scope.querySelectorAll(interactiveTags);
839
+ const matches = [];
840
+ const lowerText = text.toLowerCase().trim();
841
+ for (const candidate of candidates) {
842
+ const candidateText = (candidate.textContent || '').trim().toLowerCase();
843
+ const candidateLabel = (candidate.getAttribute('aria-label') || '').toLowerCase();
844
+ const candidateValue = (candidate.value || '').toLowerCase();
845
+ if (candidateText === lowerText || candidateLabel === lowerText || candidateValue === lowerText) {
846
+ matches.push(candidate);
847
+ } else if (candidateText.includes(lowerText) || candidateLabel.includes(lowerText)) {
848
+ matches.push(candidate);
849
+ }
850
+ }
851
+ if (matches.length === 0) {
852
+ // Broaden: search all visible elements with the text
853
+ const allEls = scope.querySelectorAll('*');
854
+ for (const candidate of allEls) {
855
+ // Only direct text content, not nested
856
+ const directText = Array.from(candidate.childNodes)
857
+ .filter(n => n.nodeType === 3)
858
+ .map(n => n.textContent.trim())
859
+ .join(' ')
860
+ .toLowerCase();
861
+ if (directText === lowerText || directText.includes(lowerText)) {
862
+ matches.push(candidate);
863
+ }
864
+ }
865
+ }
866
+ const idx = (index != null && index >= 0 && index < matches.length) ? index : 0;
867
+ el = matches[idx] || null;
868
+ matchDescription = `text="${text}"${matches.length > 1 ? ` (${matches.length} matches, using index ${idx})` : ''}`;
869
+ if (!el && matches.length === 0) {
870
+ return {
871
+ error: `No element found with text "${text}"`,
872
+ suggestion: 'Use what_page_map to see available interactive elements and their labels.',
873
+ };
874
+ }
875
+ } else if (role) {
876
+ // Only allow known HTML tag names as a fallback selector to prevent CSS injection.
877
+ // The role attribute selector is always escaped via CSS.escape().
878
+ // This maps ARIA roles to their corresponding HTML elements (e.g., role="button" -> <button>).
879
+ const roleToTag = new Set(['button', 'main', 'search', 'form', 'option', 'img', 'table', 'menu', 'dialog', 'summary']);
880
+ const roleSelector = `[role="${CSS.escape(role)}"]`;
881
+ // Only append the tag selector if the role maps to a known HTML element name
882
+ const fullSelector = roleToTag.has(role.toLowerCase()) ? `${roleSelector}, ${role.toLowerCase()}` : roleSelector;
883
+ const candidates = scope.querySelectorAll(fullSelector);
884
+ const idx = (index != null && index >= 0 && index < candidates.length) ? index : 0;
885
+ el = candidates[idx] || null;
886
+ matchDescription = `role="${role}"${candidates.length > 1 ? ` (${candidates.length} matches, using index ${idx})` : ''}`;
887
+ }
888
+
889
+ if (!el) {
890
+ return { error: `No element found matching: ${matchDescription || 'no selector provided'}` };
891
+ }
892
+
893
+ // Warn if clicking a disabled element — the click will still fire
894
+ // but frameworks typically ignore it. Surface this to AI agents.
895
+ const isDisabled = el.disabled || el.getAttribute('aria-disabled') === 'true';
896
+
897
+ // Capture path before click for navigation detection
898
+ const pathBefore = window.location.pathname;
899
+ // Capture state before click
900
+ const snapshotBefore = devtools?.getSnapshot ? devtools.safeSerialize(devtools.getSnapshot()) : null;
901
+ const signalsBefore = new Map();
902
+ if (devtools?._registries?.signals) {
903
+ for (const [id, entry] of devtools._registries.signals) {
904
+ signalsBefore.set(id, { name: entry.name, value: entry.ref.peek() });
905
+ }
906
+ }
907
+ const componentsBefore = new Set();
908
+ if (devtools?._registries?.components) {
909
+ for (const [id] of devtools._registries.components) componentsBefore.add(id);
910
+ }
911
+
912
+ // Perform the click with proper event sequence
913
+ el.focus?.();
914
+ el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
915
+ el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
916
+ el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true }));
917
+ el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
918
+ el.click();
919
+
920
+ // Wait a tick for effects to flush
921
+ await new Promise(r => setTimeout(r, 50));
922
+
923
+ // Capture what changed
924
+ const changes = { signalsChanged: [], componentsAdded: [], componentsRemoved: [], effectsTriggered: [] };
925
+ if (devtools?._registries?.signals) {
926
+ for (const [id, entry] of devtools._registries.signals) {
927
+ const before = signalsBefore.get(id);
928
+ const currentVal = entry.ref.peek();
929
+ if (before) {
930
+ if (JSON.stringify(before.value) !== JSON.stringify(currentVal)) {
931
+ changes.signalsChanged.push({
932
+ id, name: entry.name,
933
+ previousValue: devtools.safeSerialize(before.value),
934
+ currentValue: devtools.safeSerialize(currentVal),
935
+ });
936
+ }
937
+ } else {
938
+ changes.signalsChanged.push({
939
+ id, name: entry.name,
940
+ previousValue: undefined,
941
+ currentValue: devtools.safeSerialize(currentVal),
942
+ added: true,
943
+ });
944
+ }
945
+ }
946
+ }
947
+ if (devtools?._registries?.components) {
948
+ for (const [id, entry] of devtools._registries.components) {
949
+ if (!componentsBefore.has(id)) {
950
+ changes.componentsAdded.push({ id, name: entry.name });
951
+ }
952
+ }
953
+ for (const id of componentsBefore) {
954
+ if (!devtools._registries.components.has(id)) {
955
+ changes.componentsRemoved.push({ id });
956
+ }
957
+ }
958
+ }
959
+
960
+ // Detect navigation (compare against path captured before click)
961
+ const navigated = window.location.pathname !== pathBefore;
962
+
963
+ const result = {
964
+ clicked: true,
965
+ element: {
966
+ tag: el.tagName.toLowerCase(),
967
+ text: (el.textContent || '').trim().substring(0, 100),
968
+ id: el.id || undefined,
969
+ class: (typeof el.className === 'string' ? el.className : '') || undefined,
970
+ },
971
+ matched: matchDescription,
972
+ changes,
973
+ currentPath: window.location.pathname,
974
+ navigated,
975
+ };
976
+
977
+ if (isDisabled) {
978
+ result.warning = 'Element is disabled — click events may be ignored by the framework. Check if the element should be enabled first.';
979
+ }
980
+
981
+ return result;
982
+ }
983
+
984
+ // -------------------------------------------------------------------------
985
+ // fill — Semantic form filling by label, name, placeholder, componentId
986
+ // -------------------------------------------------------------------------
987
+ case 'fill': {
988
+ const { label, name, placeholder, componentId, value, inputs } = args || {};
989
+
990
+ let scope = document;
991
+ if (componentId != null) {
992
+ const registries = devtools?._registries;
993
+ if (registries?.components) {
994
+ const entry = registries.components.get(componentId);
995
+ if (entry) {
996
+ const compEl = getComponentElement(entry);
997
+ if (compEl) scope = compEl;
998
+ else return { error: `Component ${componentId} has no DOM element` };
999
+ } else {
1000
+ return { error: `Component ${componentId} not found` };
1001
+ }
1002
+ }
1003
+ }
1004
+
1005
+ // Multi-fill mode: fill all inputs in a scope using an inputs map
1006
+ if (inputs && typeof inputs === 'object') {
1007
+ const results = [];
1008
+ for (const [key, val] of Object.entries(inputs)) {
1009
+ const input = scope.querySelector(`[name="${CSS.escape(key)}"]`) ||
1010
+ scope.querySelector(`#${CSS.escape(key)}`) ||
1011
+ findInputByLabel(scope, key);
1012
+ if (input) {
1013
+ setInputValue(input, val);
1014
+ results.push({ field: key, filled: true, tag: input.tagName.toLowerCase(), type: input.type || undefined });
1015
+ } else {
1016
+ results.push({ field: key, filled: false, error: `No input found for "${key}"` });
1017
+ }
1018
+ }
1019
+
1020
+ await new Promise(r => setTimeout(r, 50));
1021
+
1022
+ return {
1023
+ filled: true,
1024
+ mode: 'multi',
1025
+ results,
1026
+ filledCount: results.filter(r => r.filled).length,
1027
+ failedCount: results.filter(r => !r.filled).length,
1028
+ };
1029
+ }
1030
+
1031
+ // Single-fill mode
1032
+ let input = null;
1033
+ let matchDescription = '';
1034
+
1035
+ if (label) {
1036
+ input = findInputByLabel(scope, label);
1037
+ matchDescription = `label="${label}"`;
1038
+ } else if (name) {
1039
+ input = scope.querySelector(`[name="${CSS.escape(name)}"]`);
1040
+ matchDescription = `name="${name}"`;
1041
+ } else if (placeholder) {
1042
+ input = scope.querySelector(`[placeholder="${CSS.escape(placeholder)}"]`);
1043
+ matchDescription = `placeholder="${placeholder}"`;
1044
+ }
1045
+
1046
+ if (!input) {
1047
+ return {
1048
+ error: `No input found matching: ${matchDescription || 'no selector provided'}`,
1049
+ suggestion: 'Use what_page_map to see available form fields.',
1050
+ };
1051
+ }
1052
+
1053
+ // Warn on disabled or readonly inputs
1054
+ const inputDisabled = input.disabled || input.readOnly;
1055
+
1056
+ // Capture before
1057
+ const beforeVal = input.value;
1058
+ setInputValue(input, value);
1059
+
1060
+ await new Promise(r => setTimeout(r, 50));
1061
+
1062
+ // Check validation state
1063
+ const validity = input.validity ? {
1064
+ valid: input.validity.valid,
1065
+ valueMissing: input.validity.valueMissing || undefined,
1066
+ typeMismatch: input.validity.typeMismatch || undefined,
1067
+ patternMismatch: input.validity.patternMismatch || undefined,
1068
+ tooShort: input.validity.tooShort || undefined,
1069
+ tooLong: input.validity.tooLong || undefined,
1070
+ rangeUnderflow: input.validity.rangeUnderflow || undefined,
1071
+ rangeOverflow: input.validity.rangeOverflow || undefined,
1072
+ customError: input.validity.customError || undefined,
1073
+ } : null;
1074
+ // Clean undefined values
1075
+ if (validity) Object.keys(validity).forEach(k => validity[k] === undefined && delete validity[k]);
1076
+
1077
+ const fillResult = {
1078
+ filled: true,
1079
+ element: {
1080
+ tag: input.tagName.toLowerCase(),
1081
+ type: input.type || undefined,
1082
+ name: input.name || undefined,
1083
+ id: input.id || undefined,
1084
+ },
1085
+ matched: matchDescription,
1086
+ previousValue: beforeVal,
1087
+ currentValue: input.value,
1088
+ validation: validity,
1089
+ };
1090
+
1091
+ if (inputDisabled) {
1092
+ fillResult.warning = `Input is ${input.disabled ? 'disabled' : 'readonly'} — the value was set programmatically but the framework may ignore or revert it.`;
1093
+ }
1094
+
1095
+ return fillResult;
1096
+ }
1097
+
1098
+ // -------------------------------------------------------------------------
1099
+ // interact — High-level compound interactions
1100
+ // -------------------------------------------------------------------------
1101
+ case 'interact': {
1102
+ const { action, componentId, label, text, value } = args || {};
1103
+
1104
+ if (!action) {
1105
+ return { error: 'No action provided. Use: submit_form, select_option, toggle, scroll_to, hover, type, clear, focus' };
1106
+ }
1107
+
1108
+ let scope = document;
1109
+ if (componentId != null) {
1110
+ const registries = devtools?._registries;
1111
+ if (registries?.components) {
1112
+ const entry = registries.components.get(componentId);
1113
+ if (entry) {
1114
+ const compEl = getComponentElement(entry);
1115
+ if (compEl) scope = compEl;
1116
+ else return { error: `Component ${componentId} has no DOM element` };
1117
+ } else {
1118
+ return { error: `Component ${componentId} not found` };
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ switch (action) {
1124
+ case 'submit_form': {
1125
+ // Find and submit a form
1126
+ const form = scope.tagName === 'FORM' ? scope :
1127
+ scope.querySelector('form');
1128
+ if (!form) {
1129
+ return { error: 'No form found in scope. Use componentId to target a specific component.' };
1130
+ }
1131
+ // Try the submit button first (more realistic)
1132
+ const submitBtn = form.querySelector('[type=submit], button:not([type=button]):not([type=reset])');
1133
+ if (submitBtn) {
1134
+ submitBtn.click();
1135
+ } else {
1136
+ form.requestSubmit?.() || form.submit();
1137
+ }
1138
+ await new Promise(r => setTimeout(r, 100));
1139
+ return {
1140
+ action: 'submit_form',
1141
+ submitted: true,
1142
+ formAction: form.action || undefined,
1143
+ formMethod: form.method || 'get',
1144
+ currentPath: window.location.pathname,
1145
+ };
1146
+ }
1147
+
1148
+ case 'select_option': {
1149
+ const select = label
1150
+ ? findInputByLabel(scope, label)
1151
+ : scope.querySelector('select');
1152
+ if (!select || select.tagName !== 'SELECT') {
1153
+ return { error: `No <select> found${label ? ` with label "${label}"` : ''}` };
1154
+ }
1155
+ const prevValue = select.value;
1156
+ // Find option by value or text
1157
+ let found = false;
1158
+ for (const opt of select.options) {
1159
+ if (opt.value === value || opt.textContent.trim().toLowerCase() === String(value).toLowerCase()) {
1160
+ select.value = opt.value;
1161
+ found = true;
1162
+ break;
1163
+ }
1164
+ }
1165
+ if (!found) {
1166
+ const options = Array.from(select.options).map(o => ({ value: o.value, text: o.textContent.trim() }));
1167
+ return { error: `Option "${value}" not found`, availableOptions: options };
1168
+ }
1169
+ select.dispatchEvent(new Event('input', { bubbles: true }));
1170
+ select.dispatchEvent(new Event('change', { bubbles: true }));
1171
+ await new Promise(r => setTimeout(r, 50));
1172
+ return {
1173
+ action: 'select_option',
1174
+ selected: true,
1175
+ previousValue: prevValue,
1176
+ currentValue: select.value,
1177
+ selectedText: select.options[select.selectedIndex]?.textContent?.trim(),
1178
+ };
1179
+ }
1180
+
1181
+ case 'toggle': {
1182
+ const checkable = text
1183
+ ? findCheckableByText(scope, text)
1184
+ : label
1185
+ ? findInputByLabel(scope, label)
1186
+ : scope.querySelector('input[type=checkbox], input[type=radio], [role=switch], [role=checkbox]');
1187
+ if (!checkable) {
1188
+ return { error: `No toggleable element found${text ? ` with text "${text}"` : ''}${label ? ` with label "${label}"` : ''}` };
1189
+ }
1190
+ const wasBefore = checkable.checked !== undefined ? checkable.checked :
1191
+ checkable.getAttribute('aria-checked') === 'true';
1192
+ checkable.click();
1193
+ await new Promise(r => setTimeout(r, 50));
1194
+ const isNow = checkable.checked !== undefined ? checkable.checked :
1195
+ checkable.getAttribute('aria-checked') === 'true';
1196
+ return {
1197
+ action: 'toggle',
1198
+ toggled: true,
1199
+ previousState: wasBefore,
1200
+ currentState: isNow,
1201
+ element: { tag: checkable.tagName.toLowerCase(), type: checkable.type || undefined },
1202
+ };
1203
+ }
1204
+
1205
+ case 'scroll_to': {
1206
+ let target = null;
1207
+ if (componentId != null && scope !== document) {
1208
+ target = scope;
1209
+ } else if (text) {
1210
+ // Find element with matching text
1211
+ const all = document.querySelectorAll('*');
1212
+ for (const el of all) {
1213
+ if ((el.textContent || '').trim().toLowerCase().includes(text.toLowerCase())) {
1214
+ target = el;
1215
+ break;
1216
+ }
1217
+ }
1218
+ }
1219
+ if (!target) {
1220
+ return { error: 'No element found to scroll to' };
1221
+ }
1222
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' });
1223
+ await new Promise(r => setTimeout(r, 300));
1224
+ const rect = target.getBoundingClientRect();
1225
+ return {
1226
+ action: 'scroll_to',
1227
+ scrolled: true,
1228
+ elementRect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
1229
+ viewportPosition: rect.y >= 0 && rect.y <= window.innerHeight ? 'visible' : 'partially visible',
1230
+ };
1231
+ }
1232
+
1233
+ case 'hover': {
1234
+ let hoverTarget = null;
1235
+ if (text) {
1236
+ const interactiveTags = 'button, a, [role=button], [role=menuitem], [role=tab], summary, details, [tabindex]';
1237
+ const candidates = scope.querySelectorAll(interactiveTags);
1238
+ for (const c of candidates) {
1239
+ if ((c.textContent || '').trim().toLowerCase().includes(text.toLowerCase())) {
1240
+ hoverTarget = c;
1241
+ break;
1242
+ }
1243
+ }
1244
+ }
1245
+ if (!hoverTarget && scope !== document) {
1246
+ hoverTarget = scope;
1247
+ }
1248
+ if (!hoverTarget) {
1249
+ return { error: `No element found to hover${text ? ` with text "${text}"` : ''}` };
1250
+ }
1251
+ hoverTarget.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }));
1252
+ hoverTarget.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
1253
+ hoverTarget.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }));
1254
+ hoverTarget.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
1255
+ await new Promise(r => setTimeout(r, 100));
1256
+ return {
1257
+ action: 'hover',
1258
+ hovered: true,
1259
+ element: {
1260
+ tag: hoverTarget.tagName.toLowerCase(),
1261
+ text: (hoverTarget.textContent || '').trim().substring(0, 80),
1262
+ },
1263
+ };
1264
+ }
1265
+
1266
+ case 'type': {
1267
+ // Type text character by character with keydown/keypress/keyup events
1268
+ const target = document.activeElement || scope.querySelector('input, textarea, [contenteditable]');
1269
+ if (!target) {
1270
+ return { error: 'No focusable input element found. Use what_fill to target by label/name first.' };
1271
+ }
1272
+ const textToType = value || text || '';
1273
+ for (const char of textToType) {
1274
+ target.dispatchEvent(new KeyboardEvent('keydown', { key: char, bubbles: true }));
1275
+ target.dispatchEvent(new KeyboardEvent('keypress', { key: char, bubbles: true }));
1276
+ if (target.value !== undefined) {
1277
+ target.value += char;
1278
+ }
1279
+ target.dispatchEvent(new InputEvent('input', { data: char, inputType: 'insertText', bubbles: true }));
1280
+ target.dispatchEvent(new KeyboardEvent('keyup', { key: char, bubbles: true }));
1281
+ }
1282
+ await new Promise(r => setTimeout(r, 50));
1283
+ return {
1284
+ action: 'type',
1285
+ typed: true,
1286
+ text: textToType,
1287
+ currentValue: target.value || target.textContent?.substring(0, 100),
1288
+ };
1289
+ }
1290
+
1291
+ case 'clear': {
1292
+ const clearTarget = label
1293
+ ? findInputByLabel(scope, label)
1294
+ : name
1295
+ ? scope.querySelector(`[name="${CSS.escape(name)}"]`)
1296
+ : document.activeElement || scope.querySelector('input, textarea');
1297
+ if (!clearTarget) {
1298
+ return { error: 'No input found to clear' };
1299
+ }
1300
+ const prevVal = clearTarget.value;
1301
+ setInputValue(clearTarget, '');
1302
+ await new Promise(r => setTimeout(r, 50));
1303
+ return {
1304
+ action: 'clear',
1305
+ cleared: true,
1306
+ previousValue: prevVal,
1307
+ };
1308
+ }
1309
+
1310
+ case 'focus': {
1311
+ let focusTarget = null;
1312
+ if (label) {
1313
+ focusTarget = findInputByLabel(scope, label);
1314
+ } else if (text) {
1315
+ const all = scope.querySelectorAll('[tabindex], input, textarea, select, button, a[href]');
1316
+ for (const el of all) {
1317
+ if ((el.textContent || '').trim().toLowerCase().includes(text.toLowerCase())) {
1318
+ focusTarget = el;
1319
+ break;
1320
+ }
1321
+ }
1322
+ }
1323
+ if (!focusTarget) {
1324
+ return { error: 'No focusable element found' };
1325
+ }
1326
+ focusTarget.focus();
1327
+ return {
1328
+ action: 'focus',
1329
+ focused: true,
1330
+ element: { tag: focusTarget.tagName.toLowerCase(), id: focusTarget.id || undefined },
1331
+ isActiveElement: document.activeElement === focusTarget,
1332
+ };
1333
+ }
1334
+
1335
+ default:
1336
+ return { error: `Unknown action: ${action}. Available: submit_form, select_option, toggle, scroll_to, hover, type, clear, focus` };
1337
+ }
1338
+ }
1339
+
1340
+ // -------------------------------------------------------------------------
1341
+ // assert — State assertions for testing (no screenshots needed)
1342
+ // -------------------------------------------------------------------------
1343
+ case 'assert': {
1344
+ const { text, visible, componentId, signalName, signalId, value, selector, count, route, exists } = args || {};
1345
+ const assertions = [];
1346
+
1347
+ // Text assertion
1348
+ if (text != null) {
1349
+ const body = document.body;
1350
+ const bodyText = body?.textContent || '';
1351
+ const found = bodyText.includes(text);
1352
+ const isVis = found ? isTextVisible(body, text) : false;
1353
+ const assertion = {
1354
+ type: 'text',
1355
+ expected: text,
1356
+ found,
1357
+ pass: visible != null ? (visible ? (found && isVis) : (!found || !isVis)) : found,
1358
+ };
1359
+ if (visible != null) assertion.visible = isVis;
1360
+ assertions.push(assertion);
1361
+ }
1362
+
1363
+ // Signal value assertion
1364
+ if (signalId != null || signalName != null) {
1365
+ const registries = devtools?._registries;
1366
+ let sigEntry = null;
1367
+ if (signalId != null && registries?.signals) {
1368
+ sigEntry = registries.signals.get(signalId);
1369
+ } else if (signalName && registries?.signals) {
1370
+ for (const [, entry] of registries.signals) {
1371
+ if (entry.name === signalName) { sigEntry = entry; break; }
1372
+ }
1373
+ }
1374
+ if (sigEntry) {
1375
+ const currentVal = sigEntry.ref.peek();
1376
+ const assertion = {
1377
+ type: 'signal',
1378
+ signalId: sigEntry.id || sigEntry._devId,
1379
+ signalName: sigEntry.name,
1380
+ currentValue: devtools?.safeSerialize ? devtools.safeSerialize(currentVal) : currentVal,
1381
+ };
1382
+ if (value !== undefined) {
1383
+ assertion.expectedValue = value;
1384
+ assertion.pass = JSON.stringify(currentVal) === JSON.stringify(value);
1385
+ } else {
1386
+ assertion.pass = true; // Signal exists
1387
+ }
1388
+ assertions.push(assertion);
1389
+ } else {
1390
+ assertions.push({
1391
+ type: 'signal',
1392
+ pass: false,
1393
+ error: `Signal ${signalId != null ? `#${signalId}` : `"${signalName}"`} not found`,
1394
+ });
1395
+ }
1396
+ }
1397
+
1398
+ // Component assertion
1399
+ if (componentId != null && !signalName && value === undefined && !signalId) {
1400
+ const registries = devtools?._registries;
1401
+ const comp = registries?.components?.get(componentId);
1402
+ assertions.push({
1403
+ type: 'component',
1404
+ componentId,
1405
+ mounted: !!comp,
1406
+ name: comp?.name || null,
1407
+ pass: exists !== false ? !!comp : !comp,
1408
+ });
1409
+ }
1410
+
1411
+ // Selector count assertion
1412
+ if (selector != null) {
1413
+ const matched = document.querySelectorAll(selector);
1414
+ const assertion = {
1415
+ type: 'selector',
1416
+ selector,
1417
+ matchedCount: matched.length,
1418
+ };
1419
+ if (count != null) {
1420
+ assertion.expectedCount = count;
1421
+ assertion.pass = matched.length === count;
1422
+ } else {
1423
+ assertion.pass = matched.length > 0;
1424
+ }
1425
+ assertions.push(assertion);
1426
+ }
1427
+
1428
+ // Route assertion
1429
+ if (route != null) {
1430
+ const currentPath = window.location.pathname;
1431
+ assertions.push({
1432
+ type: 'route',
1433
+ expected: route,
1434
+ actual: currentPath,
1435
+ pass: currentPath === route,
1436
+ });
1437
+ }
1438
+
1439
+ const allPassed = assertions.every(a => a.pass);
1440
+ const failedCount = assertions.filter(a => !a.pass).length;
1441
+
1442
+ return {
1443
+ pass: allPassed,
1444
+ assertions,
1445
+ totalAssertions: assertions.length,
1446
+ passed: assertions.filter(a => a.pass).length,
1447
+ failed: failedCount,
1448
+ summary: allPassed
1449
+ ? `All ${assertions.length} assertion(s) passed.`
1450
+ : `${failedCount} of ${assertions.length} assertion(s) failed.`,
1451
+ };
1452
+ }
1453
+
1454
+ // -------------------------------------------------------------------------
1455
+ // wait — Wait for conditions (text, component, signal, idle)
1456
+ // -------------------------------------------------------------------------
1457
+ case 'wait': {
1458
+ const { text, gone, componentId, mounted, signalId, signalName, value, idle, timeout: waitTimeout } = args || {};
1459
+ const maxWait = Math.min(waitTimeout || 5000, 30000);
1460
+ const pollInterval = 100;
1461
+ const start = Date.now();
1462
+
1463
+ let conditionMet = false;
1464
+ let lastState = null;
1465
+
1466
+ while (Date.now() - start < maxWait) {
1467
+ // Check condition
1468
+ if (text != null) {
1469
+ const bodyText = document.body?.textContent || '';
1470
+ const found = bodyText.includes(text);
1471
+ conditionMet = gone ? !found : found;
1472
+ lastState = { text, found, waitingFor: gone ? 'gone' : 'present' };
1473
+ } else if (componentId != null) {
1474
+ const registries = devtools?._registries;
1475
+ const comp = registries?.components?.get(componentId);
1476
+ conditionMet = mounted !== false ? !!comp : !comp;
1477
+ lastState = { componentId, mounted: !!comp };
1478
+ } else if (signalId != null || signalName != null) {
1479
+ const registries = devtools?._registries;
1480
+ let sigEntry = null;
1481
+ if (signalId != null && registries?.signals) {
1482
+ sigEntry = registries.signals.get(signalId);
1483
+ } else if (signalName && registries?.signals) {
1484
+ for (const [, entry] of registries.signals) {
1485
+ if (entry.name === signalName) { sigEntry = entry; break; }
1486
+ }
1487
+ }
1488
+ if (sigEntry && value !== undefined) {
1489
+ const currentVal = sigEntry.ref.peek();
1490
+ conditionMet = JSON.stringify(currentVal) === JSON.stringify(value);
1491
+ lastState = {
1492
+ signalId: sigEntry.id,
1493
+ signalName: sigEntry.name,
1494
+ currentValue: devtools?.safeSerialize ? devtools.safeSerialize(currentVal) : currentVal,
1495
+ waitingForValue: value,
1496
+ };
1497
+ } else if (sigEntry) {
1498
+ conditionMet = true;
1499
+ lastState = { signalId: sigEntry.id, signalName: sigEntry.name, exists: true };
1500
+ }
1501
+ } else if (idle) {
1502
+ // Check if no effects have run recently (last 200ms)
1503
+ const recentEffects = _signalWriteLog.filter(w => Date.now() - w.timestamp < 200);
1504
+ conditionMet = recentEffects.length === 0;
1505
+ lastState = { idle: conditionMet, recentActivity: recentEffects.length };
1506
+ }
1507
+
1508
+ if (conditionMet) break;
1509
+ await new Promise(r => setTimeout(r, pollInterval));
1510
+ }
1511
+
1512
+ const elapsed = Date.now() - start;
1513
+
1514
+ return {
1515
+ conditionMet,
1516
+ elapsed,
1517
+ timedOut: !conditionMet,
1518
+ lastState,
1519
+ summary: conditionMet
1520
+ ? `Condition met after ${elapsed}ms.`
1521
+ : `Timed out after ${maxWait}ms. Last state: ${JSON.stringify(lastState)}`,
1522
+ };
1523
+ }
1524
+
1525
+ // -------------------------------------------------------------------------
1526
+ // enhanced-page-map — Full interactive element map with action hints
1527
+ // -------------------------------------------------------------------------
1528
+ case 'enhanced-page-map': {
1529
+ const maxElements = args?.maxElements || 300;
1530
+ let count = 0;
1531
+
1532
+ // Interactive elements with full detail
1533
+ const interactives = [];
1534
+ const interactiveEls = document.querySelectorAll(
1535
+ 'button, a[href], input, select, textarea, [role=button], [role=link], [role=checkbox], [role=switch], [role=tab], [role=menuitem], [contenteditable], summary, details'
1536
+ );
1537
+ for (const el of interactiveEls) {
1538
+ if (count >= maxElements) break;
1539
+ const rect = el.getBoundingClientRect();
1540
+ if (rect.width === 0 && rect.height === 0) continue;
1541
+
1542
+ const tag = el.tagName.toLowerCase();
1543
+ const entry = {
1544
+ tag,
1545
+ type: el.getAttribute('type') || undefined,
1546
+ role: el.getAttribute('role') || undefined,
1547
+ text: (el.textContent || '').trim().substring(0, 60) || undefined,
1548
+ label: el.getAttribute('aria-label') || undefined,
1549
+ name: el.getAttribute('name') || undefined,
1550
+ placeholder: el.getAttribute('placeholder') || undefined,
1551
+ testId: el.getAttribute('data-testid') || undefined,
1552
+ id: el.id || undefined,
1553
+ disabled: el.disabled || undefined,
1554
+ checked: el.checked !== undefined ? el.checked : undefined,
1555
+ value: (tag === 'input' || tag === 'textarea' || tag === 'select')
1556
+ ? (el.value || '').substring(0, 80) || undefined
1557
+ : undefined,
1558
+ required: el.required || undefined,
1559
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
1560
+ };
1561
+
1562
+ // Suggest interaction method
1563
+ if (tag === 'button' || el.getAttribute('role') === 'button') {
1564
+ entry.interactWith = 'what_click';
1565
+ entry.clickArgs = entry.testId ? { testId: entry.testId }
1566
+ : entry.text ? { text: entry.text }
1567
+ : entry.label ? { ariaLabel: entry.label }
1568
+ : undefined;
1569
+ } else if (tag === 'a') {
1570
+ entry.interactWith = 'what_click';
1571
+ entry.href = el.getAttribute('href') || undefined;
1572
+ entry.clickArgs = entry.text ? { text: entry.text } : entry.label ? { ariaLabel: entry.label } : undefined;
1573
+ } else if (tag === 'input' || tag === 'textarea') {
1574
+ entry.interactWith = 'what_fill';
1575
+ entry.fillArgs = entry.label ? { label: entry.label }
1576
+ : entry.name ? { name: entry.name }
1577
+ : entry.placeholder ? { placeholder: entry.placeholder }
1578
+ : undefined;
1579
+ if (el.type === 'checkbox' || el.type === 'radio') {
1580
+ entry.interactWith = 'what_interact';
1581
+ entry.interactArgs = { action: 'toggle', text: entry.text || entry.label };
1582
+ }
1583
+ } else if (tag === 'select') {
1584
+ entry.interactWith = 'what_interact';
1585
+ entry.options = Array.from(el.options || []).slice(0, 10).map(o => ({
1586
+ value: o.value,
1587
+ text: o.textContent.trim(),
1588
+ selected: o.selected,
1589
+ }));
1590
+ entry.interactArgs = { action: 'select_option', label: entry.label || entry.name };
1591
+ } else if (el.getAttribute('role') === 'checkbox' || el.getAttribute('role') === 'switch') {
1592
+ entry.interactWith = 'what_interact';
1593
+ entry.interactArgs = { action: 'toggle', text: entry.text || entry.label };
1594
+ }
1595
+
1596
+ // Clean undefined values
1597
+ Object.keys(entry).forEach(k => entry[k] === undefined && delete entry[k]);
1598
+
1599
+ interactives.push(entry);
1600
+ count++;
1601
+ }
1602
+
1603
+ // Forms with structure
1604
+ const forms = [];
1605
+ const formEls = document.querySelectorAll('form');
1606
+ for (const form of formEls) {
1607
+ if (count >= maxElements) break;
1608
+ const fields = [];
1609
+ const inputs = form.querySelectorAll('input, textarea, select');
1610
+ for (const input of inputs) {
1611
+ const fieldLabel = findLabelFor(input);
1612
+ fields.push({
1613
+ tag: input.tagName.toLowerCase(),
1614
+ type: input.type || undefined,
1615
+ name: input.name || undefined,
1616
+ label: fieldLabel || undefined,
1617
+ placeholder: input.placeholder || undefined,
1618
+ value: (input.value || '').substring(0, 60) || undefined,
1619
+ required: input.required || undefined,
1620
+ disabled: input.disabled || undefined,
1621
+ });
1622
+ // Clean undefined
1623
+ const f = fields[fields.length - 1];
1624
+ Object.keys(f).forEach(k => f[k] === undefined && delete f[k]);
1625
+ }
1626
+ forms.push({
1627
+ id: form.id || undefined,
1628
+ action: form.action || undefined,
1629
+ method: form.method || 'get',
1630
+ fields,
1631
+ fieldCount: fields.length,
1632
+ });
1633
+ count++;
1634
+ }
1635
+
1636
+ // Landmarks
1637
+ const landmarks = [];
1638
+ const landmarkEls = document.querySelectorAll('[role], header, footer, nav, main, aside, section, article');
1639
+ for (const el of landmarkEls) {
1640
+ if (count >= maxElements) break;
1641
+ const rect = el.getBoundingClientRect();
1642
+ if (rect.width === 0 && rect.height === 0) continue;
1643
+ landmarks.push({
1644
+ tag: el.tagName.toLowerCase(),
1645
+ role: el.getAttribute('role') || undefined,
1646
+ id: el.id || undefined,
1647
+ text: (el.textContent || '').trim().substring(0, 50),
1648
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
1649
+ });
1650
+ count++;
1651
+ }
1652
+
1653
+ // Headings
1654
+ const headings = [];
1655
+ const headingEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
1656
+ for (const el of headingEls) {
1657
+ if (count >= maxElements) break;
1658
+ headings.push({
1659
+ level: parseInt(el.tagName[1]),
1660
+ text: (el.textContent || '').trim().substring(0, 80),
1661
+ });
1662
+ count++;
1663
+ }
1664
+
1665
+ // What FW components
1666
+ const components = [];
1667
+ const registries = devtools?._registries;
1668
+ if (registries?.components) {
1669
+ for (const [id, entry] of registries.components) {
1670
+ if (count >= maxElements) break;
1671
+ const compEl = getComponentElement(entry);
1672
+ if (!compEl) continue;
1673
+ const rect = compEl.getBoundingClientRect();
1674
+ if (rect.width === 0 && rect.height === 0) continue;
1675
+
1676
+ // Count interactive children
1677
+ const buttons = compEl.querySelectorAll('button, [role=button]').length;
1678
+ const inputs = compEl.querySelectorAll('input, textarea, select').length;
1679
+ const links = compEl.querySelectorAll('a[href]').length;
1680
+
1681
+ components.push({
1682
+ id,
1683
+ name: entry.name,
1684
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
1685
+ interactiveChildren: { buttons, inputs, links },
1686
+ });
1687
+ count++;
1688
+ }
1689
+ }
1690
+
1691
+ return {
1692
+ viewport: { width: window.innerWidth, height: window.innerHeight },
1693
+ currentPath: window.location.pathname,
1694
+ interactives,
1695
+ forms,
1696
+ landmarks,
1697
+ headings,
1698
+ components,
1699
+ totalElements: count,
1700
+ summary: `${interactives.length} interactive elements, ${forms.length} forms, ${landmarks.length} landmarks, ${headings.length} headings, ${components.length} components`,
1701
+ };
1702
+ }
1703
+
200
1704
  // -------------------------------------------------------------------------
201
1705
  // Not handled — return null so caller falls through
202
1706
  // -------------------------------------------------------------------------
@@ -204,3 +1708,128 @@ export function handleExtendedCommand(command, args, devtools) {
204
1708
  return null;
205
1709
  }
206
1710
  }
1711
+
1712
+ // ---------------------------------------------------------------------------
1713
+ // Helper: Find input by its associated label text
1714
+ // ---------------------------------------------------------------------------
1715
+ function findInputByLabel(scope, labelText) {
1716
+ const lower = labelText.toLowerCase().trim();
1717
+ // Try <label> elements first
1718
+ const labels = scope.querySelectorAll('label');
1719
+ for (const lbl of labels) {
1720
+ if ((lbl.textContent || '').trim().toLowerCase().includes(lower)) {
1721
+ // Label with for="id"
1722
+ if (lbl.htmlFor) {
1723
+ const target = scope.querySelector(`#${CSS.escape(lbl.htmlFor)}`);
1724
+ if (target) return target;
1725
+ }
1726
+ // Label wrapping input
1727
+ const nested = lbl.querySelector('input, textarea, select');
1728
+ if (nested) return nested;
1729
+ }
1730
+ }
1731
+ // Try aria-label
1732
+ const ariaMatch = scope.querySelector(`[aria-label="${CSS.escape(labelText)}"]`);
1733
+ if (ariaMatch && (ariaMatch.tagName === 'INPUT' || ariaMatch.tagName === 'TEXTAREA' || ariaMatch.tagName === 'SELECT')) {
1734
+ return ariaMatch;
1735
+ }
1736
+ // Try placeholder
1737
+ const placeholderMatch = scope.querySelector(`[placeholder="${CSS.escape(labelText)}"]`);
1738
+ if (placeholderMatch) return placeholderMatch;
1739
+ return null;
1740
+ }
1741
+
1742
+ // ---------------------------------------------------------------------------
1743
+ // Helper: Find the label text for an input
1744
+ // ---------------------------------------------------------------------------
1745
+ function findLabelFor(input) {
1746
+ // Check for label with matching for="" attribute
1747
+ if (input.id) {
1748
+ const label = document.querySelector(`label[for="${CSS.escape(input.id)}"]`);
1749
+ if (label) return label.textContent.trim();
1750
+ }
1751
+ // Check for wrapping label
1752
+ const parentLabel = input.closest('label');
1753
+ if (parentLabel) {
1754
+ // Get label text without the input's own text
1755
+ const clone = parentLabel.cloneNode(true);
1756
+ const nested = clone.querySelectorAll('input, textarea, select');
1757
+ for (const n of nested) n.remove();
1758
+ return clone.textContent.trim() || null;
1759
+ }
1760
+ // Check aria-label
1761
+ return input.getAttribute('aria-label') || null;
1762
+ }
1763
+
1764
+ // ---------------------------------------------------------------------------
1765
+ // Helper: Set input value with proper events
1766
+ // ---------------------------------------------------------------------------
1767
+ function setInputValue(input, val) {
1768
+ // Use native setter to bypass React/framework wrappers.
1769
+ // Only HTMLInputElement and HTMLTextAreaElement have overridable value setters;
1770
+ // <select> and other elements just use .value directly.
1771
+ const tag = input.tagName;
1772
+ let nativeInputValueSetter = null;
1773
+ if (tag === 'TEXTAREA') {
1774
+ nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
1775
+ } else if (tag === 'INPUT') {
1776
+ nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
1777
+ }
1778
+ if (nativeInputValueSetter) {
1779
+ nativeInputValueSetter.call(input, val);
1780
+ } else {
1781
+ input.value = val;
1782
+ }
1783
+ input.dispatchEvent(new Event('input', { bubbles: true }));
1784
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1785
+ }
1786
+
1787
+ // ---------------------------------------------------------------------------
1788
+ // Helper: Find checkbox/radio by text near it
1789
+ // ---------------------------------------------------------------------------
1790
+ function findCheckableByText(scope, text) {
1791
+ const lower = text.toLowerCase().trim();
1792
+ // Try labels
1793
+ const labels = scope.querySelectorAll('label');
1794
+ for (const lbl of labels) {
1795
+ if ((lbl.textContent || '').trim().toLowerCase().includes(lower)) {
1796
+ const input = lbl.querySelector('input[type=checkbox], input[type=radio]');
1797
+ if (input) return input;
1798
+ if (lbl.htmlFor) {
1799
+ const target = scope.querySelector(`#${CSS.escape(lbl.htmlFor)}`);
1800
+ if (target) return target;
1801
+ }
1802
+ // The label itself might be a toggle (role=switch)
1803
+ if (lbl.getAttribute('role') === 'switch' || lbl.getAttribute('role') === 'checkbox') return lbl;
1804
+ }
1805
+ }
1806
+ // Try role=switch or role=checkbox with text
1807
+ const switches = scope.querySelectorAll('[role=switch], [role=checkbox]');
1808
+ for (const s of switches) {
1809
+ if ((s.textContent || '').trim().toLowerCase().includes(lower) ||
1810
+ (s.getAttribute('aria-label') || '').toLowerCase().includes(lower)) {
1811
+ return s;
1812
+ }
1813
+ }
1814
+ return null;
1815
+ }
1816
+
1817
+ // ---------------------------------------------------------------------------
1818
+ // Helper: Check if text is visible on the page
1819
+ // ---------------------------------------------------------------------------
1820
+ function isTextVisible(root, text) {
1821
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1822
+ while (walker.nextNode()) {
1823
+ const node = walker.currentNode;
1824
+ if (node.textContent.includes(text)) {
1825
+ let el = node.parentElement;
1826
+ while (el) {
1827
+ const cs = window.getComputedStyle(el);
1828
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
1829
+ el = el.parentElement;
1830
+ }
1831
+ return true;
1832
+ }
1833
+ }
1834
+ return false;
1835
+ }