#!/usr/bin/env node /** * NORA Pre-Release UI Test Suite (Playwright) * * Comprehensive browser-based testing: * - Page rendering & structure * - CSS/styling verification * - HTMX interactivity * - Navigation & routing * - Search functionality * - Language switching (EN/RU) * - Security headers * - Responsive design (mobile/tablet/desktop) * - Accessibility (a11y basics) * - Console errors / CSP violations * - Screenshots for visual review * * Usage: * node playwright-ui-test.mjs [base_url] [screenshot_dir] * * First-time setup: * npx playwright install chromium */ import { chromium } from 'playwright'; import { mkdirSync, existsSync } from 'fs'; import { join } from 'path'; const BASE = process.argv[2] || 'http://127.0.0.1:4088'; const SCREENSHOT_DIR = process.argv[3] || '/tmp/nora-ui-screenshots'; // Ensure screenshot directory exists if (!existsSync(SCREENSHOT_DIR)) mkdirSync(SCREENSHOT_DIR, { recursive: true }); let pass = 0, fail = 0, warn = 0; const results = []; const consoleErrors = []; const cspViolations = []; function check(name, status, detail) { results.push({ name, status, detail }); if (status === 'PASS') pass++; else if (status === 'FAIL') fail++; else warn++; } function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); } async function screenshot(page, name) { const path = join(SCREENSHOT_DIR, `${name}.png`); await page.screenshot({ path, fullPage: true }); return path; } async function run() { console.log('======================================================================'); console.log(' NORA Pre-Release UI Test Suite (Playwright + Chromium)'); console.log(` Target: ${BASE}`); console.log(` Screenshots: ${SCREENSHOT_DIR}`); console.log(` ${new Date().toISOString()}`); console.log('======================================================================\n'); const browser = await chromium.launch({ headless: true }); // ================================================================ // PHASE 1: Dashboard — Desktop // ================================================================ console.log('=== Phase 1: Dashboard (Desktop 1920x1080) ===\n'); const desktopCtx = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'en-US', }); const page = await desktopCtx.newPage(); // Collect all console errors and CSP violations page.on('pageerror', e => consoleErrors.push(e.message)); page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); }); // 1.1 Dashboard loads try { const resp = await page.goto(`${BASE}/ui/`, { waitUntil: 'networkidle', timeout: 15000 }); assert(resp.status() === 200); check('1.1 Dashboard HTTP 200', 'PASS'); } catch (e) { check('1.1 Dashboard HTTP 200', 'FAIL', e.message); await browser.close(); printResults(); process.exit(1); } // 1.2 Page title try { const title = await page.title(); assert(title.length > 0, `Empty title`); check('1.2 Page has title', 'PASS', title); } catch (e) { check('1.2 Page has title', 'FAIL', e.message); } // 1.3 HTML structure try { const html = await page.content(); assert(html.includes('') || html.includes(''), 'No doctype'); assert(html.includes('') || html.includes(''), 'No closing html'); check('1.3 Valid HTML structure', 'PASS'); } catch (e) { check('1.3 Valid HTML structure', 'FAIL', e.message); } // 1.4 Registry cards present try { const body = await page.textContent('body'); for (const name of ['Docker', 'Maven', 'npm', 'Cargo', 'PyPI']) { assert(body.includes(name), `Missing ${name} card`); } check('1.4 All 5 registry cards rendered', 'PASS'); } catch (e) { check('1.4 All 5 registry cards rendered', 'FAIL', e.message); } // 1.5 Stats section (downloads, uploads, artifacts) try { const body = await page.textContent('body'); // Check for stat-like numbers or labels const hasStats = /download|upload|artifact|cache/i.test(body); assert(hasStats, 'No stats section found'); check('1.5 Stats section visible', 'PASS'); } catch (e) { check('1.5 Stats section visible', 'FAIL', e.message); } // 1.6 Mount points / endpoints table try { const body = await page.textContent('body'); const hasMounts = ['/v2/', '/maven2/', '/npm/', '/simple/'].some(m => body.includes(m)); assert(hasMounts, 'No mount points'); check('1.6 Mount points table', 'PASS'); } catch (e) { check('1.6 Mount points table', 'FAIL', e.message); } // 1.7 Activity log section try { const body = await page.textContent('body'); const hasActivity = /activity|recent|no activity|нет активности/i.test(body); assert(hasActivity, 'No activity section'); check('1.7 Activity log section', 'PASS'); } catch (e) { check('1.7 Activity log section', 'FAIL', e.message); } // 1.8 CSS applied (not unstyled) try { const bg = await page.evaluate(() => { const s = getComputedStyle(document.body); return { bg: s.backgroundColor, font: s.fontFamily, color: s.color }; }); assert(bg.bg !== 'rgba(0, 0, 0, 0)' && bg.bg !== '', `No background: ${bg.bg}`); assert(bg.font.length > 0, 'No font'); check('1.8 CSS styling applied', 'PASS', `bg=${bg.bg}`); } catch (e) { check('1.8 CSS styling applied', 'FAIL', e.message); } // 1.9 HTMX loaded try { const hasHtmx = await page.evaluate(() => typeof htmx !== 'undefined'); assert(hasHtmx, 'htmx not defined'); check('1.9 HTMX library loaded', 'PASS'); } catch (e) { check('1.9 HTMX library loaded', 'FAIL', e.message); } // 1.10 No JS errors on dashboard try { const dashErrors = consoleErrors.filter(e => !e.includes('favicon')); assert(dashErrors.length === 0, dashErrors.join('; ')); check('1.10 No JavaScript errors', 'PASS'); } catch (e) { check('1.10 No JavaScript errors', 'FAIL', e.message); } await screenshot(page, '01-dashboard-desktop'); // ================================================================ // PHASE 2: Navigation & Routing // ================================================================ console.log('=== Phase 2: Navigation & Routing ===\n'); // 2.1 All nav links resolve try { const links = await page.locator('a[href^="/ui/"]').all(); assert(links.length >= 5, `Only ${links.length} nav links`); check('2.1 Navigation links exist', 'PASS', `${links.length} links`); } catch (e) { check('2.1 Navigation links exist', 'FAIL', e.message); } // 2.2 Click through each registry for (const reg of ['docker', 'maven', 'npm', 'cargo', 'pypi']) { try { const resp = await page.goto(`${BASE}/ui/${reg}`, { waitUntil: 'networkidle', timeout: 10000 }); assert(resp.status() === 200); const html = await page.content(); assert(html.includes('') || html.includes('')); check(`2.2.${reg} ${reg} list page`, 'PASS'); await screenshot(page, `02-${reg}-list`); } catch (e) { check(`2.2.${reg} ${reg} list page`, 'FAIL', e.message); } } // 2.3 Back to dashboard try { const resp = await page.goto(`${BASE}/ui/`, { waitUntil: 'networkidle' }); assert(resp.status() === 200); check('2.3 Return to dashboard', 'PASS'); } catch (e) { check('2.3 Return to dashboard', 'FAIL', e.message); } // 2.4 Root redirect to /ui/ try { const resp = await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }); assert(page.url().includes('/ui/'), `Redirected to ${page.url()}`); check('2.4 Root / redirects to /ui/', 'PASS'); } catch (e) { check('2.4 Root / redirects to /ui/', 'FAIL', e.message); } // ================================================================ // PHASE 3: Language Switching // ================================================================ console.log('=== Phase 3: Internationalization ===\n'); // 3.1 English try { await page.goto(`${BASE}/ui/?lang=en`, { waitUntil: 'networkidle' }); const text = await page.textContent('body'); const hasEn = /download|upload|artifact|storage/i.test(text); assert(hasEn, 'No English text'); check('3.1 English locale', 'PASS'); await screenshot(page, '03-lang-en'); } catch (e) { check('3.1 English locale', 'FAIL', e.message); } // 3.2 Russian try { await page.goto(`${BASE}/ui/?lang=ru`, { waitUntil: 'networkidle' }); const text = await page.textContent('body'); assert(/[а-яА-Я]/.test(text), 'No Russian characters'); check('3.2 Russian locale', 'PASS'); await screenshot(page, '03-lang-ru'); } catch (e) { check('3.2 Russian locale', 'FAIL', e.message); } // 3.3 Language switcher exists try { // Look for language toggle (button, select, or link) const body = await page.textContent('body'); const hasLangSwitch = /EN|RU|English|Русский/i.test(body) || (await page.locator('[href*="lang="]').count()) > 0; if (hasLangSwitch) { check('3.3 Language switcher visible', 'PASS'); } else { check('3.3 Language switcher visible', 'WARN', 'Not found but pages work via ?lang='); } } catch (e) { check('3.3 Language switcher visible', 'WARN', e.message); } // ================================================================ // PHASE 4: Search (HTMX) // ================================================================ console.log('=== Phase 4: Search & Interactivity ===\n'); // 4.1 Search input exists on registry list page try { await page.goto(`${BASE}/ui/docker`, { waitUntil: 'networkidle' }); const searchInput = await page.locator('input[type="search"], input[type="text"][hx-get], input[placeholder*="earch"], input[placeholder*="оиск"]').count(); if (searchInput > 0) { check('4.1 Search input on list page', 'PASS'); } else { check('4.1 Search input on list page', 'WARN', 'No search input found'); } } catch (e) { check('4.1 Search input on list page', 'WARN', e.message); } // 4.2 HTMX search endpoint works try { const resp = await page.goto(`${BASE}/api/ui/docker/search?q=test`, { waitUntil: 'networkidle' }); assert(resp.status() === 200); check('4.2 Search API responds', 'PASS'); } catch (e) { check('4.2 Search API responds', 'FAIL', e.message); } // 4.3 Search with empty result try { const resp = await page.goto(`${BASE}/api/ui/docker/search?q=zzz_nonexistent_pkg`, { waitUntil: 'networkidle' }); const text = await page.textContent('body'); assert(resp.status() === 200); check('4.3 Empty search result', 'PASS'); } catch (e) { check('4.3 Empty search result', 'FAIL', e.message); } // ================================================================ // PHASE 5: Security Headers in Browser // ================================================================ console.log('=== Phase 5: Security Headers ===\n'); try { const resp = await page.goto(`${BASE}/ui/`, { waitUntil: 'networkidle' }); const headers = resp.headers(); const checks = [ ['x-content-type-options', 'nosniff'], ['x-frame-options', 'DENY'], ['referrer-policy', 'strict-origin-when-cross-origin'], ]; for (const [header, expected] of checks) { const val = headers[header]; if (val === expected) { check(`5.1 ${header}: ${expected}`, 'PASS'); } else { check(`5.1 ${header}: ${expected}`, 'FAIL', `Got: ${val || 'missing'}`); } } // CSP check — should contain 'self' with quotes const csp = headers['content-security-policy'] || ''; if (csp.includes("'self'")) { check('5.2 CSP contains quoted self', 'PASS'); } else { check('5.2 CSP contains quoted self', 'FAIL', `CSP: ${csp.slice(0, 80)}`); } if (csp.includes("'unsafe-inline'")) { check('5.3 CSP allows unsafe-inline (needed for UI)', 'PASS'); } else { check('5.3 CSP allows unsafe-inline', 'FAIL', 'UI may break without it'); } } catch (e) { check('5.x Security headers', 'FAIL', e.message); } // ================================================================ // PHASE 6: Responsive Design // ================================================================ console.log('=== Phase 6: Responsive Design ===\n'); // 6.1 Mobile (375x812 — iPhone) try { const mobileCtx = await browser.newContext({ viewport: { width: 375, height: 812 }, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)', }); const mobilePage = await mobileCtx.newPage(); const resp = await mobilePage.goto(`${BASE}/ui/`, { waitUntil: 'networkidle', timeout: 10000 }); assert(resp.status() === 200); // Check content is not clipped — page should scroll const bodyWidth = await mobilePage.evaluate(() => document.body.scrollWidth); const viewWidth = 375; // Allow some overflow but not extreme (like full desktop rendering on mobile) const overflowOk = bodyWidth <= viewWidth * 1.5; if (overflowOk) { check('6.1 Mobile (375px) no horizontal overflow', 'PASS', `body=${bodyWidth}px`); } else { check('6.1 Mobile (375px) no horizontal overflow', 'WARN', `body=${bodyWidth}px > ${viewWidth * 1.5}px`); } await screenshot(mobilePage, '06-mobile-375'); await mobileCtx.close(); } catch (e) { check('6.1 Mobile layout', 'FAIL', e.message); } // 6.2 Tablet (768x1024 — iPad) try { const tabletCtx = await browser.newContext({ viewport: { width: 768, height: 1024 } }); const tabletPage = await tabletCtx.newPage(); await tabletPage.goto(`${BASE}/ui/`, { waitUntil: 'networkidle', timeout: 10000 }); await screenshot(tabletPage, '06-tablet-768'); check('6.2 Tablet (768px) renders', 'PASS'); await tabletCtx.close(); } catch (e) { check('6.2 Tablet layout', 'FAIL', e.message); } // ================================================================ // PHASE 7: Swagger / API Docs // ================================================================ console.log('=== Phase 7: Swagger UI ===\n'); try { const resp = await page.goto(`${BASE}/api-docs`, { waitUntil: 'networkidle', timeout: 15000 }); assert(resp.status() === 200); const text = await page.textContent('body'); assert(text.length > 200, `Swagger page too short: ${text.length}`); check('7.1 Swagger UI loads', 'PASS'); await screenshot(page, '07-swagger'); } catch (e) { check('7.1 Swagger UI loads', 'FAIL', e.message); } // ================================================================ // PHASE 8: Accessibility Basics // ================================================================ console.log('=== Phase 8: Accessibility ===\n'); try { await page.goto(`${BASE}/ui/`, { waitUntil: 'networkidle' }); // 8.1 lang attribute on html const lang = await page.evaluate(() => document.documentElement.getAttribute('lang')); if (lang) { check('8.1 HTML lang attribute', 'PASS', lang); } else { check('8.1 HTML lang attribute', 'WARN', 'Missing — screen readers need this'); } } catch (e) { check('8.1 HTML lang attribute', 'WARN', e.message); } try { // 8.2 Images have alt text const imgsWithoutAlt = await page.locator('img:not([alt])').count(); if (imgsWithoutAlt === 0) { check('8.2 All images have alt text', 'PASS'); } else { check('8.2 All images have alt text', 'WARN', `${imgsWithoutAlt} images without alt`); } } catch (e) { check('8.2 Images alt text', 'WARN', e.message); } try { // 8.3 Color contrast — check at least body text isn't invisible const contrast = await page.evaluate(() => { const s = getComputedStyle(document.body); return { color: s.color, bg: s.backgroundColor }; }); assert(contrast.color !== contrast.bg, 'Text color equals background'); check('8.3 Text/background contrast', 'PASS', `text=${contrast.color} bg=${contrast.bg}`); } catch (e) { check('8.3 Text/background contrast', 'FAIL', e.message); } try { // 8.4 Focusable elements reachable via Tab const focusable = await page.locator('a, button, input, select, textarea, [tabindex]').count(); assert(focusable > 0, 'No focusable elements'); check('8.4 Focusable elements exist', 'PASS', `${focusable} elements`); } catch (e) { check('8.4 Focusable elements', 'WARN', e.message); } // ================================================================ // PHASE 9: Error Collection Summary // ================================================================ console.log('=== Phase 9: Error Summary ===\n'); if (consoleErrors.length === 0) { check('9.1 No console errors during session', 'PASS'); } else { const filtered = consoleErrors.filter(e => !e.includes('favicon')); if (filtered.length === 0) { check('9.1 No console errors (favicon ignored)', 'PASS'); } else { check('9.1 Console errors found', 'FAIL', filtered.slice(0, 3).join(' | ')); } } // ================================================================ // DONE // ================================================================ await browser.close(); // Print screenshots list console.log('\n=== Screenshots ==='); const { readdirSync } = await import('fs'); const shots = readdirSync(SCREENSHOT_DIR).filter(f => f.endsWith('.png')).sort(); for (const s of shots) { console.log(` ${SCREENSHOT_DIR}/${s}`); } printResults(); process.exit(fail > 0 ? 1 : 0); } function printResults() { console.log('\n======================================================================'); console.log(' NORA Playwright UI Test Results'); console.log('======================================================================\n'); for (const r of results) { const icon = r.status === 'PASS' ? '\x1b[32m[PASS]\x1b[0m' : r.status === 'FAIL' ? '\x1b[31m[FAIL]\x1b[0m' : '\x1b[33m[WARN]\x1b[0m'; const detail = r.detail ? ` — ${r.detail}` : ''; console.log(` ${icon} ${r.name}${detail}`); } console.log(`\n Total: \x1b[32m${pass} passed\x1b[0m, \x1b[31m${fail} failed\x1b[0m, \x1b[33m${warn} warnings\x1b[0m`); console.log('======================================================================\n'); } run().catch(e => { console.error('\x1b[31mFatal:\x1b[0m', e.message); process.exit(2); });