Files
proxmox/scripts/unifi/configure-static-route-playwright.js

2923 lines
129 KiB
JavaScript
Raw Normal View History

#!/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);
});