#!/usr/bin/env node /** * Comprehensive Page Mapper * * This script fully maps the UDM Pro routing page to understand: * - All UI elements and their relationships * - Page state and how it changes * - Where buttons appear based on context * - How scrolling and interactions affect element visibility */ import { chromium } from 'playwright'; import { readFileSync, writeFileSync } 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; } } loadEnvFile(envPath); const UDM_URL = process.env.UNIFI_UDM_URL || 'https://192.168.0.1'; const USERNAME = process.env.UNIFI_BROWSER_USERNAME || process.env.UNIFI_USERNAME || 'unifi_api'; const PASSWORD = process.env.UNIFI_BROWSER_PASSWORD || process.env.UNIFI_PASSWORD; console.log('πŸ—ΊοΈ Comprehensive UDM Pro Page Mapper'); console.log('=====================================\n'); if (!PASSWORD) { console.error('❌ UNIFI_PASSWORD must be set in ~/.env'); process.exit(1); } (async () => { const browser = await chromium.launch({ headless: false }); const context = await browser.newContext({ ignoreHTTPSErrors: true }); const page = await context.newPage(); try { console.log('1. Logging in...'); await page.goto(UDM_URL, { waitUntil: 'networkidle' }); await page.waitForSelector('input[type="text"]'); await page.fill('input[type="text"]', USERNAME); await page.fill('input[type="password"]', PASSWORD); await page.click('button[type="submit"]'); await page.waitForTimeout(5000); console.log('2. Navigating to Routing page...'); // First ensure we're logged in and on dashboard const currentUrl = page.url(); if (currentUrl.includes('/login')) { console.log(' Still on login, waiting for redirect...'); await page.waitForURL('**/network/**', { timeout: 15000 }).catch(() => {}); await page.waitForTimeout(3000); } // Navigate to routing page console.log(' Navigating to routing settings...'); await page.goto(`${UDM_URL}/network/default/settings/routing`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3000); // Wait for URL to be correct (handle redirects) try { await page.waitForURL('**/settings/routing**', { timeout: 20000 }); console.log(' βœ… On routing page'); } catch (error) { console.log(` ⚠️ URL check failed, current: ${page.url()}`); // Try navigating again await page.goto(`${UDM_URL}/network/default/settings/routing`, { waitUntil: 'networkidle' }); await page.waitForTimeout(5000); } // Verify we're on the right page const pageText = await page.textContent('body').catch(() => ''); if (!pageText.includes('Route') && !pageText.includes('routing') && !pageText.includes('Static')) { console.log(' ⚠️ Page may not be fully loaded, waiting more...'); await page.waitForTimeout(10000); } console.log(` Current URL: ${page.url()}`); // Wait for routes API try { await page.waitForResponse(response => response.url().includes('/rest/routing') || response.url().includes('/trafficroutes'), { timeout: 15000 } ); console.log(' Routes API loaded'); } catch (error) { console.log(' Routes API not detected'); } await page.waitForTimeout(5000); console.log('3. Comprehensive page mapping...\n'); // Take full page screenshot await page.screenshot({ path: 'mapper-full-page.png', fullPage: true }); console.log(' Screenshot saved: mapper-full-page.png'); // Map page at different scroll positions const scrollPositions = [0, 500, 1000, 1500, 2000]; const pageMaps = []; for (const scrollY of scrollPositions) { await page.evaluate((y) => window.scrollTo(0, y), scrollY); await page.waitForTimeout(2000); const map = await page.evaluate(() => { const result = { scrollY: window.scrollY, viewport: { width: window.innerWidth, height: window.innerHeight }, elements: { buttons: [], links: [], inputs: [], tables: [], sections: [], text: [], }, }; // Get all buttons with full context const buttons = Array.from(document.querySelectorAll('button, [role="button"]')); buttons.forEach((btn, index) => { const rect = btn.getBoundingClientRect(); const styles = window.getComputedStyle(btn); if (rect.width > 0 && rect.height > 0 && styles.display !== 'none') { // Check if in viewport const inViewport = rect.top >= 0 && rect.top < window.innerHeight && rect.left >= 0 && rect.left < window.innerWidth; if (inViewport) { // Get full parent hierarchy let parent = btn; const hierarchy = []; for (let i = 0; i < 5; i++) { parent = parent.parentElement; if (!parent || parent === document.body) break; const parentText = parent.textContent?.trim().substring(0, 100) || ''; const parentClass = parent.className || ''; hierarchy.push({ tag: parent.tagName, class: parentClass.substring(0, 80), text: parentText, hasRoute: parentText.includes('Route') || parentText.includes('Static'), hasTable: parent.tagName === 'TABLE' || parent.querySelector('table'), }); } result.elements.buttons.push({ index, text: btn.textContent?.trim() || '', className: btn.className || '', id: btn.id || '', ariaLabel: btn.getAttribute('aria-label') || '', dataTestId: btn.getAttribute('data-testid') || '', position: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, iconOnly: !btn.textContent?.trim() && btn.querySelector('svg'), enabled: !btn.disabled, visible: styles.visibility !== 'hidden', hierarchy: hierarchy.slice(0, 3), // Top 3 parents }); } } }); // Get all tables const tables = Array.from(document.querySelectorAll('table')); tables.forEach((table, index) => { const rect = table.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { const inViewport = rect.top >= 0 && rect.top < window.innerHeight; if (inViewport) { const headers = Array.from(table.querySelectorAll('th')).map(th => th.textContent?.trim() || ''); const rows = Array.from(table.querySelectorAll('tbody tr, tr')).length; const tableText = table.textContent || ''; result.elements.tables.push({ index, headers, rowCount: rows, hasRouteText: tableText.includes('Route') || tableText.includes('Static'), position: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, buttonsInTable: Array.from(table.querySelectorAll('button')).length, }); } } }); // Get all text content that might indicate sections const routeTexts = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node; while (node = walker.nextNode()) { const text = node.textContent?.trim() || ''; if (text && (text.includes('Static Routes') || text.includes('Route') || text.includes('Add'))) { const parent = node.parentElement; if (parent) { routeTexts.push({ text: text.substring(0, 100), tag: parent.tagName, className: parent.className?.substring(0, 80) || '', position: parent.getBoundingClientRect(), }); } } } result.elements.text = routeTexts.slice(0, 20); return result; }); pageMaps.push(map); } // Analyze results console.log('πŸ“Š Page Mapping Results:'); console.log('='.repeat(80)); // Combine all buttons found at different scroll positions const allButtons = new Map(); pageMaps.forEach((map, scrollIndex) => { map.elements.buttons.forEach(btn => { const key = btn.id || btn.className || `${btn.position.x}-${btn.position.y}`; if (!allButtons.has(key)) { allButtons.set(key, { ...btn, foundAtScroll: [scrollIndex] }); } else { allButtons.get(key).foundAtScroll.push(scrollIndex); } }); }); console.log(`\nπŸ”˜ Unique Buttons Found: ${allButtons.size}`); Array.from(allButtons.values()).forEach((btn, i) => { console.log(`\n${i + 1}. Button:`); console.log(` Text: "${btn.text}"`); console.log(` Class: ${btn.className.substring(0, 80)}`); console.log(` ID: ${btn.id || 'none'}`); console.log(` Position: (${btn.position.x}, ${btn.position.y})`); console.log(` Icon Only: ${btn.iconOnly}`); console.log(` Enabled: ${btn.enabled}`); console.log(` Found at scroll positions: ${btn.foundAtScroll.join(', ')}`); if (btn.hierarchy.length > 0) { console.log(` Parent Context:`); btn.hierarchy.forEach((parent, j) => { console.log(` ${j + 1}. <${parent.tag}> ${parent.class} - "${parent.text.substring(0, 50)}"`); console.log(` Has Route: ${parent.hasRoute}, Has Table: ${parent.hasTable}`); }); } }); // Find tables const allTables = []; pageMaps.forEach(map => { map.elements.tables.forEach(table => { if (!allTables.find(t => t.index === table.index)) { allTables.push(table); } }); }); console.log(`\nπŸ“‹ Tables Found: ${allTables.length}`); allTables.forEach((table, i) => { console.log(`\n${i + 1}. Table ${table.index}:`); console.log(` Headers: ${table.headers.join(', ')}`); console.log(` Rows: ${table.rowCount}`); console.log(` Has Route Text: ${table.hasRouteText}`); console.log(` Buttons in Table: ${table.buttonsInTable}`); console.log(` Position: (${table.position.x}, ${table.position.y})`); }); // Find route-related text const routeTexts = []; pageMaps.forEach(map => { map.elements.text.forEach(text => { if (!routeTexts.find(t => t.text === text.text)) { routeTexts.push(text); } }); }); console.log(`\nπŸ“ Route-Related Text Found: ${routeTexts.length}`); routeTexts.forEach((text, i) => { console.log(`\n${i + 1}. "${text.text}"`); console.log(` Tag: ${text.tag}, Class: ${text.className}`); console.log(` Position: (${text.position.x}, ${text.position.y})`); }); // Save full map to file const mapData = { url: page.url(), timestamp: new Date().toISOString(), scrollMaps: pageMaps, allButtons: Array.from(allButtons.values()), allTables, routeTexts, }; writeFileSync('page-map.json', JSON.stringify(mapData, null, 2)); console.log('\nπŸ’Ύ Full page map saved to: page-map.json'); // Identify most likely Add button console.log(`\n🎯 Most Likely Add Button Candidates:`); console.log('='.repeat(80)); const candidates = Array.from(allButtons.values()) .filter(btn => btn.iconOnly || btn.text.toLowerCase().includes('add') || btn.text.toLowerCase().includes('create')) .sort((a, b) => { // Prioritize buttons with route context const aHasRoute = a.hierarchy.some(p => p.hasRoute); const bHasRoute = b.hierarchy.some(p => p.hasRoute); if (aHasRoute && !bHasRoute) return -1; if (!aHasRoute && bHasRoute) return 1; // Then prioritize buttons near tables const aNearTable = a.hierarchy.some(p => p.hasTable); const bNearTable = b.hierarchy.some(p => p.hasTable); if (aNearTable && !bNearTable) return -1; if (!aNearTable && bNearTable) return 1; return 0; }); candidates.slice(0, 5).forEach((btn, i) => { console.log(`\n${i + 1}. "${btn.text}" - ${btn.className.substring(0, 60)}`); console.log(` ID: ${btn.id || 'none'}`); console.log(` Has Route Context: ${btn.hierarchy.some(p => p.hasRoute)}`); console.log(` Near Table: ${btn.hierarchy.some(p => p.hasTable)}`); console.log(` Selector: ${btn.id ? `#${btn.id}` : `.${btn.className.split(' ')[0]}`}`); }); console.log('\n\n⏸️ Page is open in browser. Inspect manually if needed.'); console.log('Press Ctrl+C to close...\n'); await page.waitForTimeout(60000); } catch (error) { console.error('❌ Error:', error.message); await page.screenshot({ path: 'mapper-error.png', fullPage: true }); } finally { await browser.close(); } })();