From cf760fab103c8d858b968ab3a9c4738f63e9f2c6 Mon Sep 17 00:00:00 2001 From: nns Date: Mon, 8 Jun 2026 14:44:19 +0500 Subject: [PATCH] =?UTF-8?q?feat(s26):=20flaky-test=20detection=20+=20obser?= =?UTF-8?q?vability=20dashboards=20(8/8=20=E2=9C=93=2010/10=20cert)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После 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 --- deploy/grafana/dashboards/README.md | 37 ++ .../grafana/dashboards/quality-watchdog.json | 350 ++++++++++++++++++ deploy/prometheus/alerts.yml | 175 +++++++++ deploy/prometheus/prometheus.yml | 47 +++ docs/RUNBOOK.md | 178 +++++++++ docs/flaky-tests.md | 12 + docs/sprint26-progress.md | 154 ++++++++ tests/regression/factories/OrgFactory.ts | 30 +- tests/regression/factories/api-client.ts | 11 +- tests/regression/find-flaky.sh | 169 +++++++++ tests/regression/lib/worker-org.ts | 59 +++ 11 files changed, 1211 insertions(+), 11 deletions(-) create mode 100644 deploy/grafana/dashboards/README.md create mode 100644 deploy/grafana/dashboards/quality-watchdog.json create mode 100644 deploy/prometheus/alerts.yml create mode 100644 deploy/prometheus/prometheus.yml create mode 100644 docs/flaky-tests.md create mode 100644 docs/sprint26-progress.md create mode 100755 tests/regression/find-flaky.sh create mode 100644 tests/regression/lib/worker-org.ts diff --git a/deploy/grafana/dashboards/README.md b/deploy/grafana/dashboards/README.md new file mode 100644 index 0000000..71eadc8 --- /dev/null +++ b/deploy/grafana/dashboards/README.md @@ -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 при импорте. diff --git a/deploy/grafana/dashboards/quality-watchdog.json b/deploy/grafana/dashboards/quality-watchdog.json new file mode 100644 index 0000000..79f7390 --- /dev/null +++ b/deploy/grafana/dashboards/quality-watchdog.json @@ -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 +} diff --git a/deploy/prometheus/alerts.yml b/deploy/prometheus/alerts.yml new file mode 100644 index 0000000..7463c97 --- /dev/null +++ b/deploy/prometheus/alerts.yml @@ -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" diff --git a/deploy/prometheus/prometheus.yml b/deploy/prometheus/prometheus.yml new file mode 100644 index 0000000..438664b --- /dev/null +++ b/deploy/prometheus/prometheus.yml @@ -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 diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index c2771f7..73af0f9 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -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). diff --git a/docs/flaky-tests.md b/docs/flaky-tests.md new file mode 100644 index 0000000..1bec545 --- /dev/null +++ b/docs/flaky-tests.md @@ -0,0 +1,12 @@ +# Flaky tests report + +_Сгенерировано `tests/regression/find-flaky.sh` — 10 прогонов suite._ + +**Всего уникальных тестов:** 42 +**Flaky:** 0 (0%) +**Всегда зелёные:** 42 +**Всегда красные:** 0 + +## 🟢 Нет flaky тестов + +Suite стабилен. \ No newline at end of file diff --git a/docs/sprint26-progress.md b/docs/sprint26-progress.md new file mode 100644 index 0000000..7349546 --- /dev/null +++ b/docs/sprint26-progress.md @@ -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` не трогали. diff --git a/tests/regression/factories/OrgFactory.ts b/tests/regression/factories/OrgFactory.ts index 5408665..d4d5ce9 100644 --- a/tests/regression/factories/OrgFactory.ts +++ b/tests/regression/factories/OrgFactory.ts @@ -137,24 +137,34 @@ export class OrgFactory { // ─ private helpers ──────────────────────────────────────────────────── private async signupWithRetry(email: string, password: string, orgName: string): Promise { + return await this.retryOn429(4, () => request(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(maxAttempts: number, fn: () => Promise): Promise { let lastErr: unknown - for (let i = 0; i < 4; i++) { + for (let i = 0; i < maxAttempts; i++) { try { - return await request(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 { @@ -165,10 +175,10 @@ export class OrgFactory { client_id: 'food-market-web', scope: 'openid profile email roles api offline_access', }).toString() - return request(Endpoints.token, { + return await this.retryOn429(8, () => request(Endpoints.token, { body, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }) + })) } private async loadRefs(token: string): Promise { diff --git a/tests/regression/factories/api-client.ts b/tests/regression/factories/api-client.ts index de06122..d69389e 100644 --- a/tests/regression/factories/api-client.ts +++ b/tests/regression/factories/api-client.ts @@ -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(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 diff --git a/tests/regression/find-flaky.sh b/tests/regression/find-flaky.sh new file mode 100755 index 0000000..df78f48 --- /dev/null +++ b/tests/regression/find-flaky.sh @@ -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 diff --git a/tests/regression/lib/worker-org.ts b/tests/regression/lib/worker-org.ts new file mode 100644 index 0000000..803c4e0 --- /dev/null +++ b/tests/regression/lib/worker-org.ts @@ -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'