fix(ui): исправить фокус списка серверов и контраст общих элементов
This commit is contained in:
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();
|
||||
}
|
||||
Reference in new issue
Block a user