Files
proxmox/scripts/unifi/configure-static-route-playwright.js
defiQUG fbda1b4beb
Some checks failed
Deploy to Phoenix / deploy (push) Has been cancelled
docs: Ledger Live integration, contract deploy learnings, NEXT_STEPS updates
- ADD_CHAIN138_TO_LEDGER_LIVE: Ledger form done; public code review repo bis-innovations/LedgerLive; init/push commands
- CONTRACT_DEPLOYMENT_RUNBOOK: Chain 138 gas price 1 gwei, 36-addr check, TransactionMirror workaround
- CONTRACT_*: AddressMapper, MirrorManager deployed 2026-02-12; 36-address on-chain check
- NEXT_STEPS_FOR_YOU: Ledger done; steps completable now (no LAN); run-completable-tasks-from-anywhere
- MASTER_INDEX, OPERATOR_OPTIONAL, SMART_CONTRACTS_INVENTORY_SIMPLE: updates
- LEDGER_BLOCKCHAIN_INTEGRATION_COMPLETE: bis-innovations/LedgerLive reference

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 15:46:57 -08:00

2923 lines
129 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});