16 шагов против настоящего стенда: регистрация→справочники→сотрудники/точки→ деньги→3 приёмки→оплаты→смена/продажи/возврат/Z→склад→отчёты→инварианты→откат. Сверяет конкретные числа и инварианты, 0×5xx/0×тостов/0×console.error. Режимы C (temp-орг) и B (Демо на реальном каталоге). Hi-res скриншоты каждого процесса + галерея. Включён в nightly-verify и quality-watchdog (скрипты в $HOME). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
11 KiB
TypeScript
214 lines
11 KiB
TypeScript
/**
|
||
* UI-харнесс full-chain: реальный headless-браузер (Playwright chromium).
|
||
* Делает скриншоты КАЖДОГО процесса в хорошем разрешении (поправка владельца
|
||
* 08.10): десктоп 1920×1080 @2x (Retina) + мобильный 390×844 @3x для экранов,
|
||
* которыми пользуются с телефона. fullPage для длинных отчётов. Имена файлов
|
||
* `NN-шаг-что.png`, папка reports/full-chain-<RUN_ID>/screenshots/.
|
||
*
|
||
* На каждой странице слушаем console.error / сетевые 5xx / error-тосты —
|
||
* шаги проверяют, что их 0. Логин кладём токеном в localStorage, поле пароля
|
||
* на экранах не светится (нет паролей/токенов на скринах).
|
||
*/
|
||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
|
||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||
import { resolve, basename } from 'node:path'
|
||
import { BASE } from './fc.js'
|
||
|
||
export interface UiErrors { console: string[]; network: string[]; toast: string[] }
|
||
export interface Shot { file: string; rel: string; caption: string; step: string; mobile: boolean }
|
||
|
||
function attachErrorListeners(page: Page, errors: UiErrors) {
|
||
page.on('console', (m) => {
|
||
if (m.type() !== 'error') return
|
||
const t = m.text()
|
||
if (/^Failed to load resource: the server responded with a status of \d+/i.test(t)) return
|
||
if (/net::(ERR_NETWORK_CHANGED|ERR_INTERNET_DISCONNECTED|ERR_CONNECTION_RESET|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_REFUSED|ERR_TIMED_OUT|ERR_ABORTED)/i.test(t)) return
|
||
errors.console.push(t)
|
||
})
|
||
page.on('response', (r) => {
|
||
const s = r.status()
|
||
if (s < 400) return
|
||
const url = r.url()
|
||
if (s === 401 && /\/(api|connect)\//.test(url)) return
|
||
errors.network.push(`${s} ${r.request().method()} ${url}`)
|
||
})
|
||
}
|
||
|
||
export class Ui {
|
||
browser!: Browser
|
||
deskCtx!: BrowserContext
|
||
mobCtx?: BrowserContext
|
||
page!: Page // desktop
|
||
mobPage?: Page
|
||
errors: UiErrors = { console: [], network: [], toast: [] }
|
||
shots: Shot[] = []
|
||
dir!: string
|
||
private token = ''
|
||
|
||
static async launch(runId: string): Promise<Ui> {
|
||
const u = new Ui()
|
||
u.dir = resolve(import.meta.dirname, '..', 'reports', `full-chain-${runId}`, 'screenshots')
|
||
mkdirSync(u.dir, { recursive: true })
|
||
u.browser = await chromium.launch({ headless: true, args: ['--ignore-certificate-errors'] })
|
||
u.deskCtx = await u.browser.newContext({
|
||
ignoreHTTPSErrors: true, locale: 'ru-RU',
|
||
viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 2, baseURL: BASE,
|
||
})
|
||
u.page = await u.deskCtx.newPage()
|
||
attachErrorListeners(u.page, u.errors)
|
||
return u
|
||
}
|
||
|
||
private async ensureMobile(): Promise<Page> {
|
||
if (!this.mobPage) {
|
||
this.mobCtx = await this.browser.newContext({
|
||
ignoreHTTPSErrors: true, locale: 'ru-RU', isMobile: true, hasTouch: true,
|
||
viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, baseURL: BASE,
|
||
})
|
||
this.mobPage = await this.mobCtx.newPage()
|
||
attachErrorListeners(this.mobPage, this.errors)
|
||
if (this.token) {
|
||
await this.mobPage.goto('/login', { waitUntil: 'domcontentloaded' })
|
||
await this.mobPage.evaluate((t) => localStorage.setItem('fm.access_token', t), this.token)
|
||
}
|
||
}
|
||
return this.mobPage
|
||
}
|
||
|
||
/** Залогинить браузер токеном (оба контекста). */
|
||
async attach(token: string, path = '/dashboard') {
|
||
this.token = token
|
||
await this.page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||
await this.page.evaluate((t) => localStorage.setItem('fm.access_token', t), token)
|
||
await this.page.goto(path, { waitUntil: 'domcontentloaded' })
|
||
await this.page.waitForTimeout(500)
|
||
}
|
||
|
||
async goto(path: string, waitMs = 600) {
|
||
await this.page.goto(path, { waitUntil: 'domcontentloaded' })
|
||
await this.page.waitForTimeout(waitMs)
|
||
}
|
||
async gotoMobile(path: string, waitMs = 700): Promise<Page> {
|
||
const m = await this.ensureMobile()
|
||
await m.goto(path, { waitUntil: 'domcontentloaded' })
|
||
await m.waitForTimeout(waitMs)
|
||
return m
|
||
}
|
||
|
||
async collectToasts() {
|
||
for (const p of [this.page, this.mobPage]) {
|
||
if (!p) continue
|
||
try {
|
||
const texts = await p.$$eval('[data-toast-kind="error"]', (els) => els.map((e) => (e.textContent ?? '').trim()))
|
||
for (const t of texts) if (t && !this.errors.toast.includes(t)) this.errors.toast.push(t)
|
||
} catch { /* навигация */ }
|
||
}
|
||
}
|
||
|
||
/** Снимок десктопа. name → NN-шаг-что (без .png). caption — рус. подпись для галереи/отчёта. */
|
||
async shot(step: string, name: string, caption: string, opts?: { fullPage?: boolean }): Promise<Shot> {
|
||
const file = resolve(this.dir, `${name}.png`)
|
||
try { await this.page.screenshot({ path: file, fullPage: opts?.fullPage ?? false }) } catch { /* ignore */ }
|
||
const s: Shot = { file, rel: this.relFor(name), caption, step, mobile: false }
|
||
this.shots.push(s)
|
||
return s
|
||
}
|
||
|
||
/** Мобильный снимок (экран, которым пользуются с телефона). Навигирует mobile-страницу сам. */
|
||
async shotMobile(step: string, name: string, caption: string, path: string, opts?: { fullPage?: boolean }): Promise<Shot> {
|
||
const m = await this.gotoMobile(path)
|
||
const file = resolve(this.dir, `${name}.png`)
|
||
try { await m.screenshot({ path: file, fullPage: opts?.fullPage ?? true }) } catch { /* ignore */ }
|
||
const s: Shot = { file, rel: this.relFor(name), caption, step, mobile: true }
|
||
this.shots.push(s)
|
||
return s
|
||
}
|
||
|
||
private relFor(name: string): string {
|
||
// относительный путь от reports/<report>.md к скрину
|
||
return `${basename(resolve(this.dir, '..'))}/screenshots/${name}.png`
|
||
}
|
||
|
||
errorSummary(): string {
|
||
const parts: string[] = []
|
||
if (this.errors.console.length) parts.push(`console: ${this.errors.console.slice(0, 3).join(' | ')}`)
|
||
if (this.errors.network.length) parts.push(`net: ${this.errors.network.slice(0, 3).join(' | ')}`)
|
||
if (this.errors.toast.length) parts.push(`toast: ${this.errors.toast.slice(0, 3).join(' | ')}`)
|
||
return parts.join('; ')
|
||
}
|
||
resetErrors() { this.errors = { console: [], network: [], toast: [] } }
|
||
|
||
/** HTML-галерея со всеми скриншотами (крупные превью, клик → оригинал). */
|
||
writeGallery(runId: string, meta: { target: string; when: string }) {
|
||
const byStep = new Map<string, Shot[]>()
|
||
for (const s of this.shots) { if (!byStep.has(s.step)) byStep.set(s.step, []); byStep.get(s.step)!.push(s) }
|
||
const sections = [...byStep.entries()].map(([stepName, shots]) => {
|
||
const cards = shots.map((s) => `
|
||
<figure>
|
||
<a href="screenshots/${basename(s.file)}" target="_blank">
|
||
<img src="screenshots/${basename(s.file)}" loading="lazy" alt="${esc(s.caption)}"/>
|
||
</a>
|
||
<figcaption>${s.mobile ? '📱 ' : '🖥 '}${esc(s.caption)}<br><small>${basename(s.file)}</small></figcaption>
|
||
</figure>`).join('')
|
||
return `<section><h2>${esc(stepName)}</h2><div class="grid">${cards}</div></section>`
|
||
}).join('\n')
|
||
const html = `<!doctype html><html lang="ru"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Full-chain e2e — скриншоты процессов (${esc(meta.target)})</title>
|
||
<style>
|
||
body{font:14px/1.5 system-ui,Segoe UI,Roboto,sans-serif;margin:0;background:#0f172a;color:#e2e8f0}
|
||
header{padding:20px 24px;background:#1e293b;position:sticky;top:0;box-shadow:0 1px 0 #334155}
|
||
h1{margin:0 0 4px;font-size:20px} .sub{color:#94a3b8;font-size:13px}
|
||
section{padding:8px 24px 24px} h2{font-size:15px;color:#7dd3fc;border-bottom:1px solid #334155;padding-bottom:6px}
|
||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:16px}
|
||
figure{margin:0;background:#1e293b;border:1px solid #334155;border-radius:10px;overflow:hidden}
|
||
img{width:100%;display:block;background:#fff;cursor:zoom-in}
|
||
figcaption{padding:8px 10px;font-size:12px;color:#cbd5e1}
|
||
small{color:#64748b}
|
||
</style></head><body>
|
||
<header><h1>Жизнь магазина — скриншоты процессов</h1>
|
||
<div class="sub">Прогон: ${esc(meta.target)} · ${esc(meta.when)} · всего кадров: ${this.shots.length}</div></header>
|
||
${sections}
|
||
</body></html>`
|
||
const out = resolve(this.dir, '..', 'index.html')
|
||
writeFileSync(out, html, 'utf8')
|
||
return out
|
||
}
|
||
|
||
/** Ссылки сайдбара для пользователя с данным токеном (эфемерный контекст). */
|
||
async sidebarHrefs(token: string): Promise<string[]> {
|
||
const c = await this.browser.newContext({ ignoreHTTPSErrors: true, locale: 'ru-RU', viewport: { width: 1920, height: 1080 }, baseURL: BASE })
|
||
const p = await c.newPage()
|
||
try {
|
||
await p.goto('/login', { waitUntil: 'domcontentloaded' })
|
||
await p.evaluate((t) => localStorage.setItem('fm.access_token', t), token)
|
||
await p.goto('/dashboard', { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(900)
|
||
return (await p.$$eval('a[href^="/"]', (els) => els.map((e) => e.getAttribute('href') ?? ''))).filter(Boolean)
|
||
} finally { await c.close() }
|
||
}
|
||
|
||
/** Скрин экрана под чужим токеном (напр. кабинет кассира — без пунктов «Деньги»/«Настройки»). */
|
||
async shotAs(step: string, token: string, path: string, name: string, caption: string): Promise<Shot> {
|
||
const c = await this.browser.newContext({ ignoreHTTPSErrors: true, locale: 'ru-RU', viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 2, baseURL: BASE })
|
||
const p = await c.newPage()
|
||
const file = resolve(this.dir, `${name}.png`)
|
||
try {
|
||
await p.goto('/login', { waitUntil: 'domcontentloaded' })
|
||
await p.evaluate((t) => localStorage.setItem('fm.access_token', t), token)
|
||
await p.goto(path, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(900)
|
||
await p.screenshot({ path: file, fullPage: false })
|
||
} catch { /* ignore */ } finally { await c.close() }
|
||
const s: Shot = { file, rel: this.relFor(name), caption, step, mobile: false }
|
||
this.shots.push(s)
|
||
return s
|
||
}
|
||
|
||
async close() { try { await this.browser.close() } catch { /* ignore */ } }
|
||
}
|
||
|
||
function esc(s: string): string {
|
||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||
}
|