/** * UI-харнесс full-chain: реальный headless-браузер (Playwright chromium). * Делает скриншоты КАЖДОГО процесса в хорошем разрешении (поправка владельца * 08.10): десктоп 1920×1080 @2x (Retina) + мобильный 390×844 @3x для экранов, * которыми пользуются с телефона. fullPage для длинных отчётов. Имена файлов * `NN-шаг-что.png`, папка reports/full-chain-/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 { 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 { 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 { 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 { 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 { 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/.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() 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) => `
${esc(s.caption)}
${s.mobile ? '📱 ' : '🖥 '}${esc(s.caption)}
${basename(s.file)}
`).join('') return `

${esc(stepName)}

${cards}
` }).join('\n') const html = ` Full-chain e2e — скриншоты процессов (${esc(meta.target)})

Жизнь магазина — скриншоты процессов

Прогон: ${esc(meta.target)} · ${esc(meta.when)} · всего кадров: ${this.shots.length}
${sections} ` const out = resolve(this.dir, '..', 'index.html') writeFileSync(out, html, 'utf8') return out } /** Ссылки сайдбара для пользователя с данным токеном (эфемерный контекст). */ async sidebarHrefs(token: string): Promise { 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 { 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, '"') }