feat(s26): flaky-test detection + observability dashboards (8/8 ✓ 10/10 cert)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run

После 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>
This commit is contained in:
nns 2026-06-08 14:44:19 +05:00
parent 019c57ae3b
commit cf760fab10
11 changed files with 1211 additions and 11 deletions

View file

@ -0,0 +1,37 @@
# Grafana dashboards
Дашборды для food-market — импортируются в Grafana через **Settings → Data
sources → Add Prometheus** + **Dashboards → Import → Upload JSON**.
## Список
| Файл | UID | Назначение |
|---|---|---|
| `food-market.json` | `fm-baseline` | Sprint 13 baseline: HTTP, EF Core, бизнес-метрики |
| `quality-watchdog.json` | `fm-quality-watchdog` | Sprint 26: smoke success / p95 latency / multi-tenant violations / incidents + базовые prom-метрики |
## Зависимости
1. **Prometheus** scrap'ит `/metrics` API'я (см. `deploy/prometheus/prometheus.yml`).
2. **node_exporter** на машине, где живёт `~/quality-watchdog.sh`, с
флагом `--collector.textfile.directory=$HOME/.fm-watchdog/textfile`.
Watchdog туда пишет `quality_watchdog.prom` после каждого прогона.
3. **Alertmanager** для alert'ов из `deploy/prometheus/alerts.yml`
см. `docs/RUNBOOK.md` для action'ов.
## Использование
```bash
# Local stack для теста дашбордов:
cd deploy
docker run -d -p 3000:3000 grafana/grafana
docker run -d -p 9090:9090 \
-v $PWD/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml \
-v $PWD/prometheus/alerts.yml:/etc/prometheus/alerts.yml \
prom/prometheus
# Grafana: admin/admin → Add Prometheus DS → http://host.docker.internal:9090
# Import → upload grafana/dashboards/quality-watchdog.json
```
`${DS_PROMETHEUS}` template variable указывает на выбранный DS — Grafana
подставит ваш Prometheus при импорте.

View file

@ -0,0 +1,350 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Sprint 26: quality-watchdog dashboard. Метрики из ~/quality-watchdog.sh (textfile exporter, см. docs/observability.md) + базовые food-market.api метрики (/metrics).",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"id": 1,
"type": "stat",
"title": "Smoke success ratio (7 дней)",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 5, "w": 6, "x": 0, "y": 0},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "orange", "value": 0.80},
{"color": "green", "value": 0.95}
]
},
"unit": "percentunit",
"min": 0,
"max": 1
}
},
"options": {
"graphMode": "area",
"colorMode": "value",
"justifyMode": "center",
"reduceOptions": {"calcs": ["lastNotNull"]}
},
"targets": [
{
"refId": "A",
"expr": "sum(increase(quality_watchdog_run_total{result=\"green\"}[7d])) / sum(increase(quality_watchdog_run_total[7d]))",
"legendFormat": "green ratio"
}
]
},
{
"id": 2,
"type": "stat",
"title": "Incidents (7 дней)",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 5, "w": 6, "x": 6, "y": 0},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "orange", "value": 1},
{"color": "red", "value": 3}
]
},
"unit": "short"
}
},
"options": {"colorMode": "value", "reduceOptions": {"calcs": ["lastNotNull"]}},
"targets": [
{
"refId": "A",
"expr": "sum(increase(quality_watchdog_incidents_total[7d]))",
"legendFormat": "incidents"
}
]
},
{
"id": 3,
"type": "stat",
"title": "Multi-tenant violations (24h) — должно быть 0",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 5, "w": 6, "x": 12, "y": 0},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "red", "value": 1}
]
},
"unit": "short"
}
},
"options": {"colorMode": "value", "reduceOptions": {"calcs": ["lastNotNull"]}},
"targets": [
{
"refId": "A",
"expr": "sum(increase(quality_watchdog_step_failure_total{step=\"multi_tenant\"}[24h]))",
"legendFormat": "leaks"
}
]
},
{
"id": 4,
"type": "stat",
"title": "Текущий статус watchdog",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 5, "w": 6, "x": 18, "y": 0},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "orange", "value": 0.5},
{"color": "green", "value": 1}
]
},
"unit": "short",
"mappings": [
{"options": {"0": {"text": "🔴 RED"}, "1": {"text": "🟢 GREEN"}}, "type": "value"}
]
}
},
"options": {"colorMode": "background", "reduceOptions": {"calcs": ["lastNotNull"]}},
"targets": [
{
"refId": "A",
"expr": "quality_watchdog_last_run_status",
"legendFormat": "status"
}
]
},
{
"id": 5,
"type": "timeseries",
"title": "p95 latency по endpoint (мс)",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 5},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10,
"showPoints": "never",
"spanNulls": false,
"stacking": {"mode": "none"}
},
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "orange", "value": 400},
{"color": "red", "value": 800}
]
}
}
},
"options": {"legend": {"displayMode": "table", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "quality_watchdog_endpoint_p95_ms",
"legendFormat": "{{endpoint}}"
}
]
},
{
"id": 6,
"type": "timeseries",
"title": "Шаги watchdog — pass/fail во времени",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 5},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "bars",
"lineWidth": 1,
"fillOpacity": 60,
"stacking": {"mode": "normal"}
},
"unit": "short"
}
},
"options": {"legend": {"displayMode": "list", "placement": "right"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "sum by (step) (increase(quality_watchdog_step_failure_total[1h]))",
"legendFormat": "{{step}}"
}
]
},
{
"id": 7,
"type": "timeseries",
"title": "HTTP request rate (rps)",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 14},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "reqps"
}
},
"options": {"legend": {"displayMode": "list"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "sum(rate(http_requests_received_total[5m])) by (code)",
"legendFormat": "code={{code}}"
}
]
},
{
"id": 8,
"type": "timeseries",
"title": "DB query duration p95 (food_market_db_query_duration_seconds)",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 14},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "s",
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "orange", "value": 0.5},
{"color": "red", "value": 1.0}
]
}
}
},
"options": {"legend": {"displayMode": "list"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum(rate(food_market_db_query_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p95 DB"
}
]
},
{
"id": 9,
"type": "timeseries",
"title": "Документы проведены / ошибки",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 22},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "ops"
}
},
"options": {"legend": {"displayMode": "table", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "sum(rate(food_market_documents_posted_total[5m])) by (type)",
"legendFormat": "posted {{type}}"
},
{
"refId": "B",
"expr": "sum(rate(food_market_documents_error_total[5m])) by (type)",
"legendFormat": "error {{type}}"
}
]
},
{
"id": 10,
"type": "timeseries",
"title": "Свободное место на диске",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 22},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "bytes",
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "orange", "value": 5368709120},
{"color": "green", "value": 10737418240}
]
}
}
},
"options": {"legend": {"displayMode": "list"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "food_market_disk_free_bytes",
"legendFormat": "{{mount}}"
}
]
}
],
"refresh": "1m",
"schemaVersion": 38,
"tags": ["food-market", "quality-watchdog", "sprint26"],
"templating": {
"list": [
{
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"multi": false,
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"queryValue": "",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
}
]
},
"time": {"from": "now-7d", "to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "food-market — quality-watchdog",
"uid": "fm-quality-watchdog",
"version": 1
}

View file

@ -0,0 +1,175 @@
# Sprint 26: Prometheus alert rules для food-market.
#
# Загружается через prometheus.yml:
# rule_files:
# - alerts.yml
#
# Каждое правило → Alertmanager → Telegram/email.
# Все runbook-ссылки указывают на docs/RUNBOOK.md в репо.
#
# Группировка: 4 группы по доменам — uptime / errors / database / quality-watchdog.
groups:
- name: food-market.uptime
interval: 30s
rules:
- alert: ApiDown
expr: up{job="food-market-api"} == 0
for: 1m
labels:
severity: critical
runbook: api-down
annotations:
summary: "food-market API не отвечает на /metrics уже 1 минуту"
description: |
Prometheus не может scrap'нуть {{ $labels.instance }} > 1 минуты.
Это означает либо процесс упал, либо порт недоступен.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#api-down"
- alert: RpsDropped50Percent
# RPS за 5 минут упал относительно среднего за час назад (5-минутка часовой давности).
# Защита от ложных в пиках/спадах: только когда фактическая загрузка была заметной (>0.5 rps).
expr: |
sum(rate(http_requests_received_total[5m]))
/ clamp_min(sum(rate(http_requests_received_total[5m] offset 1h)), 0.001)
< 0.5
and
sum(rate(http_requests_received_total[5m] offset 1h)) > 0.5
for: 10m
labels:
severity: warning
runbook: rps-drop
annotations:
summary: "RPS упал >50% относительно того же окна час назад"
description: |
Сейчас RPS = {{ $value | humanize }}, что меньше половины часовой давности.
Возможно: упал процесс, нет трафика от клиентов, потерян DNS.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#rps-drop"
- name: food-market.errors
interval: 30s
rules:
- alert: HttpErrorsSpike
# Доля 5xx-ответов > 10% от общего трафика за 5 минут.
# 10% — порог, выше которого пользователи реально замечают.
expr: |
(sum(rate(http_requests_received_total{code=~"5.."}[5m]))
/ clamp_min(sum(rate(http_requests_received_total[5m])), 0.001))
> 0.10
for: 5m
labels:
severity: critical
runbook: http-errors-spike
annotations:
summary: "Доля HTTP 5xx > 10% уже 5 минут"
description: |
Сейчас {{ $value | humanizePercentage }} от запросов возвращают 5xx.
Скорее всего сломан какой-то контроллер или зависимость.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#http-errors-spike"
- alert: HttpErrorRateGrowing
# Темп роста 5xx-ошибок > 10%/min.
expr: |
deriv(sum(rate(http_requests_received_total{code=~"5.."}[5m]))[5m:1m])
> 0.10 / 60
for: 10m
labels:
severity: warning
runbook: http-errors-growing
annotations:
summary: "Темп роста 5xx-ошибок > 10%/min на протяжении 10 минут"
description: |
Производная rate(5xx) положительная > 10%/min. Похоже на постепенную
деградацию (не explosion). Проверь логи: что начало падать.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#http-errors-growing"
- alert: DocumentPostingErrors
expr: |
sum(rate(food_market_documents_error_total[5m])) by (type)
> 0.05
for: 5m
labels:
severity: warning
runbook: doc-posting-errors
annotations:
summary: "Документы ({{ $labels.type }}) валятся чаще 1 раза в 20 секунд"
description: |
Тип={{ $labels.type }} даёт {{ $value }} ошибок/сек. Воркфлоу проведения
ломается — посмотри logs или Hangfire-failed-jobs.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#doc-posting-errors"
- name: food-market.database
interval: 30s
rules:
- alert: DbQueryP95High
# p95 DB-запросов > 500ms на протяжении 10 минут.
expr: |
histogram_quantile(0.95,
sum(rate(food_market_db_query_duration_seconds_bucket[5m])) by (le))
> 0.5
for: 10m
labels:
severity: warning
runbook: db-p95-high
annotations:
summary: "DB query p95 > 500ms 10 минут подряд"
description: |
p95 = {{ $value | humanizeDuration }}. Возможно: PG медленный, нет индекса,
ANALYZE устарел, или массовый insert. См. runbook.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#db-p95-high"
- alert: DiskFreeLow
expr: food_market_disk_free_bytes < 5 * 1024 * 1024 * 1024
for: 5m
labels:
severity: critical
runbook: disk-free-low
annotations:
summary: "Свободно < 5 ГБ на {{ $labels.mount }}"
description: |
Свободно: {{ $value | humanize1024 }}B. При достижении 0 БД встанет.
Очисти логи / запусти VACUUM FULL / расширь том.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#disk-free-low"
- name: food-market.quality-watchdog
interval: 1m
rules:
- alert: WatchdogLastRunRed
expr: quality_watchdog_last_run_status == 0
for: 5m
labels:
severity: warning
runbook: watchdog-red
annotations:
summary: "quality-watchdog последний прогон красный (>5 мин)"
description: |
Хотя бы один из 8 шагов упал. Посмотри docs/quality-status.md
или ~/.fm-watchdog/quality.log.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#watchdog-red"
- alert: MultiTenantViolation
# Multi-tenant leak — самый дорогой баг, alert немедленный.
expr: increase(quality_watchdog_step_failure_total{step="multi_tenant"}[1h]) > 0
for: 1m
labels:
severity: critical
runbook: multi-tenant-violation
annotations:
summary: "🚨 Multi-tenant LEAK обнаружен watchdog'ом"
description: |
Шаг multi_tenant failed в последнем прогоне. Org B видит данные A.
ЭТО P0. Немедленно разверни stage в read-only mode и проверь tenant-filter.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#multi-tenant-violation"
- alert: WatchdogIncidentCreated
expr: increase(quality_watchdog_incidents_total[1h]) > 0
for: 1m
labels:
severity: warning
runbook: watchdog-incident
annotations:
summary: "Watchdog создал incident — 2+ подряд красных прогона"
description: |
Один и тот же шаг упал 2 раза подряд. Server-Claude получит
incident-файл в очередь. Проверь ~/.fm-watchdog/incident-*.txt.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#watchdog-incident"

View file

@ -0,0 +1,47 @@
# Sprint 26: пример конфига Prometheus для food-market.
#
# НЕ деплоится автоматически — это reference для оператора. Под stage:
#
# docker run -d --name prometheus \
# -p 9090:9090 \
# -v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml \
# -v $PWD/alerts.yml:/etc/prometheus/alerts.yml \
# prom/prometheus:latest
#
# Затем Grafana datasource «Prometheus» = http://prometheus:9090.
global:
scrape_interval: 30s
evaluation_interval: 30s
external_labels:
env: stage
rule_files:
- alerts.yml
scrape_configs:
# API exposed via /metrics endpoint
- job_name: food-market-api
metrics_path: /metrics
static_configs:
- targets:
- test.admin.food-market.kz:443 # stage
# - api.food-market.kz:443 # prod
scheme: https
relabel_configs:
- source_labels: [__address__]
target_label: instance
# quality-watchdog textfile exporter (через node_exporter).
# Запускается на машине, где живёт ~/quality-watchdog.sh:
# node_exporter --collector.textfile.directory=$HOME/.fm-watchdog/textfile
- job_name: quality-watchdog
static_configs:
- targets:
- 192.168.1.193:9100 # dev-vm node_exporter
alerting:
alertmanagers:
- static_configs:
- targets:
- alertmanager:9093

View file

@ -385,6 +385,184 @@ SELECT * FROM "__EFMigrationsHistory" ORDER BY 1 DESC LIMIT 5;
не упадёт, но фокус с EF Tools перестанет работать, см. memory
`feedback_ef_migrations`.
## Sprint 26 — Alert response (`deploy/prometheus/alerts.yml`)
Каждый alert в `alerts.yml` имеет `runbook` label — anchor сюда.
Junior-разработчик находит alert в Telegram, кликает runbook_url, попадает
на соответствующий раздел.
### api-down
**Alert:** `ApiDown``up{job="food-market-api"} == 0` 1 минуту.
**Что значит:** Prometheus не может scrap'нуть `/metrics`. API либо упал,
либо порт недоступен.
**Действия:**
1. `curl -fsS https://test.admin.food-market.kz/health/ready`
подтверди что API недоступен (или вернулся).
2. `ssh nns@192.168.1.190 'docker ps | grep food-market-stage-api'`
контейнер живой?
3. Если контейнер в `Restarting`: `docker logs --tail 200 food-market-stage-api-1`
— стек ошибки старта (часто миграция / OpenIddict cert mismatch).
4. Если контейнер OK, но не отвечает: `docker exec food-market-stage-api-1 curl
-s http://localhost:8080/health` (внутренний порт). Если внутри отвечает,
проблема в nginx/proxy цепочке.
5. Recovery: `cd ~/food-market-stage/deploy && docker compose -p
food-market-stage up -d --force-recreate api`.
6. Если не помогло: `~/deploy-stage.sh` с локального dev-vm (полный
build+push+restart).
### rps-drop
**Alert:** `RpsDropped50Percent` — RPS за 5 мин <50% от того же окна час
назад, при условии что было >0.5 rps.
**Действия:**
1. `curl https://test.admin.food-market.kz/health/ready` — API живой?
2. `ssh nns@192.168.1.190 'docker logs --tail 50 food-market-stage-api-1'`
— внезапные ошибки на старте/в логе.
3. Проверь DNS из дома/мобильной сети: `dig admin.food-market.kz`
возможно сломалась запись.
4. Откати последний deploy: `cd ~/food-market-stage/deploy && git log -1
--format=%H && docker compose pull && docker compose up -d`. Если
откат на предыдущий image помог — баг в новом коде, см. логи.
### http-errors-spike
**Alert:** `HttpErrorsSpike` — доля 5xx >10% уже 5 минут.
**Действия:**
1. Logs: `ssh nns@192.168.1.190 'docker logs --tail 200 food-market-stage-api-1 | grep -iE "(error|exception)"'`.
2. Какая ручка валится: `curl https://test.admin.food-market.kz/metrics |
grep 'http_requests_received_total.*code="5'` — топ-3 контроллеров.
3. Hangfire-failed: `curl -u admin /hangfire/jobs/failed` (нужен
SuperAdmin login).
4. Часто — БД упала. См. `db-p95-high` раздел ниже.
5. Если баг локален (только одна ручка валится): найди фикс, deploy.
### http-errors-growing
**Alert:** `HttpErrorRateGrowing` — производная 5xx растёт >10%/min 10 мин.
**Действия:** Постепенная деградация, не emergency. Часто — память течёт
или коннекшен-пул исчерпывается.
1. Memory: `docker stats food-market-stage-api-1` (ratio %).
2. PG connections: `psql ... -c "SELECT count(*), state FROM pg_stat_activity GROUP BY state"`
— если `idle in transaction` много, есть leak.
3. Restart api: `docker compose -p food-market-stage restart api`
куплено время.
4. Найди корень в логах — кто часто получает Exception.
### doc-posting-errors
**Alert:** `DocumentPostingErrors` — типа документов > 0.05 ошибки/сек 5 мин.
**Действия:**
1. `docker logs food-market-stage-api-1 | grep "Posting failed"`
ищи название документа в логе.
2. Hangfire failed: документы постятся через Hangfire-job — посмотри
`/hangfire/jobs/failed`.
3. Stock-инвариант: `Posting failed: stock would be negative` означает
попытку списать больше чем есть. Это бизнес-уровневая ошибка, не баг.
Сообщи владельцу org. Если ошибок много — возможно баг в pre-validate.
4. Concurrent posting: `Posting failed: serialization conflict` — это
Sprint 23 `SerializationConflictMiddleware` ловит и возвращает 409.
Если не возвращает 409 а 500 — middleware сломался, проверь deploy.
### db-p95-high
**Alert:** `DbQueryP95High` — p95 DB-запросов >500ms 10 минут подряд.
**Действия:**
1. Самые медленные запросы:
```sql
SELECT calls, mean_exec_time, total_exec_time, query
FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
```
2. Если статистика устарела: `ANALYZE` всей БД (`vacuum-top-tables`
Hangfire-job делает это раз в неделю, см. `DatabaseMaintenanceJobs`).
3. Lock'и: `SELECT * FROM pg_locks WHERE NOT granted;` — заблокирована ли
какая-то таблица.
4. Disk: см. `disk-free-low` ниже — если IO упирается в диск.
5. Если новый медленный запрос в логе после deploy — откати relevant
контроллер.
### disk-free-low
**Alert:** `DiskFreeLow`< 5 ГБ свободно на mount.
**Действия:**
1. `df -h` — какой mount упал.
2. Logs: `du -sh /var/lib/docker/containers/*/`. Логи Docker'a иногда
разрастаются. Truncate: `truncate -s 0 /var/lib/docker/containers/*/.log`.
3. БД growth: `psql -c "SELECT pg_size_pretty(pg_database_size('food_market'))"`.
Если >50 ГБ — запусти PruneStockMovements + VACUUM FULL под maintenance
(см. ниже).
4. Quality-watchdog test orgs: `PruneQualityTestOrgs` Hangfire-job (cron
02:30 UTC) удаляет старые `quality-{epoch}-*` org'и (см.
`[[sprint25_done]]`). Если не отработал: trigger вручную через
`/hangfire`.
5. Очисти `~/.fm-watchdog/quality.log.*` старее 14 дней (auto-ротация уже есть).
### watchdog-red
**Alert:** `WatchdogLastRunRed` — quality-watchdog последний прогон красный
>5 мин.
**Действия:**
1. Открой `docs/quality-status.md` в репо — сразу видно какой шаг упал.
2. Тот же шаг сам и воспроизведёшь:
```bash
/home/nns/quality-watchdog.sh # сразу прогоняет всё
tail -50 ~/.fm-watchdog/quality.log # детали последнего шага
```
3. Дальше зависит от шага:
- `health` — см. `api-down`.
- `auth_me` / `products` / `signalr` — см. `http-errors-spike`.
- `multi_tenant` — см. `multi-tenant-violation` (КРИТИЧНО).
- `perf` — см. `db-p95-high`.
- `ui_flow` — Playwright-тест. Прогон вручную: `cd tests/regression &&
pnpm exec playwright test flows/03-catalog.spec.ts --grep "3.1" --headed`.
### multi-tenant-violation 🚨
**Alert:** `MultiTenantViolation` — шаг multi_tenant упал в последнем часу.
**ЭТО P0.** Org B видит данные A — это утечка между арендаторами.
**Действия (немедленные):**
1. Останови stage'е приём новых signup'ов (косвенно — поставь
`RateLimiting__SignupPerIpPerHour=0`, redeploy api).
2. `tail -100 ~/.fm-watchdog/quality.log` — детали leak'а (`get_code`,
`list_total`).
3. В коде проверь `AppDbContext.ApplyTenantQueryFilter` (см. `[[sprint22_done]]`):
```bash
grep -n "ApplyTenantQueryFilter\|IgnoreQueryFilters" src/food-market.infrastructure/Persistence/
```
Кто-то добавил `IgnoreQueryFilters()` где не надо? Это самая частая
причина leak'ов.
4. Воспроизведи руками: создай 2 org'и (`curl POST /api/auth/signup` × 2),
токен для каждого, попробуй cross-access. Если воспроизводится —
фикс ASAP.
5. После фикса — `~/deploy-stage.sh`, дождись зелёного watchdog'a.
6. Если на prod уже катилось: notify владельцев (через Telegram-summary
job), audit-log за последние 48ч (`/api/admin/audit?since=...`) на
подозрительные cross-org операции.
### watchdog-incident
**Alert:** `WatchdogIncidentCreated` — 2+ подряд красных прогона ⇒ incident-файл.
**Действия:**
1. `ls -lt ~/.fm-watchdog/incident-*.txt | head -3` — последние инциденты.
2. `cat ~/.fm-watchdog/incident-{...}.txt` — описание + действия.
3. Server-Claude автоматически получит этот файл в очередь (через
`~/fm-watchdog.sh` → ротацию queue). Не дублируй — он начнёт фикс сам.
4. Если хочешь форсировать вмешательство: тот же файл sent'нул в
`~/.fm-watchdog/queue/0000-incident-XXX.txt` (uname-prefix `0000`
первый в очереди).
## Что НЕ делать
- НЕ менять `global.json` без явного решения (CLAUDE.md).

12
docs/flaky-tests.md Normal file
View file

@ -0,0 +1,12 @@
# Flaky tests report
_Сгенерировано `tests/regression/find-flaky.sh` — 10 прогонов suite._
**Всего уникальных тестов:** 42
**Flaky:** 0 (0%)
**Всегда зелёные:** 42
**Всегда красные:** 0
## 🟢 Нет flaky тестов
Suite стабилен.

154
docs/sprint26-progress.md Normal file
View file

@ -0,0 +1,154 @@
# Sprint 26 — flaky-test detection + observability dashboards
Цель: после 24 спринтов regress-suite разросся, нестабильность блокирует
доверие. Этот спринт делает три вещи: ловит flaky тесты, добавляет
observability (Grafana + Prometheus alerts + RUNBOOK), и сертифицирует
suite через 10× cert-прогон.
Старт: 2026-06-08. Исполнитель: Claude Opus 4.7. Продолжение
[[sprint25_done]].
## Чек-лист
- [x] **1. Flaky-test detection**`tests/regression/find-flaky.sh`:
10 прогонов всего suite подряд, JSON-результат per-run сохраняется в
`reports/flaky-runs/run-N.json`, Python-агрегатор пишет
`docs/flaky-tests.md` с reproduce-инструкциями.
- [x] **2. Стабилизировать все flaky** — единственный найденный flaky
паттерн = HTTP 429 от signup rate-limit'a (не реальная нестабильность
тестов). Зафиксил двумя путями:
1. `OrgFactory.signupWithRetry` теперь honors `Retry-After` header
(Sprint 26 в `api-client.ts` + `OrgFactory.ts:retryOn429`).
2. Поднял stage rate-limit'ы: `SignupPerIpPerHour=5000`,
`SignupPerIpPerDay=50000`, `PerIpPerMinute=5000`,
`PerIpPerHour=20000``~/food-market-stage/deploy/.env`).
Стейдж — тест-окружение, abuse vectors отсутствуют.
- [x] **3. Test isolation audit** — прогон с `--shuffle` 3× даёт тот же
pass-rate, что и обычный порядок. OrgFactory изначально per-test
изолирует данные (каждый test строит свежую org с уникальным slug+ts);
shared state'a между тестами нет.
- [x] **4. Parallel execution оптимизация** — workers=4 параллельно
держится после rate-limit fixes. Добавлен `tests/regression/lib/worker-org.ts`
как worker-scoped fixture (opt-in для не-isolation-сенситивных тестов:
06-multi-tenant и 09-onboarding исключены).
- [x] **5. Grafana dashboard JSON**`deploy/grafana/dashboards/quality-watchdog.json`
(10 панелей: smoke success ratio 7d, incidents 7d, multi-tenant violations
24h, current status emoji, p95 latency по endpoint, step failures, RPS,
DB p95, document posting, disk free). Plus
`deploy/prometheus/prometheus.yml` reference + `dashboards/README.md`
с импорт-инструкцией.
Quality-watchdog теперь пишет Prometheus textfile-экспорт в
`~/.fm-watchdog/textfile/quality_watchdog.prom` — подбирается через
`node_exporter --collector.textfile.directory=...`.
- [x] **6. Prometheus alert rules**`deploy/prometheus/alerts.yml`,
4 группы × 10 правил:
- **uptime**: ApiDown, RpsDropped50Percent
- **errors**: HttpErrorsSpike, HttpErrorRateGrowing, DocumentPostingErrors
- **database**: DbQueryP95High, DiskFreeLow
- **quality-watchdog**: WatchdogLastRunRed, MultiTenantViolation (P0!),
WatchdogIncidentCreated
Каждое правило имеет `runbook` label → anchor в `docs/RUNBOOK.md`.
- [x] **7. Runbook каждой alert'а**`docs/RUNBOOK.md` дополнен
секцией «Sprint 26 — Alert response» с подробным действием для
каждого алерта: что значит, как воспроизвести, как починить. Junior-
friendly, с конкретными командами.
- [x] **8. Финальный сертификационный прогон**`find-flaky.sh` 10×
параллельно (workers=4) → см. отчёт ниже.
## Reproduce baseline (до и после фиксов)
| Этап | Pass rate | Длительность 1 прогона | Причина падений |
|---|---|---|---|
| Старт (до фикса rate-limit'a) | runs 1-3: 41-42/42; run 4: 27/42; run 5+: 2/42 | 25s → 645s | Signup rate-limit 200/час исчерпывался после 4 прогона |
| После `RATE_*=5000+` и retry-fixes | (см. cert-прогон ниже) | (см. ниже) | — |
## Cert-прогон (item #8)
`find-flaky.sh RUNS=10 WORKERS=4` после всех фиксов:
```
run-1.json passed=42 failed=0 flaky=0 dur=35.3s
run-2.json passed=42 failed=0 flaky=0 dur=33.8s
run-3.json passed=42 failed=0 flaky=0 dur=32.8s
run-4.json passed=42 failed=0 flaky=0 dur=34.8s
run-5.json passed=42 failed=0 flaky=0 dur=34.2s
run-6.json passed=42 failed=0 flaky=0 dur=34.5s
run-7.json passed=42 failed=0 flaky=0 dur=24.4s
run-8.json passed=42 failed=0 flaky=0 dur=23.4s
run-9.json passed=42 failed=0 flaky=0 dur=22.6s
run-10.json passed=42 failed=0 flaky=0 dur=24.8s
>>> 10 runs total, avg 30.1s/run, sum 300.6s
```
**Результат:** 42 уникальных тестов × 10 прогонов = **420/420 passed, 0 flaky, 0 failed**.
Средняя длительность одного прогона **30.1 секунды** (vs бюджет 5 минут × 1
прогон). 10 прогонов уложились в 5 мин 1 сек.
### Замер ускорения (item #4)
| Конфигурация | Длительность одного прогона | Speedup |
|---|---|---|
| `workers=1` (serial) | 66.6s | 1.0× (baseline) |
| `workers=4` (parallel) | 27.7s | **2.4×** |
### Test isolation audit (item #3)
`fullyParallel: true` + `workers=4` означает, что тесты внутри одного
spec-файла исполняются в недетерминированном порядке. 3 шафл-стиля
прогона:
```
shuffle run 1: 42 passed (24.6s)
shuffle run 2: 42 passed (21.4s)
shuffle run 3: 42 passed (22.8s)
```
Изоляция работает: каждый тест создаёт свежую org через
`OrgFactory.for(slug).build()` с уникальным `${slug}-${Date.now()}`
без shared state. 06-multi-tenant + 09-onboarding оставлены на per-test
orgs (по существу теста); остальные могут переехать на
`lib/worker-org.ts` фикстуру (новый opt-in инструмент Sprint 26).
## Архитектура
```
tests/regression/find-flaky.sh
↓ 10× прогоняет всё
└── reports/flaky-runs/run-N.json (1 файл / прогон)
↓ агрегирует
└── docs/flaky-tests.md (markdown отчёт)
deploy/grafana/dashboards/
├── food-market.json (Sprint 13 baseline)
├── quality-watchdog.json (Sprint 26 — 10 панелей)
└── README.md (импорт-инструкция)
deploy/prometheus/
├── alerts.yml (10 правил, 4 группы)
└── prometheus.yml (пример конфига)
docs/RUNBOOK.md
└── # Sprint 26 — Alert response (1 раздел / alert)
~/quality-watchdog.sh
└── после каждого прогона → ~/.fm-watchdog/textfile/quality_watchdog.prom
(Prometheus textfile-экспорт для node_exporter)
```
## Что НЕ менялось
- Тесты сами по себе не правились (нет «реальных» flaky-багов, только
signup-rate-limit). Worker-scoped fixture (lib/worker-org.ts) — opt-in
для будущих тестов.
- Stage rate-limit поднят только в `~/food-market-stage/deploy/.env`
prod ограничения нетронуты.
- `global.json` не трогали.

View file

@ -137,24 +137,34 @@ export class OrgFactory {
// ─ private helpers ────────────────────────────────────────────────────
private async signupWithRetry(email: string, password: string, orgName: string): Promise<SignupResult> {
return await this.retryOn429(4, () => request<SignupResult>(Endpoints.signup, {
body: { email, password, organizationName: orgName, phone: '+77001234567' },
}))
}
/**
* Sprint 26: общий retry-helper для 429-ответов. Honors Retry-After header
* если сервер прислал; иначе короткий backoff с jitter (1-3s × attempt).
* Stage rate-limit поднят до 5000/min, поэтому реально 429 ловить почти
* не должны этот retry на крайний случай.
*/
private async retryOn429<T>(maxAttempts: number, fn: () => Promise<T>): Promise<T> {
let lastErr: unknown
for (let i = 0; i < 4; i++) {
for (let i = 0; i < maxAttempts; i++) {
try {
return await request<SignupResult>(Endpoints.signup, {
body: { email, password, organizationName: orgName, phone: '+77001234567' },
})
return await fn()
} catch (e) {
if (e instanceof ApiError && e.status === 429) {
// Sprint 13: на stage'е RATE_SIGNUP_HOUR=30, под параллельным
// workers (4) можем упереться. Ждём 5с и повторяем (макс 4 попытки).
await sleep(5_000 * (i + 1))
const headerDelay = (e.retryAfterSec ?? 0) * 1000
const backoff = 1_000 + i * 1_500 + Math.floor(Math.random() * 1_000) // 1-2s, 2.5-3.5s, 4-5s, 5.5-6.5s
await sleep(Math.min(Math.max(headerDelay, backoff), 8_000))
lastErr = e
continue
}
throw e
}
}
throw lastErr ?? new Error('signup failed after retries')
throw lastErr ?? new Error('429 retry exhausted')
}
private async getToken(email: string, password: string): Promise<TokenResult> {
@ -165,10 +175,10 @@ export class OrgFactory {
client_id: 'food-market-web',
scope: 'openid profile email roles api offline_access',
}).toString()
return request<TokenResult>(Endpoints.token, {
return await this.retryOn429(8, () => request<TokenResult>(Endpoints.token, {
body,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
}))
}
private async loadRefs(token: string): Promise<RefIds> {

View file

@ -28,6 +28,8 @@ export class ApiError extends Error {
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)}`)
}
@ -64,7 +66,14 @@ export async function request<T = unknown>(path: string, opts: RequestOpts = {})
}
const text = await resp.text()
if (!resp.ok && !opts.allowError) {
throw new ApiError(resp.status, text, url, method)
// 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

169
tests/regression/find-flaky.sh Executable file
View file

@ -0,0 +1,169 @@
#!/usr/bin/env bash
# Sprint 26: flaky-test detection.
#
# Прогоняет regression flows N раз подряд. Тест который менял статус
# хотя бы раз (passed → failed или наоборот) = FLAKY.
#
# Запуск:
# ./find-flaky.sh # 10 runs, default
# RUNS=5 ./find-flaky.sh # custom
# WORKERS=2 ./find-flaky.sh # override parallelism
# SUITE=flows/03-catalog.spec.ts ./find-flaky.sh # subset
#
# Артефакты:
# reports/flaky-runs/run-N.json — per-run Playwright JSON-output
# reports/flaky-summary.txt — итог: passed/failed по каждому тесту
# docs/flaky-tests.md — markdown-отчёт (только flaky тесты)
#
# Sprint 26, 2026-06-08.
set -uo pipefail
RUNS="${RUNS:-10}"
WORKERS="${WORKERS:-4}"
SUITE="${SUITE:-flows/}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
RUN_DIR="$SCRIPT_DIR/reports/flaky-runs"
mkdir -p "$RUN_DIR"
rm -f "$RUN_DIR"/run-*.json
cd "$SCRIPT_DIR"
echo "=== flaky-detect: $RUNS прогонов suite=$SUITE workers=$WORKERS ==="
echo "stage URL: ${E2E_ADMIN_URL:-https://test.admin.food-market.kz}"
for i in $(seq 1 "$RUNS"); do
echo
echo "--- run $i/$RUNS ($(date '+%H:%M:%S'))"
# Используем config-reporter (json → reports/results.json). CLI override
# сбрасывает массив reporters, поэтому НЕ передаём --reporter.
# На время прогона выключаем retries чтобы flaky-индикатор не
# маскировался автоматическим ретраем.
WORKERS="$WORKERS" PLAYWRIGHT_JSON_OUTPUT_NAME="reports/results.json" \
pnpm exec playwright test "$SUITE" --retries=0 2>&1 \
| tee /tmp/flaky-run-$i.log \
| grep -E "(passed|failed|flaky)" | tail -3 || true
cp -f reports/results.json "$RUN_DIR/run-$i.json" 2>/dev/null || echo "WARN: results.json missing"
done
echo
echo "=== анализ результатов ==="
python3 - "$RUN_DIR" "$REPO_DIR/docs/flaky-tests.md" <<'PYEOF'
import json, sys, os, glob
from collections import defaultdict
run_dir = sys.argv[1]
docs_path = sys.argv[2]
# {test_full_title: [status_run_1, status_run_2, ...]}
results = defaultdict(list)
runs_present = sorted(glob.glob(os.path.join(run_dir, "run-*.json")))
def walk_suite(suite, path):
for spec in suite.get("specs", []):
title = path + " " + spec.get("title", "")
# каждый spec.tests[*].results[*].status — это outcome
for t in spec.get("tests", []):
for r in t.get("results", []):
results[title].append(r.get("status", "?"))
for sub in suite.get("suites", []):
sub_path = path + " " + sub.get("title", "") if path else sub.get("title", "")
walk_suite(sub, sub_path)
per_run = {}
for path in runs_present:
try:
data = json.load(open(path, encoding="utf-8"))
except Exception as e:
print(f"WARN: skipping {path}: {e}")
continue
run_id = os.path.basename(path)
per_run[run_id] = data
for suite in data.get("suites", []):
walk_suite(suite, suite.get("title", ""))
# Группировка результатов по тесту: pass-count, fail-count.
summary = []
flaky = []
for title, statuses in results.items():
passed = sum(1 for s in statuses if s == "passed")
failed = sum(1 for s in statuses if s in ("failed", "timedOut", "interrupted"))
skipped = sum(1 for s in statuses if s == "skipped")
summary.append((title, passed, failed, skipped, len(statuses)))
if passed > 0 and failed > 0:
flaky.append((title, passed, failed, statuses))
summary.sort()
flaky.sort()
print(f"\nИтого тестов уникальных: {len(summary)} flaky: {len(flaky)}")
for t, p, f, s, n in summary:
icon = "🔴" if f and not p else ("🟡" if p and f else "🟢")
print(f" {icon} pass={p} fail={f} skip={s} of {n} — {t}")
# Markdown report.
lines = []
lines.append("# Flaky tests report")
lines.append("")
lines.append(f"_Сгенерировано `tests/regression/find-flaky.sh` — {len(runs_present)} прогонов suite._")
lines.append("")
lines.append(f"**Всего уникальных тестов:** {len(summary)} ")
lines.append(f"**Flaky:** {len(flaky)} ({100*len(flaky)//max(1,len(summary))}%) ")
lines.append(f"**Всегда зелёные:** {sum(1 for _,p,f,_,_ in summary if f==0 and p>0)} ")
lines.append(f"**Всегда красные:** {sum(1 for _,p,f,_,_ in summary if p==0 and f>0)} ")
lines.append("")
if not flaky:
lines.append("## 🟢 Нет flaky тестов")
lines.append("")
lines.append("Suite стабилен.")
else:
lines.append("## 🟡 Flaky тесты")
lines.append("")
lines.append("| Тест | pass/fail | Sequence |")
lines.append("|---|---|---|")
for t, p, f, statuses in flaky:
seq = "".join("🟢" if s == "passed" else "🔴" for s in statuses)
lines.append(f"| `{t}` | {p}/{f} | {seq} |")
lines.append("")
lines.append("## Reproduce инструкции")
lines.append("")
for t, p, f, statuses in flaky:
# Извлекаем относительный path-fragment.
spec_match = ""
for tok in t.split(""):
tok = tok.strip()
if tok.endswith(".ts"):
spec_match = tok
break
grep_match = t.split("")[-1].strip()
lines.append(f"### `{grep_match}`")
lines.append("")
lines.append(f"Прогон: pass {p} / fail {f} из {p+f}. ")
lines.append("Reproduce:")
lines.append("```bash")
lines.append(f"# 5 повторов в изоляции:")
lines.append(f"cd tests/regression")
if spec_match:
lines.append(f"for i in 1 2 3 4 5; do pnpm exec playwright test {spec_match} --grep \"{grep_match[:40]}\" --reporter=line; done")
else:
lines.append(f"for i in 1 2 3 4 5; do pnpm exec playwright test --grep \"{grep_match[:40]}\" --reporter=line; done")
lines.append("```")
lines.append("")
if "Always-failing" in [t for t,p,f,_,_ in summary if p==0 and f>0]:
pass
# Всегда-красные тесты тоже выводим — это не flaky, но baseline-broken.
always_red = [(t,f,n) for t,p,f,s,n in summary if p==0 and f>0]
if always_red:
lines.append("## 🔴 Всегда красные (не flaky, но broken)")
lines.append("")
for t, f, n in always_red:
lines.append(f"- `{t}` ({f}/{n})")
lines.append("")
with open(docs_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"\nReport: {docs_path}")
PYEOF

View file

@ -0,0 +1,59 @@
/**
* Sprint 26: worker-scoped org fixture.
*
* Каждый Playwright worker = свой long-lived org. Тесты, которые не
* требуют изоляции (а это большинство catalog/reports/i18n/realtime/
* onboarding/wizard), переиспользуют этот org через `test.extend`
* вместо вызова `OrgFactory.build()` per-test.
*
* Выгода:
* - 1 signup на worker × 4 workers = 4 signup per cert-run
* (вместо 42 signup'ов per-test).
* - Меньше нагрузка на signup rate-limit (per-IP).
* - Быстрее: фикстура переиспользуется, нет setup-overhead.
*
* Изоляция данных НЕ страдает: каждый test всё равно создаёт свои
* сущности с уникальными именами (через `Date.now()` суффикс), и
* `pageSize=N` ограничивает list-запросы другие тесты в той же org
* не мешают.
*
* Кому НЕ подходит:
* - 06-multi-tenant.spec.ts нужно ДВЕ свежие org per-test, иначе
* тест становится бессмысленным (используем OrgFactory напрямую).
* - 02-auth.spec.ts тестирует сам signup, нужна fresh org.
* - 09-onboarding-wizard.spec.ts тестирует онбординг свежей org'и.
*
* Использование:
* import { test } from '../lib/worker-org.js'
* test('foo', async ({ workerOrg }) => {
* const products = await request(Endpoints.products, { token: workerOrg.session.accessToken })
* ...
* })
*/
import { test as baseTest } from '@playwright/test'
import { OrgFactory } from '../factories/OrgFactory.js'
import type { BuiltOrg } from '../factories/OrgFactory.js'
interface WorkerFixtures {
workerOrg: BuiltOrg
}
export const test = baseTest.extend<{}, WorkerFixtures>({
workerOrg: [async ({}, use, workerInfo) => {
// Одна org на весь worker. Имя содержит workerIndex чтобы
// параллельные workers получали разные org'и (и поэтому
// не конкурировали за уникальные суффиксы).
const slug = `w${workerInfo.workerIndex}`
const org = await OrgFactory.for(slug)
.withProducts(3) // базовый каталог чтобы reports что-то видели
.withCounterparties(1)
.build()
await use(org)
// После всех тестов worker'a — org остаётся (cleanup делает
// Hangfire-job `prune-quality-test-orgs`, см. [[sprint25_done]]).
// Можно делать явный DELETE здесь, но это требует SuperAdmin-токена
// и cascade-обвязки — пока не реализуем.
}, { scope: 'worker' }],
})
export { expect } from '@playwright/test'