import fs from 'node:fs'; import assert from 'node:assert/strict'; import { chromium, expect } from '@playwright/test'; import axe from 'axe-core'; // Deliberately read-only. The fixture stays outside Git; no response fixtures are used. const credentials = JSON.parse(fs.readFileSync(process.env.MMAPP_PORTAL_FIXTURE || 'tmp/portal195-private.json')); const output = process.env.MMAPP_UI_AUDIT_OUTPUT || 'tmp/ui260-audit.json'; assert(!fs.existsSync(output), 'Never overwrite an acceptance report'); const origin = 'http://localhost:3000'; const routes = (process.env.MMAPP_UI_AUDIT_ROUTES || '/dashboard,/servers,/uptime,/traffic,/statistics,/wireguard,/ipsec,/gre,/vxlan,/ospf,/bgp,/filters,/backups,/certificates,/containers,/alerts,/settings,/releases').split(','); assert(routes.every(path => /^\/[a-z-]+$/.test(path))); const requiredReads = { '/dashboard': ['/api/servers', '/api/uptime/overview', '/api/filters/rules', '/api/bgp/catalog', '/api/internet-path/latest', '/api/filters/gre-tunnels', '/api/wireguard', '/api/traffic/servers', '/api/vxlan'], '/servers': ['/api/servers'], '/uptime': ['/api/uptime/overview'], '/traffic': ['/api/traffic/servers'], '/statistics': ['/api/statistics'], '/wireguard': ['/api/wireguard'], '/ipsec': ['/api/ipsec'], '/gre': ['/api/gre/tunnels'], '/vxlan': ['/api/vxlan'], '/ospf': ['/api/ospf/catalog'], '/bgp': ['/api/bgp/catalog'], '/filters': ['/api/filters/rules'], '/backups': ['/api/backups'], '/certificates': ['/api/certificates/state'], '/containers': ['/api/containers/state'], '/alerts': ['/api/alerts'], '/users': ['/api/users'], '/firewall': ['/api/firewall/all'], '/probes': ['/api/servers'], '/terminal': ['/api/servers'], '/data-collection': ['/api/scheduler/status'], '/recursive-routes': ['/api/servers'], '/route-optimizer': ['/api/servers', '/api/filters/rules', '/api/uptime/speed-probes'], '/network-map': ['/api/servers', '/api/filters/gre-tunnels', '/api/uptime/speed-probes'], '/ip-ranges': ['/api/evobgp/settings'], '/asns': ['/api/evobgp/settings'], '/communities': ['/api/evobgp/settings'], '/domains': ['/api/evobgp/settings'], }; const report = { date: '2026-10-15', block: process.env.MMAPP_UI_AUDIT_BLOCK || '12.5.260', origin, realPortalUiLogin: true, responseMocks: false, deviceWritesPermitted: false, cases: [], blockedWrites: [], readOnlyPosts: [], bounds: ['Default rendered state only; data can still be refreshing', 'Synthetic lab timing over SSH, no CPU/network throttling; not field Core Web Vitals', 'Automated WCAG-tagged checks, not manual screen-reader certification'], }; const browser = await chromium.launch({ channel: 'msedge', headless: true }); const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']; try { for (const theme of ['dark', 'light']) { const context = await browser.newContext(); await context.addInitScript(value => { localStorage.setItem('rl-theme', value); window.__acceptanceMetrics = { lcp: null, cls: 0, longTasks: 0, longTaskMs: 0 }; for (const type of ['largest-contentful-paint', 'layout-shift', 'longtask']) { try { new PerformanceObserver(list => { for (const e of list.getEntries()) { const m = window.__acceptanceMetrics; if (type === 'largest-contentful-paint') m.lcp = e.startTime; if (type === 'layout-shift' && !e.hadRecentInput) m.cls += e.value; if (type === 'longtask') { m.longTasks++; m.longTaskMs += e.duration; } } }).observe({ type, buffered: true }); } catch { /* Browser may not expose every observer. */ } } }, theme); const page = await context.newPage(); await page.goto(origin + '/dashboard'); await page.getByLabel('Email', { exact: true }).fill(credentials.email); await page.getByLabel('Пароль', { exact: true }).fill(credentials.password); await page.getByRole('button', { name: 'Войти', exact: true }).click(); await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toBeVisible({ timeout: 30000 }); await page.route('**/api/**', async route => { const r = route.request(); if (!['GET', 'HEAD', 'OPTIONS'].includes(r.method())) { const path = new URL(r.url()).pathname; // These POST handlers were inspected: DNS resolution, remote GET catalog, // and a computed OSPF preview. None applies device configuration. const readQuery = r.method() === 'POST' && (path === '/api/network/resolve-hosts' || path === '/api/evobgp/catalog' || /^\/api\/servers\/\d+\/ospf\/optimize\/preview$/.test(path)); if (process.env.MMAPP_UI_AUDIT_ALLOW_READ_POSTS === 'true' && readQuery) { report.readOnlyPosts.push({ theme, path }); await route.continue(); return; } report.blockedWrites.push({ theme, method: r.method(), path: new URL(r.url()).pathname }); await route.abort('blockedbyclient'); } else await route.continue(); }); for (const path of routes) for (const width of [1440, 320]) { const item = { theme, path, width, pageErrors: [], apiErrors: [] }; const successfulReads = new Set(); const onError = error => item.pageErrors.push(error.name); const onResponse = response => { const u = new URL(response.url()); if (u.origin === origin && u.pathname.startsWith('/api/') && response.status() >= 400) item.apiErrors.push({ path: u.pathname, status: response.status() }); if (u.origin === origin && response.status() === 200 && response.request().method() === 'GET') successfulReads.add(u.pathname); }; page.on('pageerror', onError); page.on('response', onResponse); try { await page.setViewportSize({ width, height: 1000 }); const start = Date.now(); await page.goto(origin + path, { waitUntil: 'domcontentloaded', timeout: 35000 }); await expect(page.locator('#main-content')).toBeVisible({ timeout: 30000 }); await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toBeVisible(); item.mainVisibleMs = Date.now() - start; if (requiredReads[path]) { await expect.poll(() => requiredReads[path].every(endpoint => successfulReads.has(endpoint)), { timeout: 35000 }).toBe(true); item.requiredReads = requiredReads[path]; item.dataResponsesMs = Date.now() - start; } await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => setTimeout(resolve, 1200)); await Promise.race([ Promise.allSettled(document.getAnimations().filter(a => a.effect?.getComputedTiming().iterations !== Infinity).map(a => a.finished)), new Promise(resolve => setTimeout(resolve, 1500)), ]); }); item.layout = await page.evaluate(() => ({ overflow: document.documentElement.scrollWidth > innerWidth + 1, mainOverflow: document.querySelector('#main-content').scrollWidth > document.querySelector('#main-content').clientWidth + 1, theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light', pendingSkeletons: document.querySelectorAll('#main-content [data-slot="skeleton"]').length, })); assert.equal(item.layout.theme, theme); if (process.env.MMAPP_UI_AUDIT_KPI_LABELS === 'true') { item.kpiLabels = await page.locator('[aria-label^="Сводка"] .text-muted-foreground.truncate').evaluateAll(elements => elements.filter(el => el.getClientRects().length).map(el => ({ label: el.textContent.trim(), clipped: el.scrollWidth > el.clientWidth + 1 }))); } item.performance = await page.evaluate(() => { const n = performance.getEntriesByType('navigation')[0]; return { ...window.__acceptanceMetrics, ttfbMs: n.responseStart - n.requestStart, domInteractiveMs: n.domInteractive, durationMs: n.duration }; }); await page.addScriptTag({ content: axe.source }); item.violations = await page.evaluate(async values => { const result = await window.axe.run({ include: ['#main-content', '[aria-label="Основная навигация"]'] }, { runOnly: { type: 'tag', values } }); return result.violations.map(v => ({ id: v.id, impact: v.impact, targets: v.nodes.map(n => n.target) })); }, tags); item.auditCompleted = true; } catch (error) { item.failure = { name: error.name, message: error.message.slice(0, 250) }; } finally { page.off('pageerror', onError); page.off('response', onResponse); report.cases.push(item); fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n'); console.log(JSON.stringify({ theme, path, width, violations: item.violations?.map(v => v.id), overflow: item.layout?.overflow, failure: item.failure?.name })); } } await context.close(); } report.completed = true; report.passed = report.blockedWrites.length === 0 && report.cases.every(c => c.auditCompleted && !c.layout.overflow && !c.layout.mainOverflow && c.violations.length === 0 && c.pageErrors.length === 0 && c.apiErrors.length === 0 && !c.kpiLabels?.some(label => label.clipped)); if (!report.passed) process.exitCode = 1; } finally { fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n'); await browser.close(); }