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.
- package/package.json +3 -3
- package/src/bridge.js +66 -2
- package/src/client-commands.js +1636 -7
- package/src/client.js +53 -12
- package/src/index.js +478 -0
- package/src/tools-agent.js +183 -2
- package/src/tools-extended.js +426 -10
- package/src/tools-interact.js +420 -0
- package/src/tools.js +110 -14
- package/src/vite-plugin.js +44 -6
package/src/client-commands.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
+
}
|