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
JavaScript
/**
* 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}`
}]
};
}
}
}