food-market/tests/e2e/lib/fc-ui.ts
nns 886cb92f30 test(e2e): сквозной сценарий full-chain «жизнь магазина» + харнес со скриншотами
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>
2026-10-08 02:10:11 +05:00

214 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}