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>
370 lines
14 KiB
JavaScript
Executable File
370 lines
14 KiB
JavaScript
Executable File
#!/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();
|
||
}
|
||
})();
|