Sprint 16 — постоянный regression-контур: flows + visual + nightly +
CI workflow + README badges.
Ключевые цифры:
- 35 flow-тестов: 35/35 ✓ за ~30 секунд (workers=2 локально).
- 60 visual snapshot'ов (15 страниц × 2 темы × 2 viewport'a):
60/60 ✓ за ~4 минуты с retries=1.
- Полный регресс прогон: ~5 минут (цель была < 15).
Что сделано:
1. tests/regression/ — Playwright + factories + 8 spec-файлов.
OrgFactory builder создаёт org через API за O(N) HTTP вызовов
(signup → token → refs → products → counterparties → posted supplies).
Каждый flow независим, использует свой fresh-org.
2. tests/regression/visual/ — 15 страниц × 2 темы × 2 viewport'a.
Маски на динамический контент (артикулы с Date.now, KPI'ы,
delta-стрелки) чтобы 0.2% threshold не флакал. snapshotPathTemplate
c {projectName} — desktop+mobile не затирают друг друга.
3. tests/regression/factories/OrgFactory.ts — builder с .withProducts
.withCounterparties .withSupplies. Retry signup'a на 429.
4. .forgejo/workflows/regression.yml — on workflow_run после
Docker API/Web; cache на pnpm-store + Playwright-browsers;
артефакты при failure; Telegram-уведомление в обоих случаях.
5. ~/nightly-verify.sh + cron `0 4 * * *`: health → redeploy если
нужно → smoke flows; в воскресенье полный flows+visual. Логи с
ротацией 14 дней. Telegram на провал (~/.fm-watchdog/telegram-*).
6. scripts/generate-badges.sh — coverage из cobertura.xml в SVG через
shields.io (offline fallback). 4 CI-status badge + coverage badge в
README; CI step «Update coverage badge» авто-коммитит обновлённый
SVG на push в main.
Локальное число flake'ов: 1/60 visual на retry=1 (product-new light) —
случайная гонка маски, retry'ит и проходит.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
59 lines
2.3 KiB
TypeScript
59 lines
2.3 KiB
TypeScript
/**
|
||
* Sprint 16: UI helpers для regression-flow тестов.
|
||
*
|
||
* `attachSession(page, sess)` — устанавливает access_token в localStorage
|
||
* и идёт на нужный путь. Без UI-логина (быстрее).
|
||
*
|
||
* `watchPage(page)` — слушатель console-error + network-4xx/5xx с
|
||
* фильтром на ожидаемое (см. `expectNoErrors`).
|
||
*/
|
||
import { type Page, type ConsoleMessage } from '@playwright/test'
|
||
import type { OrgSession } from '../factories/types.js'
|
||
|
||
export async function attachSession(page: Page, sess: OrgSession, gotoPath = '/dashboard'): Promise<void> {
|
||
// Открываем /login — там SPA уже сделал init и слушает localStorage.
|
||
await page.goto('/login')
|
||
await page.evaluate((tok) => localStorage.setItem('fm.access_token', tok), sess.accessToken)
|
||
await page.goto(gotoPath, { waitUntil: 'domcontentloaded' })
|
||
}
|
||
|
||
export interface CollectedErrors {
|
||
console: string[]
|
||
network: string[]
|
||
}
|
||
|
||
export function watchPage(page: Page, opts?: {
|
||
expectedConsoleContains?: string[]
|
||
expected4xxContains?: string[]
|
||
}): CollectedErrors {
|
||
const acc: CollectedErrors = { console: [], network: [] }
|
||
page.on('console', (msg: ConsoleMessage) => {
|
||
if (msg.type() !== 'error') return
|
||
const t = msg.text()
|
||
if (/^Failed to load resource: the server responded with a status of \d+/i.test(t)) return
|
||
if (/net::ERR_(NETWORK_CHANGED|INTERNET_DISCONNECTED|CONNECTION_RESET|NAME_NOT_RESOLVED|CONNECTION_REFUSED|TIMED_OUT|ABORTED)/i.test(t)) return
|
||
if ((opts?.expectedConsoleContains ?? []).some(s => t.includes(s))) return
|
||
acc.console.push(t)
|
||
})
|
||
page.on('response', (resp) => {
|
||
const status = resp.status()
|
||
if (status < 400) return
|
||
const url = resp.url()
|
||
if (status === 401 && /\/(api|connect)\//.test(url)) return
|
||
if ((opts?.expected4xxContains ?? []).some(s => url.includes(s))) return
|
||
acc.network.push(`${status} ${resp.request().method()} ${url}`)
|
||
})
|
||
return acc
|
||
}
|
||
|
||
export function expectNoErrors(acc: CollectedErrors, where: string): void {
|
||
if (acc.console.length || acc.network.length) {
|
||
const msg = [
|
||
`Errors on ${where}:`,
|
||
...acc.console.map(c => ` CONSOLE: ${c}`),
|
||
...acc.network.map(n => ` NET: ${n}`),
|
||
].join('\n')
|
||
throw new Error(msg)
|
||
}
|
||
}
|