browser-debugger-cli
Version:
DevTools telemetry in your terminal. For humans and agents. Direct WebSocket to Chrome's debugging port.
442 lines (435 loc) • 17 kB
JavaScript
/**
* Form interaction helpers for filling inputs, clicking buttons, etc.
*/
import { CommandError } from '../../ui/errors/index.js';
import { createLogger } from '../../ui/logging/index.js';
import { fillableElementNotFoundError, clickableElementNotFoundError, keyPressFailedError, unexpectedResponseFormatError, operationFailedError, } from '../../ui/messages/errors.js';
import { delay } from '../../utils/async.js';
import { EXIT_CODES } from '../../utils/exitCodes.js';
import { detectSelectorQuoteDamage } from '../../utils/shellDetection.js';
import { getKeyDefinition, parseModifiers } from './keyMapping.js';
import { REACT_FILL_SCRIPT, CLICK_ELEMENT_SCRIPT, isFillResult, isClickResult, } from './reactEventHelpers.js';
const log = createLogger('dom');
/** Network idle threshold for post-action stability (ms) */
const ACTION_NETWORK_IDLE_MS = 150;
/** Maximum time to wait for post-action stability (ms) */
const ACTION_STABILITY_TIMEOUT_MS = 2000;
/** Check interval for stability polling (ms) */
const STABILITY_CHECK_INTERVAL_MS = 50;
/**
* Format exception details into a user-friendly error message with troubleshooting hints.
*
* Shows the actual expression sent to CDP to help diagnose escaping issues.
* Detects shell quote damage patterns and provides specific recovery suggestions.
*
* @param exceptionDetails - CDP exception details
* @param selector - CSS selector that was used
* @param operationType - Type of operation (fill or click) for tailored hints
* @param expression - The actual JavaScript expression sent to CDP
* @returns Formatted error message with context
*/
function formatScriptExecutionError(exceptionDetails, selector, operationType = 'fill', expression) {
const errorText = exceptionDetails.text || 'Unknown error';
const location = exceptionDetails.lineNumber !== undefined && exceptionDetails.columnNumber !== undefined
? ` at line ${exceptionDetails.lineNumber + 1}, column ${exceptionDetails.columnNumber + 1}`
: '';
const lines = [];
lines.push(`Script execution failed: ${errorText}${location}`);
if (expression) {
const truncated = expression.length > 150 ? expression.slice(0, 150) + '...' : expression;
lines.push('');
lines.push(`Expression received: ${truncated}`);
const selectorCheck = detectSelectorQuoteDamage(selector);
if (selectorCheck.damaged) {
lines.push('');
lines.push('Shell quote damage detected in selector:');
if (selectorCheck.details) {
lines.push(` ${selectorCheck.details}`);
}
lines.push('');
lines.push('Try using the two-step pattern:');
lines.push(` 1. bdg dom query '${selector}'`);
lines.push(` 2. bdg dom ${operationType} 0${operationType === 'fill' ? ' "value"' : ''}`);
return lines.join('\n');
}
}
const troubleshootingSteps = operationType === 'fill'
? [
`1. Verify element exists: bdg dom query "${selector}"`,
'2. Check element is visible and not disabled',
`3. Try direct eval: bdg dom eval "document.querySelector('${escapeSelectorForJS(selector)}').value = 'your-value'"`,
]
: [
`1. Verify element exists: bdg dom query "${selector}"`,
'2. Check element is visible and clickable',
`3. Try direct eval: bdg dom eval "document.querySelector('${escapeSelectorForJS(selector)}').click()"`,
];
lines.push('');
lines.push('Troubleshooting:');
lines.push(` ${troubleshootingSteps.join('\n ')}`);
return lines.join('\n');
}
/**
* Fill a form element with a value in a React-compatible way.
*
* @param cdp - CDP connection
* @param selector - CSS selector for the element
* @param value - Value to fill
* @param options - Fill options including optional index for multiple matches
* @returns Promise resolving to fill result
*
* @throws CommandError When element operations fail
*
* @example
* ```typescript
* const result = await fillElement(cdp, 'input[name="email"]', 'test@example.com');
* if (result.success) {
* console.log(`Filled ${result.elementType} with value: ${result.value}`);
* }
* ```
*/
export async function fillElement(cdp, selector, value, options = {}) {
const scriptOptions = {
blur: options.blur ?? true,
index: options.index,
};
const expression = `(${REACT_FILL_SCRIPT})('${escapeSelectorForJS(selector)}', '${escapeValueForJS(value)}', ${JSON.stringify(scriptOptions)})`;
try {
const response = await cdp.send('Runtime.evaluate', {
expression,
returnByValue: true,
userGesture: true,
});
const cdpResponse = response;
if (cdpResponse.exceptionDetails) {
const errorMessage = formatScriptExecutionError(cdpResponse.exceptionDetails, selector, 'fill', expression);
const err = fillableElementNotFoundError(selector);
throw new CommandError(errorMessage, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
}
if (cdpResponse.result?.value && isFillResult(cdpResponse.result.value)) {
return cdpResponse.result.value;
}
const err = unexpectedResponseFormatError('FillResult');
throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
}
catch (error) {
if (error instanceof CommandError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
const err = operationFailedError('fill element', errorMessage);
throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
}
}
/**
* Click an element.
*
* @param cdp - CDP connection
* @param selector - CSS selector for the element
* @param options - Click options including optional index for multiple matches
* @returns Promise resolving to click result
*
* @throws CommandError When element operations fail
*
* @example
* ```typescript
* const result = await clickElement(cdp, 'button[type="submit"]');
* if (result.success) {
* console.log(`Clicked ${result.elementType}`);
* }
* ```
*/
export async function clickElement(cdp, selector, options = {}) {
const indexArg = options.index ?? 'null';
const expression = `(${CLICK_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}', ${indexArg})`;
try {
const response = await cdp.send('Runtime.evaluate', {
expression,
returnByValue: true,
userGesture: true,
});
const cdpResponse = response;
if (cdpResponse.exceptionDetails) {
const errorMessage = formatScriptExecutionError(cdpResponse.exceptionDetails, selector, 'click', expression);
const err = clickableElementNotFoundError(selector);
throw new CommandError(errorMessage, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
}
if (cdpResponse.result?.value && isClickResult(cdpResponse.result.value)) {
return cdpResponse.result.value;
}
const err = unexpectedResponseFormatError('ClickResult');
throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
}
catch (error) {
if (error instanceof CommandError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
const err = operationFailedError('click element', errorMessage);
throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
}
}
/**
* Escape CSS selector for safe inclusion in JavaScript single-quoted string.
* Uses JSON.stringify for special characters, then escapes single quotes
* since the expression wrapper uses single quotes.
*
* @param selector - CSS selector to escape
* @returns Escaped selector safe for single-quoted JS string
*
* @internal
*/
function escapeSelectorForJS(selector) {
return JSON.stringify(selector).slice(1, -1).replace(/'/g, "\\'");
}
/**
* Escape value for safe inclusion in JavaScript single-quoted string.
* Uses JSON.stringify for special characters, then escapes single quotes
* since the expression wrapper uses single quotes.
*
* @param value - Value to escape
* @returns Escaped value safe for single-quoted JS string
*
* @internal
*/
function escapeValueForJS(value) {
return JSON.stringify(value).slice(1, -1).replace(/'/g, "\\'");
}
/**
* Wait for page to stabilize after an action (click, fill, etc.).
*
* This is a lightweight stability check designed for post-action waiting:
* - Waits for network to be idle for 150ms
* - Times out after 2s to avoid hanging
* - Does not block on slow background requests
*
* @param cdp - CDP connection
* @returns Promise that resolves when stable or timeout reached
*
* @remarks
* This is intentionally less strict than page readiness on initial load.
* It's designed to catch immediate reactions to user actions (AJAX, re-renders)
* without waiting for unrelated background activity.
*/
export async function waitForActionStability(cdp) {
const deadline = Date.now() + ACTION_STABILITY_TIMEOUT_MS;
let activeRequests = 0;
let lastActivity = Date.now();
const onRequestStarted = () => {
activeRequests++;
lastActivity = Date.now();
};
const onRequestFinished = () => {
activeRequests--;
if (activeRequests === 0) {
lastActivity = Date.now();
}
};
await cdp.send('Network.enable');
const cleanupRequest = cdp.on('Network.requestWillBeSent', onRequestStarted);
const cleanupFinished = cdp.on('Network.loadingFinished', onRequestFinished);
const cleanupFailed = cdp.on('Network.loadingFailed', onRequestFinished);
try {
while (Date.now() < deadline) {
if (activeRequests === 0) {
const idleTime = Date.now() - lastActivity;
if (idleTime >= ACTION_NETWORK_IDLE_MS) {
log.debug(`Network stable after ${idleTime}ms idle`);
return;
}
}
await delay(STABILITY_CHECK_INTERVAL_MS);
}
log.debug('Stability timeout reached, proceeding');
}
finally {
cleanupRequest();
cleanupFinished();
cleanupFailed();
}
}
/**
* Script to focus an element by selector and optional index.
*
* @returns Object with success status and element info
*/
const FOCUS_ELEMENT_SCRIPT = `
(function(selector, index) {
const allMatches = document.querySelectorAll(selector);
if (allMatches.length === 0) {
return { success: false, error: 'No nodes found matching selector: ' + selector };
}
let el;
if (typeof index === 'number' && index >= 0) {
if (index >= allMatches.length) {
return {
success: false,
error: 'Index ' + index + ' out of range (found ' + allMatches.length + ' nodes, use 0-' + (allMatches.length - 1) + ')'
};
}
el = allMatches[index];
} else {
el = allMatches[0];
}
el.focus();
return {
success: true,
selector: selector,
elementType: el.tagName.toLowerCase(),
focused: document.activeElement === el
};
})`;
/**
* Press a key on an element.
*
* Focuses the element first, then dispatches keyDown and keyUp events via CDP.
*
* @param cdp - CDP connection
* @param selector - CSS selector for the element
* @param keyName - Key name (e.g., "Enter", "Tab", "a")
* @param options - Press key options
* @returns Promise resolving to press key result
*
* @throws CommandError When element or key operations fail
*
* @example
* ```typescript
* // Press Enter on a todo input
* const result = await pressKeyElement(cdp, '.new-todo', 'Enter');
*
* // Press Tab 3 times with Shift held
* const result = await pressKeyElement(cdp, 'input', 'Tab', {
* times: 3,
* modifiers: 'shift'
* });
* ```
*/
export async function pressKeyElement(cdp, selector, keyName, options = {}) {
const keyDef = getKeyDefinition(keyName);
if (!keyDef) {
return {
success: false,
error: `Unknown key: "${keyName}". Supported keys: Enter, Tab, Escape, Space, Backspace, Delete, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown, F1-F12, a-z, 0-9`,
};
}
const times = options.times ?? 1;
const modifierFlags = parseModifiers(options.modifiers);
const indexArg = options.index ?? 'null';
const focusExpression = `(${FOCUS_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}', ${indexArg})`;
try {
const focusResponse = await cdp.send('Runtime.evaluate', {
expression: focusExpression,
returnByValue: true,
});
const focusCdpResponse = focusResponse;
if (focusCdpResponse.exceptionDetails) {
const err = keyPressFailedError(`focus: ${focusCdpResponse.exceptionDetails.text}`);
throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
}
const focusResult = focusCdpResponse.result?.value;
if (!focusResult?.success) {
return {
success: false,
error: focusResult?.error ?? 'Failed to focus element',
selector,
};
}
for (let i = 0; i < times; i++) {
await dispatchKeyEvent(cdp, 'keyDown', keyDef, modifierFlags);
await dispatchSyntheticKeyEvents(cdp, keyDef, modifierFlags);
await dispatchKeyEvent(cdp, 'keyUp', keyDef, modifierFlags);
}
return {
success: true,
selector,
key: keyName,
times,
modifiers: modifierFlags,
elementType: focusResult.elementType,
};
}
catch (error) {
if (error instanceof CommandError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
const err = operationFailedError('press key', errorMessage);
throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
}
}
/**
* Dispatch a single key event via CDP Input.dispatchKeyEvent.
*
* @param cdp - CDP connection
* @param type - Event type (keyDown or keyUp)
* @param keyDef - Key definition with code, key, and keyCode
* @param modifiers - Modifier bit flags
*/
async function dispatchKeyEvent(cdp, type, keyDef, modifiers) {
await cdp.send('Input.dispatchKeyEvent', {
type,
code: keyDef.code,
key: keyDef.key,
windowsVirtualKeyCode: keyDef.keyCode,
nativeVirtualKeyCode: keyDef.keyCode,
modifiers,
});
}
/**
* Dispatch synthetic keyboard events that CDP Input.dispatchKeyEvent misses.
*
* CDP's Input.dispatchKeyEvent only fires keydown/keyup at the browser level.
* Many frameworks (React, Vue) and legacy handlers listen for:
* - keypress: Deprecated but widely used for Enter key handling
* - input/change: Required for React synthetic event system
* - submit: Required for Enter-to-submit behavior in forms
*
* This function injects JavaScript to fire these events on the focused element.
*
* @param cdp - CDP connection
* @param keyDef - Key definition with code, key, and keyCode
* @param modifiers - Modifier bit flags
*/
async function dispatchSyntheticKeyEvents(cdp, keyDef, modifiers) {
const isEnterKey = keyDef.key === 'Enter';
const script = `
(function() {
const el = document.activeElement;
if (!el) return;
el.dispatchEvent(new KeyboardEvent('keypress', {
key: '${keyDef.key}',
code: '${keyDef.code}',
keyCode: ${keyDef.keyCode},
charCode: ${keyDef.keyCode},
which: ${keyDef.keyCode},
shiftKey: ${Boolean(modifiers & 1)},
ctrlKey: ${Boolean(modifiers & 2)},
altKey: ${Boolean(modifiers & 4)},
metaKey: ${Boolean(modifiers & 8)},
bubbles: true,
cancelable: true
}));
const tagName = el.tagName;
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') {
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
if (${isEnterKey} && (tagName === 'INPUT' || tagName === 'TEXTAREA')) {
const form = el.closest('form');
if (form) {
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
const shouldSubmit = form.dispatchEvent(submitEvent);
if (shouldSubmit) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
}
}
})()
`;
await cdp.send('Runtime.evaluate', { expression: script });
}
//# sourceMappingURL=formFillHelpers.js.map