UNPKG

supapup

Version:

⚡ Lightning-fast MCP browser dev tool. Navigate → Get instant structured data. No screenshots needed! Puppeteer: 📸 → CSS selectors → JS eval. Supapup: semantic IDs ready to use. 10x faster, 90% fewer tokens.

419 lines (418 loc) 19.4 kB
/** * Form automation tools for Supapup * Enables filling entire forms with JSON data */ export class FormTools { page; constructor(page) { this.page = page; } /** * Fill an entire form using a JSON object * Maps JSON keys to element IDs or data-mcp-ids */ async fillForm(formData, options) { const result = { success: true, filled: [], errors: [], warnings: [] }; try { // Execute in browser context const fillResult = await this.page.evaluate(({ data, opts }) => { const filled = []; const errors = []; const warnings = []; // Helper to fill a single field function fillField(element, value) { const tagName = element.tagName.toLowerCase(); const type = element.getAttribute('type')?.toLowerCase(); try { // Helper function to trigger React events // Universal React trigger function - also available in browser via window.__AGENT_PAGE__.triggerReactChange function triggerReactChange(inputElement, newValue) { const lastValue = inputElement.value; inputElement.value = newValue; // Trigger React's synthetic event handling const tracker = inputElement._valueTracker; if (tracker) { tracker.setValue(lastValue); } // Dispatch input event first (for React onChange) const inputEvent = new Event('input', { bubbles: true }); inputElement.dispatchEvent(inputEvent); // Then dispatch change event (for traditional handlers) const changeEvent = new Event('change', { bubbles: true }); inputElement.dispatchEvent(changeEvent); // Also trigger blur event for validation const blurEvent = new Event('blur', { bubbles: true }); inputElement.dispatchEvent(blurEvent); } if (tagName === 'input') { if (type === 'checkbox' || type === 'radio') { element.checked = Boolean(value); element.dispatchEvent(new Event('change', { bubbles: true })); } else if (type === 'number') { triggerReactChange(element, String(Number(value))); } else { triggerReactChange(element, String(value)); } return true; } else if (tagName === 'textarea') { triggerReactChange(element, String(value)); return true; } else if (tagName === 'select') { const selectEl = element; const inputValue = String(value); // Try to find option by value first let option = Array.from(selectEl.options).find(opt => opt.value === inputValue); // If not found by value, try by display text (case-insensitive) if (!option && inputValue) { option = Array.from(selectEl.options).find(opt => opt.textContent?.trim().toLowerCase() === inputValue.toLowerCase()); } // If still not found, try partial match on display text if (!option && inputValue) { option = Array.from(selectEl.options).find(opt => opt.textContent?.trim().toLowerCase().includes(inputValue.toLowerCase())); } if (option) { selectEl.value = option.value; element.dispatchEvent(new Event('change', { bubbles: true })); return true; } else { warnings.push(`No matching option for select field: ${inputValue}. Available options: ${Array.from(selectEl.options).filter(opt => opt.value).map(opt => `"${opt.textContent?.trim()}" (${opt.value})`).join(', ')}`); return false; } } } catch (e) { errors.push(`Error filling field: ${e}`); return false; } return false; } // Try to find fields by various methods for (const [key, value] of Object.entries(data)) { let element = null; let foundBy = ''; // 1. Try by data-mcp-id element = document.querySelector(`[data-mcp-id="${key}"]`); if (element) foundBy = 'data-mcp-id'; // 2. Try by id if (!element) { element = document.getElementById(key); if (element) foundBy = 'id'; } // 3. Try by name if (!element) { element = document.querySelector(`[name="${key}"]`); if (element) foundBy = 'name'; } // 4. Try by data-mcp-id with common prefixes if (!element && opts?.formId) { element = document.querySelector(`[data-mcp-id="${opts.formId}-${key}"]`); if (element) foundBy = 'data-mcp-id-prefixed'; } if (element) { if (fillField(element, value)) { filled.push(`${key} (found by ${foundBy})`); } } else { warnings.push(`Field not found: ${key}`); } } // Validate required fields if requested if (opts?.validateRequired) { const requiredFields = document.querySelectorAll('[required], [data-required="true"]'); requiredFields.forEach(field => { const id = field.getAttribute('data-mcp-id') || field.id || field.getAttribute('name'); const value = field.value; if (!value || value.trim() === '') { warnings.push(`Required field empty: ${id}`); } }); } return { filled, errors, warnings }; }, { data: formData, opts: options }); result.filled = fillResult.filled; result.errors = fillResult.errors; result.warnings = fillResult.warnings; result.success = fillResult.errors.length === 0; // Submit form if requested if (options?.submitAfter && result.success) { const submitResult = await this.submitForm(options.formId); if (!submitResult.success) { result.success = false; result.errors.push(...submitResult.errors); } } } catch (error) { result.success = false; result.errors.push(`Form fill failed: ${error}`); } return result; } /** * Submit a form by ID or find the first form */ async submitForm(formId) { try { const submitted = await this.page.evaluate((id) => { let form = null; if (id) { // Try by id form = document.getElementById(id); // Try by data-mcp-id if (!form) { form = document.querySelector(`[data-mcp-id="${id}"]`); } } else { // Find first form form = document.querySelector('form'); } if (form && form.tagName === 'FORM') { // Check if there's a submit button to click instead const submitBtn = form.querySelector('[type="submit"], [data-mcp-action="click"][data-mcp-type="submit"]'); if (submitBtn) { submitBtn.click(); } else { form.submit(); } return true; } // Try to find a submit button outside the form const submitBtn = document.querySelector('[type="submit"], [data-mcp-action="click"][data-mcp-type="submit"]'); if (submitBtn) { submitBtn.click(); return true; } return false; }, formId); return { success: submitted, errors: submitted ? [] : ['No form or submit button found'] }; } catch (error) { return { success: false, errors: [`Submit failed: ${error}`] }; } } /** * Get current form data as JSON */ async getFormData(formId) { return await this.page.evaluate((id) => { const data = {}; let selector = '[data-mcp-id]'; if (id) { // Get fields within specific form selector = `#${id} [data-mcp-id], [data-mcp-id^="${id}-"]`; } const fields = document.querySelectorAll(selector); fields.forEach(field => { const mcpId = field.getAttribute('data-mcp-id') || ''; const tagName = field.tagName.toLowerCase(); const type = field.getAttribute('type')?.toLowerCase(); if (tagName === 'input') { if (type === 'checkbox' || type === 'radio') { data[mcpId] = field.checked; } else { data[mcpId] = field.value; } } else if (tagName === 'textarea') { data[mcpId] = field.value; } else if (tagName === 'select') { data[mcpId] = field.value; } }); return data; }, formId); } /** * Validate form data against rules */ async validateForm(formId) { return await this.page.evaluate((id) => { const errors = []; let selector = '[data-mcp-id]'; if (id) { selector = `#${id} [data-mcp-id], [data-mcp-id^="${id}-"]`; } const fields = document.querySelectorAll(selector); fields.forEach(field => { const mcpId = field.getAttribute('data-mcp-id') || ''; const required = field.hasAttribute('required') || field.getAttribute('data-required') === 'true'; const type = field.getAttribute('type')?.toLowerCase(); const value = field.value; // Required field validation if (required && (!value || value.trim() === '')) { errors.push({ field: mcpId, message: 'This field is required' }); } // Type-specific validation if (value && value.trim() !== '') { if (type === 'email' && !value.includes('@')) { errors.push({ field: mcpId, message: 'Invalid email format' }); } else if (type === 'tel' && !/^[+\d\s\-()]+$/.test(value)) { errors.push({ field: mcpId, message: 'Invalid phone number format' }); } else if (type === 'url' && !value.match(/^https?:\/\//)) { errors.push({ field: mcpId, message: 'URL must start with http:// or https://' }); } } }); return { valid: errors.length === 0, errors }; }, formId); } /** * Detect all forms on the page and generate JSON templates */ async detectForms() { try { const forms = await this.page.evaluate(() => { const formTemplates = []; // Only find actual form elements const allContainers = Array.from(document.querySelectorAll('form')); allContainers.forEach((container, index) => { const fields = Array.from(container.querySelectorAll('input, textarea, select, button[type="submit"]')); if (fields.length === 0) return; const formId = container.id || `form-${index}`; const formTemplate = { formId, formName: getFormName(container), fields: [], jsonTemplate: {}, example: {}, description: '' }; fields.forEach((field) => { const fieldInfo = extractFieldInfo(field); if (fieldInfo) { formTemplate.fields.push(fieldInfo); formTemplate.jsonTemplate[fieldInfo.id] = getFieldTemplate(fieldInfo); formTemplate.example[fieldInfo.id] = getFieldExample(fieldInfo); } }); if (formTemplate.fields.length > 0) { formTemplate.description = `Form with ${formTemplate.fields.length} fields: ${formTemplate.fields.map((f) => f.type).join(', ')}`; formTemplates.push(formTemplate); } }); return formTemplates; function getFormName(container) { const title = container.querySelector('h1, h2, h3, legend, .form-title, .title'); if (title) return title.textContent?.trim() || 'Unnamed Form'; const aria = container.getAttribute('aria-label'); if (aria) return aria; const id = container.id; if (id) return id.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); return 'Unnamed Form'; } function extractFieldInfo(field) { const tagName = field.tagName.toLowerCase(); const type = field.getAttribute('type') || tagName; const id = field.id || field.getAttribute('data-mcp-id') || field.getAttribute('name') || `field-${Math.random().toString(36).substr(2, 9)}`; const label = getFieldLabel(field); const placeholder = field.getAttribute('placeholder') || ''; const required = field.hasAttribute('required'); return { id, type, name: field.getAttribute('name') || id, required, placeholder, label, value: field.value || '', tagName }; } function getFieldLabel(field) { // Try to find associated label const id = field.id; if (id) { const label = document.querySelector(`label[for="${id}"]`); if (label) return label.textContent?.trim() || ''; } // Check if field is inside a label const parentLabel = field.closest('label'); if (parentLabel) return parentLabel.textContent?.trim() || ''; // Look for nearby text const prev = field.previousElementSibling; if (prev && prev.textContent) return prev.textContent.trim(); return field.getAttribute('placeholder') || field.getAttribute('name') || ''; } function getFieldTemplate(fieldInfo) { switch (fieldInfo.type) { case 'email': return 'user@example.com'; case 'password': return 'string'; case 'number': return 0; case 'checkbox': return false; case 'radio': return 'option'; case 'select': return 'option'; default: return 'string'; } } function getFieldExample(fieldInfo) { switch (fieldInfo.type) { case 'email': return 'john.doe@example.com'; case 'password': return 'mySecurePassword123'; case 'text': if (fieldInfo.label.toLowerCase().includes('name')) return 'John Doe'; if (fieldInfo.label.toLowerCase().includes('phone')) return '+1-555-123-4567'; return 'Sample text'; case 'number': return 42; case 'checkbox': return true; case 'radio': return 'yes'; case 'select': return 'option1'; default: return 'example value'; } } }); return { content: [{ type: 'text', text: `📋 Form Detection Results\n\nFound ${forms.length} form(s):\n\n${JSON.stringify(forms, null, 2)}` }] }; } catch (error) { return { content: [{ type: 'text', text: `❌ Form detection failed: ${error.message}` }] }; } } }