#!/usr/bin/env node /** * E2E Test Suite for APAW Landing Page * Tests: page load, console errors, API state, analytics, heatmap modal, * close interactions, visual regression. * * Usage: node e2e-landing-test.js * Environment: TARGET_URL (default http://host.docker.internal:3002) */ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); const pixelmatch = require('pixelmatch'); const { PNG } = require('pngjs'); const { launchBrowser, newContext, navigateTo } = require('./lib/browser-launcher'); const TARGET_URL = process.env.TARGET_URL || 'http://host.docker.internal:3002'; const REPORTS_DIR = process.env.REPORTS_DIR || path.join(__dirname, '..', 'reports'); const BASELINE_DIR = process.env.BASELINE_DIR || path.join(__dirname, '..', 'visual', 'baseline'); const CURRENT_DIR = process.env.CURRENT_DIR || path.join(__dirname, '..', 'visual', 'current'); const VIEWPORT = { width: 1280, height: 900 }; async function main() { console.log('═══════════════════════════════════════════════════'); console.log(' APAW Landing E2E Tests'); console.log('═══════════════════════════════════════════════════\n'); console.log(`Target: ${TARGET_URL}\n`); for (const dir of [REPORTS_DIR, CURRENT_DIR]) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } const browser = await launchBrowser(); const context = await newContext(browser, { viewport: VIEWPORT }); const page = await context.newPage(); const consoleErrors = []; const consoleWarnings = []; const networkErrors = []; let networkRequests = []; const results = []; page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); else if (msg.type() === 'warning') consoleWarnings.push(msg.text()); }); page.on('requestfailed', request => { networkErrors.push({ url: request.url(), failure: request.failure()?.errorText || 'Unknown' }); }); page.on('response', response => { if (response.status() >= 400) { networkErrors.push({ url: response.url(), status: response.status() }); } }); // ============================================================ // Test 1: Page loads without console errors // ============================================================ { console.log('┌─────────────────────────────────────────────────┐'); console.log('│ Test 1: Page loads without console errors │'); console.log('└─────────────────────────────────────────────────┘'); try { const response = await navigateTo(page, `${TARGET_URL}`, { waitUntil: 'commit', timeout: 30000, delay: 3000 }); const status = response?.status() || 0; // Wait for analytics section to be present in DOM await page.waitForSelector('#analytics', { timeout: 10000 }).catch(() => {}); const title = await page.title(); const pageLoaded = status === 200 && title.includes('APAW'); result(results, '1_page_load', pageLoaded && consoleErrors.length === 0, `HTTP ${status}, title: "${title}", console errors: ${consoleErrors.length}`); } catch (e) { result(results, '1_page_load', false, e.message); } } // ============================================================ // Test 2: /api/state loads successfully // ============================================================ { console.log('\n┌─────────────────────────────────────────────────┐'); console.log('│ Test 2: /api/state loads successfully │'); console.log('└─────────────────────────────────────────────────┘'); try { const apiResponse = await page.evaluate(async (url) => { const res = await fetch(`${url}/api/state`); const data = await res.json().catch(() => null); return { status: res.status, ok: res.ok, hasAgents: !!(data && Array.isArray(data.agents) && data.agents.length > 0) }; }, TARGET_URL); result(results, '2_api_state', apiResponse.ok && apiResponse.hasAgents, `status=${apiResponse.status}, hasAgents=${apiResponse.hasAgents}`); } catch (e) { result(results, '2_api_state', false, e.message); } } // ============================================================ // Test 3: #analytics section is visible with heatmap rendered // ============================================================ { console.log('\n┌─────────────────────────────────────────────────┐'); console.log('│ Test 3: #analytics visible + heatmap rendered │'); console.log('└─────────────────────────────────────────────────┘'); try { const analytics = await page.locator('#analytics').first(); const isVisible = await analytics.isVisible().catch(() => false); // Scroll to analytics await page.evaluate(() => { const el = document.getElementById('analytics'); if (el) el.scrollIntoView({ block: 'start' }); }); await page.waitForTimeout(800); const heatmap = await page.locator('#fit-heatmap').first(); const heatmapVisible = await heatmap.isVisible().catch(() => false); const cellCount = await heatmap.locator('.heatmap__cell').count().catch(() => 0); result(results, '3_analytics_heatmap', isVisible && heatmapVisible && cellCount > 0, `analytics visible=${isVisible}, heatmap visible=${heatmapVisible}, cells=${cellCount}`); } catch (e) { result(results, '3_analytics_heatmap', false, e.message); } } // ============================================================ // Test 4: Clicking a heatmap cell opens #fit-modal // ============================================================ { console.log('\n┌─────────────────────────────────────────────────┐'); console.log('│ Test 4: Clicking heatmap cell opens #fit-modal │'); console.log('└─────────────────────────────────────────────────┘'); try { const cell = await page.locator('#fit-heatmap .heatmap__cell').first(); await cell.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); await cell.click(); await page.waitForTimeout(500); const modal = await page.locator('#fit-modal').first(); const modalVisible = await modal.isVisible().catch(() => false); const isOpen = await modal.evaluate(el => el.classList.contains('is-open')).catch(() => false); result(results, '4_click_opens_modal', modalVisible && isOpen, `modal visible=${modalVisible}, class is-open=${isOpen}`); } catch (e) { result(results, '4_click_opens_modal', false, e.message); } } // ============================================================ // Test 5: Modal displays agent name, model, fit score, // breakdown dimensions, and explanation // ============================================================ { console.log('\n┌─────────────────────────────────────────────────┐'); console.log('│ Test 5: Modal content (name, model, score, etc) │'); console.log('└─────────────────────────────────────────────────┘'); try { const modal = await page.locator('#fit-modal').first(); const agentName = await modal.locator('#modal-agent-name').textContent().catch(() => ''); const modelText = await modal.locator('#modal-model').textContent().catch(() => ''); const scoreText = await modal.locator('#modal-score').textContent().catch(() => ''); const explanation = await modal.locator('#modal-explanation').textContent().catch(() => ''); const dims = await modal.locator('#modal-breakdown .modal__dimension').count().catch(() => 0); const nameOk = agentName.trim().length > 0 && agentName !== 'Agent'; const modelOk = modelText.trim().length > 0; const scoreOk = !isNaN(parseInt(scoreText, 10)) && parseInt(scoreText, 10) > 0; const dimsOk = dims >= 4; const explOk = explanation.trim().length > 0; result(results, '5_modal_content', nameOk && modelOk && scoreOk && dimsOk && explOk, `name="${agentName.trim()}", model="${modelText.trim()}", score="${scoreText.trim()}", dimensions=${dims}, explanation=${explOk ? 'present' : 'missing'}`); } catch (e) { result(results, '5_modal_content', false, e.message); } } // ============================================================ // Test 6: Modal can be closed via close button and Escape key // ============================================================ { const modal = await page.locator('#fit-modal').first(); // 6a: close button { console.log('\n┌─────────────────────────────────────────────────┐'); console.log('│ Test 6a: Close via close button │'); console.log('└─────────────────────────────────────────────────┘'); try { await modal.locator('.modal__close').click(); await page.waitForTimeout(600); // If CSS transition leaves it briefly visible, wait a tick const isOpen = await modal.evaluate(el => el.classList.contains('is-open')).catch(() => true); const visible = await modal.isVisible().catch(() => true); result(results, '6_close_button', !isOpen && !visible, `is-open=${isOpen}, visible=${visible}`); } catch (e) { result(results, '6_close_button', false, e.message); } } // 6b: Escape key { console.log('\n┌─────────────────────────────────────────────────┐'); console.log('│ Test 6b: Close via Escape key │'); console.log('└─────────────────────────────────────────────────┘'); try { // If modal is still open (bug), force close via JS const stillOpen = await modal.evaluate(el => el.classList.contains('is-open')).catch(() => false); if (stillOpen) await page.evaluate(() => { if (typeof closeFitModal === 'function') closeFitModal(); }); await page.waitForTimeout(400); const cell = await page.locator('#fit-heatmap .heatmap__cell').first(); await cell.evaluate(el => el.scrollIntoView({ block: 'center' })); await cell.click({ force: true }); await page.waitForTimeout(500); await page.keyboard.press('Escape'); await page.waitForTimeout(500); const isOpen = await modal.evaluate(el => el.classList.contains('is-open')).catch(() => true); const visible = await modal.isVisible().catch(() => true); result(results, '6_escape_key', !isOpen && !visible, `is-open=${isOpen}, visible=${visible}`); } catch (e) { result(results, '6_escape_key', false, e.message); } } } // ============================================================ // Screenshot of opened modal // ============================================================ { console.log('\n┌─────────────────────────────────────────────────┐'); console.log('│ Capturing modal screenshot │'); console.log('└─────────────────────────────────────────────────┘'); try { const cell = await page.locator('#fit-heatmap .heatmap__cell').first(); await cell.click(); await page.waitForTimeout(600); const modal = await page.locator('#fit-modal').first(); const modalBox = await modal.boundingBox().catch(() => null); const screenshotPath = path.join(CURRENT_DIR, 'modal_opened.png'); if (modalBox) { await page.screenshot({ path: screenshotPath, clip: modalBox }); } else { await page.screenshot({ path: screenshotPath }); } console.log(` ✅ Screenshot saved: ${screenshotPath}`); result(results, 'screenshot_modal', true, screenshotPath); } catch (e) { console.log(` ❌ Screenshot failed: ${e.message}`); result(results, 'screenshot_modal', false, e.message); } } // ============================================================ // Test 7: No visual regressions from baseline // ============================================================ { console.log('\n┌─────────────────────────────────────────────────┐'); console.log('│ Test 7: Visual regression (baseline vs current) │'); console.log('└─────────────────────────────────────────────────┘'); const baselinePath = path.join(BASELINE_DIR, 'homepage_desktop.png'); const currentPath = path.join(CURRENT_DIR, 'homepage_desktop.png'); // Capture current homepage for comparison try { await navigateTo(page, `${TARGET_URL}`, { waitUntil: 'commit', delay: 3000 }); await page.screenshot({ path: currentPath, fullPage: true }); } catch (e) { console.log(` ⚠️ Could not capture current screenshot: ${e.message}`); } if (!fs.existsSync(baselinePath)) { console.log(` ⚠️ Baseline not found at ${baselinePath}`); result(results, '7_visual_regression', null, 'SKIP: baseline missing'); } else if (!fs.existsSync(currentPath)) { result(results, '7_visual_regression', false, 'Current screenshot capture failed'); } else { try { const baseline = PNG.sync.read(fs.readFileSync(baselinePath)); const current = PNG.sync.read(fs.readFileSync(currentPath)); if (baseline.width !== current.width || baseline.height !== current.height) { result(results, '7_visual_regression', false, `Size mismatch: ${baseline.width}x${baseline.height} vs ${current.width}x${current.height}`); } else { const diff = new PNG({ width: baseline.width, height: baseline.height }); const numDiff = pixelmatch(baseline.data, current.data, diff.data, baseline.width, baseline.height, { threshold: 0.1 }); const diffPercent = (numDiff / (baseline.width * baseline.height)) * 100; const passed = diffPercent <= 5.0; // 5% tolerance result(results, '7_visual_regression', passed, `diff pixels=${numDiff} (${diffPercent.toFixed(2)}%)`); if (!passed) { const diffPath = path.join(CURRENT_DIR, 'homepage_desktop_diff.png'); fs.writeFileSync(diffPath, PNG.sync.write(diff)); console.log(` 📸 Diff saved: ${diffPath}`); } } } catch (e) { result(results, '7_visual_regression', false, e.message); } } } await context.close(); await browser.close(); // ============================================================ // Summary // ============================================================ console.log('\n═══════════════════════════════════════════════════'); console.log(' Results Summary'); console.log('═══════════════════════════════════════════════════\n'); for (const r of results) { const icon = r.pass === true ? '✅' : r.pass === false ? '❌' : '⏭️'; console.log(`${icon} ${r.name}`); console.log(` ${r.detail}`); } console.log(`\n📊 Console errors: ${consoleErrors.length}`); console.log(`📊 Console warnings: ${consoleWarnings.length}`); console.log(`📊 Network errors: ${networkErrors.length}`); const failures = results.filter(r => r.pass === false); const passed = results.filter(r => r.pass === true); const skipped = results.filter(r => r.pass === null); console.log(`\n✅ Passed: ${passed.length}`); console.log(`❌ Failed: ${failures.length}`); console.log(`⏭️ Skipped: ${skipped.length}`); const reportPath = path.join(REPORTS_DIR, 'e2e-landing-report.json'); fs.writeFileSync(reportPath, JSON.stringify({ timestamp: new Date().toISOString(), targetUrl: TARGET_URL, results, summary: { passed: passed.length, failed: failures.length, skipped: skipped.length, consoleErrors: consoleErrors.length, consoleWarnings: consoleWarnings.length, networkErrors: networkErrors.length, }, }, null, 2)); console.log(`\n📄 Report: ${reportPath}`); process.exit(failures.length > 0 ? 1 : 0); } function result(list, name, pass, detail) { list.push({ name, pass, detail }); const icon = pass === true ? '✅' : pass === false ? '❌' : '⏭️'; console.log(` ${icon} ${name}: ${detail}`); } main().catch(err => { console.error('Fatal:', err); process.exit(1); });