#!/usr/bin/env node /** * Configure Static Route via Browser Automation * * This script uses Playwright to automate the UDM Pro web interface * to configure a static route from 192.168.0.0/24 to 192.168.11.0/24 (VLAN 11) * * IMPORTANT: This is a careful, methodical implementation with: * - Step-by-step navigation with verification * - Error handling and retry logic * - Screenshot capture for debugging * - Dry-run mode for testing * - Detailed logging */ import { chromium } from 'playwright'; import { readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; // Load environment variables const envPath = join(homedir(), '.env'); function loadEnvFile(filePath) { try { const envFile = readFileSync(filePath, 'utf8'); const envVars = envFile.split('\n').filter( (line) => line.includes('=') && !line.trim().startsWith('#') ); for (const line of envVars) { const [key, ...values] = line.split('='); if (key && values.length > 0 && /^[A-Z_][A-Z0-9_]*$/.test(key.trim())) { let value = values.join('=').trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } process.env[key.trim()] = value; } } return true; } catch { return false; } } // Save environment variables BEFORE loading .env (so .env doesn't override command line/env vars) const ENV_USERNAME = process.env.UNIFI_BROWSER_USERNAME || process.env.UNIFI_USERNAME; const ENV_PASSWORD = process.env.UNIFI_BROWSER_PASSWORD || process.env.UNIFI_PASSWORD; loadEnvFile(envPath); // Configuration const UDM_URL = process.env.UNIFI_UDM_URL || 'https://192.168.0.1'; // Allow override via command line or environment variable (environment takes precedence over .env) // Default to unifi_api for browser automation const USERNAME = process.argv.find(arg => arg.startsWith('--username='))?.split('=')[1] || ENV_USERNAME // Use saved value from before .env load || process.env.UNIFI_USERNAME // Fallback to .env value || process.env.UNIFI_API_USERNAME || 'unifi_api'; // Default to unifi_api for browser automation const PASSWORD = process.argv.find(arg => arg.startsWith('--password='))?.split('=')[1] || ENV_PASSWORD // Use saved value from before .env load || process.env.UNIFI_PASSWORD // Fallback to .env value || process.env.UNIFI_API_PASSWORD; const DRY_RUN = process.env.DRY_RUN === 'true' || process.argv.includes('--dry-run'); const HEADLESS = process.env.HEADLESS !== 'false' && !process.argv.includes('--headed'); const PAUSE_MODE = process.env.PAUSE_MODE === 'true' || process.argv.includes('--pause'); const PAUSE_DURATION = parseInt(process.env.PAUSE_DURATION || '5000'); // Default 5 seconds const AGGRESSIVE_AUTO_CLICK = process.env.AGGRESSIVE_AUTO_CLICK === 'true' || process.argv.includes('--aggressive-click'); // Debug: Verify credentials are loaded (without showing password) if (!USERNAME || !PASSWORD) { console.error('❌ Credentials not loaded from environment'); console.error(` USERNAME: ${USERNAME ? '✓' : '✗'}`); console.error(` PASSWORD: ${PASSWORD ? '✓ (' + PASSWORD.length + ' chars)' : '✗'}`); } const SCREENSHOT_DIR = join(process.cwd(), 'scripts', 'unifi', 'screenshots'); // Route configuration const ROUTE_CONFIG = { name: 'Route to VLAN 11', destination: '192.168.11.0/24', gateway: '192.168.11.1', interface: null, // Will be auto-selected distance: 1, }; // Create screenshot directory try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch (error) { // Directory may already exist } // Logging utility function log(level, message, ...args) { const timestamp = new Date().toISOString(); const prefix = { info: 'ℹ️', success: '✅', error: '❌', warning: '⚠️', debug: '🔍', }[level] || '•'; console.log(`${prefix} [${timestamp}] ${message}`, ...args); } // Pause utility (needs page parameter) async function pause(page, message = 'Paused - press any key to continue...', duration = null) { if (PAUSE_MODE && !HEADLESS) { log('info', `⏸️ ${message}`); await page.waitForTimeout(duration || PAUSE_DURATION); } else if (duration) { await page.waitForTimeout(duration); } } // Take screenshot for debugging async function takeScreenshot(page, name) { try { const screenshotPath = join(SCREENSHOT_DIR, `${Date.now()}-${name}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); log('debug', `Screenshot saved: ${screenshotPath}`); return screenshotPath; } catch (error) { log('warning', `Failed to take screenshot: ${error.message}`); } } // Wait for element with retry async function waitForElement(page, selector, options = {}) { const maxRetries = options.maxRetries || 3; const timeout = options.timeout || 10000; for (let i = 0; i < maxRetries; i++) { try { await page.waitForSelector(selector, { timeout, state: options.state || 'visible' }); return true; } catch (error) { if (i === maxRetries - 1) { log('error', `Element not found after ${maxRetries} attempts: ${selector}`); await takeScreenshot(page, `element-not-found-${selector.replace(/[^a-zA-Z0-9]/g, '-')}`); throw error; } log('warning', `Retry ${i + 1}/${maxRetries} for selector: ${selector}`); await page.waitForTimeout(1000); } } } // Main automation function async function configureStaticRoute() { log('info', 'Starting Static Route Configuration via Browser Automation'); log('info', `UDM URL: ${UDM_URL}`); log('info', `Dry Run: ${DRY_RUN ? 'YES' : 'NO'}`); log('info', `Headless: ${HEADLESS ? 'YES' : 'NO'}`); console.log(''); if (!USERNAME || !PASSWORD) { log('error', 'UNIFI_USERNAME and UNIFI_PASSWORD must be set in ~/.env'); process.exit(1); } let browser = null; let page = null; try { // Launch browser log('info', 'Launching browser...'); browser = await chromium.launch({ headless: HEADLESS, slowMo: DRY_RUN ? 500 : 100, // Slower in dry-run for observation }); const context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, ignoreHTTPSErrors: true, }); page = await context.newPage(); // Enable request/response logging page.on('request', async (request) => { if (request.url().includes('192.168.0.1')) { const method = request.method(); const url = request.url(); if (url.includes('/api/auth/login')) { // Log login request details (without password) try { const postData = request.postData(); if (postData) { const data = JSON.parse(postData); log('debug', `Login request: ${method} ${url}`); log('debug', ` Username: ${data.username || data.email || 'not found'}`); log('debug', ` Password: ${data.password ? '***' + data.password.length + ' chars' : 'not found'}`); } } catch (error) { log('debug', `Request: ${method} ${url}`); } } else { log('debug', `Request: ${method} ${url}`); } } }); page.on('response', async (response) => { if (response.url().includes('192.168.0.1')) { const status = response.status(); const url = response.url(); if (status >= 400) { if (url.includes('/api/auth/login')) { // Try to get error message from response try { const body = await response.text(); log('warning', `Login response: ${status} ${url}`); if (body) { try { const json = JSON.parse(body); log('warning', ` Error: ${JSON.stringify(json)}`); } catch { log('warning', ` Response body: ${body.substring(0, 200)}`); } } } catch (error) { log('warning', `Response: ${status} ${url}`); } } else { log('warning', `Response: ${status} ${url}`); } } } }); // Step 1: Navigate to login page log('info', 'Step 1: Navigating to UDM Pro login page...'); await page.goto(UDM_URL, { waitUntil: 'networkidle', timeout: 30000 }); await takeScreenshot(page, '01-login-page'); // Wait for login form log('info', 'Waiting for login form...'); await waitForElement(page, 'input[type="text"], input[name="username"], input[type="email"]', { timeout: 15000, }); // Step 2: Fill login credentials log('info', 'Step 2: Filling login credentials...'); // Try multiple possible selectors for username const usernameSelectors = [ 'input[type="text"]', 'input[name="username"]', 'input[type="email"]', 'input[placeholder*="username" i]', 'input[placeholder*="email" i]', ]; let usernameFilled = false; for (const selector of usernameSelectors) { try { const element = await page.$(selector); if (element) { await element.fill(USERNAME); usernameFilled = true; log('debug', `Username filled using selector: ${selector}`); break; } } catch (error) { // Try next selector } } if (!usernameFilled) { throw new Error('Could not find username input field'); } // Try multiple possible selectors for password const passwordSelectors = [ 'input[type="password"]', 'input[name="password"]', ]; let passwordFilled = false; for (const selector of passwordSelectors) { try { const element = await page.$(selector); if (element) { // Click first to focus, then type character by character for special characters await element.click(); await page.waitForTimeout(100); // Small delay await element.fill(''); // Clear any existing value await element.type(PASSWORD, { delay: 50 }); // Type with delay for special chars passwordFilled = true; log('debug', `Password filled using selector: ${selector} (${PASSWORD.length} chars)`); break; } } catch (error) { // Try next selector } } if (!passwordFilled) { throw new Error('Could not find password input field'); } await takeScreenshot(page, '02-credentials-filled'); // Step 3: Submit login log('info', 'Step 3: Submitting login form...'); // Try multiple possible selectors for submit button const submitSelectors = [ 'button[type="submit"]', 'button:has-text("Sign In")', 'button:has-text("Login")', 'button:has-text("Log In")', 'input[type="submit"]', 'form button', ]; let submitted = false; for (const selector of submitSelectors) { try { const element = await page.$(selector); if (element && await element.isVisible()) { if (DRY_RUN) { log('info', `[DRY RUN] Would click submit button: ${selector}`); // In dry-run, we still perform login to test navigation // We only skip saving the final route await element.click(); submitted = true; log('debug', `[DRY RUN] Login submitted using selector: ${selector}`); break; } else { await element.click(); submitted = true; log('debug', `Login submitted using selector: ${selector}`); break; } } } catch (error) { // Try next selector } } if (!submitted) { // Try pressing Enter as fallback await page.keyboard.press('Enter'); log('debug', 'Login submitted using Enter key'); submitted = true; } // Step 4: Wait for navigation after login log('info', 'Step 4: Waiting for login to complete...'); try { // Wait for URL to change (login redirect) or check for error messages await page.waitForTimeout(2000); // Give login time to process // Check for login errors const errorSelectors = [ 'text=Invalid username or password', 'text=Authentication failed', '[class*="error"]', '[class*="alert"]', ]; let loginError = false; for (const selector of errorSelectors) { try { const element = await page.locator(selector).first(); if (await element.isVisible({ timeout: 1000 })) { const errorText = await element.textContent(); if (errorText && (errorText.toLowerCase().includes('invalid') || errorText.toLowerCase().includes('failed'))) { loginError = true; log('error', `Login error detected: ${errorText}`); break; } } } catch (error) { // No error found with this selector } } if (loginError) { await takeScreenshot(page, '03-login-error'); throw new Error('Login failed - invalid credentials or authentication error'); } // Wait for URL to change (login redirect) try { await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 10000 }); await page.waitForLoadState('networkidle', { timeout: 10000 }); // Verify we're authenticated by checking for dashboard elements const authSelectors = [ 'text=Dashboard', 'text=Devices', 'text=Clients', 'text=Settings', '[data-testid*="dashboard"]', ]; let authenticated = false; for (const selector of authSelectors) { try { await page.waitForSelector(selector, { timeout: 5000 }); authenticated = true; break; } catch (error) { // Try next selector } } if (!authenticated) { // Check if we're still on login page const currentUrl = page.url(); if (currentUrl.includes('/login')) { throw new Error('Still on login page - authentication may have failed'); } log('warning', 'Could not find dashboard elements, but URL changed - proceeding'); } await takeScreenshot(page, '03-after-login'); log('success', 'Login successful'); } catch (error) { // Check if we're still on login page const currentUrl = page.url(); if (currentUrl.includes('/login')) { await takeScreenshot(page, '03-login-error'); throw new Error(`Login failed - still on login page. Error: ${error.message}`); } // URL changed but couldn't verify - might still be OK log('warning', `Could not fully verify login, but URL changed: ${error.message}`); await takeScreenshot(page, '03-after-login'); } } catch (error) { await takeScreenshot(page, '03-login-error'); throw new Error(`Login failed: ${error.message}`); } // Step 5: Navigate directly to Routing & Firewall (more reliable than clicking) log('info', 'Step 5: Navigating to Routing & Firewall settings...'); // Direct navigation is more reliable than clicking through UI const routingUrl = `${UDM_URL}/network/default/settings/routing`; log('debug', `Navigating to: ${routingUrl}`); try { await page.goto(routingUrl, { waitUntil: 'networkidle', timeout: 30000 }); await page.waitForTimeout(2000); // Wait for page to fully load // Check if we got redirected to login (authentication failed) const currentUrl = page.url(); if (currentUrl.includes('/login')) { if (DRY_RUN) { log('warning', '[DRY RUN] Would be redirected to login - authentication required'); } else { throw new Error('Authentication failed - redirected to login page'); } } await takeScreenshot(page, '05-routing-page'); log('success', 'Navigated to Routing & Firewall settings'); } catch (error) { await takeScreenshot(page, '05-navigation-error'); log('warning', `Direct navigation failed: ${error.message}`); log('info', 'Trying alternative: clicking through Settings menu...'); // Fallback: Try clicking through Settings const settingsSelectors = [ 'text=Settings', 'a:has-text("Settings")', '[aria-label*="Settings" i]', 'button:has-text("Settings")', ]; let settingsClicked = false; for (const selector of settingsSelectors) { try { const element = await page.locator(selector).first(); if (await element.isVisible({ timeout: 5000 })) { if (DRY_RUN) { log('info', `[DRY RUN] Would click Settings: ${selector}`); settingsClicked = true; break; } else { await element.click(); await page.waitForTimeout(1000); settingsClicked = true; log('debug', `Settings clicked using selector: ${selector}`); break; } } } catch (error) { // Try next selector } } if (!settingsClicked) { throw new Error('Could not navigate to Settings or Routing page'); } await takeScreenshot(page, '05-settings-opened'); } // Step 6: Look for Static Routes section or tab log('info', 'Step 6: Looking for Static Routes section...'); // Try to find and click the Static Routes tab if we're on a different tab const staticRoutesTabSelectors = [ 'button:has-text("Static Routes")', 'a:has-text("Static Routes")', '[role="tab"]:has-text("Static Routes")', '[role="tab"]:has-text("Static")', 'button[aria-label*="Static Routes" i]', 'a[aria-label*="Static Routes" i]', 'div:has-text("Static Routes"):has(button)', 'div:has-text("Static Routes"):has(a)', ]; for (const selector of staticRoutesTabSelectors) { try { const tab = await page.locator(selector).first(); if (await tab.isVisible({ timeout: 2000 })) { log('info', `Found Static Routes tab, clicking...`); await tab.click(); await page.waitForTimeout(2000); break; } } catch (error) { // Try next selector } } // The routing page may have tabs or sections for Static Routes // Try to find and click on Static Routes tab/section const staticRouteSelectors = [ 'text=Static Routes', 'a:has-text("Static Routes")', 'button:has-text("Static Routes")', 'text=Routes', '[role="tab"]:has-text("Static")', '[role="tab"]:has-text("Routes")', 'button[aria-label*="Static Routes" i]', ]; let staticRoutesClicked = false; for (const selector of staticRouteSelectors) { try { const element = await page.locator(selector).first(); if (await element.isVisible({ timeout: 5000 })) { if (DRY_RUN) { log('info', `[DRY RUN] Would click Static Routes: ${selector}`); staticRoutesClicked = true; break; } else { await element.click(); await page.waitForTimeout(1000); staticRoutesClicked = true; log('debug', `Static Routes clicked using selector: ${selector}`); break; } } } catch (error) { // Try next selector } } if (!staticRoutesClicked) { log('info', 'Static Routes tab not found - may already be on the correct page'); } await takeScreenshot(page, '06-static-routes-page'); // Step 8: Check if route already exists log('info', 'Step 8: Checking if route already exists...'); const pageContent = await page.content(); if (pageContent.includes(ROUTE_CONFIG.destination)) { log('warning', `Route to ${ROUTE_CONFIG.destination} may already exist`); log('info', 'Checking route list...'); await takeScreenshot(page, '07-route-exists-check'); } // Pause here if in pause mode await pause(page, 'At Static Routes page - checking for Add button...', 2000); // Step 9: Analyze page content and find Add/Create button log('info', 'Step 9: Analyzing page and looking for Add/Create button...'); // First, let's see what's on the page const pageText = await page.textContent('body'); log('debug', `Page contains text: ${pageText?.substring(0, 200)}...`); // Wait for React to fully render - increased wait time log('info', 'Waiting for page to fully render...'); await page.waitForTimeout(3000); // Wait for routes data to load (check for API response) log('info', 'Waiting for routes data to load...'); try { await page.waitForResponse(response => response.url().includes('/rest/routing') && response.status() === 200, { timeout: 10000 } ); log('debug', 'Routes data loaded'); await page.waitForTimeout(2000); // Wait for UI to update } catch (error) { log('debug', 'Routes API response not detected, continuing...'); } // Wait for any loading indicators to disappear try { await page.waitForSelector('[class*="loading"], [class*="spinner"], [class*="skeleton"]', { state: 'hidden', timeout: 5000 }).catch(() => {}); // Ignore if no loading indicator log('debug', 'Loading indicators cleared'); } catch (error) { log('debug', 'No loading indicators found'); } // Scroll page to ensure all elements are visible await page.evaluate(() => { window.scrollTo(0, 0); }); await page.waitForTimeout(1000); // Try to find the Static Routes section/table first - improved detection // Also look for any content area that might contain the routes const routesSectionSelectors = [ ':text("Static Routes")', ':text("Static Route")', ':text("Routes")', ':text("Static")', '[class*="route" i]', '[class*="routing" i]', '[data-testid*="route" i]', '[data-testid*="routing" i]', 'table', '[role="table"]', '[role="grid"]', 'div:has-text("Static Routes")', 'div:has-text("Static Route")', 'section:has-text("Routes")', '[class*="content" i]:has-text("Route")', '[class*="panel" i]:has-text("Route")', '[class*="section" i]:has-text("Route")', ]; let routesSection = null; for (const selector of routesSectionSelectors) { try { const elements = await page.locator(selector).all(); for (const element of elements) { if (await element.isVisible({ timeout: 2000 })) { const text = await element.textContent(); // Make sure it's actually related to routes if (text && (text.includes('Route') || text.includes('Static') || selector.includes('table') || selector.includes('grid'))) { routesSection = element; log('success', `Found routes section with selector: ${selector}`); break; } } } if (routesSection) break; } catch (error) { // Try next selector } } if (!routesSection) { log('warning', 'Could not find routes section - will search entire page for Add button'); } // Take a screenshot to see current state await takeScreenshot(page, '07-before-add-button'); // Try multiple strategies to find the Add button - comprehensive list const addButtonSelectors = [ // Text-based selectors (most specific first) 'button:has-text("Add Route")', 'button:has-text("Create Route")', 'button:has-text("New Route")', 'button:has-text("+ Add Route")', 'button:has-text("Add")', 'button:has-text("Create")', 'button:has-text("New")', 'button:has-text("+ Add")', 'button:has-text("+ Create")', 'button:has-text("+")', // Aria label selectors (buttons and links) 'button[aria-label*="Add Route" i]', 'button[aria-label*="Create Route" i]', 'button[aria-label*="Add" i]', 'button[aria-label*="Create" i]', 'button[aria-label*="New" i]', '[aria-label*="Add Route" i]', '[aria-label*="Create Route" i]', '[aria-label*="Add" i]', '[aria-label*="Create" i]', // Link selectors (Add might be a link) 'a:has-text("Add Route")', 'a:has-text("Create Route")', 'a:has-text("Add")', 'a:has-text("Create")', 'a[href*="add" i]', 'a[href*="create" i]', 'a[href*="new" i]', 'a[class*="add" i]', 'a[class*="create" i]', // Data attributes 'button[data-testid*="add-route" i]', 'button[data-testid*="create-route" i]', 'button[data-testid*="add" i]', 'button[data-testid*="create" i]', 'button[data-testid*="new" i]', '[data-testid*="add-route" i]', '[data-testid*="create-route" i]', // Class-based (common patterns) 'button[class*="add-route" i]', 'button[class*="create-route" i]', 'button[class*="add" i]', 'button[class*="create" i]', 'button[class*="new" i]', // Icon-based (plus icon - various patterns) 'button:has(svg[class*="plus" i])', 'button:has(svg[class*="add" i])', 'button:has(svg[data-icon*="plus" i])', 'button:has(svg[data-icon*="add" i])', 'button:has(svg):has-text("")', // Icon-only buttons // Position-based (near "Static Routes" text) 'button:right-of(:text("Static Routes"))', 'button:right-of(:text("Routes"))', 'button:near(:text("Static Routes"))', 'button:near(:text("Routes"))', // Toolbar/header buttons '[class*="toolbar" i] button', '[class*="header" i] button', '[class*="action" i] button', '[class*="actions" i] button', '[role="toolbar"] button', // Table header buttons 'thead button', 'th button', '[class*="table-header" i] button', '[class*="table-actions" i] button', // Within routes section if found ...(routesSection ? ['button'] : []), ]; // Also try XPath selectors for more complex cases const xpathSelectors = [ '//button[contains(translate(text(), "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "add")]', '//button[contains(translate(text(), "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "create")]', '//button[contains(@aria-label, "add") or contains(@aria-label, "Add")]', '//button[contains(@class, "add") or contains(@class, "create")]', '//button[.//svg[contains(@class, "plus") or contains(@class, "add")]]', '//button[not(text()) and .//svg]', // Icon-only buttons ]; let addButtonClicked = false; let foundButton = null; // First, try to find buttons AND links within the routes section if we found it if (routesSection) { try { // Look for both buttons and links const buttonsInSection = await routesSection.locator('button, a[class*="button" i], a[href*="add" i], a[href*="create" i]').all(); log('debug', `Found ${buttonsInSection.length} buttons/links in routes section`); for (const button of buttonsInSection) { if (await button.isVisible({ timeout: 1000 })) { const text = await button.textContent(); const ariaLabel = await button.getAttribute('aria-label'); const className = await button.getAttribute('class').catch(() => ''); const tag = await button.evaluate(el => el.tagName); log('debug', `${tag} in routes section: text="${text}", aria-label="${ariaLabel}", class="${className.substring(0, 50)}"`); if (!text || text.trim() === '' || text.trim() === '+') { // Icon-only button - likely the Add button foundButton = button; log('info', 'Found icon-only button in routes section - likely Add button'); break; } } } } catch (error) { log('debug', `Error finding buttons in routes section: ${error.message}`); } } // Try XPath selectors if standard selectors didn't work if (!foundButton) { log('info', 'Trying XPath selectors for Add button...'); for (const xpath of xpathSelectors) { try { const elements = await page.locator(`xpath=${xpath}`).all(); for (const element of elements) { if (await element.isVisible({ timeout: 2000 }).catch(() => false)) { const text = await element.textContent().catch(() => ''); const isNavigation = text && ( text.toLowerCase().includes('home') || text.toLowerCase().includes('back') || text.toLowerCase().includes('support') ); if (!isNavigation) { foundButton = element; log('success', `Found Add button using XPath: ${xpath}`); break; } } } if (foundButton) break; } catch (error) { // Continue to next XPath } } } // If routes section not found, try clicking icon-only buttons with smart detection if (!foundButton) { log('info', 'Standard selectors did not find Add button, trying comprehensive button search...'); // First, try to find buttons in toolbars or headers log('info', 'Searching for buttons in toolbars/headers...'); const toolbarSelectors = [ '[class*="toolbar" i]', '[class*="header" i]', '[class*="action" i]', '[class*="actions" i]', '[role="toolbar"]', 'thead', '[class*="table-header" i]', '[class*="table-actions" i]', ]; for (const toolbarSelector of toolbarSelectors) { try { const toolbar = await page.locator(toolbarSelector).first(); if (await toolbar.isVisible({ timeout: 2000 }).catch(() => false)) { const toolbarButtons = await toolbar.locator('button').all(); log('debug', `Found ${toolbarButtons.length} buttons in ${toolbarSelector}`); for (const button of toolbarButtons) { if (await button.isVisible({ timeout: 1000 }).catch(() => false)) { const text = await button.textContent().catch(() => ''); const className = await button.getAttribute('class').catch(() => ''); if ((!text || text.trim() === '' || text.trim() === '+') && !className.includes('site') && !className.includes('support')) { log('info', `Found potential Add button in toolbar: ${toolbarSelector}`); foundButton = button; break; } } } if (foundButton) break; } } catch (error) { // Continue } } const allButtons = await page.locator('button').all(); log('debug', `Found ${allButtons.length} total buttons on page`); // First, try buttons within routes section if we have it if (routesSection) { const sectionButtons = await routesSection.locator('button').all(); log('debug', `Found ${sectionButtons.length} buttons in routes section`); for (const button of sectionButtons) { if (await button.isVisible({ timeout: 1000 }).catch(() => false)) { const text = await button.textContent().catch(() => ''); const ariaLabel = await button.getAttribute('aria-label').catch(() => null); const className = await button.getAttribute('class').catch(() => ''); // Icon-only or small text buttons are likely Add buttons if ((!text || text.trim() === '' || text.trim() === '+' || text.length < 5) && !className.toLowerCase().includes('site') && !className.toLowerCase().includes('support') && !className.toLowerCase().includes('home') && !className.toLowerCase().includes('back')) { log('info', `Trying button in routes section: text="${text}", aria="${ariaLabel}"`); try { const currentUrl = page.url(); await button.click(); await page.waitForTimeout(2000); // Check if form/modal appeared or URL changed const hasForm = await page.locator('input[name="name"], input[name="destination"], input[placeholder*="destination" i], input[placeholder*="network" i]').first().isVisible({ timeout: 3000 }).catch(() => false); const urlChanged = page.url() !== currentUrl; if (hasForm || urlChanged) { foundButton = button; addButtonClicked = true; log('success', `Found Add button in routes section - form appeared`); break; } else { // Not the right button, try to go back if URL changed if (urlChanged) { await page.goBack(); await page.waitForTimeout(1000); } } } catch (error) { log('debug', `Error testing button: ${error.message}`); } } } } } // Try looking for links or other elements that might be the Add button if (!foundButton) { log('info', 'Trying to find Add button as link or other element type...'); const linkSelectors = [ 'a:has-text("Add")', 'a:has-text("Create")', 'a:has-text("New")', 'a[aria-label*="Add" i]', 'a[aria-label*="Create" i]', '[role="button"]:has-text("Add")', '[role="button"]:has-text("Create")', 'div[onclick*="add" i]', 'div[onclick*="create" i]', '[class*="add" i][class*="button" i]', '[class*="create" i][class*="button" i]', ]; for (const selector of linkSelectors) { try { const element = await page.locator(selector).first(); if (await element.isVisible({ timeout: 2000 }).catch(() => false)) { const text = await element.textContent().catch(() => ''); log('info', `Found potential Add element (not button): ${selector}, text="${text}"`); foundButton = element; break; } } catch (error) { // Continue } } } // Try keyboard shortcut (Ctrl+N or + key) if no button found if (!foundButton) { log('info', 'Trying keyboard shortcuts to trigger Add action...'); try { // Try Ctrl+N (common for "New") await page.keyboard.press('Control+N'); await page.waitForTimeout(2000); // Check if form appeared const hasForm = await page.locator('input[name="name"], input[name="destination"]').first().isVisible({ timeout: 2000 }).catch(() => false); if (hasForm) { log('success', 'Keyboard shortcut Ctrl+N opened the form!'); addButtonClicked = true; } else { // Try + key await page.keyboard.press('+'); await page.waitForTimeout(2000); const hasForm2 = await page.locator('input[name="name"], input[name="destination"]').first().isVisible({ timeout: 2000 }).catch(() => false); if (hasForm2) { log('success', 'Keyboard shortcut + opened the form!'); addButtonClicked = true; } } } catch (error) { log('debug', `Keyboard shortcuts did not work: ${error.message}`); } } // If still not found, systematically test ALL buttons to find which one opens the form if (!foundButton && !addButtonClicked) { log('info', 'Trying systematic button testing - clicking each button to find Add...'); log('info', `Found ${allButtons.length} total buttons on page, testing up to 20...`); // Test buttons systematically, but skip known non-Add buttons const skipButtons = ['UDM Pro', 'Sign In', 'Submit Support Ticket', 'Go back to Home']; for (let i = 0; i < Math.min(allButtons.length, 20); i++) { try { const button = allButtons[i]; const isVisible = await button.isVisible({ timeout: 1000 }).catch(() => false); if (!isVisible) continue; const isEnabled = await button.isEnabled().catch(() => false); const text = await button.textContent().catch(() => ''); const ariaLabel = await button.getAttribute('aria-label').catch(() => null); const className = await button.getAttribute('class').catch(() => ''); // Skip navigation and known non-Add buttons const shouldSkip = skipButtons.some(skip => text && text.includes(skip)); if (shouldSkip) { log('debug', `Skipping button ${i}: "${text}" (known navigation button)`); continue; } const isNavigation = text && ( text.toLowerCase().includes('home') || text.toLowerCase().includes('back') || text.toLowerCase().includes('support') || text.toLowerCase().includes('site switcher') || text.toLowerCase().includes('udm pro') ); // Skip disabled buttons unless they're clearly Add buttons if (!isEnabled && !className.includes('add') && !className.includes('create')) { continue; } // Look for icon-only buttons or buttons with Add-related text const looksLikeAddButton = !isNavigation && ( (!text || text.trim() === '' || text.trim() === '+') || text.toLowerCase().includes('add') || text.toLowerCase().includes('create') || (ariaLabel && (ariaLabel.toLowerCase().includes('add') || ariaLabel.toLowerCase().includes('create'))) || className.toLowerCase().includes('add') || className.toLowerCase().includes('create') ); if (looksLikeAddButton) { log('info', `Testing button ${i}/${Math.min(allButtons.length, 25)}: enabled=${isEnabled}, text="${text}", aria="${ariaLabel || 'none'}", class="${className.substring(0, 60)}"`); // Skip disabled buttons (they won't work) if (!isEnabled) { log('debug', `Button ${i} is disabled, skipping`); continue; } try { // Save current URL before clicking const currentUrl = page.url(); // Scroll to button await button.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); // Try clicking with multiple methods let clicked = false; try { await button.click({ timeout: 3000 }); clicked = true; } catch (error1) { try { await button.click({ force: true, timeout: 3000 }); clicked = true; } catch (error2) { try { await button.evaluate((el) => { if (el.click) el.click(); else { const event = new MouseEvent('click', { bubbles: true, cancelable: true }); el.dispatchEvent(event); } }); clicked = true; } catch (error3) { log('debug', `All click methods failed for button ${i}`); } } } if (!clicked) continue; await page.waitForTimeout(3000); // Wait for form to appear // Comprehensive form detection const formSelectors = [ 'input[name="name"]', 'input[name="destination"]', 'input[name="network"]', 'input[name="gateway"]', 'input[id*="name" i]', 'input[id*="destination" i]', 'input[id*="network" i]', 'input[placeholder*="destination" i]', 'input[placeholder*="network" i]', 'input[placeholder*="route" i]', 'input[placeholder*="name" i]', '[role="dialog"] input', '[role="dialog"] form', 'form input[type="text"]', 'form input[type="text"]:first-of-type', ]; let hasForm = false; for (const formSelector of formSelectors) { hasForm = await page.locator(formSelector).first().isVisible({ timeout: 3000 }).catch(() => false); if (hasForm) { log('success', `Form detected with selector: ${formSelector}`); break; } } // Check current URL to see if we navigated const currentUrlAfter = page.url(); const urlChanged = currentUrlAfter !== currentUrl; const isFormUrl = currentUrlAfter.includes('add') || currentUrlAfter.includes('create') || currentUrlAfter.includes('new'); if (hasForm || (urlChanged && isFormUrl)) { foundButton = button; addButtonClicked = true; log('success', `✅✅✅ SUCCESS! Button ${i} opened the form! ✅✅✅`); log('info', `Button details: text="${text}", aria-label="${ariaLabel || 'none'}", class="${className}"`); break; } else { // Check if menu appeared - if so, try to find Add in menu const hasMenu = await page.locator('[role="menu"], [role="listbox"], [aria-expanded="true"]').first().isVisible({ timeout: 2000 }).catch(() => false); if (hasMenu) { log('debug', `Button ${i} opened a menu, checking for Add option...`); // Get all menu items - more comprehensive const menuItems = await page.evaluate(() => { // Find menu let menu = document.querySelector('[role="menu"], [role="listbox"]'); if (!menu) { // Try finding by aria-expanded const expanded = document.querySelector('[aria-expanded="true"]'); if (expanded) { const menuId = expanded.getAttribute('aria-controls'); if (menuId) { menu = document.getElementById(menuId); } } } if (!menu) { // Try finding popover/menu by class menu = document.querySelector('[class*="menu" i], [class*="popover" i], [class*="dropdown" i]'); } if (!menu) return []; // Get all potentially clickable items const allItems = Array.from(menu.querySelectorAll('*')); return allItems.map(item => { const text = item.textContent?.trim() || ''; const rect = item.getBoundingClientRect(); const styles = window.getComputedStyle(item); const isVisible = rect.width > 0 && rect.height > 0 && styles.display !== 'none' && styles.visibility !== 'hidden'; return { text: text, tag: item.tagName, id: item.id || null, className: item.className || '', isVisible: isVisible, xpath: item.id ? `//*[@id="${item.id}"]` : null, }; }).filter(item => item.isVisible && item.text.length > 0); }); log('info', `Menu has ${menuItems.length} items: ${menuItems.map(m => `"${m.text}"`).join(', ')}`); // Try clicking any item that contains Add, Create, Route, or New const addItems = menuItems.filter(item => item.text.toLowerCase().includes('add') || item.text.toLowerCase().includes('create') || item.text.toLowerCase().includes('new') || item.text.toLowerCase().includes('route') ); if (addItems.length > 0) { log('info', `Found ${addItems.length} Add-related menu items, trying each...`); for (const menuItem of addItems) { try { log('info', `Trying menu item: "${menuItem.text}"`); // Try clicking by text await page.click(`text="${menuItem.text}"`, { timeout: 3000 }).catch(async () => { // Try XPath if available if (menuItem.xpath) { await page.evaluate((xpath) => { const el = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; if (el) el.click(); }, menuItem.xpath); } }); await page.waitForTimeout(3000); // Check for form const hasForm2 = await page.locator('input[name="name"], input[name="destination"]').first().isVisible({ timeout: 3000 }).catch(() => false); if (hasForm2) { log('success', `✅✅✅ SUCCESS! Menu item "${menuItem.text}" from button ${i} opened the form! ✅✅✅`); foundButton = button; addButtonClicked = true; break; } } catch (error) { log('debug', `Error clicking menu item "${menuItem.text}": ${error.message}`); } } if (addButtonClicked) break; } // Close menu await page.keyboard.press('Escape'); await page.waitForTimeout(1000); } // Not the right button, go back if we navigated away if (urlChanged && !currentUrlAfter.includes('/settings/routing')) { log('debug', `Button ${i} navigated away, going back...`); await page.goBack(); await page.waitForTimeout(2000); // Re-navigate to routing page await page.goto(`${UDM_URL}/network/default/settings/routing`, { waitUntil: 'networkidle' }); await page.waitForTimeout(3000); // Re-find buttons after navigation allButtons = await page.locator('button').all(); } } } catch (error) { log('debug', `Error testing button ${i}: ${error.message}`); // If we navigated away, go back if (!page.url().includes('/settings/routing')) { try { await page.goBack(); await page.waitForTimeout(2000); await page.goto(`${UDM_URL}/network/default/settings/routing`, { waitUntil: 'networkidle' }); await page.waitForTimeout(3000); allButtons = await page.locator('button').all(); } catch (navError) { // Continue } } } } } catch (error) { // Continue to next button } } } } // If not found in routes section, try all selectors if (!foundButton) { for (const selector of addButtonSelectors) { try { const elements = await page.locator(selector).all(); for (const element of elements) { if (await element.isVisible({ timeout: 2000 })) { const text = await element.textContent(); const ariaLabel = await element.getAttribute('aria-label'); log('debug', `Found potential button: ${selector}, text: "${text}", aria-label: "${ariaLabel}"`); // Check if it looks like an Add button if (text && (text.toLowerCase().includes('add') || text.toLowerCase().includes('create') || text.toLowerCase().includes('new'))) { foundButton = element; break; } if (ariaLabel && (ariaLabel.toLowerCase().includes('add') || ariaLabel.toLowerCase().includes('create'))) { foundButton = element; break; } } } if (foundButton) { break; } } catch (error) { // Try next selector } } } if (foundButton) { if (DRY_RUN) { log('info', `[DRY RUN] Would click Add button`); addButtonClicked = true; } else { await foundButton.click(); await page.waitForTimeout(2000); // Wait for form/modal to appear addButtonClicked = true; log('success', 'Add button clicked'); } } // If still not found, try finding all buttons and logging them if (!addButtonClicked) { log('warning', 'Could not find Add button with standard selectors'); log('info', 'Listing all visible buttons on page...'); const allButtons = await page.locator('button').all(); log('debug', `Found ${allButtons.length} total buttons on page`); for (let i = 0; i < Math.min(allButtons.length, 20); i++) { try { const button = allButtons[i]; const isVisible = await button.isVisible().catch(() => false); if (isVisible) { const text = await button.textContent().catch(() => ''); const ariaLabel = await button.getAttribute('aria-label').catch(() => null); const className = await button.getAttribute('class').catch(() => ''); const dataTestId = await button.getAttribute('data-testid').catch(() => null); log('debug', `Button ${i}: text="${text}", aria-label="${ariaLabel}", class="${className.substring(0, 50)}", data-testid="${dataTestId}"`); // Try clicking any button that might be an Add button (icon-only, primary style, etc.) // But exclude navigation buttons const isNavigationButton = text && ( text.toLowerCase().includes('home') || text.toLowerCase().includes('back') || text.toLowerCase().includes('go back') || text.toLowerCase().includes('submit support') || text.toLowerCase().includes('site switcher') ); if (!addButtonClicked && !isNavigationButton && ( className.toLowerCase().includes('add') || className.toLowerCase().includes('create') || (text && (text.trim() === '+' || text.trim() === 'Add' || text.trim() === 'Create' || text.trim() === 'New')) || (ariaLabel && (ariaLabel.toLowerCase().includes('add') || ariaLabel.toLowerCase().includes('create'))) || // Only try primary buttons if they're near route-related content (className.toLowerCase().includes('primary') && !text) )) { log('info', `Trying button ${i} as potential Add button...`); try { if (DRY_RUN) { log('info', `[DRY RUN] Would click button ${i}`); addButtonClicked = true; break; } else { await button.click(); await page.waitForTimeout(1000); addButtonClicked = true; log('success', `Clicked button ${i} as Add button`); break; } } catch (error) { log('warning', `Failed to click button ${i}: ${error.message}`); } } } } catch (error) { // Skip } } if (!addButtonClicked) { if (!DRY_RUN) { log('error', 'Could not find Add/Create button automatically.'); log('info', 'Options:'); log('info', ' 1. Run with HEADLESS=false to see the page and manually click Add'); log('info', ' 2. Run inspect-routing-page.js to identify the Add button selector'); log('info', ' 3. Check screenshot: scripts/unifi/screenshots/06-static-routes-page.png'); // Try one more approach: look for buttons by evaluating JavaScript on the page log('info', 'Trying JavaScript evaluation to find Add button...'); try { log('debug', 'Executing JavaScript evaluation on page...'); const addButtonInfo = await page.evaluate(() => { // Find all buttons and check their properties // Also check for links and other clickable elements that might be the Add button const buttons = Array.from(document.querySelectorAll('button, [role="button"], a[class*="button" i], a[href*="add" i], a[href*="create" i], a[href*="new" i]')); const candidates = []; // Also check for icon-only elements that might be clickable const iconElements = Array.from(document.querySelectorAll('[class*="icon" i], svg, [class*="add" i], [class*="plus" i]')); iconElements.forEach(icon => { // Check if icon is inside a clickable parent let parent = icon.parentElement; for (let i = 0; i < 3; i++) { if (parent && (parent.tagName === 'BUTTON' || parent.tagName === 'A' || parent.getAttribute('role') === 'button')) { if (!buttons.includes(parent)) { buttons.push(parent); } break; } parent = parent?.parentElement; } }); for (const btn of buttons) { const rect = btn.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { const styles = window.getComputedStyle(btn); if (styles.display !== 'none' && styles.visibility !== 'hidden') { const text = btn.textContent?.trim() || ''; const className = btn.className || ''; const ariaLabel = btn.getAttribute('aria-label') || ''; const parent = btn.parentElement; const parentText = parent?.textContent?.trim() || ''; // Look for buttons near "Static Routes" or "Routes" text, or icon-only buttons // Also check if button is in a routes-related section let isInRoutesSection = parentText.includes('Static Routes') || parentText.includes('Routes'); // Check parent hierarchy more thoroughly let current = btn.parentElement; let depth = 0; let foundThemeMenu = false; let foundRouteContent = false; while (current && depth < 5) { const currentText = current.textContent || ''; const currentClass = current.className || ''; // Check for theme/settings indicators (exclude these buttons) if (currentText.toLowerCase().includes('light') || currentText.toLowerCase().includes('dark') || currentText.toLowerCase().includes('theme') || currentClass.toLowerCase().includes('theme') || currentClass.toLowerCase().includes('settings') && !currentText.includes('Route')) { foundThemeMenu = true; } // Check for route-related content if (currentText.includes('Static Routes') || currentText.includes('Routes') || currentClass.toLowerCase().includes('route') || currentClass.toLowerCase().includes('routing') || current.tagName === 'TABLE' || current.getAttribute('role') === 'table') { foundRouteContent = true; isInRoutesSection = true; break; } current = current.parentElement; depth++; } // Prioritize buttons in routes section or with Add-related text // Also consider icon-only buttons (empty text) as potential Add buttons // since we're on the routing page const isIconOnly = !text || text.trim() === '' || text.trim() === '+'; const hasAddText = text.toLowerCase().includes('add') || text.toLowerCase().includes('create') || text.toLowerCase().includes('new'); const hasAddClass = className.toLowerCase().includes('add') || className.toLowerCase().includes('create') || className.toLowerCase().includes('new'); const hasAddAria = ariaLabel.toLowerCase().includes('add') || ariaLabel.toLowerCase().includes('create') || ariaLabel.toLowerCase().includes('new'); // Check if button is near a table (likely the routes table) // Also check if button is in table header or toolbar let isNearTable = false; let isInTableHeader = false; let isInToolbar = false; let tableParent = btn.parentElement; let tableDepth = 0; while (tableParent && tableDepth < 5) { const parentTag = tableParent.tagName; const parentClass = tableParent.className || ''; // Check if in table if (parentTag === 'TABLE' || tableParent.querySelector('table')) { isNearTable = true; // Check if in thead (table header) if (tableParent.querySelector('thead') && tableParent.querySelector('thead').contains(btn)) { isInTableHeader = true; } break; } // Check if in toolbar if (parentClass.toLowerCase().includes('toolbar') || parentClass.toLowerCase().includes('header') || parentClass.toLowerCase().includes('action') || parentTag === 'HEADER') { isInToolbar = true; } tableParent = tableParent.parentElement; tableDepth++; } // Match if: // 1. In routes section and icon-only (highest priority) // 2. In table header and icon-only (very high priority) // 3. In toolbar near routes and icon-only (high priority) // 4. Near table and icon-only (high priority) // 5. Has add/create text // 6. Has add/create class // 7. Has add/create aria-label // 8. Icon-only button in toolbar (lower priority) const looksLikeAddButton = (isInRoutesSection && isIconOnly) || (isInTableHeader && isIconOnly) || (isInToolbar && isInRoutesSection && isIconOnly) || (isNearTable && isIconOnly) || hasAddText || hasAddClass || hasAddAria || (isInToolbar && isIconOnly); // Toolbar icon-only buttons // Calculate priority: table header > routes section > toolbar near routes > near table > has add text > icon-only let priority = 99; if (isInTableHeader && isIconOnly) priority = 0; // Highest priority else if (isInRoutesSection && isIconOnly) priority = 1; else if (isInToolbar && isInRoutesSection && isIconOnly) priority = 2; else if (isNearTable && isIconOnly) priority = 3; else if (hasAddText) priority = 4; else if (hasAddClass || hasAddAria) priority = 5; else if (isInToolbar && isIconOnly) priority = 6; else if (isIconOnly) priority = 7; if (looksLikeAddButton) { // Additional filtering: exclude theme buttons even if they match const isThemeButton = foundThemeMenu && !foundRouteContent && (text.toLowerCase().includes('light') || text.toLowerCase().includes('dark') || ariaLabel.toLowerCase().includes('theme')); // Penalize theme buttons with very low priority, but don't exclude them completely // This way we can still find them if they're the only option, but prioritize others let finalPriority = priority; if (isThemeButton) { finalPriority = 99; // Very low priority for theme buttons } candidates.push({ tag: btn.tagName, text: text, className: className, id: btn.id, ariaLabel: ariaLabel, xpath: getXPath(btn), selector: getSelector(btn), isInRoutesSection: !!isInRoutesSection, isNearTable: isNearTable, isInTableHeader: isInTableHeader, isInToolbar: isInToolbar, priority: finalPriority, // Lower number = higher priority foundThemeMenu: foundThemeMenu, foundRouteContent: foundRouteContent, isThemeButton: isThemeButton, }); } } } } // Return the best candidate (lowest priority number = highest priority) if (candidates.length > 0) { // Sort by priority candidates.sort((a, b) => (a.priority || 99) - (b.priority || 99)); return candidates[0]; } return null; function getXPath(element) { if (element.id) return `//*[@id="${element.id}"]`; if (element === document.body) return '/html/body'; let ix = 0; const siblings = element.parentNode.childNodes; for (let i = 0; i < siblings.length; i++) { if (siblings[i] === element) { return getXPath(element.parentNode) + '/' + element.tagName.toLowerCase() + '[' + (ix + 1) + ']'; } if (siblings[i].nodeType === 1 && siblings[i].tagName === element.tagName) ix++; } } function getSelector(element) { if (element.id) return `#${element.id}`; if (element.className) { const classes = element.className.split(' ').filter(c => c).slice(0, 2).join('.'); return `${element.tagName.toLowerCase()}.${classes}`; } return element.tagName.toLowerCase(); } }); log('debug', `JavaScript evaluation completed. Result: ${addButtonInfo ? 'Found button' : 'No button found (null)'}`); if (addButtonInfo) { log('success', `Found Add button via JavaScript evaluation!`); log('debug', `Button details: ${JSON.stringify(addButtonInfo, null, 2)}`); log('info', ` Tag: ${addButtonInfo.tag}`); log('info', ` Text: "${addButtonInfo.text}"`); log('info', ` Class: ${addButtonInfo.className.substring(0, 80)}`); log('info', ` ID: ${addButtonInfo.id || 'none'}`); log('info', ` Aria Label: ${addButtonInfo.ariaLabel || 'none'}`); log('info', ` XPath: ${addButtonInfo.xpath}`); log('info', ` Selector: ${addButtonInfo.selector}`); log('info', ` Priority: ${addButtonInfo.priority || 'N/A'}`); log('info', ` In Routes Section: ${addButtonInfo.isInRoutesSection || false}`); log('info', ` Near Table: ${addButtonInfo.isNearTable || false}`); log('info', ` In Table Header: ${addButtonInfo.isInTableHeader || false}`); log('info', ` In Toolbar: ${addButtonInfo.isInToolbar || false}`); log('info', ` Is Theme Button: ${addButtonInfo.isThemeButton || false}`); // If this button is in routes section, prioritize it if (addButtonInfo.isInRoutesSection) { log('info', 'Button is in routes section - high confidence this is the Add button!'); } // Try multiple methods to click the button let clicked = false; // Method 1: Try using class selector (more stable than ID) if (addButtonInfo.className && !clicked) { try { const classParts = addButtonInfo.className.split(' ').filter(c => c && !c.includes('__') && c.length > 3).slice(0, 2); if (classParts.length > 0) { const classSelector = `${addButtonInfo.tag.toLowerCase()}.${classParts.join('.')}`; log('info', `Trying to click using class selector: ${classSelector}`); const button = await page.locator(classSelector).first(); if (await button.isVisible({ timeout: 3000 })) { await button.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); await button.click({ timeout: 5000 }); await page.waitForTimeout(3000); const hasForm = await page.locator('input[name="name"], input[name="destination"], input[placeholder*="destination" i], input[placeholder*="network" i]').first().isVisible({ timeout: 3000 }).catch(() => false); if (hasForm) { log('success', 'Add button clicked using class selector and form appeared!'); addButtonClicked = true; clicked = true; } } } } catch (error) { log('debug', `Class selector click failed: ${error.message}`); } } // Method 1b: Try using the original selector (ID-based) if (addButtonInfo.selector && !clicked) { try { log('info', `Trying to click using selector: ${addButtonInfo.selector}`); const button = await page.locator(addButtonInfo.selector).first(); if (await button.isVisible({ timeout: 3000 })) { await button.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); await button.click({ timeout: 5000 }); await page.waitForTimeout(3000); const hasForm = await page.locator('input[name="name"], input[name="destination"], input[placeholder*="destination" i], input[placeholder*="network" i]').first().isVisible({ timeout: 3000 }).catch(() => false); if (hasForm) { log('success', 'Add button clicked and form appeared!'); addButtonClicked = true; clicked = true; } } } catch (error) { log('debug', `Selector click failed: ${error.message}`); } } // Method 2: Try using XPath if (addButtonInfo.xpath && !clicked) { try { log('info', `Trying to click using XPath: ${addButtonInfo.xpath}`); const button = await page.locator(`xpath=${addButtonInfo.xpath}`).first(); if (await button.isVisible({ timeout: 3000 })) { await button.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); await button.click({ timeout: 5000 }); await page.waitForTimeout(3000); const hasForm = await page.locator('input[name="name"], input[name="destination"]').first().isVisible({ timeout: 3000 }).catch(() => false); if (hasForm) { log('success', 'Add button clicked via XPath and form appeared!'); addButtonClicked = true; clicked = true; } } } catch (error) { log('debug', `XPath click failed: ${error.message}`); } } // Method 3: Try JavaScript click directly using multiple approaches if (!clicked) { try { log('info', 'Trying JavaScript click directly...'); // Try clicking by ID first const clickedById = await page.evaluate((id) => { const element = document.getElementById(id); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); element.click(); return true; } return false; }, addButtonInfo.id).catch(() => false); if (clickedById) { log('info', 'JavaScript click by ID executed'); await page.waitForTimeout(2000); // Take screenshot after click to see menu state await takeScreenshot(page, '08-after-button-click-menu'); // Check if a menu/dropdown appeared (button might open a menu) // Also check for popover, popup, overlay that might contain menu const menuSelectors = [ '[role="menu"]', '[role="listbox"]', '[role="dialog"]', '[class*="menu" i]', '[class*="dropdown" i]', '[class*="popover" i]', '[class*="popup" i]', '[aria-expanded="true"]', '[data-testid*="menu" i]', '[data-testid*="dropdown" i]', '[aria-haspopup="true"][aria-expanded="true"]', ]; let hasMenu = false; let menuElement = null; for (const selector of menuSelectors) { try { const element = await page.locator(selector).first(); if (await element.isVisible({ timeout: 2000 })) { hasMenu = true; menuElement = element; log('info', `Menu detected using selector: ${selector}`); break; } } catch (error) { // Try next selector } } if (hasMenu) { log('info', 'Menu/dropdown detected after button click, looking for "Add Route" option...'); // Get all clickable elements in the menu - improved detection // First, close any underlay that might be blocking try { const underlay = await page.locator('[data-testid="underlay"], [aria-hidden="true"][data-testid*="underlay" i]').first(); if (await underlay.isVisible({ timeout: 1000 }).catch(() => false)) { log('debug', 'Underlay detected, attempting to handle...'); // Try clicking through underlay or closing it await page.evaluate(() => { const underlay = document.querySelector('[data-testid="underlay"]'); if (underlay) { // Try to make it not intercept clicks underlay.style.pointerEvents = 'none'; } }); } } catch (error) { // Continue } const menuItems = await page.evaluate(() => { // Try multiple selectors for menu const menuSelectors = [ '[role="menu"]', '[role="listbox"]', '[class*="menu" i]', '[class*="dropdown" i]', '[aria-expanded="true"]', '[data-testid*="menu" i]', '[data-testid*="dropdown" i]', ]; let menu = null; for (const selector of menuSelectors) { menu = document.querySelector(selector); if (menu) break; } if (!menu) { // Try to find any visible popup/overlay const overlays = Array.from(document.querySelectorAll('[class*="overlay" i], [class*="popup" i], [class*="dialog" i], [role="dialog"]')); for (const overlay of overlays) { const styles = window.getComputedStyle(overlay); if (styles.display !== 'none' && styles.visibility !== 'hidden') { menu = overlay; break; } } } if (!menu) return []; // Get all potentially clickable items const itemSelectors = [ '[role="menuitem"]', '[role="option"]', 'li', 'a', 'button', 'div[class*="item" i]', 'div[class*="option" i]', 'div[onclick]', '[tabindex="0"]', ]; const items = []; for (const selector of itemSelectors) { const found = Array.from(menu.querySelectorAll(selector)); items.push(...found); } // Remove duplicates const uniqueItems = Array.from(new Set(items)); return uniqueItems.map((item, index) => { const rect = item.getBoundingClientRect(); const styles = window.getComputedStyle(item); const text = item.textContent?.trim() || ''; const tag = item.tagName.toLowerCase(); const className = item.className || ''; const isVisible = rect.width > 0 && rect.height > 0 && styles.display !== 'none' && styles.visibility !== 'hidden'; return { index, text, tag, className: className.substring(0, 100), isVisible, xpath: getXPath(item), }; }).filter(item => item.isVisible && (item.text.length > 0 || item.tag === 'button' || item.tag === 'a' || item.className.includes('item'))); function getXPath(element) { if (element.id) return `//*[@id="${element.id}"]`; if (element === document.body) return '/html/body'; let ix = 0; const siblings = element.parentNode.childNodes; for (let i = 0; i < siblings.length; i++) { if (siblings[i] === element) { return getXPath(element.parentNode) + '/' + element.tagName.toLowerCase() + '[' + (ix + 1) + ']'; } if (siblings[i].nodeType === 1 && siblings[i].tagName === element.tagName) ix++; } } }); log('info', `Found ${menuItems.length} items in menu`); for (const item of menuItems) { log('debug', ` Menu item ${item.index}: ${item.tag} - "${item.text}"`); } // Look for "Add Route" or "Add" option in the menu const menuOptions = [ '[role="menuitem"]:has-text("Add Route")', '[role="menuitem"]:has-text("Add")', '[role="menuitem"]:has-text("Create Route")', '[role="menuitem"]:has-text("Create")', '[role="menuitem"]:has-text("New Route")', '[role="menuitem"]:has-text("New")', '[role="option"]:has-text("Add Route")', '[role="option"]:has-text("Add")', 'li:has-text("Add Route")', 'li:has-text("Add")', 'li:has-text("Create Route")', 'a:has-text("Add Route")', 'a:has-text("Add")', 'button:has-text("Add Route")', 'button:has-text("Add")', 'div:has-text("Add Route")', 'div:has-text("Add")', // Try finding by text content using JavaScript ]; // Also try clicking any item that contains "Add" or "Route" const addRelatedItems = menuItems.filter(item => item.text.toLowerCase().includes('add') || item.text.toLowerCase().includes('create') || item.text.toLowerCase().includes('new') || item.text.toLowerCase().includes('route') ); if (addRelatedItems.length > 0) { log('info', `Found ${addRelatedItems.length} menu items related to "Add":`); for (const item of addRelatedItems) { log('info', ` - "${item.text}" (${item.tag}, priority: ${item.priority || 'N/A'})`); } // Sort by priority (if available) or text relevance addRelatedItems.sort((a, b) => { // Prioritize items with "Route" in text const aHasRoute = a.text.toLowerCase().includes('route'); const bHasRoute = b.text.toLowerCase().includes('route'); if (aHasRoute && !bHasRoute) return -1; if (!aHasRoute && bHasRoute) return 1; // Then by priority if available if (a.priority && b.priority) return a.priority - b.priority; return 0; }); // Try clicking each add-related item until one works for (const item of addRelatedItems) { try { log('info', `Attempting to click menu item: "${item.text}" (${item.tag})`); // Try multiple methods to click let clicked = false; // Method 1: XPath if (item.xpath) { clicked = await page.evaluate((xpath) => { const element = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (element.click) { element.click(); } else { const event = new MouseEvent('click', { bubbles: true, cancelable: true, view: window }); element.dispatchEvent(event); } return true; } return false; }, item.xpath).catch(() => false); } // Method 2: Try by text content if (!clicked && item.text) { try { const menuItem = await page.locator(`text="${item.text}"`).first(); if (await menuItem.isVisible({ timeout: 2000 })) { await menuItem.click(); clicked = true; } } catch (error) { // Try next method } } if (clicked) { log('success', `Clicked menu item: "${item.text}"`); await page.waitForTimeout(3000); // Check for form with multiple selectors const formSelectors = [ 'input[name="name"]', 'input[name="destination"]', 'input[name="network"]', 'input[placeholder*="destination" i]', 'input[placeholder*="network" i]', 'input[placeholder*="route" i]', 'form input', '[role="dialog"] input', '[class*="form" i] input', ]; let hasForm = false; for (const selector of formSelectors) { try { hasForm = await page.locator(selector).first().isVisible({ timeout: 2000 }); if (hasForm) { log('success', `Form detected using selector: ${selector}`); break; } } catch (error) { // Try next selector } } if (hasForm) { log('success', 'Menu option clicked and form appeared!'); addButtonClicked = true; clicked = true; break; // Success, exit loop } else { log('debug', 'Form did not appear after clicking menu item, trying next item...'); } } } catch (error) { log('debug', `Error clicking menu item "${item.text}": ${error.message}`); } } } // If JavaScript click didn't work, try selectors with multiple methods if (!clicked) { log('info', 'Trying selector-based menu item clicking...'); for (const optionSelector of menuOptions) { try { const option = await page.locator(optionSelector).first(); if (await option.isVisible({ timeout: 2000 })) { log('info', `Found menu option with selector: ${optionSelector}`); // Try multiple click methods try { await option.click({ timeout: 3000 }); } catch (error1) { log('debug', `Regular click failed, trying force click...`); try { await option.click({ force: true, timeout: 3000 }); } catch (error2) { log('debug', `Force click failed, trying JavaScript click...`); await option.evaluate((el) => el.click()); } } await page.waitForTimeout(3000); // Check for form with multiple selectors and longer timeout const formSelectors = [ 'input[name="name"]', 'input[name="destination"]', 'input[name="network"]', 'input[name="gateway"]', 'input[placeholder*="destination" i]', 'input[placeholder*="network" i]', 'input[placeholder*="route" i]', 'form input[type="text"]', '[role="dialog"] input', '[class*="form" i] input', '[class*="dialog" i] input', ]; let hasForm = false; for (const formSelector of formSelectors) { try { hasForm = await page.locator(formSelector).first().isVisible({ timeout: 3000 }); if (hasForm) { log('success', `Form detected using selector: ${formSelector}`); break; } } catch (error) { // Try next selector } } if (hasForm) { log('success', 'Add Route option clicked and form appeared!'); addButtonClicked = true; clicked = true; break; } else { log('debug', 'Form did not appear, trying next menu option...'); } } } catch (error) { // Try next option } } } // If still no form, try clicking any visible menu item that might be "Add" if (!clicked && menuItems.length > 0) { log('info', 'Trying to click any menu item that might be Add...'); for (const item of menuItems) { if (item.text && (item.text.toLowerCase().includes('add') || item.text.toLowerCase().includes('create') || item.text.toLowerCase().includes('new'))) { try { log('info', `Attempting to click menu item: "${item.text}"`); // Try by text first const textLocator = await page.locator(`text="${item.text}"`).first(); if (await textLocator.isVisible({ timeout: 2000 })) { await textLocator.click(); await page.waitForTimeout(3000); // Check for form const hasForm = await page.locator('input[name="name"], input[name="destination"]').first().isVisible({ timeout: 3000 }).catch(() => false); if (hasForm) { log('success', `Menu item "${item.text}" clicked and form appeared!`); addButtonClicked = true; clicked = true; break; } } } catch (error) { log('debug', `Error clicking menu item "${item.text}": ${error.message}`); } } } } } else { // No menu, check if form appeared directly const hasForm = await page.locator('input[name="name"], input[name="destination"], input[placeholder*="destination" i], input[placeholder*="network" i]').first().isVisible({ timeout: 3000 }).catch(() => false); if (hasForm) { log('success', 'Add button clicked via JavaScript (ID) and form appeared!'); addButtonClicked = true; clicked = true; } } } // If ID click didn't work, try by class if (!clicked && addButtonInfo.className) { const classParts = addButtonInfo.className.split(' ').filter(c => c && c.length > 3).slice(0, 2); if (classParts.length > 0) { const clickedByClass = await page.evaluate((tag, classes) => { const selector = `${tag}.${classes.join('.')}`; const element = document.querySelector(selector); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); element.click(); return true; } return false; }, addButtonInfo.tag.toLowerCase(), classParts).catch(() => false); if (clickedByClass) { log('info', 'JavaScript click by class executed'); await page.waitForTimeout(3000); const hasForm = await page.locator('input[name="name"], input[name="destination"], input[placeholder*="destination" i], input[placeholder*="network" i]').first().isVisible({ timeout: 3000 }).catch(() => false); if (hasForm) { log('success', 'Add button clicked via JavaScript (class) and form appeared!'); addButtonClicked = true; clicked = true; } } } } // Last resort: try XPath if (!clicked && addButtonInfo.xpath) { const clickedByXPath = await page.evaluate((xpath) => { const element = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Try multiple click methods if (element.click) { element.click(); } else { const event = new MouseEvent('click', { bubbles: true, cancelable: true, view: window }); element.dispatchEvent(event); } return true; } return false; }, addButtonInfo.xpath).catch(() => false); if (clickedByXPath) { log('info', 'JavaScript click by XPath executed'); await page.waitForTimeout(3000); const hasForm = await page.locator('input[name="name"], input[name="destination"], input[placeholder*="destination" i], input[placeholder*="network" i]').first().isVisible({ timeout: 3000 }).catch(() => false); if (hasForm) { log('success', 'Add button clicked via JavaScript (XPath) and form appeared!'); addButtonClicked = true; clicked = true; } } } if (!clicked) { log('warning', 'JavaScript click methods did not result in form appearing'); } } catch (error) { log('debug', `JavaScript click failed: ${error.message}`); } } } } catch (error) { log('error', `JavaScript evaluation failed: ${error.message}`); log('error', `Stack: ${error.stack}`); } } log('debug', `After JavaScript evaluation, addButtonClicked = ${addButtonClicked}`); if (!addButtonClicked) { // Enhanced context-aware detection - try smarter methods before brute force log('info', 'Trying enhanced context-aware detection for Add button...'); // Strategy 1: Find buttons specifically near "Static Routes" text try { const routesTextElements = await page.locator(':text("Static Routes"), :text("Routes"), :text("Static")').all(); for (const textElement of routesTextElements) { try { // Look for buttons in parent, siblings, and nearby containers const nearbyButtons = await page.evaluate((element) => { if (!element) return []; const allButtons = []; // Check parent element let current = element.parentElement; for (let depth = 0; depth < 3 && current; depth++) { const buttons = current.querySelectorAll('button, a[class*="button"], [role="button"]'); buttons.forEach(btn => { const rect = btn.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { allButtons.push({ text: btn.textContent?.trim() || '', className: btn.className || '', ariaLabel: btn.getAttribute('aria-label') || '', id: btn.id || null, hasPlusIcon: btn.querySelector('svg[class*="plus"], svg[class*="add"]') !== null, isInTableHeader: btn.closest('thead, th, [class*="table-header"]') !== null, tag: btn.tagName, }); } }); current = current.parentElement; } // Check siblings let sibling = element.nextElementSibling; for (let i = 0; i < 2 && sibling; i++) { const buttons = sibling.querySelectorAll('button, a[class*="button"], [role="button"]'); buttons.forEach(btn => { const rect = btn.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { allButtons.push({ text: btn.textContent?.trim() || '', className: btn.className || '', ariaLabel: btn.getAttribute('aria-label') || '', id: btn.id || null, hasPlusIcon: btn.querySelector('svg[class*="plus"], svg[class*="add"]') !== null, isInTableHeader: btn.closest('thead, th, [class*="table-header"]') !== null, tag: btn.tagName, }); } }); sibling = sibling.nextElementSibling; } return allButtons; }, await textElement.evaluateHandle(el => el)); if (nearbyButtons && nearbyButtons.length > 0) { log('info', `Found ${nearbyButtons.length} buttons near "Static Routes" text`); // Prioritize buttons with plus icons, in table headers, or empty text (icon-only) const prioritized = nearbyButtons.sort((a, b) => { if (a.hasPlusIcon && !b.hasPlusIcon) return -1; if (!a.hasPlusIcon && b.hasPlusIcon) return 1; if (a.isInTableHeader && !b.isInTableHeader) return -1; if (!a.isInTableHeader && b.isInTableHeader) return 1; if (!a.text && b.text) return -1; if (a.text && !b.text) return 1; return 0; }); for (const btnInfo of prioritized) { try { const selector = btnInfo.id ? `#${btnInfo.id}` : btnInfo.className ? `${btnInfo.tag.toLowerCase()}.${btnInfo.className.split(' ').filter(c => c).slice(0, 2).join('.')}` : null; if (!selector) continue; const btn = await page.locator(selector).first(); if (await btn.isVisible({ timeout: 2000 })) { log('info', `Testing context-aware button: text="${btnInfo.text}", hasPlusIcon=${btnInfo.hasPlusIcon}, isInTableHeader=${btnInfo.isInTableHeader}`); await btn.click({ timeout: 3000 }).catch(() => {}); await page.waitForTimeout(3000); const hasForm = await page.locator('input[name="name"], input[name="destination"]').first().isVisible({ timeout: 2000 }).catch(() => false); if (hasForm) { log('success', `✅✅✅ SUCCESS! Context-aware detection found Add button! ✅✅✅`); addButtonClicked = true; break; } // Check for menu const hasMenu = await page.locator('[role="menu"]').first().isVisible({ timeout: 1000 }).catch(() => false); if (hasMenu) { await page.keyboard.press('Escape'); await page.waitForTimeout(1000); } } } catch (error) { // Continue } } if (addButtonClicked) break; } } catch (error) { // Continue } } } catch (error) { log('debug', `Context-aware detection error: ${error.message}`); } // Strategy 2: Look for buttons in table headers specifically if (!addButtonClicked) { try { const tableHeaders = await page.locator('thead, th, [class*="table-header"]').all(); for (const header of tableHeaders) { const headerButtons = await header.locator('button, a[class*="button"], [role="button"]').all(); if (headerButtons.length > 0) { log('info', `Found ${headerButtons.length} buttons in table header`); for (const btn of headerButtons) { if (await btn.isVisible({ timeout: 2000 })) { const text = await btn.textContent().catch(() => ''); const hasPlusIcon = await btn.evaluate(el => el.querySelector('svg[class*="plus"], svg[class*="add"]') !== null); if (hasPlusIcon || !text || text.trim() === '' || text.trim() === '+') { log('info', `Testing table header button: text="${text}", hasPlusIcon=${hasPlusIcon}`); await btn.click({ timeout: 3000 }).catch(() => {}); await page.waitForTimeout(3000); const hasForm = await page.locator('input[name="name"], input[name="destination"]').first().isVisible({ timeout: 2000 }).catch(() => false); if (hasForm) { log('success', `✅✅✅ SUCCESS! Table header button opened the form! ✅✅✅`); addButtonClicked = true; break; } const hasMenu = await page.locator('[role="menu"]').first().isVisible({ timeout: 1000 }).catch(() => false); if (hasMenu) { await page.keyboard.press('Escape'); await page.waitForTimeout(1000); } } } if (addButtonClicked) break; } } if (addButtonClicked) break; } } catch (error) { log('debug', `Table header detection error: ${error.message}`); } } // Strategy 3: Aggressive auto-click (LAST RESORT - only if enabled) if (!addButtonClicked && AGGRESSIVE_AUTO_CLICK) { log('warning', '⚠️ Using aggressive auto-click as last resort (AGGRESSIVE_AUTO_CLICK=true)'); log('warning', '⚠️ This will click through buttons systematically - may cause side effects'); // Get ALL clickable elements let allClickable = await page.locator('button, a, [role="button"], [onclick]').all(); log('info', `Found ${allClickable.length} clickable elements, testing each one...`); const skipTexts = ['Sign In', 'Submit Support Ticket', 'Go back to Home', 'UDM Pro', 'Light', 'Dark', 'System', 'Sign Out']; for (let i = 0; i < Math.min(allClickable.length, 30); i++) { try { const element = allClickable[i]; const isVisible = await element.isVisible({ timeout: 1000 }).catch(() => false); if (!isVisible) continue; const isEnabled = await element.isEnabled().catch(() => false); if (!isEnabled) continue; const text = await element.textContent().catch(() => '') || ''; const className = await element.getAttribute('class').catch(() => '') || ''; const tag = await element.evaluate(el => el.tagName); // Skip known non-Add buttons const shouldSkip = skipTexts.some(skip => text.includes(skip)); if (shouldSkip) { log('debug', `Skipping element ${i}: "${text}"`); continue; } log('info', `🖱️ Auto-clicking element ${i}/${Math.min(allClickable.length, 30)}: ${tag}, text="${text.substring(0, 30)}", class="${className.substring(0, 40)}"`); // Save current URL const urlBefore = page.url(); // Try clicking try { await element.click({ timeout: 2000, force: true }); } catch (clickError) { // Try JavaScript click await element.evaluate(el => { if (el.click) el.click(); else { const event = new MouseEvent('click', { bubbles: true, cancelable: true }); el.dispatchEvent(event); } }); } await page.waitForTimeout(3000); // Wait for any action // Check if form appeared const formSelectors = [ 'input[name="name"]', 'input[name="destination"]', 'input[name="network"]', 'input[name="gateway"]', 'input[placeholder*="destination" i]', 'input[placeholder*="network" i]', 'input[placeholder*="route" i]', ]; let formFound = false; for (const selector of formSelectors) { try { formFound = await page.locator(selector).first().isVisible({ timeout: 2000 }); if (formFound) { log('success', `✅✅✅ SUCCESS! Element ${i} opened the form! ✅✅✅`); log('info', `Element details: ${tag}, text="${text}", class="${className}"`); addButtonClicked = true; break; } } catch (error) { // Continue } } if (formFound) break; // Check if menu appeared - if so, try clicking "Add" items in menu const hasMenu = await page.locator('[role="menu"], [role="listbox"]').first().isVisible({ timeout: 2000 }).catch(() => false); if (hasMenu) { log('debug', `Element ${i} opened a menu, checking for Add option...`); const menuItems = await page.evaluate(() => { const menu = document.querySelector('[role="menu"], [role="listbox"]'); if (!menu) return []; return Array.from(menu.querySelectorAll('*')).map(item => ({ text: item.textContent?.trim() || '', tag: item.tagName, })).filter(item => item.text && ( item.text.toLowerCase().includes('add') || item.text.toLowerCase().includes('create') || item.text.toLowerCase().includes('new') || item.text.toLowerCase().includes('route') )); }); if (menuItems.length > 0) { log('info', `Found ${menuItems.length} Add-related menu items, trying each...`); for (const menuItem of menuItems) { try { await page.click(`text="${menuItem.text}"`, { timeout: 2000 }); await page.waitForTimeout(3000); const hasForm2 = await page.locator('input[name="name"], input[name="destination"]').first().isVisible({ timeout: 2000 }).catch(() => false); if (hasForm2) { log('success', `✅✅✅ SUCCESS! Menu item "${menuItem.text}" opened the form! ✅✅✅`); addButtonClicked = true; break; } } catch (error) { // Continue } } } if (addButtonClicked) break; // Close menu await page.keyboard.press('Escape'); await page.waitForTimeout(1000); } // If URL changed to non-routing page, go back const urlAfter = page.url(); if (urlAfter !== urlBefore && !urlAfter.includes('/settings/routing')) { log('debug', `Element ${i} navigated away, going back...`); await page.goBack(); await page.waitForTimeout(2000); // Re-get clickable elements after navigation allClickable = await page.locator('button, a, [role="button"], [onclick]').all(); } } catch (error) { log('debug', `Error testing element ${i}: ${error.message}`); } } } else if (!addButtonClicked && !AGGRESSIVE_AUTO_CLICK) { log('info', 'ℹ️ Aggressive auto-click is disabled. Use AGGRESSIVE_AUTO_CLICK=true to enable.'); } } if (!addButtonClicked) { if (!HEADLESS || PAUSE_MODE) { log('info', '⏸️ Browser is open. Please manually click the Add button...'); log('info', ' The script will automatically detect when the form appears.'); log('info', ' You have 120 seconds to click the Add button.'); // Take a screenshot to help user see the page await takeScreenshot(page, '09-waiting-for-manual-add-click'); // Wait for user to click Add button manually - check periodically const maxWaitTime = 120; // 120 seconds const checkInterval = 2; // Check every 2 seconds const maxChecks = maxWaitTime / checkInterval; for (let i = 0; i < maxChecks; i++) { await page.waitForTimeout(checkInterval * 1000); // Check for form with comprehensive selectors const formSelectors = [ 'input[name="name"]', 'input[name="destination"]', 'input[name="network"]', 'input[name="gateway"]', 'input[placeholder*="destination" i]', 'input[placeholder*="network" i]', 'input[placeholder*="route" i]', 'form input[type="text"]', '[role="dialog"] input', '[class*="form" i] input', '[class*="dialog" i] input', '[class*="modal" i] input', ]; let hasForm = false; for (const selector of formSelectors) { try { hasForm = await page.locator(selector).first().isVisible({ timeout: 1000 }); if (hasForm) { log('success', `Form detected after manual Add button click! (using selector: ${selector})`); addButtonClicked = true; break; } } catch (error) { // Try next selector } } if (hasForm) { break; } // Show progress every 10 seconds if ((i + 1) % (10 / checkInterval) === 0) { const elapsed = (i + 1) * checkInterval; log('info', ` Still waiting... (${elapsed}/${maxWaitTime} seconds)`); } } if (!addButtonClicked) { throw new Error(`Add button was not clicked or form did not appear after ${maxWaitTime} seconds. Please click the Add button manually in the browser window and ensure the form appears.`); } } else { throw new Error('Could not find Add/Create button for static route. Run with HEADLESS=false PAUSE_MODE=true for manual intervention or use analyze-page-visually.js to find the selector.'); } } } else { log('warning', '[DRY RUN] Could not find Add button - script would fail in real run'); } } await takeScreenshot(page, '08-add-route-form'); // Pause if form appeared await pause(page, 'Add route form should be visible - verifying...', 2000); // Step 10: Fill route form log('info', 'Step 10: Filling route form...'); // Wait for form to be fully visible await page.waitForTimeout(1000); // Fill route name - improved selectors const nameSelectors = [ 'input[name="name"]', 'input[id*="name" i]', 'input[placeholder*="name" i]', 'input[aria-label*="name" i]', 'label:has-text("Name") + input', 'label:has-text("Name") ~ input', 'form input[type="text"]:first-of-type', '[role="dialog"] input[type="text"]:first-of-type', 'input[type="text"]:first-of-type', ]; for (const selector of nameSelectors) { try { const element = await page.$(selector); if (element && await element.isVisible()) { if (DRY_RUN) { log('info', `[DRY RUN] Would fill name: ${ROUTE_CONFIG.name}`); } else { await element.click(); // Click first to focus await element.fill(ROUTE_CONFIG.name); log('debug', `Route name filled: ${ROUTE_CONFIG.name}`); } break; } } catch (error) { // Try next selector } } // Fill destination network - improved selectors const destinationSelectors = [ 'input[name="destination"]', 'input[name="network"]', 'input[id*="destination" i]', 'input[id*="network" i]', 'input[placeholder*="destination" i]', 'input[placeholder*="network" i]', 'input[placeholder*="192.168" i]', 'input[aria-label*="destination" i]', 'input[aria-label*="network" i]', 'label:has-text("Destination") + input', 'label:has-text("Network") + input', 'label:has-text("Destination") ~ input', 'label:has-text("Network") ~ input', 'form input[type="text"]:nth-of-type(2)', '[role="dialog"] input[type="text"]:nth-of-type(2)', ]; for (const selector of destinationSelectors) { try { const element = await page.$(selector); if (element && await element.isVisible()) { if (DRY_RUN) { log('info', `[DRY RUN] Would fill destination: ${ROUTE_CONFIG.destination}`); } else { await element.fill(ROUTE_CONFIG.destination); log('debug', `Destination filled: ${ROUTE_CONFIG.destination}`); } break; } } catch (error) { // Try next selector } } // Fill gateway - improved selectors const gatewaySelectors = [ 'input[name="gateway"]', 'input[name="nexthop"]', 'input[name="next-hop"]', 'input[id*="gateway" i]', 'input[id*="nexthop" i]', 'input[placeholder*="gateway" i]', 'input[placeholder*="nexthop" i]', 'input[placeholder*="next hop" i]', 'input[aria-label*="gateway" i]', 'input[aria-label*="nexthop" i]', 'label:has-text("Gateway") + input', 'label:has-text("Next Hop") + input', 'label:has-text("Gateway") ~ input', 'label:has-text("Next Hop") ~ input', 'form input[type="text"]:nth-of-type(3)', '[role="dialog"] input[type="text"]:nth-of-type(3)', ]; for (const selector of gatewaySelectors) { try { const element = await page.$(selector); if (element && await element.isVisible()) { if (DRY_RUN) { log('info', `[DRY RUN] Would fill gateway: ${ROUTE_CONFIG.gateway}`); } else { await element.fill(ROUTE_CONFIG.gateway); log('debug', `Gateway filled: ${ROUTE_CONFIG.gateway}`); } break; } } catch (error) { // Try next selector } } await takeScreenshot(page, '09-form-filled'); // Pause before saving await pause(page, 'Form filled - ready to save route...', 1000); // Step 11: Save route log('info', 'Step 11: Saving route...'); // Improved save button selectors const saveButtonSelectors = [ 'button:has-text("Save")', 'button:has-text("Apply")', 'button:has-text("Create")', 'button:has-text("Add Route")', 'button:has-text("Create Route")', 'button[type="submit"]', 'button[aria-label*="Save" i]', 'button[aria-label*="Apply" i]', 'button[aria-label*="Create" i]', 'button[data-testid*="save" i]', 'button[data-testid*="submit" i]', 'button[class*="primary"]:has-text("Save")', 'button[class*="primary"]:has-text("Apply")', 'button[class*="primary"]:has-text("Create")', 'form button[type="submit"]', '[role="dialog"] button[type="submit"]', '[role="dialog"] button:has-text("Save")', '[role="dialog"] button:has-text("Apply")', 'button:has-text("Add")', ]; let saved = false; for (const selector of saveButtonSelectors) { try { const elements = await page.locator(selector).all(); for (const element of elements) { if (await element.isVisible({ timeout: 3000 })) { const text = await element.textContent(); const ariaLabel = await element.getAttribute('aria-label'); log('debug', `Found potential save button: ${selector}, text="${text}", aria="${ariaLabel}"`); // Make sure it's actually a save button if (text && ( text.toLowerCase().includes('save') || text.toLowerCase().includes('apply') || text.toLowerCase().includes('create') || text.toLowerCase().includes('add') ) || ariaLabel && ( ariaLabel.toLowerCase().includes('save') || ariaLabel.toLowerCase().includes('apply') || ariaLabel.toLowerCase().includes('create') ) || selector.includes('submit')) { if (DRY_RUN) { log('info', `[DRY RUN] Would click Save button: ${selector}`); log('info', '[DRY RUN] Route configuration would be:'); log('info', ` Name: ${ROUTE_CONFIG.name}`); log('info', ` Destination: ${ROUTE_CONFIG.destination}`); log('info', ` Gateway: ${ROUTE_CONFIG.gateway}`); saved = true; break; } else { await element.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); await element.click(); await page.waitForTimeout(3000); // Wait for save to complete saved = true; log('success', `Save button clicked using selector: ${selector}`); break; } } } } if (saved) break; } catch (error) { log('debug', `Error with selector ${selector}: ${error.message}`); } } // If still not found, try finding all buttons in form/dialog if (!saved && !DRY_RUN) { log('warning', 'Standard save button selectors failed, trying comprehensive search...'); try { const formButtons = await page.locator('form button, [role="dialog"] button').all(); for (const button of formButtons) { if (await button.isVisible({ timeout: 2000 }).catch(() => false)) { const text = await button.textContent(); const className = await button.getAttribute('class').catch(() => ''); if (text && ( text.toLowerCase().includes('save') || text.toLowerCase().includes('apply') || text.toLowerCase().includes('create') || className.toLowerCase().includes('primary') )) { await button.click(); await page.waitForTimeout(3000); saved = true; log('success', `Found and clicked save button: "${text}"`); break; } } } } catch (error) { log('debug', `Error in comprehensive save button search: ${error.message}`); } } if (!saved && !DRY_RUN) { // Try context-aware Save button detection first log('info', 'Trying context-aware detection for Save button...'); // Look for Save button in form/dialog area try { const formArea = await page.locator('form, [role="dialog"], [class*="dialog"], [class*="modal"]').first(); if (await formArea.isVisible({ timeout: 2000 }).catch(() => false)) { const formButtons = await formArea.locator('button, [role="button"], input[type="submit"]').all(); log('info', `Found ${formButtons.length} buttons in form area`); for (const btn of formButtons) { if (await btn.isVisible({ timeout: 1000 })) { const text = await btn.textContent().catch(() => '') || ''; const className = await btn.getAttribute('class').catch(() => '') || ''; const type = await btn.getAttribute('type').catch(() => '') || ''; const looksLikeSave = text.toLowerCase().includes('save') || text.toLowerCase().includes('add') || text.toLowerCase().includes('create') || text.toLowerCase().includes('submit') || type === 'submit' || className.toLowerCase().includes('save') || className.toLowerCase().includes('submit') || className.toLowerCase().includes('primary'); const isCancel = text.toLowerCase().includes('cancel') || text.toLowerCase().includes('close'); if (looksLikeSave && !isCancel) { log('info', `Testing context-aware Save button: text="${text}"`); await btn.click({ timeout: 3000 }).catch(() => {}); await page.waitForTimeout(3000); const formStillVisible = await page.locator('input[name="name"], input[name="destination"]').first().isVisible({ timeout: 2000 }).catch(() => false); if (!formStillVisible) { log('success', `✅✅✅ SUCCESS! Context-aware Save button worked! ✅✅✅`); saved = true; break; } } } } } } catch (error) { log('debug', `Context-aware Save button detection error: ${error.message}`); } // Aggressive auto-click for Save button (LAST RESORT - only if enabled) if (!saved && AGGRESSIVE_AUTO_CLICK) { log('warning', '⚠️ Using aggressive auto-click for Save button (AGGRESSIVE_AUTO_CLICK=true)'); const allButtons = await page.locator('button, [role="button"], input[type="submit"]').all(); log('info', `Found ${allButtons.length} buttons, testing each for Save...`); for (let i = 0; i < allButtons.length; i++) { try { const btn = allButtons[i]; const isVisible = await btn.isVisible({ timeout: 1000 }).catch(() => false); if (!isVisible) continue; const isEnabled = await btn.isEnabled().catch(() => false); if (!isEnabled) continue; const text = await btn.textContent().catch(() => '') || ''; const className = await btn.getAttribute('class').catch(() => '') || ''; const value = await btn.getAttribute('value').catch(() => '') || ''; const type = await btn.getAttribute('type').catch(() => '') || ''; // Check if this looks like a Save button const looksLikeSave = text.toLowerCase().includes('save') || text.toLowerCase().includes('add') || text.toLowerCase().includes('create') || text.toLowerCase().includes('submit') || value.toLowerCase().includes('save') || type === 'submit' || className.toLowerCase().includes('save') || className.toLowerCase().includes('submit') || className.toLowerCase().includes('primary'); // Skip Cancel/Close buttons const isCancel = text.toLowerCase().includes('cancel') || text.toLowerCase().includes('close') || className.toLowerCase().includes('cancel'); if (looksLikeSave && !isCancel) { log('info', `🖱️ Auto-clicking potential Save button ${i}: text="${text}", class="${className.substring(0, 40)}"`); try { await btn.click({ timeout: 3000, force: true }); await page.waitForTimeout(3000); // Check if form closed (route was saved) const formStillVisible = await page.locator('input[name="name"], input[name="destination"]').first().isVisible({ timeout: 2000 }).catch(() => false); if (!formStillVisible) { log('success', `✅✅✅ SUCCESS! Button ${i} saved the route! ✅✅✅`); saved = true; break; } } catch (error) { // Try JavaScript click await btn.evaluate(el => { if (el.click) el.click(); else { const event = new MouseEvent('click', { bubbles: true, cancelable: true }); el.dispatchEvent(event); } }); await page.waitForTimeout(3000); const formStillVisible2 = await page.locator('input[name="name"], input[name="destination"]').first().isVisible({ timeout: 2000 }).catch(() => false); if (!formStillVisible2) { log('success', `✅✅✅ SUCCESS! Button ${i} (JS click) saved the route! ✅✅✅`); saved = true; break; } } } } catch (error) { log('debug', `Error testing Save button ${i}: ${error.message}`); } } } else if (!saved && !AGGRESSIVE_AUTO_CLICK) { log('info', 'ℹ️ Aggressive auto-click is disabled. Use AGGRESSIVE_AUTO_CLICK=true to enable.'); } if (!saved) { throw new Error('Could not find Save button. Check screenshots for form state. Enable AGGRESSIVE_AUTO_CLICK=true to try aggressive auto-click.'); } } await takeScreenshot(page, '10-route-saved'); // Step 12: Verify route was created if (!DRY_RUN) { log('info', 'Step 12: Verifying route was created...'); await page.waitForTimeout(2000); // Check if route appears in the list const finalContent = await page.content(); if (finalContent.includes(ROUTE_CONFIG.destination)) { log('success', `Route to ${ROUTE_CONFIG.destination} appears to have been created`); } else { log('warning', `Route to ${ROUTE_CONFIG.destination} may not have been created - verify manually`); } await takeScreenshot(page, '11-verification'); } log('success', 'Static route configuration process completed'); if (DRY_RUN) { log('info', 'This was a DRY RUN - no changes were made'); log('info', 'Run without --dry-run to actually configure the route'); } } catch (error) { log('error', `Automation failed: ${error.message}`); if (page) { await takeScreenshot(page, 'error-state'); log('error', 'Error screenshot saved'); } if (error.stack) { log('error', `Stack trace: ${error.stack}`); } process.exit(1); } finally { if (browser) { await browser.close(); log('info', 'Browser closed'); } } } // Run the automation configureStaticRoute().catch((error) => { console.error('Fatal error:', error); process.exit(1); });