UNPKG

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
/** * 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