fix(ui): исправить фокус списка серверов и контраст общих элементов

This commit is contained in:
Denozordec committed 2026-10-15 14:15:56 +07:00
1 parent 9b0d7bb6e8
commit 3c9096804b
21 files changed
+6952 -58

No files matched your search

@@ -0,0 +1,137 @@
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);
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);
if (!report.passed) process.exitCode = 1;
} finally {
fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n');
await browser.close();
}