b64ec76998
Зависимости и навык spfx-qa для проверки портала в браузере (сценарии, снимки, проверка доступности). Каталоги сессии и отчётов (.auth/, .qa-reports/) — в .gitignore. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
115 lines
5.5 KiB
JavaScript
115 lines
5.5 KiB
JavaScript
#!/usr/bin/env node
|
||
// Проба для /spfx-qa: открывает URL на нескольких ширинах, собирает доказательства, печатает JSON.
|
||
// Зависимости (devDependencies проекта): playwright, @axe-core/playwright (опционально).
|
||
//
|
||
// node probe.mjs --url <url> --out <dir> --name <prefix> [--widths 320,640,1024,375]
|
||
// [--wait-for <css>] [--timeout 20000]
|
||
// [--tenant --storage-state .auth/tenant.json --site <url сайта> [--data-hosts api.tsnu.kz] [--har <файл.har>] [--local-build]]
|
||
//
|
||
// В режиме --tenant запросы, меняющие данные, блокируются (см. guard.mjs).
|
||
|
||
import { chromium } from 'playwright';
|
||
import fs from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import { newTenantContext } from './guard.mjs';
|
||
|
||
const args = Object.fromEntries(
|
||
process.argv.slice(2).reduce((acc, a, i, arr) => {
|
||
if (!a.startsWith('--')) return acc;
|
||
const next = arr[i + 1];
|
||
acc.push([a.slice(2), next && !next.startsWith('--') ? next : true]);
|
||
return acc;
|
||
}, [])
|
||
);
|
||
|
||
const url = args.url;
|
||
const out = args.out || '.qa-reports/tmp';
|
||
const name = args.name || 'probe';
|
||
const widths = String(args.widths || '320,640,1024,375').split(',').map(Number);
|
||
const timeout = Number(args.timeout || 20000);
|
||
const tenant = Boolean(args.tenant);
|
||
|
||
if (!url) { console.error('--url обязателен'); process.exit(2); }
|
||
|
||
const host = new URL(url).hostname;
|
||
const isLocal = /^(localhost|127\.0\.0\.1|\[::1\])$|\.(localhost|test)$/.test(host);
|
||
if (!isLocal && !tenant) {
|
||
console.error(`Не локальный адрес (${host}). Для тенанта нужен явный флаг --tenant.`);
|
||
process.exit(2);
|
||
}
|
||
if (tenant && (!args['storage-state'] || !args.site)) {
|
||
console.error('Режим --tenant требует --storage-state (сессию сохраняет пользователь сам: save-session.mjs) и --site (разрешённый сайт).');
|
||
process.exit(2);
|
||
}
|
||
|
||
let AxeBuilder = null;
|
||
try { ({ default: AxeBuilder } = await import('@axe-core/playwright')); } catch { /* axe не установлен */ }
|
||
|
||
await fs.mkdir(out, { recursive: true });
|
||
const browser = await chromium.launch();
|
||
const result = { url, surface: tenant ? 'tenant' : 'harness', date: new Date().toISOString(), axe: Boolean(AxeBuilder), runs: [] };
|
||
|
||
// Признаки непереведённых строк и утёкших значений в тексте
|
||
const SUSPICIOUS = [/\bundefined\b/, /\[object Object\]/, /\bNaN\b/, /\b[A-Z][A-Za-z]+Strings\.[A-Za-z]+/, /\{\d+\}/];
|
||
|
||
for (const width of widths) {
|
||
const mobile = width === 375;
|
||
const viewport = { width, height: mobile ? 812 : 900 };
|
||
let context, guardLog = null;
|
||
if (tenant) {
|
||
const har = args.har ? String(args.har).replace(/\.har$/, `-${width}.har`) : undefined;
|
||
({ context, log: guardLog } = await newTenantContext(browser, {
|
||
storageState: args['storage-state'], siteUrl: args.site, harPath: har,
|
||
dataHosts: args['data-hosts'] ? String(args['data-hosts']).split(',') : undefined,
|
||
localBuild: Boolean(args['local-build']), viewport, isMobile: mobile, deviceScaleFactor: mobile ? 2 : 1,
|
||
}));
|
||
} else {
|
||
context = await browser.newContext({ viewport, deviceScaleFactor: mobile ? 2 : 1, isMobile: mobile });
|
||
}
|
||
const page = await context.newPage();
|
||
const run = { width, consoleErrors: [], consoleWarnings: [], pageErrors: [], failedRequests: [], dataRequests: 0 };
|
||
|
||
page.on('console', m => {
|
||
if (m.type() === 'error') run.consoleErrors.push(m.text().slice(0, 500));
|
||
if (m.type() === 'warning' && /react|key|unmounted|act\(/i.test(m.text())) run.consoleWarnings.push(m.text().slice(0, 300));
|
||
});
|
||
page.on('pageerror', e => run.pageErrors.push(String(e.message).slice(0, 500)));
|
||
page.on('requestfailed', r => { if (r.failure()?.errorText !== 'net::ERR_BLOCKED_BY_CLIENT') run.failedRequests.push({ url: r.url().slice(0, 200), error: r.failure()?.errorText }); });
|
||
page.on('response', r => { if (r.status() >= 400) run.failedRequests.push({ url: r.url().slice(0, 200), status: r.status() }); });
|
||
page.on('request', r => {
|
||
if (/\/_api\/|graph\.microsoft\.com|\/api\//.test(r.url())) run.dataRequests++;
|
||
});
|
||
|
||
const t0 = Date.now();
|
||
try {
|
||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
|
||
if (args['wait-for']) await page.waitForSelector(String(args['wait-for']), { timeout });
|
||
await page.waitForLoadState('networkidle', { timeout }).catch(() => {});
|
||
run.loadMs = Date.now() - t0;
|
||
|
||
run.overflowX = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1);
|
||
const text = await page.evaluate(() => document.body.innerText);
|
||
run.suspiciousText = SUSPICIOUS.flatMap(re => (text.match(new RegExp(re, 'g')) || []).slice(0, 3));
|
||
run.textSample = text.slice(0, 1500);
|
||
|
||
if (AxeBuilder) {
|
||
const axe = await new AxeBuilder({ page }).analyze();
|
||
run.a11y = axe.violations
|
||
.filter(v => ['serious', 'critical'].includes(v.impact))
|
||
.map(v => ({ id: v.id, impact: v.impact, help: v.help, nodes: v.nodes.length }));
|
||
}
|
||
|
||
const shot = path.join(out, `${name}-${width}.jpg`);
|
||
await page.screenshot({ path: shot, type: 'jpeg', quality: 60, fullPage: true });
|
||
run.screenshot = shot;
|
||
} catch (e) {
|
||
run.error = String(e.message).slice(0, 500);
|
||
}
|
||
if (guardLog) run.guard = guardLog;
|
||
await context.close();
|
||
result.runs.push(run);
|
||
}
|
||
|
||
await browser.close();
|
||
console.log(JSON.stringify(result, null, 2));
|