UNPKG

browser-debugger-cli

Version:

DevTools telemetry in your terminal. For humans and agents. Direct WebSocket to Chrome's debugging port.

439 lines 14.6 kB
/** * Form discovery command for semantic form inspection. * * Discovers forms on the page with semantic labels, current values, * validation state, and suggested commands for agent consumption. */ import { FORM_DISCOVERY_SCRIPT, isRawFormData } from '../../commands/dom/formDiscovery.js'; import { runCommand } from '../../commands/shared/CommandRunner.js'; import { jsonOption } from '../../commands/shared/commonOptions.js'; import { QueryCacheManager } from '../../session/QueryCacheManager.js'; import { CommandError } from '../../ui/errors/index.js'; import { formatFormDiscovery } from '../../ui/formatters/form.js'; import { createLogger } from '../../ui/logging/index.js'; import { noFormsFoundError, formInIframeError } from '../../ui/messages/errors.js'; import { EXIT_CODES } from '../../utils/exitCodes.js'; const log = createLogger('dom'); /** * Execute form discovery in page context. * * @param cdp - CDP connection * @returns Raw form data from page */ async function executeFormDiscovery(cdp) { const response = await cdp.send('Runtime.evaluate', { expression: FORM_DISCOVERY_SCRIPT, returnByValue: true, }); const cdpResponse = response; if (cdpResponse.exceptionDetails) { throw new CommandError(`Form discovery failed: ${cdpResponse.exceptionDetails.text}`, { suggestion: 'Check if page has loaded completely' }, EXIT_CODES.SOFTWARE_ERROR); } const rawData = cdpResponse.result?.value; if (!isRawFormData(rawData)) { throw new CommandError('Unexpected form discovery response', { suggestion: 'This may be a bug - please report it' }, EXIT_CODES.SOFTWARE_ERROR); } return rawData; } /** * Build validation state from raw field data. * * @param raw - Raw field data * @returns Structured validation state */ function buildValidation(raw) { const hasNativeError = !raw.isValid && raw.validationMessage; const hasAriaError = raw.ariaInvalid; const hasSiblingError = !!raw.siblingErrorText; const hasClassError = raw.hasErrorClass; if (hasNativeError) { return { valid: false, message: raw.validationMessage, source: 'native', confidence: 'high', }; } if (hasAriaError) { return { valid: false, message: raw.siblingErrorText ?? 'Field is invalid', source: 'aria', confidence: 'high', }; } if (hasSiblingError) { return { valid: false, message: raw.siblingErrorText, source: 'sibling', confidence: 'medium', }; } if (hasClassError) { return { valid: false, message: 'Field has error styling', source: 'heuristic', confidence: 'low', }; } return { valid: true, confidence: 'high', }; } /** * Build field state from raw value. * * @param raw - Raw field data * @returns Field state */ function buildFieldState(raw) { const type = raw.type.toLowerCase(); if (type === 'checkbox' || type === 'radio' || type === 'switch') { return raw.checked || raw.value === true ? 'checked' : 'unchecked'; } if (Array.isArray(raw.value)) { return raw.value.length > 0 ? 'filled' : 'empty'; } if (typeof raw.value === 'string') { return raw.value.length > 0 ? 'filled' : 'empty'; } return 'empty'; } /** * Build masked value for password fields. * * @param raw - Raw field data * @returns Masked value string */ function buildMaskedValue(raw) { if (raw.inputType === 'password' && typeof raw.value === 'string' && raw.value.length > 0) { return '•'.repeat(Math.min(raw.value.length, 8)); } return undefined; } /** * Build interaction warning for non-native fields. * * @param raw - Raw field data * @returns Warning message or undefined */ function buildInteractionWarning(raw) { if (raw.native) { return undefined; } const type = raw.type.toLowerCase(); if (type === 'contenteditable') { return 'Custom contenteditable - use click + type instead of fill'; } if (type === 'combobox' || type === 'listbox') { return 'Custom dropdown - click to open, then select option'; } if (type === 'textbox') { return 'Custom textbox - fill may not trigger framework events'; } return 'Custom component - standard fill may not work'; } /** * Build fill command for a field. * * @param index - Global element index * @param type - Field type * @returns Command string */ function buildFieldCommand(index, type) { const lowerType = type.toLowerCase(); if (lowerType === 'checkbox' || lowerType === 'radio' || lowerType === 'switch') { return `bdg dom click ${index}`; } if (lowerType === 'file') { return `bdg dom click ${index}`; } return `bdg dom fill ${index} "<value>"`; } /** * Build selector-based command for a field. * * @param selector - CSS selector * @param type - Field type * @returns Command string */ function buildSelectorCommand(selector, type) { const lowerType = type.toLowerCase(); // Escape backslashes first, then double quotes (CodeQL js/incomplete-string-escaping) const escaped = selector.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); if (lowerType === 'checkbox' || lowerType === 'radio' || lowerType === 'switch') { return `bdg dom click "${escaped}"`; } if (lowerType === 'file') { return `bdg dom click "${escaped}"`; } return `bdg dom fill "${escaped}" "<value>"`; } /** * Build alternative command for non-native fields. * * @param index - Global element index * @param raw - Raw field data * @returns Alternative command or undefined */ function buildAlternativeCommand(index, raw) { if (raw.native) { return undefined; } const type = raw.type.toLowerCase(); if (type === 'contenteditable' || type === 'textbox') { return `bdg dom click ${index} && bdg dom pressKey ${index} "<value>"`; } return undefined; } /** * Transform raw field to structured FormField. * * @param raw - Raw field data * @returns Structured FormField */ function transformField(raw) { return { index: raw.index, formIndex: raw.formIndex, selector: raw.selector, type: raw.type, inputType: raw.inputType, label: raw.label, name: raw.name, placeholder: raw.placeholder, required: raw.required, disabled: raw.disabled, readOnly: raw.readOnly, hidden: raw.hidden, native: raw.native, interactionWarning: buildInteractionWarning(raw), state: buildFieldState(raw), value: raw.value, maskedValue: buildMaskedValue(raw), validation: buildValidation(raw), options: raw.options, command: buildFieldCommand(raw.index, raw.type), selectorCommand: buildSelectorCommand(raw.selector, raw.type), alternativeCommand: buildAlternativeCommand(raw.index, raw), }; } /** * Transform raw button to structured FormButton. * * @param raw - Raw button data * @returns Structured FormButton */ function transformButton(raw) { return { index: raw.index, selector: raw.selector, label: raw.label, type: raw.type, primary: raw.isPrimary, enabled: !raw.disabled, disabledReason: raw.disabled ? 'Button is disabled' : undefined, command: `bdg dom click ${raw.index}`, }; } /** * Calculate form summary statistics. * * @param fields - Transformed form fields * @param buttons - Transformed form buttons * @returns Form summary */ function calculateSummary(fields, buttons) { const visibleFields = fields.filter((f) => !f.hidden); const requiredFields = visibleFields.filter((f) => f.required); const filledFields = visibleFields.filter((f) => f.state === 'filled' || f.state === 'checked'); const validFields = visibleFields.filter((f) => f.validation.valid); const invalidFields = visibleFields.filter((f) => !f.validation.valid); const emptyRequired = requiredFields.filter((f) => f.state === 'empty' || f.state === 'unchecked'); const blockers = []; for (const field of emptyRequired) { blockers.push({ index: field.index, label: field.label, reason: 'Required field is empty', command: field.command, }); } for (const field of invalidFields) { if (!emptyRequired.includes(field)) { blockers.push({ index: field.index, label: field.label, reason: field.validation.message ?? 'Validation failed', command: field.command, }); } } const submitButton = buttons.find((b) => b.type === 'submit' && b.primary); if (submitButton && !submitButton.enabled) { blockers.push({ index: submitButton.index, label: submitButton.label, reason: submitButton.disabledReason ?? 'Submit button is disabled', command: submitButton.command, }); } return { totalFields: visibleFields.length, filledFields: filledFields.length, emptyFields: visibleFields.length - filledFields.length, validFields: validFields.length, invalidFields: invalidFields.length, requiredTotal: requiredFields.length, requiredFilled: requiredFields.length - emptyRequired.length, requiredRemaining: emptyRequired.length, readyToSubmit: blockers.length === 0, blockers, }; } /** * Transform raw form to structured DiscoveredForm. * * @param raw - Raw form data * @returns Structured DiscoveredForm */ function transformForm(raw) { const fields = raw.fields.map(transformField); const buttons = raw.buttons.map(transformButton); return { index: raw.index, name: raw.name, action: raw.action, method: raw.method, step: raw.step ?? undefined, relevanceScore: raw.relevanceScore, fields, buttons, summary: calculateSummary(fields, buttons), }; } /** * Cache form elements for index-based access. * * @param forms - Discovered forms */ async function cacheFormElements(forms) { const cacheManager = QueryCacheManager.getInstance(); const allElements = []; for (const form of forms) { for (const field of form.fields) { allElements.push({ index: field.index, nodeId: 0, selector: field.selector, }); } for (const button of form.buttons) { allElements.push({ index: button.index, nodeId: 0, selector: button.selector, }); } } const navigationId = await cacheManager.getCurrentNavigationId(); await cacheManager.set({ selector: 'form:auto-discovered', count: allElements.length, nodes: allElements.map((el) => ({ index: el.index, nodeId: el.nodeId, tag: 'input', preview: el.selector, })), ...(navigationId !== null && { navigationId }), }); log.debug(`Cached ${allElements.length} form elements`); } /** * Execute CDP connection lifecycle for form discovery. * * @param fn - Function to execute with CDP connection * @returns Result from function */ async function withCDPConnection(fn) { const { CDPConnection } = await import('../../connection/cdp.js'); const { validateActiveSession, getValidatedSessionMetadata, verifyTargetExists } = await import('../../commands/dom/evalHelpers.js'); validateActiveSession(); const metadata = getValidatedSessionMetadata(); const port = 9222; await verifyTargetExists(metadata, port); const cdp = new CDPConnection(); if (!metadata.webSocketDebuggerUrl) { throw new CommandError('Session metadata missing webSocketDebuggerUrl', { suggestion: 'Start a new session with: bdg <url>' }, EXIT_CODES.SESSION_FILE_ERROR); } await cdp.connect(metadata.webSocketDebuggerUrl); try { return await fn(cdp); } finally { cdp.close(); } } /** * Handle form discovery command. * * @param options - Command options */ async function handleFormCommand(options) { await runCommand(async () => { return await withCDPConnection(async (cdp) => { const rawData = await executeFormDiscovery(cdp); if (rawData.forms.length === 0) { const err = noFormsFoundError(); return { success: false, error: err.message, exitCode: EXIT_CODES.NO_FORMS_FOUND, errorContext: { suggestion: err.suggestion }, }; } const iframeForm = rawData.forms.find((f) => f.inIframe); if (iframeForm && rawData.forms.length === 1) { const err = formInIframeError(iframeForm.iframeUrl ?? 'unknown', iframeForm.crossOrigin ?? false); return { success: false, error: err.message, exitCode: EXIT_CODES.FORM_IN_IFRAME, errorContext: { suggestion: err.suggestion }, }; } const allForms = rawData.forms.map(transformForm); const forms = options.all ? allForms : [allForms[0]]; // Cache ALL forms so global indices work with bdg dom fill/click await cacheFormElements(allForms); const result = { formCount: rawData.forms.length, selectedForm: 0, forms, brief: options.brief, }; return { success: true, data: result }; }); }, options, formatFormDiscovery); } /** * Register form discovery command. * * @param domCommand - DOM command group */ export function registerFormCommand(domCommand) { domCommand .command('form') .description('Discover forms with semantic labels, values, and validation state') .option('--all', 'Show all forms expanded') .option('--brief', 'Quick scan: field names, types, and required status only') .addOption(jsonOption()) .action(async (options) => { await handleFormCommand(options); }); } //# sourceMappingURL=form.js.map