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>
84 lines
2.9 KiB
TypeScript
84 lines
2.9 KiB
TypeScript
/**
|
||
* Sprint 16: тонкий HTTP-клиент для regression-factories.
|
||
*
|
||
* Не используем @playwright/test request — он завязан на test-context;
|
||
* a factory должна работать как из теста, так и из standalone-скриптов
|
||
* (nightly verify, seed-rare-data). Делаем поверх `fetch` (Node 20+ ships
|
||
* нативно).
|
||
*/
|
||
|
||
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
|
||
|
||
interface RequestOpts {
|
||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||
body?: unknown
|
||
/** Bearer-токен, если запрос требует авторизации. */
|
||
token?: string
|
||
/** Доп. headers. */
|
||
headers?: Record<string, string>
|
||
/** Не падать при non-2xx. По умолчанию падаем. */
|
||
allowError?: boolean
|
||
/** Кастомный timeout (ms). По умолчанию 30 секунд. */
|
||
timeoutMs?: number
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
public status: number,
|
||
public bodyText: string,
|
||
public url: string,
|
||
public method: string,
|
||
) {
|
||
super(`${method} ${url} → ${status}: ${bodyText.substring(0, 400)}`)
|
||
}
|
||
}
|
||
|
||
/** Универсальный helper. Возвращает распарсенный JSON (или текст для не-JSON). */
|
||
export async function request<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> {
|
||
const url = path.startsWith('http') ? path : `${BASE}${path}`
|
||
const method = opts.method ?? (opts.body !== undefined ? 'POST' : 'GET')
|
||
const headers: Record<string, string> = {
|
||
'Accept': 'application/json',
|
||
...(opts.headers ?? {}),
|
||
}
|
||
let body: BodyInit | undefined
|
||
if (opts.body !== undefined) {
|
||
if (typeof opts.body === 'string') {
|
||
body = opts.body
|
||
// если уже задан Content-Type — оставляем как есть
|
||
headers['Content-Type'] ??= 'application/x-www-form-urlencoded'
|
||
} else {
|
||
body = JSON.stringify(opts.body)
|
||
headers['Content-Type'] = 'application/json'
|
||
}
|
||
}
|
||
if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`
|
||
|
||
const controller = new AbortController()
|
||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 30_000)
|
||
let resp: Response
|
||
try {
|
||
resp = await fetch(url, { method, headers, body, signal: controller.signal })
|
||
} finally {
|
||
clearTimeout(timer)
|
||
}
|
||
const text = await resp.text()
|
||
if (!resp.ok && !opts.allowError) {
|
||
throw new ApiError(resp.status, text, url, method)
|
||
}
|
||
// 204 / пустой body → undefined
|
||
if (text.length === 0) return undefined as T
|
||
try {
|
||
return JSON.parse(text) as T
|
||
} catch {
|
||
return text as unknown as T
|
||
}
|
||
}
|
||
|
||
/** Sleep helper для retry'ев в фабрике (rate-limit signup). */
|
||
export function sleep(ms: number): Promise<void> {
|
||
return new Promise(res => setTimeout(res, ms))
|
||
}
|
||
|
||
export const baseUrl = BASE
|