what-devtools-mcp 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/bridge.js +66 -2
- package/src/client-commands.js +604 -5
- package/src/client.js +53 -12
- package/src/index.js +450 -0
- package/src/tools-agent.js +183 -2
- package/src/tools-extended.js +408 -10
- package/src/tools.js +103 -14
- package/src/vite-plugin.js +32 -2
package/src/client-commands.js
CHANGED
|
@@ -1,30 +1,151 @@
|
|
|
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
|
+
const code = (args.code || '').trim();
|
|
136
|
+
const isSafeRead = /^[\w.[\]'"]+$/.test(code) || // property access: document.title, window.innerWidth
|
|
137
|
+
/^typeof\s+\w+/.test(code) || // typeof checks
|
|
138
|
+
/^document\.(title|URL|readyState|visibilityState|characterSet|contentType)$/.test(code) ||
|
|
139
|
+
/^window\.(innerWidth|innerHeight|devicePixelRatio|screen\.\w+)$/.test(code) ||
|
|
140
|
+
/^navigator\.\w+$/.test(code) ||
|
|
141
|
+
/^location\.\w+$/.test(code);
|
|
142
|
+
|
|
143
|
+
if (!evalEnabled && !isSafeRead) {
|
|
144
|
+
return {
|
|
145
|
+
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.',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
28
149
|
const start = performance.now();
|
|
29
150
|
try {
|
|
30
151
|
// Use Function constructor to execute in global scope
|
|
@@ -61,7 +182,7 @@ export function handleExtendedCommand(command, args, devtools) {
|
|
|
61
182
|
return { error: `Component ${componentId} not found` };
|
|
62
183
|
}
|
|
63
184
|
|
|
64
|
-
const el = entry
|
|
185
|
+
const el = getComponentElement(entry);
|
|
65
186
|
if (!el) {
|
|
66
187
|
return { error: `Component "${entry.name}" (id: ${componentId}) has no DOM element` };
|
|
67
188
|
}
|
|
@@ -197,6 +318,484 @@ export function handleExtendedCommand(command, args, devtools) {
|
|
|
197
318
|
}
|
|
198
319
|
}
|
|
199
320
|
|
|
321
|
+
// -------------------------------------------------------------------------
|
|
322
|
+
// validate-code — Compile or statically analyse a code snippet
|
|
323
|
+
// -------------------------------------------------------------------------
|
|
324
|
+
case 'validate-code': {
|
|
325
|
+
const { code, format } = args || {};
|
|
326
|
+
if (!code) return { valid: false, errors: [{ message: 'No code provided' }], warnings: [] };
|
|
327
|
+
|
|
328
|
+
const errors = [];
|
|
329
|
+
const warnings = [];
|
|
330
|
+
|
|
331
|
+
// 1. Try the Babel/What compiler if available on window
|
|
332
|
+
if (typeof window !== 'undefined' && window.__WHAT_COMPILER__) {
|
|
333
|
+
try {
|
|
334
|
+
const result = window.__WHAT_COMPILER__.compile(code, { format: format || 'jsx' });
|
|
335
|
+
return {
|
|
336
|
+
valid: !result.errors || result.errors.length === 0,
|
|
337
|
+
output: result.output || result.code || null,
|
|
338
|
+
errors: result.errors || [],
|
|
339
|
+
warnings: result.warnings || [],
|
|
340
|
+
};
|
|
341
|
+
} catch (e) {
|
|
342
|
+
// Compiler threw — fall through to static analysis
|
|
343
|
+
errors.push({ message: `Compiler error: ${e.message}` });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 2. Static analysis fallback
|
|
348
|
+
|
|
349
|
+
// --- Bracket/brace matching ---
|
|
350
|
+
const brackets = { '(': ')', '[': ']', '{': '}' };
|
|
351
|
+
const closers = new Set([')', ']', '}']);
|
|
352
|
+
const stack = [];
|
|
353
|
+
// Strip string literals and comments to avoid false positives
|
|
354
|
+
const stripped = code
|
|
355
|
+
.replace(/\/\/[^\n]*/g, '')
|
|
356
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
357
|
+
.replace(/(["'`])(?:\\[\s\S]|(?!\1)[^\\])*\1/g, '');
|
|
358
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
359
|
+
const ch = stripped[i];
|
|
360
|
+
if (brackets[ch]) {
|
|
361
|
+
stack.push({ char: ch, pos: i });
|
|
362
|
+
} else if (closers.has(ch)) {
|
|
363
|
+
const last = stack.pop();
|
|
364
|
+
if (!last) {
|
|
365
|
+
errors.push({ message: `Unexpected '${ch}' at position ${i}`, pos: i });
|
|
366
|
+
} else if (brackets[last.char] !== ch) {
|
|
367
|
+
errors.push({ message: `Mismatched '${last.char}' at position ${last.pos} and '${ch}' at position ${i}`, pos: i });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (stack.length > 0) {
|
|
372
|
+
for (const item of stack) {
|
|
373
|
+
errors.push({ message: `Unclosed '${item.char}' at position ${item.pos}`, pos: item.pos });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// --- Common import errors ---
|
|
378
|
+
const importFromWhat = code.match(/import\s*\{([^}]+)\}\s*from\s*['"]what['"]/g);
|
|
379
|
+
if (importFromWhat) {
|
|
380
|
+
warnings.push({ message: "Import from 'what' detected. Use 'what-framework' instead.", rule: 'import-path' });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// --- Lint patterns ---
|
|
384
|
+
|
|
385
|
+
// 1. Signal read without () — look for JSX expressions like {count} where count is likely a signal
|
|
386
|
+
const signalWithoutCall = stripped.match(/\{(\s*[a-z][a-zA-Z0-9_]*\s*)\}/g);
|
|
387
|
+
if (signalWithoutCall) {
|
|
388
|
+
for (const match of signalWithoutCall) {
|
|
389
|
+
const name = match.replace(/[{}\s]/g, '');
|
|
390
|
+
// Heuristic: if the same name appears with () elsewhere, it's a signal used without ()
|
|
391
|
+
if (stripped.includes(`${name}(`) && !match.includes('(')) {
|
|
392
|
+
warnings.push({ message: `Possible signal '${name}' used without () — renders as [Function]. Use {${name}()}.`, rule: 'signal-read-without-call' });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 2. innerHTML without __html marker
|
|
398
|
+
if (/innerHTML\s*=/.test(stripped) && !/__html/.test(stripped)) {
|
|
399
|
+
warnings.push({ message: 'innerHTML set without __html safety marker. XSS risk. Use { __html: content }.', rule: 'unsafe-innerhtml' });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 3. Effect cycle — effect that reads and writes the same signal
|
|
403
|
+
const effectBodies = code.matchAll(/effect\s*\(\s*\(\s*\)\s*=>\s*\{([^}]+)\}/g);
|
|
404
|
+
for (const m of effectBodies) {
|
|
405
|
+
const body = m[1];
|
|
406
|
+
// Find signal names that appear as both read sig() and write sig(value)
|
|
407
|
+
const reads = [...body.matchAll(/(\w+)\(\)/g)].map(r => r[1]);
|
|
408
|
+
const writes = [...body.matchAll(/(\w+)\([^)]+\)/g)].map(r => r[1]);
|
|
409
|
+
for (const name of reads) {
|
|
410
|
+
if (writes.includes(name) && name !== 'untrack') {
|
|
411
|
+
warnings.push({ message: `Potential effect cycle: '${name}' is both read and written inside effect. Use untrack() for the read.`, rule: 'effect-cycle' });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 4. Missing cleanup — effect with addEventListener but no removeEventListener
|
|
417
|
+
if (/effect\s*\(/.test(code) && /addEventListener/.test(code) && !/removeEventListener/.test(code)) {
|
|
418
|
+
warnings.push({ message: 'Effect adds event listener but no cleanup detected (missing removeEventListener). Return a cleanup function.', rule: 'missing-cleanup' });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
valid: errors.length === 0,
|
|
423
|
+
output: null,
|
|
424
|
+
errors,
|
|
425
|
+
warnings,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// -------------------------------------------------------------------------
|
|
430
|
+
// get-app-info — Return app metadata for bootstrap
|
|
431
|
+
// -------------------------------------------------------------------------
|
|
432
|
+
case 'get-app-info': {
|
|
433
|
+
return {
|
|
434
|
+
url: window.location.href,
|
|
435
|
+
title: document.title,
|
|
436
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
437
|
+
// Try to detect framework version
|
|
438
|
+
version: window.__WHAT_CORE__?.version || window.__WHAT_DEVTOOLS__?.version || 'unknown',
|
|
439
|
+
// Get the entry point from Vite's module graph if available
|
|
440
|
+
entryPoint: document.querySelector('script[type="module"][src]')?.getAttribute('src') ||
|
|
441
|
+
document.querySelector('script[type="module"]')?.textContent?.match(/from ['"]([^'"]+)['"]/)?.[1] || 'unknown',
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// -------------------------------------------------------------------------
|
|
446
|
+
// visual-inspect — Computed visual info about a component (no image)
|
|
447
|
+
// -------------------------------------------------------------------------
|
|
448
|
+
case 'visual-inspect': {
|
|
449
|
+
const { componentId } = args || {};
|
|
450
|
+
const registries = devtools?._registries;
|
|
451
|
+
if (!registries?.components) return { error: 'DevTools registries not available' };
|
|
452
|
+
|
|
453
|
+
const entry = registries.components.get(componentId);
|
|
454
|
+
if (!entry) return { error: `Component ${componentId} not found` };
|
|
455
|
+
|
|
456
|
+
const el = getComponentElement(entry);
|
|
457
|
+
if (!el) return { error: `Component "${entry.name}" has no DOM element` };
|
|
458
|
+
|
|
459
|
+
const rect = el.getBoundingClientRect();
|
|
460
|
+
const cs = window.getComputedStyle(el);
|
|
461
|
+
|
|
462
|
+
// Key computed styles
|
|
463
|
+
const styles = {
|
|
464
|
+
display: cs.display,
|
|
465
|
+
position: cs.position,
|
|
466
|
+
flexDirection: cs.flexDirection !== 'row' ? cs.flexDirection : undefined,
|
|
467
|
+
flexWrap: cs.flexWrap !== 'nowrap' ? cs.flexWrap : undefined,
|
|
468
|
+
gridTemplateColumns: cs.gridTemplateColumns !== 'none' ? cs.gridTemplateColumns : undefined,
|
|
469
|
+
gridTemplateRows: cs.gridTemplateRows !== 'none' ? cs.gridTemplateRows : undefined,
|
|
470
|
+
backgroundColor: cs.backgroundColor,
|
|
471
|
+
color: cs.color,
|
|
472
|
+
fontSize: cs.fontSize,
|
|
473
|
+
fontFamily: cs.fontFamily?.split(',')[0]?.trim(),
|
|
474
|
+
padding: cs.padding,
|
|
475
|
+
margin: cs.margin,
|
|
476
|
+
border: cs.border !== 'none' && cs.borderWidth !== '0px' ? cs.border : undefined,
|
|
477
|
+
borderRadius: cs.borderRadius !== '0px' ? cs.borderRadius : undefined,
|
|
478
|
+
zIndex: cs.zIndex !== 'auto' ? cs.zIndex : undefined,
|
|
479
|
+
opacity: cs.opacity !== '1' ? cs.opacity : undefined,
|
|
480
|
+
overflow: cs.overflow !== 'visible' ? cs.overflow : undefined,
|
|
481
|
+
visibility: cs.visibility !== 'visible' ? cs.visibility : undefined,
|
|
482
|
+
width: cs.width,
|
|
483
|
+
height: cs.height,
|
|
484
|
+
maxWidth: cs.maxWidth !== 'none' ? cs.maxWidth : undefined,
|
|
485
|
+
};
|
|
486
|
+
// Remove undefined values
|
|
487
|
+
Object.keys(styles).forEach(k => styles[k] === undefined && delete styles[k]);
|
|
488
|
+
|
|
489
|
+
// Text content preview
|
|
490
|
+
const textContent = (el.textContent || '').trim().substring(0, 200);
|
|
491
|
+
|
|
492
|
+
// Child element types
|
|
493
|
+
const childTypes = {};
|
|
494
|
+
const selectors = ['button', 'a', 'input', 'select', 'textarea', 'img', 'form', 'table', 'ul', 'ol', 'video', 'canvas', 'svg'];
|
|
495
|
+
for (const sel of selectors) {
|
|
496
|
+
const count = el.querySelectorAll(sel).length;
|
|
497
|
+
if (count > 0) childTypes[sel] = count;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Accessibility info
|
|
501
|
+
const a11y = {};
|
|
502
|
+
const role = el.getAttribute('role');
|
|
503
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
504
|
+
const tabIndex = el.getAttribute('tabindex');
|
|
505
|
+
if (role) a11y.role = role;
|
|
506
|
+
if (ariaLabel) a11y.ariaLabel = ariaLabel;
|
|
507
|
+
if (tabIndex) a11y.tabIndex = tabIndex;
|
|
508
|
+
|
|
509
|
+
// Layout classification
|
|
510
|
+
let layout = styles.display || 'block';
|
|
511
|
+
const childCount = el.children.length;
|
|
512
|
+
if (cs.display === 'flex') {
|
|
513
|
+
layout = `flex ${cs.flexDirection === 'column' ? 'column' : 'row'} with ${childCount} children`;
|
|
514
|
+
} else if (cs.display === 'grid') {
|
|
515
|
+
const cols = cs.gridTemplateColumns.split(' ').length;
|
|
516
|
+
const rows = cs.gridTemplateRows.split(' ').length;
|
|
517
|
+
layout = `grid ${cols}×${rows} with ${childCount} children`;
|
|
518
|
+
} else if (cs.display === 'block' || cs.display === 'flow-root') {
|
|
519
|
+
layout = `block with ${childCount} children`;
|
|
520
|
+
} else if (cs.display === 'inline-flex' || cs.display === 'inline-block') {
|
|
521
|
+
layout = `${cs.display} with ${childCount} children`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
componentName: entry.name,
|
|
526
|
+
componentId,
|
|
527
|
+
boundingRect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
|
528
|
+
styles,
|
|
529
|
+
textContent: textContent || '(empty)',
|
|
530
|
+
childElements: childTypes,
|
|
531
|
+
totalChildren: childCount,
|
|
532
|
+
accessibility: Object.keys(a11y).length > 0 ? a11y : undefined,
|
|
533
|
+
layout,
|
|
534
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// -------------------------------------------------------------------------
|
|
539
|
+
// page-map — Structured map of the entire visible page
|
|
540
|
+
// -------------------------------------------------------------------------
|
|
541
|
+
case 'page-map': {
|
|
542
|
+
const maxElements = args?.maxElements || 200;
|
|
543
|
+
let count = 0;
|
|
544
|
+
|
|
545
|
+
// Landmarks
|
|
546
|
+
const landmarks = [];
|
|
547
|
+
const landmarkEls = document.querySelectorAll('[role], header, footer, nav, main, aside, section, article');
|
|
548
|
+
for (const el of landmarkEls) {
|
|
549
|
+
if (count >= maxElements) break;
|
|
550
|
+
const rect = el.getBoundingClientRect();
|
|
551
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
552
|
+
landmarks.push({
|
|
553
|
+
tag: el.tagName.toLowerCase(),
|
|
554
|
+
role: el.getAttribute('role') || undefined,
|
|
555
|
+
id: el.id || undefined,
|
|
556
|
+
text: (el.textContent || '').trim().substring(0, 50),
|
|
557
|
+
rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
|
|
558
|
+
});
|
|
559
|
+
count++;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Interactive elements
|
|
563
|
+
const interactives = [];
|
|
564
|
+
const interactiveEls = document.querySelectorAll('button, a[href], input, select, textarea, [role=button], [role=link], [contenteditable]');
|
|
565
|
+
for (const el of interactiveEls) {
|
|
566
|
+
if (count >= maxElements) break;
|
|
567
|
+
const rect = el.getBoundingClientRect();
|
|
568
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
569
|
+
const label = el.getAttribute('aria-label') || el.textContent?.trim().substring(0, 40) || el.getAttribute('placeholder') || el.getAttribute('name') || '';
|
|
570
|
+
interactives.push({
|
|
571
|
+
tag: el.tagName.toLowerCase(),
|
|
572
|
+
type: el.getAttribute('type') || undefined,
|
|
573
|
+
label: label || '(unlabeled)',
|
|
574
|
+
disabled: el.disabled || undefined,
|
|
575
|
+
rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
|
|
576
|
+
});
|
|
577
|
+
count++;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Headings
|
|
581
|
+
const headings = [];
|
|
582
|
+
const headingEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
583
|
+
for (const el of headingEls) {
|
|
584
|
+
if (count >= maxElements) break;
|
|
585
|
+
headings.push({
|
|
586
|
+
level: parseInt(el.tagName[1]),
|
|
587
|
+
text: (el.textContent || '').trim().substring(0, 80),
|
|
588
|
+
});
|
|
589
|
+
count++;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// WhatFW component boundaries
|
|
593
|
+
const components = [];
|
|
594
|
+
const registries = devtools?._registries;
|
|
595
|
+
if (registries?.components) {
|
|
596
|
+
for (const [id, entry] of registries.components) {
|
|
597
|
+
if (count >= maxElements) break;
|
|
598
|
+
const compEl = getComponentElement(entry);
|
|
599
|
+
if (!compEl) continue;
|
|
600
|
+
const rect = compEl.getBoundingClientRect();
|
|
601
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
602
|
+
components.push({
|
|
603
|
+
id,
|
|
604
|
+
name: entry.name,
|
|
605
|
+
rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
|
|
606
|
+
});
|
|
607
|
+
count++;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return {
|
|
612
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
613
|
+
landmarks,
|
|
614
|
+
interactives,
|
|
615
|
+
headings,
|
|
616
|
+
components,
|
|
617
|
+
totalElements: count,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// -------------------------------------------------------------------------
|
|
622
|
+
// get-signal-writers — Correlate signal writes with effect runs
|
|
623
|
+
// Uses the module-level ring buffer populated by initEventTracking().
|
|
624
|
+
// -------------------------------------------------------------------------
|
|
625
|
+
case 'get-signal-writers': {
|
|
626
|
+
const { signalId } = args || {};
|
|
627
|
+
const registries = devtools?._registries;
|
|
628
|
+
|
|
629
|
+
if (!registries?.signals) {
|
|
630
|
+
return { error: 'DevTools registries not available' };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const sigEntry = registries.signals.get(signalId);
|
|
634
|
+
if (!sigEntry) {
|
|
635
|
+
return { error: `Signal ${signalId} not found` };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Lazily initialize event tracking on first call
|
|
639
|
+
initEventTracking(devtools);
|
|
640
|
+
|
|
641
|
+
// Filter the write log for this signal
|
|
642
|
+
const writes = _signalWriteLog
|
|
643
|
+
.filter(w => w.signalId === signalId)
|
|
644
|
+
.slice(-20); // Last 20 writes
|
|
645
|
+
|
|
646
|
+
const totalWrites = _signalWriteLog.filter(w => w.signalId === signalId).length;
|
|
647
|
+
|
|
648
|
+
return {
|
|
649
|
+
signalId,
|
|
650
|
+
signalName: sigEntry.name,
|
|
651
|
+
currentValue: devtools.safeSerialize
|
|
652
|
+
? devtools.safeSerialize(sigEntry.ref.peek())
|
|
653
|
+
: sigEntry.ref.peek(),
|
|
654
|
+
recentWrites: writes,
|
|
655
|
+
totalWrites,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// -------------------------------------------------------------------------
|
|
660
|
+
// component-screenshot — Render component to base64 image via foreignObject
|
|
661
|
+
// -------------------------------------------------------------------------
|
|
662
|
+
case 'component-screenshot': {
|
|
663
|
+
const { componentId, maxWidth = 400, quality = 0.7, format = 'jpeg' } = args || {};
|
|
664
|
+
const registries = devtools?._registries;
|
|
665
|
+
|
|
666
|
+
if (!registries?.components) return { error: 'DevTools registries not available' };
|
|
667
|
+
|
|
668
|
+
const entry = registries.components.get(componentId);
|
|
669
|
+
if (!entry) return { error: `Component ${componentId} not found` };
|
|
670
|
+
|
|
671
|
+
const el = getComponentElement(entry);
|
|
672
|
+
if (!el) return { error: `Component "${entry.name}" has no DOM element` };
|
|
673
|
+
|
|
674
|
+
try {
|
|
675
|
+
const rect = el.getBoundingClientRect();
|
|
676
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
677
|
+
return { error: `Component "${entry.name}" has zero dimensions (${rect.width}x${rect.height}). It may be hidden.` };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Clone and inline styles so foreignObject renders correctly
|
|
681
|
+
const clone = el.cloneNode(true);
|
|
682
|
+
|
|
683
|
+
function inlineStyles(source, target) {
|
|
684
|
+
const cs = window.getComputedStyle(source);
|
|
685
|
+
const importantProps = [
|
|
686
|
+
'display', 'position', 'top', 'left', 'right', 'bottom',
|
|
687
|
+
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
|
|
688
|
+
'margin', 'padding', 'border', 'border-radius',
|
|
689
|
+
'background', 'background-color', 'background-image',
|
|
690
|
+
'color', 'font-family', 'font-size', 'font-weight', 'line-height', 'text-align', 'text-decoration',
|
|
691
|
+
'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'gap',
|
|
692
|
+
'grid-template-columns', 'grid-template-rows',
|
|
693
|
+
'overflow', 'opacity', 'visibility', 'z-index',
|
|
694
|
+
'box-shadow', 'text-shadow', 'transform',
|
|
695
|
+
'white-space', 'word-break', 'letter-spacing',
|
|
696
|
+
];
|
|
697
|
+
for (const prop of importantProps) {
|
|
698
|
+
const val = cs.getPropertyValue(prop);
|
|
699
|
+
if (val && val !== '' && val !== 'none' && val !== 'normal' && val !== 'auto' && val !== '0px') {
|
|
700
|
+
target.style.setProperty(prop, val);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const sourceChildren = source.children;
|
|
704
|
+
const targetChildren = target.children;
|
|
705
|
+
const maxChildren = Math.min(sourceChildren.length, targetChildren.length, 100);
|
|
706
|
+
for (let i = 0; i < maxChildren; i++) {
|
|
707
|
+
inlineStyles(sourceChildren[i], targetChildren[i]);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
inlineStyles(el, clone);
|
|
712
|
+
|
|
713
|
+
// Reset position so it renders at 0,0 inside the SVG
|
|
714
|
+
clone.style.position = 'static';
|
|
715
|
+
clone.style.margin = '0';
|
|
716
|
+
|
|
717
|
+
// Serialize to SVG foreignObject
|
|
718
|
+
const serialized = new XMLSerializer().serializeToString(clone);
|
|
719
|
+
const svgWidth = Math.ceil(rect.width);
|
|
720
|
+
const svgHeight = Math.ceil(rect.height);
|
|
721
|
+
|
|
722
|
+
const svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgWidth}" height="${svgHeight}">
|
|
723
|
+
<foreignObject width="100%" height="100%">
|
|
724
|
+
<div xmlns="http://www.w3.org/1999/xhtml" style="width:${svgWidth}px;height:${svgHeight}px;overflow:hidden;">
|
|
725
|
+
${serialized}
|
|
726
|
+
</div>
|
|
727
|
+
</foreignObject>
|
|
728
|
+
</svg>`;
|
|
729
|
+
|
|
730
|
+
// Render SVG to canvas
|
|
731
|
+
const dpr = window.devicePixelRatio || 1;
|
|
732
|
+
const scale = Math.min(1, maxWidth / svgWidth);
|
|
733
|
+
const canvasWidth = Math.ceil(svgWidth * scale * dpr);
|
|
734
|
+
const canvasHeight = Math.ceil(svgHeight * scale * dpr);
|
|
735
|
+
|
|
736
|
+
const canvas = document.createElement('canvas');
|
|
737
|
+
canvas.width = canvasWidth;
|
|
738
|
+
canvas.height = canvasHeight;
|
|
739
|
+
const ctx = canvas.getContext('2d');
|
|
740
|
+
ctx.scale(scale * dpr, scale * dpr);
|
|
741
|
+
|
|
742
|
+
// Load SVG blob as image
|
|
743
|
+
const img = new Image();
|
|
744
|
+
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
|
745
|
+
const url = URL.createObjectURL(blob);
|
|
746
|
+
|
|
747
|
+
await new Promise((resolve, reject) => {
|
|
748
|
+
img.onload = resolve;
|
|
749
|
+
img.onerror = () => reject(new Error('SVG rendering failed — component may contain cross-origin resources'));
|
|
750
|
+
img.src = url;
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
ctx.drawImage(img, 0, 0);
|
|
754
|
+
URL.revokeObjectURL(url);
|
|
755
|
+
|
|
756
|
+
// Export to base64
|
|
757
|
+
const mimeType = format === 'png' ? 'image/png' : 'image/jpeg';
|
|
758
|
+
let dataUrl = canvas.toDataURL(mimeType, format === 'png' ? undefined : quality);
|
|
759
|
+
let base64 = dataUrl.split(',')[1];
|
|
760
|
+
let sizeBytes = Math.ceil(base64.length * 3 / 4);
|
|
761
|
+
|
|
762
|
+
// Size safety: if over 100KB, reduce quality then dimensions
|
|
763
|
+
if (sizeBytes > 102400 && format !== 'png') {
|
|
764
|
+
dataUrl = canvas.toDataURL('image/jpeg', 0.3);
|
|
765
|
+
base64 = dataUrl.split(',')[1];
|
|
766
|
+
sizeBytes = Math.ceil(base64.length * 3 / 4);
|
|
767
|
+
}
|
|
768
|
+
if (sizeBytes > 102400) {
|
|
769
|
+
const smallCanvas = document.createElement('canvas');
|
|
770
|
+
smallCanvas.width = Math.ceil(canvasWidth / 2);
|
|
771
|
+
smallCanvas.height = Math.ceil(canvasHeight / 2);
|
|
772
|
+
const smallCtx = smallCanvas.getContext('2d');
|
|
773
|
+
smallCtx.drawImage(canvas, 0, 0, smallCanvas.width, smallCanvas.height);
|
|
774
|
+
dataUrl = smallCanvas.toDataURL('image/jpeg', 0.3);
|
|
775
|
+
base64 = dataUrl.split(',')[1];
|
|
776
|
+
sizeBytes = Math.ceil(base64.length * 3 / 4);
|
|
777
|
+
}
|
|
778
|
+
if (sizeBytes > 102400) {
|
|
779
|
+
return { error: 'Screenshot exceeds 100KB even after reduction. Use what_look for text-based visual info instead.' };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
base64,
|
|
784
|
+
format: format === 'png' ? 'png' : 'jpeg',
|
|
785
|
+
mimeType,
|
|
786
|
+
width: Math.round(svgWidth * scale),
|
|
787
|
+
height: Math.round(svgHeight * scale),
|
|
788
|
+
sizeBytes,
|
|
789
|
+
componentName: entry.name,
|
|
790
|
+
};
|
|
791
|
+
} catch (e) {
|
|
792
|
+
return {
|
|
793
|
+
error: `Screenshot failed: ${e.message}`,
|
|
794
|
+
fallback: 'Use what_look for text-based visual inspection without an image.',
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
200
799
|
// -------------------------------------------------------------------------
|
|
201
800
|
// Not handled — return null so caller falls through
|
|
202
801
|
// -------------------------------------------------------------------------
|