//! Command implementations for debug server
mod input;
mod navigation;
pub mod screenshot;
mod script;
mod snapshot;
/// JavaScript code to build accessibility tree snapshot
/// Note: This code is wrapped by eval_with_result, so it should end with a return statement
pub const SNAPSHOT_JS: &str = r#"
let refCounter = 0;
const refMap = new Map();
// Store ref map globally for later use (click by ref, etc.)
window.__MCP_REF_MAP__ = refMap;
function getRole(el) {
// Explicit ARIA role
if (el.getAttribute('role')) return el.getAttribute('role');
// Implicit roles based on tag
const tag = el.tagName.toLowerCase();
const type = el.getAttribute('type');
const roleMap = {
'a': el.href ? 'link' : null,
'button': 'button',
'input': {
'text': 'textbox',
'email': 'textbox',
'password': 'textbox',
'search': 'searchbox',
'tel': 'textbox',
'url': 'textbox',
'number': 'spinbutton',
'checkbox': 'checkbox',
'radio': 'radio',
'submit': 'button',
'button': 'button',
'reset': 'button',
'range': 'slider',
},
'select': 'combobox',
'textarea': 'textbox',
'img': 'img',
'h1': 'heading',
'h2': 'heading',
'h3': 'heading',
'h4': 'heading',
'h5': 'heading',
'h6': 'heading',
'ul': 'list',
'ol': 'list',
'li': 'listitem',
'table': 'table',
'tr': 'row',
'td': 'cell',
'th': 'columnheader',
'nav': 'navigation',
'main': 'main',
'header': 'banner',
'footer': 'contentinfo',
'aside': 'complementary',
'form': 'form',
'dialog': 'dialog',
'article': 'article',
'section': 'region',
};
if (tag === 'input') {
return roleMap['input'][type] || 'textbox';
}
return roleMap[tag] || null;
}
function getAccessibleName(el) {
// aria-label
if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');
// aria-labelledby
const labelledBy = el.getAttribute('aria-labelledby');
if (labelledBy) {
const labelEl = document.getElementById(labelledBy);
if (labelEl) return labelEl.textContent.trim();
}
// label for input
if (el.id) {
const label = document.querySelector(`label[for="${el.id}"]`);
if (label) return label.textContent.trim();
}
// placeholder
if (el.placeholder) return el.placeholder;
// alt for images
if (el.alt) return el.alt;
// title
if (el.title) return el.title;
// Direct text content for certain elements
const tag = el.tagName.toLowerCase();
if (['button', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label', 'li'].includes(tag)) {
const text = el.textContent.trim();
if (text && text.length < 100) return text;
}
return null;
}
function isVisible(el) {
if (!el.offsetParent && el.tagName.toLowerCase() !== 'body') {
const pos = window.getComputedStyle(el).position;
if (pos !== 'fixed' && pos !== 'sticky') return false;
}
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
return true;
}
function isInteractive(el) {
const tag = el.tagName.toLowerCase();
const interactiveTags = ['a', 'button', 'input', 'select', 'textarea'];
if (interactiveTags.includes(tag)) return true;
if (el.getAttribute('role') === 'button') return true;
if (el.onclick || el.getAttribute('onclick')) return true;
if (el.tabIndex >= 0) return true;
return false;
}
function buildTree(el, depth = 0) {
if (!el || el.nodeType !== Node.ELEMENT_NODE) return null;
if (!isVisible(el)) return null;
const role = getRole(el);
const name = getAccessibleName(el);
const isInter = isInteractive(el);
// Skip non-semantic elements unless they have children worth showing
const skipTags = ['div', 'span', 'script', 'style', 'noscript', 'svg', 'path'];
const tag = el.tagName.toLowerCase();
let children = [];
for (const child of el.children) {
const childNode = buildTree(child, depth + 1);
if (childNode) children.push(childNode);
}
// Skip non-semantic containers with single child (flatten)
if (skipTags.includes(tag) && !role && !isInter && children.length === 1) {
return children[0];
}
// Skip empty non-semantic elements
if (skipTags.includes(tag) && !role && !isInter && children.length === 0 && !name) {
return null;
}
const ref = ++refCounter;
refMap.set(ref, el);
const node = { ref };
if (role) node.role = role;
else node.tag = tag;
if (name) node.name = name;
if (isInter) node.interactive = true;
// Add value for form elements
if (el.value !== undefined && el.value !== '') {
node.value = el.value;
}
// Add checked state
if (el.checked !== undefined) {
node.checked = el.checked;
}
// Add disabled state
if (el.disabled) {
node.disabled = true;
}
if (children.length > 0) {
node.children = children;
}
return node;
}
function treeToText(node, indent = 0) {
if (!node) return '';
const prefix = ' '.repeat(indent);
let line = prefix + `- [ref=${node.ref}]`;
if (node.role) line += ` ${node.role}`;
else if (node.tag) line += ` <${node.tag}>`;
if (node.name) line += ` "${node.name}"`;
if (node.value) line += ` value="${node.value}"`;
if (node.checked) line += ` [checked]`;
if (node.disabled) line += ` [disabled]`;
let result = line + '\n';
if (node.children) {
for (const child of node.children) {
result += treeToText(child, indent + 1);
}
}
return result;
}
const tree = buildTree(document.body);
const snapshot = treeToText(tree);
// Build health status from HMR monitoring
const buildHealth = {
frontend: (window.__MCP_BUILD_LOGS__ || []).some(log => log.level === 'error') ? 'error' : 'healthy',
hmrConnected: window.__MCP_HMR_STATUS__ === 'connected',
lastError: (window.__MCP_BUILD_LOGS__ || []).filter(log => log.level === 'error').slice(-1)[0] || null,
};
// Include warning if there are build errors
const result = {
window: window.__MCP_WINDOW_LABEL__ || 'main',
url: window.location.href,
title: document.title,
snapshot: snapshot,
buildHealth: buildHealth,
};
if (buildHealth.frontend === 'error' && buildHealth.lastError) {
result.warning = `Build error: ${buildHealth.lastError.message}`;
if (buildHealth.lastError.file) {
result.warning += ` (${buildHealth.lastError.file}:${buildHealth.lastError.line || '?'})`;
}
}
return result;
"#;
/// JavaScript code to click an element by CSS selector
pub fn click_js(selector: &str) -> String {
format!(
r#"
const el = document.querySelector({selector});
if (!el) {{
return {{ success: false, error: 'Element not found: {raw_selector}' }};
}}
el.click();
return {{ success: true }};
"#,
selector = serde_json::to_string(selector).unwrap(),
raw_selector = selector
)
}
/// JavaScript code to click an element by ref number
pub fn click_ref_js(ref_num: u32) -> String {
format!(
r#"
const refMap = window.__MCP_REF_MAP__;
if (!refMap) {{
return {{ success: false, error: 'No snapshot taken yet. Call snapshot first.' }};
}}
const el = refMap.get({ref_num});
if (!el) {{
return {{ success: false, error: 'Element ref={ref_num} not found. Snapshot may be stale.' }};
}}
el.scrollIntoView({{ behavior: 'instant', block: 'center' }});
el.click();
return {{ success: true }};
"#,
ref_num = ref_num
)
}
/// JavaScript code to fill an input by CSS selector
/// Uses native value setter to properly trigger React's synthetic event system
pub fn fill_js(selector: &str, value: &str) -> String {
format!(
r#"
const el = document.querySelector({selector});
if (!el) {{
return {{ success: false, error: 'Element not found: {raw_selector}' }};
}}
// Get the native value setter to bypass React's synthetic event system
// This is required for React controlled inputs to properly update state
const tagName = el.tagName.toLowerCase();
const prototype = tagName === 'textarea' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
const nativeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
if (nativeValueSetter) {{
nativeValueSetter.call(el, {value});
}} else {{
el.value = {value};
}}
// Dispatch input event with bubbles to trigger React's onChange
const inputEvent = new Event('input', {{ bubbles: true, cancelable: true }});
// React 16+ uses this property to track the event
Object.defineProperty(inputEvent, 'simulated', {{ value: true }});
el.dispatchEvent(inputEvent);
// Also dispatch change event for completeness
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
return {{ success: true }};
"#,
selector = serde_json::to_string(selector).unwrap(),
raw_selector = selector,
value = serde_json::to_string(value).unwrap()
)
}
/// JavaScript code to fill an input by ref number
/// Uses native value setter to properly trigger React's synthetic event system
pub fn fill_ref_js(ref_num: u32, value: &str) -> String {
format!(
r#"
const refMap = window.__MCP_REF_MAP__;
if (!refMap) {{
return {{ success: false, error: 'No snapshot taken yet. Call snapshot first.' }};
}}
const el = refMap.get({ref_num});
if (!el) {{
return {{ success: false, error: 'Element ref={ref_num} not found. Snapshot may be stale.' }};
}}
el.scrollIntoView({{ behavior: 'instant', block: 'center' }});
el.focus();
// Get the native value setter to bypass React's synthetic event system
// This is required for React controlled inputs to properly update state
const tagName = el.tagName.toLowerCase();
const prototype = tagName === 'textarea' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
const nativeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
if (nativeValueSetter) {{
nativeValueSetter.call(el, {value});
}} else {{
el.value = {value};
}}
// Dispatch input event with bubbles to trigger React's onChange
const inputEvent = new Event('input', {{ bubbles: true, cancelable: true }});
// React 16+ uses this property to track the event
Object.defineProperty(inputEvent, 'simulated', {{ value: true }});
el.dispatchEvent(inputEvent);
// Also dispatch change event for completeness
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
return {{ success: true }};
"#,
ref_num = ref_num,
value = serde_json::to_string(value).unwrap()
)
}
/// JavaScript code to press a key
pub fn press_key_js(key: &str) -> String {
format!(
r#"
const activeEl = document.activeElement || document.body;
const keyEvent = new KeyboardEvent('keydown', {{
key: {key},
bubbles: true,
cancelable: true
}});
activeEl.dispatchEvent(keyEvent);
const keyUpEvent = new KeyboardEvent('keyup', {{
key: {key},
bubbles: true,
cancelable: true
}});
activeEl.dispatchEvent(keyUpEvent);
return {{ success: true }};
"#,
key = serde_json::to_string(key).unwrap()
)
}
/// JavaScript code to navigate
pub fn navigate_js(url: &str) -> String {
format!(
r#"
window.location.href = {url};
return {{ success: true }};
"#,
url = serde_json::to_string(url).unwrap()
)
}
/// JavaScript code to get console logs
pub fn get_console_logs_js(clear: bool) -> String {
format!(
r#"
const logs = window.__MCP_CONSOLE_LOGS__ || [];
const result = {{ logs: [...logs] }};
if ({clear}) {{
window.__MCP_CONSOLE_LOGS__ = [];
}}
return result;
"#,
clear = if clear { "true" } else { "false" }
)
}
/// JavaScript code to get network logs
pub fn get_network_logs_js(clear: bool) -> String {
format!(
r#"
const logs = window.__MCP_NETWORK_LOGS__ || [];
const result = {{ logs: [...logs] }};
if ({clear}) {{
window.__MCP_NETWORK_LOGS__ = [];
}}
return result;
"#,
clear = if clear { "true" } else { "false" }
)
}
/// JavaScript code to get frontend logs (console, build, network) and HMR status
pub fn get_frontend_logs_js(clear: bool) -> String {
format!(
r#"
const consoleLogs = (window.__MCP_CONSOLE_LOGS__ || []).map(log => ({{
source: 'console',
category: 'runtime-frontend',
level: log.level === 'error' ? 'error' : log.level === 'warn' ? 'warning' : 'info',
message: log.args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '),
timestamp: log.timestamp,
}}));
const buildLogs = (window.__MCP_BUILD_LOGS__ || []).map(log => ({{
source: log.source,
category: 'build-frontend',
level: log.level,
message: log.message,
timestamp: log.timestamp,
details: log.file ? {{ file: log.file, line: log.line, column: log.column }} : undefined,
}}));
const networkLogs = (window.__MCP_NETWORK_LOGS__ || []).map(log => ({{
source: 'network',
category: 'runtime-frontend-network',
level: log.status >= 400 || log.error ? 'error' : 'info',
message: log.error ? `${{log.method}} ${{log.url}} - ERROR: ${{log.error}}` : `${{log.method}} ${{log.url}} - ${{log.status}}`,
timestamp: log.timestamp,
details: {{
url: log.url,
method: log.method,
status: log.status,
duration: log.duration,
error: log.error,
}},
}}));
const hmrStatus = {{
connected: window.__MCP_HMR_STATUS__ === 'connected',
status: window.__MCP_HMR_STATUS__ || 'unknown',
lastSuccess: window.__MCP_HMR_LAST_SUCCESS__ || null,
}};
if ({clear}) {{
window.__MCP_CONSOLE_LOGS__ = [];
window.__MCP_BUILD_LOGS__ = [];
window.__MCP_NETWORK_LOGS__ = [];
}}
return {{ consoleLogs, buildLogs, networkLogs, hmrStatus }};
"#,
clear = if clear { "true" } else { "false" }
)
}
/// JavaScript code to get HMR update events (files that triggered hot reload)
pub fn get_hmr_updates_js(clear: bool) -> String {
format!(
r#"
const updates = window.__MCP_HMR_UPDATES__ || [];
const result = {{ updates: [...updates] }};
if ({clear}) {{
window.__MCP_HMR_UPDATES__ = [];
}}
return result;
"#,
clear = if clear { "true" } else { "false" }
)
}