UNPKG

panoptical

Version:

A modern testing tool that makes Playwright easy to use with simple YAML syntax

1,333 lines (1,157 loc) 46.5 kB
import chalk from 'chalk'; import fs from 'fs'; import path from 'path'; /** * Panoptical Action Helpers - High-level automation methods that make testing delightful * These methods wrap Playwright complexity into simple, powerful YAML steps */ export class ActionHelpers { constructor(browser) { this.browser = browser; this.variables = new Map(); // Store variables for later use } /** * Get the current page object safely */ getPage() { return this.browser.getPage(); } // ===== Core Browser Actions (wrapped & improved) ===== /** * login - Logs in with username/password (custom flow, domain-specific) */ async login(credentials) { const { username, password, usernameSelector, passwordSelector, submitSelector, successIndicator } = credentials; try { // Wait for login form to be ready await this.browser.waitForSelector(usernameSelector); await this.browser.waitForSelector(passwordSelector); // Clear and fill username const page = this.getPage(); await page.fill(usernameSelector, ''); await this.browser.fill(usernameSelector, username); // Clear and fill password await page.fill(passwordSelector, ''); await this.browser.fill(passwordSelector, password); // Submit the form if (submitSelector) { await this.browser.click(submitSelector); } else { // Try to submit by pressing Enter in password field await page.press(passwordSelector, 'Enter'); } // Wait for successful login if (successIndicator) { if (typeof successIndicator === 'string') { // Check if it's a selector (starts with #, ., [, etc.) or text if (successIndicator.startsWith('#') || successIndicator.startsWith('.') || successIndicator.startsWith('[')) { // It's a selector await this.browser.waitForSelector(successIndicator, 30000); console.log(chalk.green(`✓ Successfully logged in as `) + chalk.bold.green(`${username}`)); } else { // It's text to search for const page = this.getPage(); await page.waitForFunction( (searchText) => { if (!document || !document.body) return false; return document.body.innerText && document.body.innerText.includes(searchText); }, successIndicator, { timeout: 30000 } ); console.log(chalk.green(`✓ Successfully logged in as `) + chalk.bold.green(`${username}`)); } } else if (typeof successIndicator === 'object') { // Object format: { selector: "#element", text: "Dashboard" } if (successIndicator.selector) { await this.browser.waitForSelector(successIndicator.selector, 30000); } if (successIndicator.text) { const page = this.getPage(); await page.waitForFunction( (searchText) => { if (!document || !document.body) return false; return document.body.innerText && document.body.innerText.includes(searchText); }, successIndicator.text, { timeout: 30000 } ); } console.log(chalk.green(`✓ Successfully logged in as `) + chalk.bold.green(`${username}`)); } } else { // Wait for navigation or URL change await this.browser.waitForLoadState('networkidle'); console.log(chalk.green(`✓ Login completed`)); } return true; } catch (error) { console.error(chalk.red(`✗ Login failed: ${error.message}`)); throw error; } } /** * logout - Logs out and verifies the user is redirected to the login screen */ async logout(logoutSelector, successIndicator) { try { // Handle flexible logout selector (selector, text, or object) if (typeof logoutSelector === 'string') { // Check if it's a selector (starts with #, ., [, etc.) or text if (logoutSelector.startsWith('#') || logoutSelector.startsWith('.') || logoutSelector.startsWith('[')) { // It's a selector await this.browser.waitForSelector(logoutSelector); await this.browser.click(logoutSelector); } else { // It's text to search for and click const page = this.getPage(); await page.waitForFunction( (searchText) => { if (!document || !document.body) return false; return document.body.innerText && document.body.innerText.includes(searchText); }, logoutSelector, { timeout: 30000 } ); await page.click(`text="${logoutSelector}"`); } } else if (typeof logoutSelector === 'object') { // Object format: { selector: "#element", text: "Log Out" } if (logoutSelector.selector) { await this.browser.waitForSelector(logoutSelector.selector); await this.browser.click(logoutSelector.selector); } if (logoutSelector.text) { const page = this.getPage(); await page.waitForFunction( (searchText) => { if (!document || !document.body) return false; return document.body.innerText && document.body.innerText.includes(searchText); }, logoutSelector.text, { timeout: 30000 } ); await page.click(`text="${logoutSelector.text}"`); } } // Wait for logout to complete await this.browser.waitForLoadState('networkidle'); // Verify we're on login page if (successIndicator) { if (typeof successIndicator === 'string') { // Check if it's a selector or text if (successIndicator.startsWith('#') || successIndicator.startsWith('.') || successIndicator.startsWith('[')) { await this.browser.waitForSelector(successIndicator, 30000); } else { // It's text to search for const page = this.getPage(); await page.waitForFunction( (searchText) => { if (!document || !document.body) return false; return document.body.innerText && document.body.innerText.includes(searchText); }, successIndicator, { timeout: 30000 } ); } } console.log(chalk.green(`✓ Successfully logged out`)); } else { console.log(chalk.green(`✓ Logout completed`)); } return true; } catch (error) { console.error(chalk.red(`✗ Logout failed: ${error.message}`)); throw error; } } /** * goto_with_auth - Navigates to a page but attaches an auth token/session automatically */ async gotoWithAuth(url, authToken, tokenHeader = 'Authorization') { try { // Set auth header before navigation const page = this.getPage(); await page.setExtraHTTPHeaders({ [tokenHeader]: `Bearer ${authToken}` }); // Navigate to the page await this.browser.goto(url); console.log(chalk.green(`✓ Navigated to `) + chalk.bold.green(`${url}`) + chalk.green(` with authentication`)); return true; } catch (error) { console.error(chalk.red(`✗ Navigation with auth failed: ${error.message}`)); throw error; } } /** * click_if_visible - Clicks only if an element is visible, otherwise skips */ async clickIfVisible(selector, options = {}) { try { const isVisible = await this.browser.isVisible(selector); if (isVisible) { await this.browser.click(selector, options); console.log(chalk.green(`✓ Clicked visible element: `) + chalk.bold.green(`${selector}`)); return true; } else { console.log(chalk.yellow(`⚠ Element not visible, skipping click: ${selector}`)); return false; } } catch (error) { console.error(chalk.red(`✗ Click if visible failed: ${error.message}`)); throw error; } } // ===== UI Interaction Helpers ===== /** * select_from_dropdown - Chooses an option by text, not by value (more human-friendly) */ async selectFromDropdown(selector, optionText, options = {}) { try { const timeout = options.timeout || 30000; // Wait for dropdown to be ready await this.browser.waitForSelector(selector, timeout); // Try multiple approaches for dropdown selection const page = this.getPage(); // Method 1: Try using Playwright's selectOption with label try { await page.selectOption(selector, { label: optionText }); console.log(chalk.green(`✓ Selected `) + chalk.bold.green(`"${optionText}"`) + chalk.green(` from dropdown using label`)); return true; } catch (selectError) { // Method 2: Try using selectOption with text try { await page.selectOption(selector, { text: optionText }); console.log(chalk.green(`✓ Selected `) + chalk.bold.green(`"${optionText}"`) + chalk.green(` from dropdown using text`)); return true; } catch (textError) { // Method 3: Manual click approach for custom dropdowns // Click to open dropdown await page.click(selector); // Wait a moment for dropdown to open await new Promise(resolve => setTimeout(resolve, 500)); // Try to find and click the option by text content const optionElement = await page.locator(`text="${optionText}"`).first(); if (await optionElement.isVisible()) { await optionElement.click(); console.log(chalk.green(`✓ Selected `) + chalk.bold.green(`"${optionText}"`) + chalk.green(` from dropdown using manual click`)); return true; } else { throw new Error(`Option "${optionText}" not visible in dropdown`); } } } } catch (error) { console.error(chalk.red(`✗ Dropdown selection failed: ${error.message}`)); throw error; } } /** * hover_and_click - Hover over a menu, then click a submenu item */ async hoverAndClick(hoverSelector, clickSelector, options = {}) { try { // Hover over the main element const page = this.getPage(); await page.hover(hoverSelector); // Wait for submenu to appear await this.browser.waitForSelector(clickSelector, 30000); // Click the submenu item await this.browser.click(clickSelector, options); console.log(chalk.green(`✓ Hover and click completed successfully`)); return true; } catch (error) { console.error(chalk.red(`✗ Hover and click failed: ${error.message}`)); throw error; } } /** * upload_file - Attaches a file to a file input and verifies it's uploaded */ async uploadFile(fileInputSelector, filePath, successIndicator, uploadButtonSelector = null) { try { // Wait for file input to be ready await this.browser.waitForSelector(fileInputSelector); // Upload the file const page = this.getPage(); await page.setInputFiles(fileInputSelector, filePath); // Click upload button if specified if (uploadButtonSelector) { await this.browser.waitForSelector(uploadButtonSelector); await this.browser.click(uploadButtonSelector); console.log(chalk.green(`✓ Clicked upload button: `) + chalk.bold.green(`${uploadButtonSelector}`)); } // Wait for upload to complete if (successIndicator) { await this.browser.waitForSelector(successIndicator, 30000); console.log(chalk.green(`✓ File uploaded successfully: `) + chalk.bold.green(`${path.basename(filePath)}`)); } else { // Wait for any upload progress to complete await this.browser.waitForLoadState('networkidle'); console.log(chalk.green(`✓ File upload completed: `) + chalk.bold.green(`${path.basename(filePath)}`)); } return true; } catch (error) { console.error(chalk.red(`✗ File upload failed: ${error.message}`)); throw error; } } /** * download_and_verify - Downloads a file, checks its size or contents */ async downloadAndVerify(downloadSelector, expectedSize, expectedContent = null) { try { // Set up download listener const downloadPromise = this.browser.waitForEvent('download'); // Click download button/link await this.browser.click(downloadSelector); // Wait for download to start const download = await downloadPromise; // Get download path const downloadPath = await download.path(); if (!downloadPath) { throw new Error('Download path not available'); } // Wait for file to be written await new Promise(resolve => setTimeout(resolve, 1000)); // Verify file size const stats = fs.statSync(downloadPath); const fileSize = stats.size; if (expectedSize && fileSize < expectedSize) { throw new Error(`File size too small: ${fileSize} bytes, expected at least ${expectedSize} bytes`); } // Verify file content if specified if (expectedContent) { const fileContent = fs.readFileSync(downloadPath, 'utf8'); if (!fileContent.includes(expectedContent)) { throw new Error(`Expected content not found in downloaded file`); } } console.log(chalk.green(`✓ File downloaded and verified: `) + chalk.bold.green(`${path.basename(downloadPath)}`) + chalk.green(` (${fileSize} bytes)`)); // Clean up downloaded file fs.unlinkSync(downloadPath); return true; } catch (error) { console.error(chalk.red(`✗ Download and verify failed: ${error.message}`)); throw error; } } /** * take_screenshot - Saves a screenshot with a custom name for debugging */ async takeScreenshot(name, options = {}) { try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `${name}_${timestamp}.png`; // Use the screenshot manager to get the proper path for step screenshots const screenshotManager = new (await import('../utils/screenshots.js')).ScreenshotManager(); const screenshotPath = screenshotManager.getStepScreenshotPath(filename); await this.browser.screenshot(screenshotPath, options); console.log(chalk.green(`✓ Screenshot saved: `) + chalk.bold.green(`${screenshotPath}`)); return screenshotPath; } catch (error) { console.error(chalk.red(`✗ Screenshot failed: ${error.message}`)); throw error; } } // ===== Data & Verification ===== /** * verify_table_row - Asserts that a table contains a row with specific cell values */ async verifyTableRow(tableSelector, expectedRow) { try { const page = this.getPage(); const tableData = await page.evaluate(({ selector, expected }) => { const table = document.querySelector(selector); if (!table) return null; const rows = Array.from(table.querySelectorAll('tr')); const headers = Array.from(rows[0]?.querySelectorAll('th, td') || []).map(cell => cell.textContent.trim()); for (let i = 1; i < rows.length; i++) { const cells = Array.from(rows[i].querySelectorAll('td')).map(cell => cell.textContent.trim()); let match = true; for (const [key, value] of Object.entries(expected)) { const colIndex = headers.indexOf(key); if (colIndex === -1 || cells[colIndex] !== value) { match = false; break; } } if (match) return true; } return false; }, { selector: tableSelector, expected: expectedRow }); if (tableData) { console.log(chalk.green(`✓ Table row verified successfully`)); return true; } else { throw new Error(`Expected table row not found: ${JSON.stringify(expectedRow)}`); } } catch (error) { console.error(chalk.red(`✗ Table row verification failed: ${error.message}`)); throw error; } } /** * assert_element_count - Checks how many elements match a selector */ async assertElementCount(selector, expectedCount, operator = '==') { try { const actualCount = await this.browser.locator(selector).count(); let assertionPassed = false; switch (operator) { case '==': assertionPassed = actualCount === expectedCount; break; case '>': assertionPassed = actualCount > expectedCount; break; case '>=': assertionPassed = actualCount >= expectedCount; break; case '<': assertionPassed = actualCount < expectedCount; break; case '<=': assertionPassed = actualCount <= expectedCount; break; default: throw new Error(`Invalid operator: ${operator}. Use ==, >, >=, <, or <=`); } if (assertionPassed) { console.log(chalk.green(`✓ Element count assertion passed: `) + chalk.bold.green(`${actualCount} ${operator} ${expectedCount}`)); return true; } else { throw new Error(`Element count assertion failed: ${actualCount} ${operator} ${expectedCount}`); } } catch (error) { console.error(chalk.red(`✗ Element count assertion failed: ${error.message}`)); throw error; } } /** * check_api_response - Sends an HTTP request and validates the response (API + UI combo testing) */ async checkApiResponse(url, method = 'GET', headers = {}, body = null, expectedStatus = 200, expectedContent = null) { try { const response = await this.browser.request.fetch(url, { method, headers, data: body }); // Check status code if (response.status() !== expectedStatus) { throw new Error(`Expected status ${expectedStatus}, got ${response.status()}`); } // Check response content if specified if (expectedContent) { const responseText = await response.text(); if (!responseText.includes(expectedContent)) { throw new Error(`Expected content not found in response`); } } console.log(chalk.green(`✓ API response check passed: `) + chalk.bold.green(`${response.status()}`)); return response; } catch (error) { console.error(chalk.red(`✗ API response check failed: ${error.message}`)); throw error; } } /** * assert_element_not_present - Ensures an element does not exist (good for post-action checks) */ async assertElementNotPresent(selector, timeout = 30000) { try { // Wait a bit to see if element appears await new Promise(resolve => setTimeout(resolve, timeout)); const isVisible = await this.browser.isVisible(selector); if (!isVisible) { console.log(chalk.green(`✓ Element not present as expected: `) + chalk.bold.green(`${selector}`)); return true; } else { throw new Error(`Element should not be present: ${selector}`); } } catch (error) { console.error(chalk.red(`✗ Element not present assertion failed: ${error.message}`)); throw error; } } /** * measure_performance - Records page load time or specific action performance */ async measurePerformance(action, options = {}) { try { const startTime = performance.now(); // Execute the action if (typeof action === 'function') { await action(); } else if (action === 'pageLoad') { await this.browser.waitForLoadState('networkidle'); } else if (action === 'navigation') { await this.browser.waitForLoadState('domcontentloaded'); } const endTime = performance.now(); const duration = endTime - startTime; console.log(chalk.green(`✓ Performance measured: `) + chalk.bold.green(`${action}`) + chalk.green(` took `) + chalk.bold.green(`${duration.toFixed(2)}ms`)); // Store performance data for later comparison this.variables.set(`performance_${action}`, duration); return duration; } catch (error) { console.error(chalk.red(`✗ Performance measurement failed: ${error.message}`)); throw error; } } // ===== Flow Control ===== /** * repeat - Loops a set of steps multiple times */ async repeat(steps, count, options = {}) { try { for (let i = 0; i < count; i++) { for (const step of steps) { // Execute each step in the repeat block await this.executeStep(step); } // Optional delay between iterations if (options.delay && i < count - 1) { await new Promise(resolve => setTimeout(resolve, options.delay)); } } console.log(chalk.green(`✓ Repeat completed: `) + chalk.bold.green(`${count} iterations`)); return true; } catch (error) { console.error(chalk.red(`✗ Repeat failed: ${error.message}`)); throw error; } } /** * run_if - Runs steps only if a condition is met (e.g., element exists) */ async runIf(condition, steps, options = {}) { try { let shouldRun = false; // Check various condition types if (typeof condition === 'string' && condition.startsWith('element_exists:')) { const selector = condition.replace('element_exists:', ''); shouldRun = await this.browser.isVisible(selector); } else if (typeof condition === 'string' && condition.startsWith('text_present:')) { const text = condition.replace('text_present:', ''); shouldRun = await this.browser.evaluate((searchText) => document.body.innerText.includes(searchText), text); } else if (typeof condition === 'function') { shouldRun = await condition(); } else if (typeof condition === 'boolean') { shouldRun = condition; } if (shouldRun) { for (const step of steps) { await this.executeStep(step); } console.log(chalk.green(`✓ Conditional execution completed successfully`)); return true; } else { console.log(chalk.yellow(`Condition not met, skipping steps`)); return false; } } catch (error) { console.error(chalk.red(`✗ Conditional execution failed: ${error.message}`)); throw error; } } /** * store_text - Saves text from an element into a variable for later steps */ async storeText(selector, variableName) { try { const text = await this.browser.getText(selector); // Store in variables map this.variables.set(variableName, text); console.log(chalk.green(`✓ Stored text: `) + chalk.bold.green(`"${text}"`) + chalk.green(` → `) + chalk.bold.green(`$${variableName}`)); return text; } catch (error) { console.error(chalk.red(`✗ Store text failed: ${error.message}`)); throw error; } } /** * compare_values - Compares stored variables or UI values and passes/fails based on equality */ async compareValues(value1, value2, operator = '==', options = {}) { try { // Resolve variables if they start with $ const resolveValue = (val) => { if (typeof val === 'string' && val.startsWith('$')) { const varName = val.substring(1); return this.variables.get(varName); } return val; }; const resolvedValue1 = resolveValue(value1); const resolvedValue2 = resolveValue(value2); let comparisonPassed = false; switch (operator) { case '==': comparisonPassed = resolvedValue1 === resolvedValue2; break; case '!=': comparisonPassed = resolvedValue1 !== resolvedValue2; break; case '>': comparisonPassed = resolvedValue1 > resolvedValue2; break; case '>=': comparisonPassed = resolvedValue1 >= resolvedValue2; break; case '<': comparisonPassed = resolvedValue1 < resolvedValue2; break; case '<=': comparisonPassed = resolvedValue1 <= resolvedValue2; break; case 'contains': comparisonPassed = String(resolvedValue1).includes(String(resolvedValue2)); break; default: throw new Error(`Invalid operator: ${operator}`); } if (comparisonPassed) { console.log(chalk.green(`✓ Comparison passed: `) + chalk.bold.green(`${resolvedValue1} ${operator} ${resolvedValue2}`)); return true; } else { throw new Error(`Comparison failed: ${resolvedValue1} ${operator} ${resolvedValue2}`); } } catch (error) { console.error(chalk.red(`✗ Value comparison failed: ${error.message}`)); throw error; } } /** * random_fill - Generates random test data (names, emails, addresses) and fills forms automatically */ async randomFill(formData) { try { for (const [selector, fieldType] of Object.entries(formData)) { let randomValue = ''; // Generate appropriate random data based on field type switch (fieldType) { case 'firstName': randomValue = this.generateRandomName('first'); break; case 'lastName': randomValue = this.generateRandomName('last'); break; case 'email': randomValue = this.generateRandomEmail(); break; case 'phone': randomValue = this.generateRandomPhone(); break; case 'address': randomValue = this.generateRandomAddress(); break; case 'company': randomValue = this.generateRandomCompany(); break; case 'username': randomValue = this.generateRandomUsername(); break; case 'password': randomValue = this.generateRandomPassword(); break; case 'age': case 'number': randomValue = String(Math.floor(Math.random() * 100) + 18); // Generate age 18-117 as string break; default: randomValue = `test_${fieldType}_${Date.now()}`; } // Fill the field await this.browser.waitForSelector(selector); await this.browser.fill(selector, randomValue); } console.log(chalk.green(`✓ Random form filling completed successfully`)); return true; } catch (error) { console.error(chalk.red(`✗ Random fill failed: ${error.message}`)); throw error; } } // ===== Mobile & Responsive Testing ===== /** * resize_viewport - Resizes the viewport to test responsive design */ async resizeViewport(width, height, device = null) { try { const page = this.getPage(); // Set viewport size await page.setViewportSize({ width, height }); // If device is specified, set user agent and other device-specific properties if (device) { const deviceConfig = this.getDeviceConfig(device); if (deviceConfig) { await page.setUserAgent(deviceConfig.userAgent); await page.setViewportSize(deviceConfig.viewport); } } console.log(chalk.green(`✓ Viewport resized to `) + chalk.bold.green(`${width}x${height}`)); return true; } catch (error) { console.error(chalk.red(`✗ Viewport resize failed: ${error.message}`)); throw error; } } /** * swipe - Performs swipe gesture (useful for mobile testing) */ async swipe(selector, direction, distance = 200) { try { const page = this.getPage(); // Wait for element to be ready await this.browser.waitForSelector(selector); // Get element bounding box const element = await page.locator(selector); const box = await element.boundingBox(); if (!box) { throw new Error('Element not found or not visible'); } // Calculate start and end points based on direction let startX, startY, endX, endY; const centerX = box.x + box.width / 2; const centerY = box.y + box.height / 2; switch (direction.toLowerCase()) { case 'left': startX = centerX + distance / 2; startY = centerY; endX = centerX - distance / 2; endY = centerY; break; case 'right': startX = centerX - distance / 2; startY = centerY; endX = centerX + distance / 2; endY = centerY; break; case 'up': startX = centerX; startY = centerY + distance / 2; endX = centerX; endY = centerY - distance / 2; break; case 'down': startX = centerX; startY = centerY - distance / 2; endX = centerX; endY = centerY + distance / 2; break; default: throw new Error(`Invalid direction: ${direction}. Use left, right, up, or down`); } // Perform the swipe await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(endX, endY); await page.mouse.up(); console.log(chalk.green(`✓ Swiped `) + chalk.bold.green(`${direction}`) + chalk.green(` on `) + chalk.bold.green(`${selector}`)); return true; } catch (error) { console.error(chalk.red(`✗ Swipe failed: ${error.message}`)); throw error; } } /** * tap - Performs tap gesture with configurable pressure */ async tap(selector, pressure = 0.5) { try { const page = this.getPage(); // Wait for element to be ready await this.browser.waitForSelector(selector); // Get element center const element = await page.locator(selector); const box = await element.boundingBox(); if (!box) { throw new Error('Element not found or not visible'); } const centerX = box.x + box.width / 2; const centerY = box.y + box.height / 2; // Simulate tap with pressure (pressure affects the visual feedback) await page.mouse.move(centerX, centerY); await page.mouse.down(); await page.mouse.up(); console.log(chalk.green(`✓ Tapped `) + chalk.bold.green(`${selector}`)); return true; } catch (error) { console.error(chalk.red(`✗ Tap failed: ${error.message}`)); throw error; } } // ===== Advanced Element Interactions ===== /** * drag_and_drop - Drags an element and drops it on a target */ async dragAndDrop(sourceSelector, targetSelector) { try { const page = this.getPage(); // Wait for both elements to be ready await this.browser.waitForSelector(sourceSelector); await this.browser.waitForSelector(targetSelector); // Get source element const sourceElement = await page.locator(sourceSelector); const sourceBox = await sourceElement.boundingBox(); if (!sourceBox) { throw new Error('Source element not found or not visible'); } // Get target element const targetElement = await page.locator(targetSelector); const targetBox = await targetElement.boundingBox(); if (!targetBox) { throw new Error('Target element not found or not visible'); } // Calculate center points const sourceCenterX = sourceBox.x + sourceBox.width / 2; const sourceCenterY = sourceBox.y + sourceBox.height / 2; const targetCenterX = targetBox.x + targetBox.width / 2; const targetCenterY = targetBox.y + targetBox.height / 2; // Perform drag and drop await page.mouse.move(sourceCenterX, sourceCenterY); await page.mouse.down(); await page.mouse.move(targetCenterX, targetCenterY); await page.mouse.up(); console.log(chalk.green(`✓ Dragged `) + chalk.bold.green(`${sourceSelector}`) + chalk.green(` to `) + chalk.bold.green(`${targetSelector}`)); return true; } catch (error) { console.error(chalk.red(`✗ Drag and drop failed: ${error.message}`)); throw error; } } /** * multi_select - Selects multiple options from a group of checkboxes or multi-select */ async multiSelect(selector, options) { try { const page = this.getPage(); // Wait for the selector group to be ready await this.browser.waitForSelector(selector); // Handle different types of multi-select elements const element = await page.locator(selector); const tagName = await element.evaluate(el => el.tagName.toLowerCase()); if (tagName === 'select' && await element.getAttribute('multiple')) { // Multi-select dropdown for (const option of options) { await element.selectOption({ label: option }); } } else { // Checkbox group or radio buttons for (const option of options) { const optionSelector = `${selector} input[value="${option}"], ${selector} input[data-value="${option}"]`; try { await this.browser.click(optionSelector); } catch (e) { // Try alternative selector for checkboxes const altSelector = `${selector} input[type="checkbox"], ${selector} input[type="radio"]`; const inputs = await page.locator(altSelector).all(); for (const input of inputs) { const label = await input.evaluate(el => { const labelEl = el.closest('label') || el.parentElement; return labelEl ? labelEl.textContent.trim() : ''; }); if (label.includes(option)) { await input.check(); break; } } } } } console.log(chalk.green(`✓ Selected multiple options: `) + chalk.bold.green(`${options.join(', ')}`)); return true; } catch (error) { console.error(chalk.red(`✗ Multi-select failed: ${error.message}`)); throw error; } } /** * press_keys - Presses keyboard keys or key combinations */ async pressKeys(keys, targetSelector = null) { try { const page = this.getPage(); // Focus on target element if specified if (targetSelector) { await this.browser.waitForSelector(targetSelector); await page.locator(targetSelector).focus(); } // Press the key combination await page.keyboard.press(keys.join('+')); console.log(chalk.green(`✓ Pressed keys: `) + chalk.bold.green(`${keys.join(' + ')}`)); return true; } catch (error) { console.error(chalk.red(`✗ Key press failed: ${error.message}`)); throw error; } } /** * scroll_to_element - Scrolls to make an element visible */ async scrollToElement(selector, behavior = 'smooth') { try { const page = this.getPage(); // Wait for element to exist await this.browser.waitForSelector(selector); // Scroll to element await page.locator(selector).scrollIntoViewIfNeeded(); console.log(chalk.green(`✓ Scrolled to element: `) + chalk.bold.green(`${selector}`)); return true; } catch (error) { console.error(chalk.red(`✗ Scroll to element failed: ${error.message}`)); throw error; } } /** * hover_element - Hovers over an element to trigger hover effects */ async hoverElement(selector, duration = 1000) { try { const page = this.getPage(); // Wait for element to be ready await this.browser.waitForSelector(selector); // Hover over element await page.hover(selector); // Keep hover for specified duration if (duration > 0) { await new Promise(resolve => setTimeout(resolve, duration)); } console.log(chalk.green(`✓ Hovered over element: `) + chalk.bold.green(`${selector}`)); return true; } catch (error) { console.error(chalk.red(`✗ Hover failed: ${error.message}`)); throw error; } } /** * iframe_action - Performs actions inside iframes (click, type, get_text, etc.) */ async iframeAction(iframeSelector, action, targetSelector, options = {}) { try { const page = this.getPage(); // Wait for iframe to be ready await this.browser.waitForSelector(iframeSelector); // Get iframe element const iframe = await page.locator(iframeSelector); // Wait for iframe content to load await iframe.waitFor({ state: 'attached' }); // Get iframe frame const frame = iframe.contentFrame(); if (!frame) { throw new Error(`Could not access iframe content: ${iframeSelector}`); } // Wait for target element in iframe using Playwright's locator API const targetElement = frame.locator(targetSelector); await targetElement.waitFor({ timeout: options.timeout || 10000 }); let result = null; // Perform the requested action switch (action.toLowerCase()) { case 'click': await targetElement.click(); console.log(chalk.green(`✓ Clicked `) + chalk.bold.green(`${targetSelector}`) + chalk.green(` in iframe `) + chalk.bold.green(`${iframeSelector}`)); break; case 'type': const text = options.text || ''; if (!text) { throw new Error('Text is required for type action'); } await targetElement.fill(text); console.log(chalk.green(`✓ Typed `) + chalk.bold.green(`"${text}"`) + chalk.green(` into `) + chalk.bold.green(`${targetSelector}`) + chalk.green(` in iframe `) + chalk.bold.green(`${iframeSelector}`)); break; case 'get_text': result = await targetElement.textContent(); console.log(chalk.green(`✓ Got text from `) + chalk.bold.green(`${targetSelector}`) + chalk.green(` in iframe: `) + chalk.bold.green(`"${result}"`)); break; case 'wait': await targetElement.waitFor({ timeout: options.timeout || 10000 }); console.log(chalk.green(`✓ Waited for `) + chalk.bold.green(`${targetSelector}`) + chalk.green(` in iframe `) + chalk.bold.green(`${iframeSelector}`)); break; case 'is_visible': result = await targetElement.isVisible(); console.log(chalk.green(`✓ Element `) + chalk.bold.green(`${targetSelector}`) + chalk.green(` in iframe is `) + chalk.bold.green(result ? 'visible' : 'hidden')); break; case 'evaluate': const script = options.script || ''; if (!script) { throw new Error('Script is required for evaluate action'); } result = await targetElement.evaluate(script); console.log(chalk.green(`✓ Executed script on `) + chalk.bold.green(`${targetSelector}`) + chalk.green(` in iframe `) + chalk.bold.green(`${iframeSelector}`)); break; default: throw new Error(`Unsupported iframe action: ${action}. Use: click, type, get_text, wait, is_visible, or evaluate`); } return result; } catch (error) { console.error(chalk.red(`✗ Iframe action failed: ${error.message}`)); throw error; } } // ===== Helper Methods ===== /** * Execute a single step (used by repeat and run_if) */ async executeStep(step) { if (step.click) { // Enhanced click action that handles multiple formats if (typeof step.click === 'string') { // Traditional selector click: click: "#button" await this.browser.click(step.click); } else if (step.click.text) { // Text-based click: click: text: "Button Text" const page = this.getPage(); const timeout = step.click.timeout || 30000; // Wait for the text to be available await page.waitForFunction( (searchText) => { if (!document || !document.body) return false; return document.body.innerText && document.body.innerText.includes(searchText); }, step.click.text, { timeout } ); // Click on the element with the matching text await page.click(`text="${step.click.text}"`); } else if (step.click.selector) { // Selector with timeout: click: selector: "#button", timeout: 5000 const timeout = step.click.timeout || 30000; await this.browser.waitForSelector(step.click.selector, timeout); await this.browser.click(step.click.selector); } else { throw new Error('click action must specify either a string selector, text, or selector object'); } } else if (step.fill) { await this.browser.fill(step.fill.selector, step.fill.text); } else if (step.type) { const delay = step.type.delay || 100; await this.browser.type(step.type.selector, step.type.text, { delay }); } else if (step.selectOption) { await this.browser.selectOption(step.selectOption.selector, step.selectOption.value); } else if (step.evaluate) { await this.browser.evaluate(step.evaluate.script, step.evaluate.selector); } else if (step.randomFill) { await this.randomFill(step.randomFill); } else if (step.wait) { if (step.wait.text) { const page = this.getPage(); const timeout = step.wait.timeout || 30000; await page.waitForFunction( (searchText) => { if (!document || !document.body) return false; return document.body.innerText && document.body.innerText.includes(searchText); }, step.wait.text, { timeout } ); } else if (step.wait.selector) { await this.browser.waitForSelector(step.wait.selector, step.wait.timeout); } } // Add more step types as needed } /** * Generate random first names */ generateRandomName(type) { const firstNames = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack']; const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez']; if (type === 'first') { return firstNames[Math.floor(Math.random() * firstNames.length)]; } else { return lastNames[Math.floor(Math.random() * lastNames.length)]; } } /** * Generate random email */ generateRandomEmail() { const domains = ['example.com', 'test.org', 'demo.net', 'sample.io']; const domain = domains[Math.floor(Math.random() * domains.length)]; const timestamp = Date.now(); return `user_${timestamp}@${domain}`; } /** * Generate random phone number */ generateRandomPhone() { const areaCode = Math.floor(Math.random() * 900) + 100; const prefix = Math.floor(Math.random() * 900) + 100; const lineNumber = Math.floor(Math.random() * 9000) + 1000; return `(${areaCode}) ${prefix}-${lineNumber}`; } /** * Generate random address */ generateRandomAddress() { const numbers = Math.floor(Math.random() * 9999) + 1; const streets = ['Main St', 'Oak Ave', 'Pine Rd', 'Elm St', 'Cedar Ln']; const street = streets[Math.floor(Math.random() * streets.length)]; return `${numbers} ${street}`; } /** * Generate random company name */ generateRandomCompany() { const companies = ['TechCorp', 'InnovateInc', 'FutureTech', 'Digital Solutions', 'Smart Systems']; return companies[Math.floor(Math.random() * companies.length)]; } /** * Generate random username */ generateRandomUsername() { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 8); return `user_${random}_${timestamp}`; } /** * Generate random password */ generateRandomPassword() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; let password = ''; for (let i = 0; i < 12; i++) { password += chars.charAt(Math.floor(Math.random() * chars.length)); } return password; } /** * Get all stored variables */ getVariables() { return Object.fromEntries(this.variables); } /** * Clear all stored variables */ clearVariables() { this.variables.clear(); } /** * Get device configuration for mobile testing */ getDeviceConfig(deviceName) { const devices = { 'iPhone SE': { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1', viewport: { width: 375, height: 667 } }, 'iPhone 12 Pro': { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1', viewport: { width: 390, height: 844 } }, 'iPad': { userAgent: 'Mozilla/5.0 (iPad; CPU OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1', viewport: { width: 768, height: 1024 } }, 'Android': { userAgent: 'Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36', viewport: { width: 360, height: 640 } } }; return devices[deviceName] || null; } }