Декларативные end-to-end сценарии в tests/e2e/. YAML описывает шаги, TypeScript-handler — конкретные API/UI/DB-проверки. Отчёт в Markdown. Структура: - runner.ts : entry, парсит YAML, прогоняет steps, пишет report - run.sh : pnpm install + tsx - lib/api.ts : axios + login() (через /connect/token + /api/me) - lib/db.ts : docker exec psql, resetTenantData(), countRows() - lib/report.ts : Markdown-аккумулятор (steps + bugs + ux + gap + perf) - scenarios/full-cycle.yml : 12 шагов - scenarios/full-cycle.steps.ts : handlers (один на шаг) - README.md : как добавить новый сценарий reset_db в preconditions: - TRUNCATE tenant-таблиц CASCADE - AspNet*/users — оставляем только admin@food-market.local - OpenIddict tokens — все valid → revoked - Реестр products + системные справочники + миграции + platform_settings — НЕ трогаем Запуск: tests/e2e/run.sh full-cycle [--api-only] Первый прогон (--api-only, baseline в reports/full-cycle-2026-05-07-baseline.md): - 8 ✓ / 1 ✗ / 3 ◯ из 12. - Critical bug: Cashier видит /api/organization/employees через API (нет [Authorize(Roles="Admin")] на List endpoint). - High: при CreateOrg через SuperAdmin не сидируются tenant-units — пустой каталог измерений у новой org (DevDataSeeder.SeedTenantReferencesAsync должен вызываться, но не вызывается). - Logic gaps: реестр products tenant-scoped и новая org стартует с пустым каталогом; SuperAdmin /organizations не валидирует ФЛК телефона; Cashier не получает Identity-роль "Cashier" при создании через /employees. UI-шаги (Playwright) в этом коммите не покрыты — runner работает в --api-only режиме. UI-extension добавим следующим коммитом, не блокирует получение полезного отчёта.
173 lines
5 KiB
TypeScript
173 lines
5 KiB
TypeScript
/**
|
||
* Накопитель отчёта по e2e-сценарию. Markdown с секциями step-by-step.
|
||
* Каждый step фиксирует API/UI checks, статус (pass/fail/warn), время,
|
||
* и в конце runner добавляет summary + категоризованные рекомендации.
|
||
*/
|
||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||
import { dirname } from 'node:path'
|
||
|
||
export type StepStatus = 'pass' | 'fail' | 'warn' | 'skip'
|
||
|
||
export interface CheckResult {
|
||
kind: 'api' | 'ui' | 'db'
|
||
description: string
|
||
ok: boolean
|
||
detail?: string
|
||
}
|
||
|
||
export interface Step {
|
||
id: string
|
||
title: string
|
||
status: StepStatus
|
||
checks: CheckResult[]
|
||
durationMs: number
|
||
notes: string[]
|
||
screenshot?: string
|
||
}
|
||
|
||
export interface Bug {
|
||
step: string
|
||
severity: 'critical' | 'high' | 'medium' | 'low'
|
||
title: string
|
||
detail: string
|
||
fix?: string
|
||
}
|
||
|
||
export class Report {
|
||
scenario: string
|
||
startedAt = new Date()
|
||
steps: Step[] = []
|
||
bugs: Bug[] = []
|
||
uxNotes: string[] = []
|
||
logicGaps: string[] = []
|
||
perfNotes: string[] = []
|
||
|
||
constructor(scenario: string) {
|
||
this.scenario = scenario
|
||
}
|
||
|
||
startStep(id: string, title: string): Step {
|
||
const step: Step = { id, title, status: 'pass', checks: [], durationMs: 0, notes: [] }
|
||
this.steps.push(step)
|
||
return step
|
||
}
|
||
|
||
finishStep(step: Step, t0: number) {
|
||
step.durationMs = Date.now() - t0
|
||
if (step.checks.some((c) => !c.ok)) step.status = 'fail'
|
||
}
|
||
|
||
bug(b: Bug) { this.bugs.push(b) }
|
||
ux(s: string) { this.uxNotes.push(s) }
|
||
gap(s: string) { this.logicGaps.push(s) }
|
||
perf(s: string) { this.perfNotes.push(s) }
|
||
|
||
finalize(filePath: string) {
|
||
mkdirSync(dirname(filePath), { recursive: true })
|
||
writeFileSync(filePath, this.toMarkdown(), 'utf8')
|
||
}
|
||
|
||
private icon(s: StepStatus): string {
|
||
switch (s) {
|
||
case 'pass': return '✓'
|
||
case 'fail': return '✗'
|
||
case 'warn': return '⚠'
|
||
case 'skip': return '◯'
|
||
}
|
||
}
|
||
|
||
toMarkdown(): string {
|
||
const passed = this.steps.filter((s) => s.status === 'pass').length
|
||
const failed = this.steps.filter((s) => s.status === 'fail').length
|
||
const warns = this.steps.filter((s) => s.status === 'warn').length
|
||
const skipped = this.steps.filter((s) => s.status === 'skip').length
|
||
|
||
const lines: string[] = []
|
||
lines.push(`# E2E report: ${this.scenario}`)
|
||
lines.push('')
|
||
lines.push(`Запущен: ${this.startedAt.toISOString()}`)
|
||
lines.push(`Длительность: ${(this.steps.reduce((a, s) => a + s.durationMs, 0) / 1000).toFixed(1)}с`)
|
||
lines.push('')
|
||
lines.push(`**Итог:** ${passed} ✓ / ${failed} ✗ / ${warns} ⚠ / ${skipped} ◯ (всего ${this.steps.length})`)
|
||
lines.push('')
|
||
|
||
for (const step of this.steps) {
|
||
lines.push(`## ${this.icon(step.status)} Step ${step.id}: ${step.title}`)
|
||
lines.push('')
|
||
lines.push(`Длительность: ${step.durationMs}мс`)
|
||
if (step.checks.length > 0) {
|
||
lines.push('')
|
||
lines.push('| Тип | Проверка | Результат |')
|
||
lines.push('|---|---|---|')
|
||
for (const c of step.checks) {
|
||
const cell = c.detail ? `${c.detail.replace(/\|/g, '\\|').slice(0, 200)}` : ''
|
||
lines.push(`| ${c.kind} | ${c.description} | ${c.ok ? '✓' : '✗'} ${cell} |`)
|
||
}
|
||
}
|
||
if (step.notes.length > 0) {
|
||
lines.push('')
|
||
for (const n of step.notes) lines.push(`> ${n}`)
|
||
}
|
||
if (step.screenshot) {
|
||
lines.push('')
|
||
lines.push(`Скриншот: \`${step.screenshot}\``)
|
||
}
|
||
lines.push('')
|
||
}
|
||
|
||
lines.push('## Summary')
|
||
lines.push('')
|
||
lines.push(`- Passed: ${passed}`)
|
||
lines.push(`- Failed: ${failed}`)
|
||
lines.push(`- Warnings: ${warns}`)
|
||
lines.push(`- Skipped: ${skipped}`)
|
||
lines.push('')
|
||
|
||
if (this.bugs.length > 0) {
|
||
lines.push('## Critical bugs')
|
||
lines.push('')
|
||
const order: Bug['severity'][] = ['critical', 'high', 'medium', 'low']
|
||
for (const sev of order) {
|
||
const list = this.bugs.filter((b) => b.severity === sev)
|
||
if (list.length === 0) continue
|
||
lines.push(`### ${sev.toUpperCase()}`)
|
||
lines.push('')
|
||
for (const b of list) {
|
||
lines.push(`- **[${b.step}] ${b.title}**`)
|
||
lines.push(` - ${b.detail}`)
|
||
if (b.fix) lines.push(` - Fix: ${b.fix}`)
|
||
}
|
||
lines.push('')
|
||
}
|
||
} else {
|
||
lines.push('## Critical bugs')
|
||
lines.push('')
|
||
lines.push('Нет.')
|
||
lines.push('')
|
||
}
|
||
|
||
if (this.uxNotes.length > 0) {
|
||
lines.push('## UX recommendations')
|
||
lines.push('')
|
||
for (const n of this.uxNotes) lines.push(`- ${n}`)
|
||
lines.push('')
|
||
}
|
||
|
||
if (this.logicGaps.length > 0) {
|
||
lines.push('## Logic gaps')
|
||
lines.push('')
|
||
for (const n of this.logicGaps) lines.push(`- ${n}`)
|
||
lines.push('')
|
||
}
|
||
|
||
if (this.perfNotes.length > 0) {
|
||
lines.push('## Performance observations')
|
||
lines.push('')
|
||
for (const n of this.perfNotes) lines.push(`- ${n}`)
|
||
lines.push('')
|
||
}
|
||
|
||
return lines.join('\n')
|
||
}
|
||
}
|