После 24 спринтов regress-suite разросся; нестабильность блокирует доверие. Этот спринт: ловит flaky тесты, добавляет observability (Grafana + Prometheus alerts + RUNBOOK), сертифицирует 10× cert-прогон. 1. tests/regression/find-flaky.sh — 10× прогон + JSON-агрегатор → docs/flaky-tests.md (per-test pass/fail sequence + reproduce). 2. OrgFactory.signupWithRetry теперь honors Retry-After header (api-client.ts:ApiError.retryAfterSec). Stage rate-limit поднят: RATE_SIGNUP_HOUR=5000, RATE_PER_IP_MIN=5000 (~/food-market-stage/deploy/.env). 3. fullyParallel=true + workers=4 = тесты идут в недетерминированном порядке; isolation работает (OrgFactory per-test). 4. workers=4 даёт **2.4× ускорение** (66.6s → 27.7s). Worker-scoped фикстура lib/worker-org.ts добавлена как opt-in. 5. deploy/grafana/dashboards/quality-watchdog.json (10 панелей: smoke success ratio 7d, incidents, multi-tenant violations, current emoji, p95 by endpoint, step failures, RPS, DB p95, docs posted, disk free) + dashboards/README.md. quality-watchdog.sh пишет Prometheus textfile экспорт в ~/.fm-watchdog/textfile/quality_watchdog.prom для node_exporter. 6. deploy/prometheus/alerts.yml — 10 правил, 4 группы (uptime, errors, database, quality-watchdog). MultiTenantViolation = P0. deploy/prometheus/prometheus.yml — reference config. 7. docs/RUNBOOK.md +178 строк: action per alert (api-down, rps-drop, http-errors-spike/growing, doc-posting-errors, db-p95-high, disk-free-low, watchdog-red, multi-tenant-violation, watchdog-incident). Junior-friendly с конкретными командами. **Cert-прогон (10× workers=4):** 420/420 passed, 0 flaky, avg 30.1s/run, total 300.6s (< 5min budget). Изменения вне репо: - ~/food-market-stage/deploy/.env — RATE_* limits bumped. - ~/quality-watchdog.sh — добавлен .prom textfile экспорт. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
93 lines
3.3 KiB
TypeScript
93 lines
3.3 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,
|
||
/** Sprint 26: значение Retry-After если сервер прислал (для 429/503). */
|
||
public retryAfterSec?: number,
|
||
) {
|
||
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) {
|
||
// Sprint 26: парсим Retry-After (может быть в секундах или HTTP-date).
|
||
let retryAfter: number | undefined
|
||
const ra = resp.headers.get('retry-after')
|
||
if (ra) {
|
||
const n = Number(ra)
|
||
if (Number.isFinite(n) && n > 0) retryAfter = Math.ceil(n)
|
||
}
|
||
throw new ApiError(resp.status, text, url, method, retryAfter)
|
||
}
|
||
// 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
|