#!/usr/bin/env node /** * Visual Test Pipeline — Full Analysis * * Captures screenshots, extracts UI elements with bounding boxes, * detects console errors, and compares against baselines. * * Usage: node visual-test-pipeline.js [URL] * * Environment: * TARGET_URL - App URL (default: http://host.docker.internal:3000) * PIXELMATCH_THRESHOLD - Diff threshold (default: 0.05 = 5%) * PAGES - Comma-separated page paths (default: /,/admin/login) * GITEA_ISSUE - Gitea issue number to post results (optional) */ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); const gitea = require('./lib/gitea-client'); const { BASE_ARGS } = require('./lib/browser-launcher'); const TARGET_URL = process.argv[2] || process.env.TARGET_URL || 'http://host.docker.internal:3000'; const THRESHOLD = parseFloat(process.env.PIXELMATCH_THRESHOLD || '0.05'); const PAGES_ARG = process.env.PAGES || '/,/admin/login'; const PAGE_PATHS = PAGES_ARG.split(',').map(p => p.trim()).filter(Boolean); const GITEA_ISSUE = parseInt(process.env.GITEA_ISSUE, 10) || null; const VISUAL_DIR = path.join(__dirname, '..', 'visual'); const BASELINE_DIR = path.join(VISUAL_DIR, 'baseline'); const CURRENT_DIR = path.join(VISUAL_DIR, 'current'); const DIFF_DIR = path.join(VISUAL_DIR, 'diff'); const REPORTS_DIR = path.join(__dirname, '..', 'reports'); const VIEWPORTS = [ { name: 'mobile', width: 375, height: 667 }, { name: 'tablet', width: 768, height: 1024 }, { name: 'desktop', width: 1280, height: 720 }, ]; function pageNameFromPath(p) { if (p === '/' || p === '') return 'homepage'; return p.replace(/^\//, '').replace(/[\/\.]/g, '-'); } const PAGES = PAGE_PATHS.map(p => ({ name: pageNameFromPath(p), path: p.startsWith('/') ? p : '/' + p })); function ensureDir(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } /** * Extract UI elements with bounding boxes from page */ async function extractElements(page) { return await page.evaluate(() => { const elements = []; const seen = new Set(); function processNode(node) { if (node.nodeType !== 1) return; const tag = node.tagName.toLowerCase(); const skipTags = new Set(['script','style','link','meta','noscript','svg','path','br','hr','wbr']); if (skipTags.has(tag)) return; const rect = node.getBoundingClientRect(); if (rect.width < 1 || rect.height < 1) return; const id = `${tag}-` + (node.id || '') + '-' + Math.random().toString(36).slice(2, 8); if (seen.has(id)) return; seen.add(id); const styles = window.getComputedStyle(node); const el = { tag, id: node.id || null, className: node.className?.toString()?.slice(0, 120) || null, text: (node.textContent || '').slice(0, 80).trim() || null, href: node.href || null, type: node.type || null, placeholder: node.placeholder || null, role: node.getAttribute('role') || null, ariaLabel: node.getAttribute('aria-label') || null, visible: styles.display !== 'none' && styles.visibility !== 'hidden' && styles.opacity !== '0', bbox: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height), }, }; elements.push(el); } function walk(root) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false); let node; while (node = walker.nextNode()) processNode(node); } walk(document.body); return elements; }); } /** * Capture screenshots and extract elements for a single page+viewport */ async function capturePage(browser, pageConf, vp, outputDir, mode) { const filename = `${pageConf.name}_${vp.name}.png`; const filePath = path.join(outputDir, filename); const url = `${TARGET_URL}${pageConf.path}`; const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, deviceScaleFactor: 1, }); const page = await context.newPage(); const consoleErrors = []; const networkErrors = []; page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); }); page.on('response', resp => { if (resp.status() >= 400) networkErrors.push({ url: resp.url(), status: resp.status() }); }); page.on('requestfailed', req => { networkErrors.push({ url: req.url(), failure: req.failure()?.errorText || 'failed' }); }); try { console.log(` Capturing: ${pageConf.name} @ ${vp.name} (${vp.width}x${vp.height})`); const response = await page.goto(url, { waitUntil: 'commit', timeout: 30000 }); await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {}); await page.waitForTimeout(1500); await page.screenshot({ path: filePath, fullPage: true }); const fileSize = fs.statSync(filePath).size; const elements = await extractElements(page); const title = await page.title(); console.log(` āœ… ${filename} (${(fileSize / 1024).toFixed(1)} KB, ${elements.length} elements)`); return { filename, page: pageConf.name, viewport: vp.name, status: 'PASS', size: fileSize, url, httpStatus: response?.status() || null, title, elements, consoleErrors, networkErrors, }; } catch (err) { console.log(` āŒ ${filename}: ${err.message}`); return { filename, page: pageConf.name, viewport: vp.name, status: 'FAIL', error: err.message, elements: [], consoleErrors, networkErrors: [] }; } finally { await context.close(); } } async function captureAll(mode) { ensureDir(mode === 'baseline' ? BASELINE_DIR : CURRENT_DIR); const outputDir = mode === 'baseline' ? BASELINE_DIR : CURRENT_DIR; console.log(`\nšŸ“ø Capturing ${mode} screenshots...`); console.log(` Target: ${TARGET_URL}`); console.log(` Pages: ${PAGES.map(p => p.path).join(', ')}`); console.log(` Output: ${outputDir}\n`); const browser = await chromium.launch({ headless: true, args: [...BASE_ARGS, '--disable-setuid-sandbox'] }); const results = []; for (const pageConf of PAGES) { for (const vp of VIEWPORTS) { const r = await capturePage(browser, pageConf, vp, outputDir, mode); results.push(r); } } await browser.close(); return results; } async function compareScreenshots() { const pixelmatch = require('pixelmatch'); const PNG = require('pngjs').PNG; ensureDir(DIFF_DIR); console.log(`\nšŸ” Comparing screenshots (threshold: ${THRESHOLD * 100}%)...\n`); const baselines = fs.existsSync(BASELINE_DIR) ? fs.readdirSync(BASELINE_DIR).filter(f => f.endsWith('.png')) : []; const results = []; let passed = 0, failed = 0; for (const file of baselines) { const currentPath = path.join(CURRENT_DIR, file); const diffPath = path.join(DIFF_DIR, file.replace('.png', '_diff.png')); if (!fs.existsSync(currentPath)) { console.log(` āš ļø Missing current: ${file}`); results.push({ filename: file, status: 'MISSING', diffPercent: null }); failed++; continue; } try { const baselineImg = PNG.sync.read(fs.readFileSync(path.join(BASELINE_DIR, file))); const currentImg = PNG.sync.read(fs.readFileSync(currentPath)); const { width, height } = baselineImg; if (width !== currentImg.width || height !== currentImg.height) { console.log(` āŒ Size mismatch: ${file}`); results.push({ filename: file, status: 'SIZE_MISMATCH', diffPercent: null }); failed++; continue; } const diffImg = new PNG({ width, height }); const diffPixels = pixelmatch(baselineImg.data, currentImg.data, diffImg.data, width, height, { threshold: 0.1, diffColor: [255, 0, 0] }); fs.writeFileSync(diffPath, PNG.sync.write(diffImg)); const diffPercent = (diffPixels / (width * height)) * 100; const ok = diffPercent <= THRESHOLD * 100; ok ? passed++ : failed++; console.log(` ${ok ? 'āœ…' : 'āŒ'} ${file}: ${diffPercent.toFixed(2)}% diff`); results.push({ filename: file, status: ok ? 'PASS' : 'FAIL', diffPercent: diffPercent.toFixed(2), diffPixels, totalPixels: width * height }); } catch (err) { console.log(` āŒ Error: ${file}: ${err.message}`); results.push({ filename: file, status: 'ERROR', error: err.message }); failed++; } } return { results, passed, failed }; } async function main() { console.log('═══════════════════════════════════════════════════'); console.log(' Visual Test Pipeline — Full Analysis'); console.log('═══════════════════════════════════════════════════\n'); ensureDir(REPORTS_DIR); const hasBaselines = fs.existsSync(BASELINE_DIR) && fs.readdirSync(BASELINE_DIR).filter(f => f.endsWith('.png')).length > 0; if (!hasBaselines) { console.log('āš ļø No baselines — capturing baseline screenshots first.\n'); await captureAll('baseline'); console.log('\nāœ… Baselines created. Now capturing current screenshots.\n'); } const captureResults = await captureAll('current'); const compareResult = await compareScreenshots(); const allElements = {}; const allConsoleErrors = []; const allNetworkErrors = []; for (const r of captureResults) { const key = `${r.page}_${r.viewport}`; allElements[key] = r.elements || []; if (r.consoleErrors?.length) allConsoleErrors.push(...r.consoleErrors.map(e => ({ page: r.page, viewport: r.viewport, error: e }))); if (r.networkErrors?.length) allNetworkErrors.push(...r.networkErrors.map(e => ({ page: r.page, viewport: r.viewport, ...e }))); } const report = { timestamp: new Date().toISOString(), targetUrl: TARGET_URL, pages: PAGES.map(p => p.path), viewports: VIEWPORTS.map(v => v.name), threshold: THRESHOLD, summary: { screenshotsCaptured: captureResults.filter(r => r.status === 'PASS').length, screenshotsFailed: captureResults.filter(r => r.status === 'FAIL').length, comparisonsPassed: compareResult.passed, comparisonsFailed: compareResult.failed, totalElements: Object.values(allElements).reduce((s, a) => s + a.length, 0), totalConsoleErrors: allConsoleErrors.length, totalNetworkErrors: allNetworkErrors.length, overallPassed: compareResult.passed >= compareResult.failed && captureResults.filter(r => r.status === 'FAIL').length === 0, }, capture: captureResults.map(r => ({ filename: r.filename, page: r.page, viewport: r.viewport, status: r.status, httpStatus: r.httpStatus, title: r.title, elementCount: r.elements?.length || 0, consoleErrorCount: r.consoleErrors?.length || 0, networkErrorCount: r.networkErrors?.length || 0, })), elements: allElements, consoleErrors: allConsoleErrors, networkErrors: allNetworkErrors, comparison: compareResult.results, }; const reportPath = path.join(REPORTS_DIR, 'visual-test-report.json'); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); console.log('\n═══════════════════════════════════════════════════'); console.log(` šŸ“Š RESULTS SUMMARY`); console.log(` ─────────────────────────────────────────────────`); console.log(` Screenshots: ${report.summary.screenshotsCaptured} captured, ${report.summary.screenshotsFailed} failed`); console.log(` Elements: ${report.summary.totalElements}`); console.log(` Comparison: ${compareResult.passed} passed, ${compareResult.failed} failed`); console.log(` Console Errs: ${allConsoleErrors.length}`); console.log(` Network Errs: ${allNetworkErrors.length}`); if (allConsoleErrors.length > 0) { console.log(`\n āŒ Console Errors:`); for (const e of allConsoleErrors.slice(0, 10)) { console.log(` [${e.page}/${e.viewport}] ${e.error.slice(0, 120)}`); } } if (allNetworkErrors.length > 0) { console.log(`\n āŒ Network Errors:`); for (const e of allNetworkErrors.slice(0, 10)) { console.log(` [${e.page}/${e.viewport}] ${e.status || e.failure} ${e.url?.slice(0, 80)}`); } } console.log(`\n šŸ“„ Report: ${reportPath}`); console.log('═══════════════════════════════════════════════════\n'); if (GITEA_ISSUE) { try { console.log(`šŸ“¤ Posting results to Gitea Issue #${GITEA_ISSUE}...`); const commentBody = gitea.formatVisualReport(report); const diffFiles = fs.existsSync(DIFF_DIR) ? fs.readdirSync(DIFF_DIR).filter(f => f.endsWith('.png')).map(f => path.join(DIFF_DIR, f)) : []; const currentFiles = fs.existsSync(CURRENT_DIR) ? fs.readdirSync(CURRENT_DIR).filter(f => f.endsWith('.png')).map(f => path.join(CURRENT_DIR, f)) : []; if (diffFiles.length > 0) { await gitea.uploadAndComment(GITEA_ISSUE, diffFiles, commentBody); console.log(` āœ… Posted comment with ${diffFiles.length} diff screenshots`); } else if (currentFiles.length > 0) { await gitea.uploadAndComment(GITEA_ISSUE, currentFiles, commentBody); console.log(` āœ… Posted comment with ${currentFiles.length} current screenshots`); } else { await gitea.postComment(GITEA_ISSUE, commentBody); console.log(' āœ… Posted comment (no screenshots to upload)'); } } catch (err) { console.error(` āŒ Gitea posting failed: ${err.message}`); } } process.exit(report.summary.overallPassed ? 0 : 1); } main().catch(err => { console.error('Fatal:', err); process.exit(1); });