#!/usr/bin/env node // Проба для /spfx-qa: открывает URL на нескольких ширинах, собирает доказательства, печатает JSON. // Зависимости (devDependencies проекта): playwright, @axe-core/playwright (опционально). // // node probe.mjs --url --out --name [--widths 320,640,1024,375] // [--wait-for ] [--timeout 20000] // [--tenant --storage-state .auth/tenant.json --site [--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));