Some checks failed
Deploy to Phoenix / deploy (push) Has been cancelled
- 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>
2923 lines
129 KiB
JavaScript
Executable File
2923 lines
129 KiB
JavaScript
Executable File
#!/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);
|
||
});
|