UNPKG

umbrellamode

Version:

UmbrellaMode shared library

768 lines (767 loc) 29.2 kB
export class Actor { /** * Clicks on an element using native DOM events * * This method finds the element by selector, focuses it, and dispatches a native * click event. It works with all clickable elements including buttons, links, * and custom elements with click handlers. * * @param args - Click action arguments * @param args.selector - CSS selector to target the element * * @throws {Error} When element is not found * * @example * ```typescript * // Click a button * await actor.click({ selector: '#submit-button' }); * * // Click using data attributes * await actor.click({ selector: '[data-testid="save-btn"]' }); * * // Click a link * await actor.click({ selector: 'a[href="/dashboard"]' }); * ``` */ async click({ selector }) { // Find the element using native DOM methods const element = document.querySelector(selector); if (!element) { throw new Error(`Element not found with selector: ${selector}`); } // Focus the element first to ensure it's interactive if (element instanceof HTMLElement) { element.focus(); } // Create and dispatch a click event const clickEvent = new MouseEvent("click", { bubbles: true, cancelable: true, view: window, }); element.dispatchEvent(clickEvent); } /** * Types text into an input element with optional typing simulation * * This method supports input elements, textareas, and contenteditable elements. * It uses native DOM setters to bypass React's synthetic events and properly * triggers input and change events. When simulateTyping is true, it types * character by character with delays to mimic human behavior. * * @param args - Type action arguments * @param args.selector - CSS selector to target the input element * @param args.text - Text to type into the element * @param args.simulateTyping - Whether to simulate human-like typing with delays * * @throws {Error} When element is not found or not a supported input type * * @example * ```typescript * // Fast typing (recommended for most cases) * await actor.type({ * selector: '#username', * text: 'john.doe@example.com', * simulateTyping: false * }); * * // Simulated typing for more realistic user interaction * await actor.type({ * selector: '#message', * text: 'Hello, how are you?', * simulateTyping: true * }); * * // Works with different input types * await actor.type({ selector: 'input[type="email"]', text: 'test@example.com', simulateTyping: false }); * await actor.type({ selector: 'textarea[name="comment"]', text: 'Great product!', simulateTyping: false }); * ``` */ async type({ selector, text, simulateTyping }) { // Find the element using native DOM methods const element = document.querySelector(selector); if (!element) { throw new Error(`Element not found with selector: ${selector}`); } // Focus the element first if (element instanceof HTMLElement) { element.focus(); } // Handle different types of input elements if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { // Store native setter to bypass React's setter const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set; if (simulateTyping) { // Clear existing content first if (element instanceof HTMLInputElement && nativeInputValueSetter) { nativeInputValueSetter.call(element, ""); } else if (element instanceof HTMLTextAreaElement && nativeTextAreaValueSetter) { nativeTextAreaValueSetter.call(element, ""); } // Simulate typing character by character let currentValue = ""; for (let i = 0; i < text.length; i++) { const char = text[i]; if (!char) continue; currentValue += char; // Use native setter to bypass React if (element instanceof HTMLInputElement && nativeInputValueSetter) { nativeInputValueSetter.call(element, currentValue); } else if (element instanceof HTMLTextAreaElement && nativeTextAreaValueSetter) { nativeTextAreaValueSetter.call(element, currentValue); } // Dispatch input event const inputEvent = new InputEvent("input", { data: char, inputType: "insertText", bubbles: true, cancelable: true, }); element.dispatchEvent(inputEvent); // Small delay to simulate human typing await new Promise((resolve) => setTimeout(resolve, 50)); } } else { // Fast typing - set value directly using native setter if (element instanceof HTMLInputElement && nativeInputValueSetter) { nativeInputValueSetter.call(element, text); } else if (element instanceof HTMLTextAreaElement && nativeTextAreaValueSetter) { nativeTextAreaValueSetter.call(element, text); } // Dispatch input event const inputEvent = new Event("input", { bubbles: true, cancelable: true, }); element.dispatchEvent(inputEvent); } // Dispatch final change event const changeEvent = new Event("change", { bubbles: true, cancelable: true, }); element.dispatchEvent(changeEvent); } else if (element instanceof HTMLElement && element.contentEditable === "true") { // Handle contenteditable elements element.textContent = ""; if (simulateTyping) { // Simulate typing character by character for contenteditable for (let i = 0; i < text.length; i++) { const char = text[i]; if (!char) continue; // Add the character to the current text content element.textContent += char; // Dispatch input event with the character data const inputEvent = new InputEvent("input", { data: char, inputType: "insertText", bubbles: true, cancelable: true, }); element.dispatchEvent(inputEvent); // Small delay to simulate human typing await new Promise((resolve) => setTimeout(resolve, 50)); } } else { // Fast typing - set text content directly element.textContent = text; // Dispatch input event const inputEvent = new Event("input", { bubbles: true, cancelable: true, }); element.dispatchEvent(inputEvent); } } else { throw new Error(`Element with selector "${selector}" is not a supported input element (input, textarea, or contenteditable)`); } } /** * Clears the content of an input element * * This method removes all text from input elements, textareas, and contenteditable * elements. It properly triggers input and change events to notify React components * of the change. * * @param args - Clear action arguments * @param args.selector - CSS selector to target the input element * * @throws {Error} When element is not found or not a supported input type * * @example * ```typescript * // Clear a search input * await actor.clear({ selector: '#search-input' }); * * // Clear a textarea * await actor.clear({ selector: 'textarea[name="description"]' }); * * // Clear contenteditable div * await actor.clear({ selector: '[contenteditable="true"]' }); * ``` */ async clear({ selector }) { // Clear the content of an input element const element = document.querySelector(selector); if (!element) { throw new Error(`Element not found with selector: ${selector}`); } if (element instanceof HTMLElement) { element.focus(); } if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { element.value = ""; // Dispatch events to notify of the change const inputEvent = new Event("input", { bubbles: true, cancelable: true, }); const changeEvent = new Event("change", { bubbles: true, cancelable: true, }); element.dispatchEvent(inputEvent); element.dispatchEvent(changeEvent); } else if (element instanceof HTMLElement && element.contentEditable === "true") { element.textContent = ""; const inputEvent = new Event("input", { bubbles: true, cancelable: true, }); element.dispatchEvent(inputEvent); } else { throw new Error(`Element with selector "${selector}" is not a supported input element (input, textarea, or contenteditable)`); } } /** * Waits for an element to appear in the DOM * * This method uses a MutationObserver to watch for DOM changes and resolves * when the target element becomes available. It's useful for waiting for * dynamically loaded content or elements that appear after async operations. * * @param args - Wait for element action arguments * @param args.selector - CSS selector to wait for * @param args.timeout - Maximum time to wait in milliseconds (default: 5000) * * @returns Promise that resolves to the found Element * * @throws {Error} When element is not found within the timeout period * * @example * ```typescript * // Wait for a loading spinner to appear * await actor.waitForElement({ selector: '.loading-spinner' }); * * // Wait for success message with custom timeout * await actor.waitForElement({ * selector: '.success-message', * timeout: 10000 * }); * * // Wait for dynamically loaded content * await actor.waitForElement({ selector: '[data-testid="dynamic-content"]' }); * ``` */ async waitForElement({ selector, timeout = 5000, }) { // Wait for an element to appear in the DOM return new Promise((resolve, reject) => { const element = document.querySelector(selector); if (element) { resolve(element); return; } const observer = new MutationObserver(() => { const element = document.querySelector(selector); if (element) { observer.disconnect(); resolve(element); } }); observer.observe(document.body, { childList: true, subtree: true, }); // Set timeout setTimeout(() => { observer.disconnect(); reject(new Error(`Element with selector "${selector}" not found within ${timeout}ms`)); }, timeout); }); } /** * Scrolls to specific coordinates on the page * * This method scrolls the page to the exact pixel coordinates specified. * Use this for precise scroll positioning or when you know the exact * scroll position you want to reach. * * @param args - Scroll to action arguments * @param args.x - Horizontal scroll position in pixels * @param args.y - Vertical scroll position in pixels * * @example * ```typescript * // Scroll to top of page * await actor.scrollTo({ x: 0, y: 0 }); * * // Scroll to specific position * await actor.scrollTo({ x: 0, y: 500 }); * * // Scroll horizontally (for horizontal scrollable content) * await actor.scrollTo({ x: 200, y: 0 }); * ``` */ async scrollTo({ x, y }) { // Scroll to specific coordinates window.scrollTo(x, y); } /** * Scrolls to the top of the page * * Convenience method that scrolls to the very top of the page (0, 0). * Equivalent to calling scrollTo({ x: 0, y: 0 }). * * @example * ```typescript * await actor.scrollToTop(); * ``` */ async scrollToTop() { // Scroll to the top of the page window.scrollTo(0, 0); } /** * Scrolls to the bottom of the page * * Convenience method that scrolls to the very bottom of the page. * Uses document.documentElement.scrollHeight to determine the maximum scroll position. * * @example * ```typescript * await actor.scrollToBottom(); * ``` */ async scrollToBottom() { // Scroll to the bottom of the page window.scrollTo(0, document.documentElement.scrollHeight); } /** * Scrolls by relative amounts from the current position * * This method scrolls relative to the current scroll position. Positive values * scroll down/right, negative values scroll up/left. Useful for incremental * scrolling or when you want to scroll a specific amount regardless of current position. * * @param args - Scroll by action arguments * @param args.x - Horizontal scroll amount in pixels (positive = right, negative = left) * @param args.y - Vertical scroll amount in pixels (positive = down, negative = up) * * @example * ```typescript * // Scroll down 200px * await actor.scrollBy({ x: 0, y: 200 }); * * // Scroll up 100px * await actor.scrollBy({ x: 0, y: -100 }); * * // Scroll right 50px * await actor.scrollBy({ x: 50, y: 0 }); * * // Scroll diagonally * await actor.scrollBy({ x: 100, y: -50 }); * ``` */ async scrollBy({ x, y }) { // Scroll by relative amounts window.scrollBy(x, y); } /** * Scrolls an element into view with customizable options * * This method scrolls the page so that the specified element becomes visible. * It uses the native scrollIntoView API with customizable options for * fine-tuning the scroll behavior and positioning. * * @param args - Scroll to element action arguments * @param args.selector - CSS selector to target the element * @param args.options - ScrollIntoView options for fine-tuning scroll behavior * * @throws {Error} When element is not found * * @example * ```typescript * // Scroll to center of viewport (default) * await actor.scrollToElement({ selector: '#section-3' }); * * // Scroll to top of viewport * await actor.scrollToElement({ * selector: '.important-content', * options: { block: 'start' } * }); * * // Scroll to bottom of viewport * await actor.scrollToElement({ * selector: '#footer', * options: { block: 'end' } * }); * * // Smooth scrolling * await actor.scrollToElement({ * selector: '#hero', * options: { behavior: 'smooth', block: 'center' } * }); * ``` */ async scrollToElement({ selector, options = {}, }) { // Scroll an element into view const element = document.querySelector(selector); if (!element) { throw new Error(`Element not found with selector: ${selector}`); } // Default scroll options for smooth behavior const defaultOptions = { behavior: "smooth", block: "center", inline: "center", ...options, }; element.scrollIntoView(defaultOptions); } /** * Scrolls an element to the top of the viewport * * Convenience method that scrolls the specified element to the top of the * viewport. Equivalent to calling scrollToElement with { block: 'start' }. * * @param args - Scroll to element top action arguments * @param args.selector - CSS selector to target the element * * @throws {Error} When element is not found * * @example * ```typescript * await actor.scrollToElementTop({ selector: '#header' }); * await actor.scrollToElementTop({ selector: '.navigation' }); * ``` */ async scrollToElementTop({ selector }) { // Scroll element to the top of the viewport await this.scrollToElement({ selector, options: { block: "start" } }); } /** * Scrolls an element to the bottom of the viewport * * Convenience method that scrolls the specified element to the bottom of the * viewport. Equivalent to calling scrollToElement with { block: 'end' }. * * @param args - Scroll to element bottom action arguments * @param args.selector - CSS selector to target the element * * @throws {Error} When element is not found * * @example * ```typescript * await actor.scrollToElementBottom({ selector: '#footer' }); * await actor.scrollToElementBottom({ selector: '.contact-info' }); * ``` */ async scrollToElementBottom({ selector, }) { // Scroll element to the bottom of the viewport await this.scrollToElement({ selector, options: { block: "end" } }); } /** * Scrolls down by one viewport height * * Convenience method that scrolls down by the height of the current viewport. * Useful for pagination or step-by-step scrolling through content. * * @param args - Scroll page down action arguments (empty object) * * @example * ```typescript * await actor.scrollPageDown({}); * ``` */ async scrollPageDown({}) { // Scroll down by one viewport height const viewportHeight = window.innerHeight; await this.scrollBy({ x: 0, y: viewportHeight }); } /** * Scrolls up by one viewport height * * Convenience method that scrolls up by the height of the current viewport. * Useful for pagination or step-by-step scrolling through content. * * @param args - Scroll page up action arguments (empty object) * * @example * ```typescript * await actor.scrollPageUp({}); * ``` */ async scrollPageUp({}) { // Scroll up by one viewport height const viewportHeight = window.innerHeight; await this.scrollBy({ x: 0, y: -viewportHeight }); } /** * Gets the current scroll position of the page * * Returns the current horizontal and vertical scroll positions in pixels. * Useful for tracking scroll state or implementing custom scroll behaviors. * * @param args - Get scroll position action arguments (empty object) * * @returns Object containing x and y scroll positions in pixels * * @example * ```typescript * const position = actor.getScrollPosition({}); * console.log(`Current scroll: ${position.x}, ${position.y}`); * * // Use for conditional logic * if (position.y > 1000) { * console.log('User has scrolled past 1000px'); * } * ``` */ getScrollPosition({}) { // Get current scroll position return { x: window.pageXOffset || document.documentElement.scrollLeft, y: window.pageYOffset || document.documentElement.scrollTop, }; } /** * Checks if an element is currently visible in the viewport * * This method determines whether the specified element is fully visible within * the current viewport. Useful for implementing lazy loading, scroll-based * animations, or conditional logic based on element visibility. * * @param args - Is element in viewport action arguments * @param args.selector - CSS selector to target the element * * @returns True if element is fully visible in viewport, false otherwise * * @example * ```typescript * const isVisible = actor.isElementInViewport({ selector: '#hero-section' }); * if (isVisible) { * console.log('Hero section is visible!'); * } * * // Use for lazy loading * if (actor.isElementInViewport({ selector: '.lazy-image' })) { * // Load the image * } * ``` */ isElementInViewport({ selector, }) { // Check if an element is currently visible in the viewport const element = document.querySelector(selector); if (!element) { return false; } const rect = element.getBoundingClientRect(); const windowHeight = window.innerHeight || document.documentElement.clientHeight; const windowWidth = window.innerWidth || document.documentElement.clientWidth; return (rect.top >= 0 && rect.left >= 0 && rect.bottom <= windowHeight && rect.right <= windowWidth); } /** * Focuses an element * * This method sets focus to the specified element, making it the active element * for keyboard input. Useful for preparing elements for typing or ensuring * proper tab order behavior. * * @param args - Focus action arguments * @param args.selector - CSS selector to target the element * * @throws {Error} When element is not found * * @example * ```typescript * await actor.focus({ selector: '#email-input' }); * await actor.focus({ selector: 'input[name="password"]' }); * * // Focus before typing * await actor.focus({ selector: '#search' }); * await actor.type({ selector: '#search', text: 'query', simulateTyping: false }); * ``` */ focus({ selector }) { const element = document.querySelector(selector); if (!element) { throw new Error(`Element not found with selector: ${selector}`); } if (element instanceof HTMLElement) { element.focus(); } } /** * Removes focus from an element * * This method removes focus from the specified element, which can trigger * blur events and validation. Useful for testing form validation or * ensuring proper focus management. * * @param args - Blur action arguments * @param args.selector - CSS selector to target the element * * @throws {Error} When element is not found * * @example * ```typescript * await actor.blur({ selector: '#search-input' }); * await actor.blur({ selector: 'textarea[name="comment"]' }); * * // Test form validation on blur * await actor.type({ selector: '#email', text: 'invalid-email', simulateTyping: false }); * await actor.blur({ selector: '#email' }); * // Check for validation error * ``` */ blur({ selector }) { const element = document.querySelector(selector); if (!element) { throw new Error(`Element not found with selector: ${selector}`); } if (element instanceof HTMLElement) { element.blur(); } } /** * Fast typing without simulation (convenience method) * * This is a convenience method that calls the type method with simulateTyping * set to false. It's equivalent to calling type({ selector, text, simulateTyping: false }) * but provides a cleaner API for the common case of fast typing. * * @param args - Type fast action arguments * @param args.selector - CSS selector to target the input element * @param args.text - Text to type into the element * * @throws {Error} When element is not found or not a supported input type * * @example * ```typescript * // Fast typing for form fields * await actor.typeFast({ selector: '#username', text: 'john.doe' }); * await actor.typeFast({ selector: '#password', text: 'secret123' }); * * // Fast typing for search * await actor.typeFast({ selector: '#search', text: 'query' }); * * // Equivalent to: * // await actor.type({ selector: '#username', text: 'john.doe', simulateTyping: false }); * ``` */ async typeFast({ selector, text }) { return this.type({ selector, text, simulateTyping: false }); } /** * Highlights an element with a temporary visual effect * * This method temporarily highlights an element by adding a colored background and border. * Uses CSS !important declarations to ensure the highlight is always visible, overriding * any existing styles including Tailwind CSS classes. The highlight automatically * disappears after the specified duration. Useful for debugging, testing, or drawing * attention to specific elements during automation. * * @param args - Highlight action arguments * @param args.selector - CSS selector to target the element * @param args.color - Highlight color (default: 'red') * @param args.style - Custom CSS border style (overrides color if provided) * @param args.duration - Duration in milliseconds before removing highlight (default: 2000) * * @throws {Error} When element is not found * * @example * ```typescript * // Highlight with default red background and border * await actor.highlight({ selector: '#important-section' }); * * // Highlight with custom color (overrides Tailwind classes) * await actor.highlight({ * selector: '.error-message', * color: 'red', * duration: 3000 * }); * * // Highlight with custom style * await actor.highlight({ * selector: '#success-banner', * style: '3px solid green', * duration: 2000 * }); * * // Highlight for debugging (always visible) * await actor.highlight({ * selector: '[data-testid="submit-btn"]', * color: 'blue', * duration: 1000 * }); * ``` */ async highlight({ selector, color = "red", style, duration = 2000, }) { // Find the element using native DOM methods const element = document.querySelector(selector); if (!element) { throw new Error(`Element not found with selector: ${selector}`); } if (!(element instanceof HTMLElement)) { throw new Error(`Element with selector "${selector}" is not an HTMLElement`); } // Store original styles to restore later const originalOutline = element.style.outline; const originalBackgroundColor = element.style.backgroundColor; const originalTransition = element.style.transition; const originalBoxShadow = element.style.boxShadow; // Apply highlight style with !important to override existing styles if (style) { // Use custom style (typically a border) with !important element.style.setProperty("outline", style, "important"); element.style.setProperty("box-shadow", "0 0 0 2px rgba(255, 255, 0, 0.3)", "important"); } else { // Use default color highlighting with !important element.style.setProperty("background-color", color, "important"); element.style.setProperty("box-shadow", `0 0 0 2px ${color}`, "important"); } // Add smooth transition for better visual effect element.style.setProperty("transition", "all 0.3s ease", "important"); // Remove highlight after specified duration setTimeout(() => { // Restore original styles element.style.outline = originalOutline; element.style.backgroundColor = originalBackgroundColor; element.style.transition = originalTransition; element.style.boxShadow = originalBoxShadow; }, duration); } }