UNPKG

@sashbot/uibridge

Version:

🤖 AI-friendly live session automation with REAL screenshot backgrounds (no transparency issues!) - control your EXISTING browser with visual debug panel. Perfect for AI agents!

271 lines (242 loc) • 7.59 kB
/** * Click Command - Synthetic click interactions */ export const clickCommand = { name: 'click', description: 'Clicks on an element using synthetic mouse events', examples: [ "execute('click', '#submit-button')", "execute('click', { text: 'Submit' })", "execute('click', { testId: 'login-btn' })", "execute('click', '#button', { position: 'center', clickCount: 2 })" ], parameters: [ { name: 'selector', type: 'Selector', required: true, description: 'Element to click (string, CSS selector, or selector object)' }, { name: 'options', type: 'ClickOptions', required: false, description: 'Click options: { force?, position?, button?, clickCount?, delay? }' } ], async execute(bridge, selector, options = {}) { const element = bridge.findElement(selector); if (!element) { throw new Error(`Element not found: ${JSON.stringify(selector)}`); } // Default options const opts = { force: false, position: 'center', // center, topLeft, topRight, bottomLeft, bottomRight button: 'left', // left, right, middle clickCount: 1, delay: 0, scrollIntoView: true, ...options }; // Log the action bridge._log(`Clicking element: ${bridge.selectorEngine.getElementInfo(element)?.tag || 'unknown'}`); // Scroll element into view if requested if (opts.scrollIntoView) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Small delay to allow scroll to complete await new Promise(resolve => setTimeout(resolve, 100)); } // Check visibility unless force is true if (!opts.force) { const isVisible = bridge.selectorEngine.isVisible(element); if (!isVisible) { throw new Error('Element is not visible. Use { force: true } to click anyway.'); } } // Check if element is actionable (not covered by another element) if (!opts.force) { const isActionable = this._isElementActionable(element); if (!isActionable) { throw new Error('Element is covered by another element. Use { force: true } to click anyway.'); } } // Calculate click position const rect = element.getBoundingClientRect(); const position = this._calculatePosition(rect, opts.position); // Create synthetic mouse events const eventInit = { bubbles: true, cancelable: true, view: window, clientX: position.x, clientY: position.y, button: this._getButtonCode(opts.button), buttons: this._getButtonsCode(opts.button), detail: opts.clickCount }; // Dispatch mouse events sequence try { // Hover first element.dispatchEvent(new MouseEvent('mouseover', eventInit)); element.dispatchEvent(new MouseEvent('mouseenter', eventInit)); // Mouse down element.dispatchEvent(new MouseEvent('mousedown', eventInit)); // Optional delay between mousedown and mouseup if (opts.delay > 0) { await new Promise(resolve => setTimeout(resolve, opts.delay)); } // Mouse up element.dispatchEvent(new MouseEvent('mouseup', eventInit)); // Click event(s) for (let i = 0; i < opts.clickCount; i++) { if (i > 0) { await new Promise(resolve => setTimeout(resolve, 50)); // Small delay between multiple clicks } element.dispatchEvent(new MouseEvent('click', { ...eventInit, detail: i + 1 })); } // Focus element if it's focusable if (bridge.selectorEngine._isFocusable(element)) { element.focus(); } // Special handling for different element types await this._handleSpecialElements(element, opts); } catch (error) { throw new Error(`Failed to click element: ${error.message}`); } return { success: true, element: bridge.selectorEngine.getElementInfo(element), position: position, timestamp: new Date().toISOString() }; }, /** * Check if element is actionable (not covered by another element) * @param {Element} element - Element to check * @returns {boolean} True if element is actionable * @private */ _isElementActionable(element) { const rect = element.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const elementAtPoint = document.elementFromPoint(centerX, centerY); // Check if the element at the point is the same element or a descendant return element === elementAtPoint || element.contains(elementAtPoint); }, /** * Calculate click position based on position option * @param {DOMRect} rect - Element bounding rectangle * @param {string} position - Position option * @returns {Object} Coordinates {x, y} * @private */ _calculatePosition(rect, position) { const positions = { center: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, topLeft: { x: rect.left + 1, y: rect.top + 1 }, topRight: { x: rect.right - 1, y: rect.top + 1 }, bottomLeft: { x: rect.left + 1, y: rect.bottom - 1 }, bottomRight: { x: rect.right - 1, y: rect.bottom - 1 }, topCenter: { x: rect.left + rect.width / 2, y: rect.top + 1 }, bottomCenter: { x: rect.left + rect.width / 2, y: rect.bottom - 1 }, leftCenter: { x: rect.left + 1, y: rect.top + rect.height / 2 }, rightCenter: { x: rect.right - 1, y: rect.top + rect.height / 2 } }; return positions[position] || positions.center; }, /** * Get mouse button code * @param {string} button - Button name * @returns {number} Button code * @private */ _getButtonCode(button) { const buttons = { left: 0, middle: 1, right: 2 }; return buttons[button] || 0; }, /** * Get mouse buttons bitmask * @param {string} button - Button name * @returns {number} Buttons bitmask * @private */ _getButtonsCode(button) { const buttons = { left: 1, middle: 4, right: 2 }; return buttons[button] || 1; }, /** * Handle special element types (forms, checkboxes, etc.) * @param {Element} element - Element that was clicked * @param {Object} opts - Click options * @private */ async _handleSpecialElements(element, opts) { const tagName = element.tagName.toLowerCase(); const inputType = element.type?.toLowerCase(); // Handle form submission if (tagName === 'button' && element.type === 'submit') { const form = element.closest('form'); if (form) { // Let the natural form submission happen return; } } // Handle checkbox/radio button state if (tagName === 'input' && (inputType === 'checkbox' || inputType === 'radio')) { // The native click event should handle the state change return; } // Handle select elements if (tagName === 'select') { // Dispatch change event after click setTimeout(() => { element.dispatchEvent(new Event('change', { bubbles: true })); }, 10); } // Handle links if (tagName === 'a' && element.href) { // Let natural navigation happen, but can be intercepted if needed return; } } };