feat(s25): autonomous continuous quality monitoring (8/8)
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
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
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
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Hourly smoke watchdog + auto-fix loop + dashboard + multi-tenant guard + perf regression + cleanup job + README badge. 1. ~/quality-watchdog.sh (cron 5 * * * *) — 8 checks (~60s): /health/ready, signup→login→/api/me, GET products, Playwright UI smoke (3.1 product CRUD), /metrics format, /hubs/notifications negotiate with token, multi-tenant isolation, perf p95. 2. Auto-fix loop: 2× consecutive red → ~/.fm-watchdog/incident-*.txt + queue/0000-incident-* to bump it ahead of Server-Claude's sprint queue. fm-watchdog.sh sees prefix 0000- as next. 3. scripts/quality-dashboard.py — renders docs/quality-status.md (current emoji, 8-step table, perf baseline, 7-day history, 24-run sparkline) + injects README badge 🟢/🟡/🔴. 4. Multi-tenant smoke: signup 2 orgs `quality-{epoch}-A/B`, create product in A, verify B sees 404/403 + total=0. 5. Perf regression: p95 over 10 reqs for /api/me, products, sales/retail/stats. Baseline = median of last 10 samples (robust to noise). >50% from baseline → alert. First 5 runs always green (warm-up). 6. HousekeepingJobs.PruneQualityTestOrgsAsync (cron 30 2 * * * UTC): finds orgs `quality-%` older than 24h, dynamically scans information_schema for tables with OrganizationId, iteratively DELETEs with FK-violation retry (up to 10 passes), then cleans AspNetUser*/OpenIddict* by email pattern `quality-%@test-fm.local`, finally users + organizations. 7. README badge: <!-- quality-badge --> marker updated each run. Validated: stage deploy ✓, Hangfire job registered ✓, dry-run SQL on 24 stage candidates → 0 remaining ✓, 3 cron-triggered runs all 8/8 green (12:42/12:45/12:48 +05) ✓. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4cc4922463
commit
019c57ae3b
|
|
@ -1,5 +1,7 @@
|
||||||
# food-market
|
# food-market
|
||||||
|
|
||||||
|
<!-- quality-badge --> 🟢 **Quality:** [`docs/quality-status.md`](docs/quality-status.md) <!-- /quality-badge -->
|
||||||
|
|
||||||
[](http://192.168.1.193:3000/nns/food-market/actions)
|
[](http://192.168.1.193:3000/nns/food-market/actions)
|
||||||
[](http://192.168.1.193:3000/nns/food-market/actions)
|
[](http://192.168.1.193:3000/nns/food-market/actions)
|
||||||
[](http://192.168.1.193:3000/nns/food-market/actions)
|
[](http://192.168.1.193:3000/nns/food-market/actions)
|
||||||
|
|
|
||||||
60
docs/quality-status.md
Normal file
60
docs/quality-status.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Quality status
|
||||||
|
|
||||||
|
_Обновлено: 2026-06-08T07:48:24+00:00 · auto-gen из `~/quality-watchdog.sh`_
|
||||||
|
|
||||||
|
## 🟢 Текущий статус
|
||||||
|
|
||||||
|
**Последний прогон:** `2026-06-08T12:48:01+05:00`
|
||||||
|
**Зелёных шагов:** 8/8
|
||||||
|
**Красных шагов:** 0
|
||||||
|
|
||||||
|
## Шаги smoke-suite
|
||||||
|
|
||||||
|
| Шаг | Статус | Последнее изменение | Consecutive fail |
|
||||||
|
|---|---|---|---|
|
||||||
|
| /health/ready | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||||
|
| signup→login→/api/me | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||||
|
| GET /api/catalog/products | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||||
|
| Playwright UI (product CRUD) | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||||
|
| /metrics (Prometheus) | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||||
|
| /hubs/notifications/negotiate | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||||
|
| Multi-tenant isolation | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||||
|
| Performance p95 vs baseline | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||||
|
|
||||||
|
## Performance baseline (p95, ms)
|
||||||
|
|
||||||
|
| Endpoint | p95 (ms) |
|
||||||
|
|---|---|
|
||||||
|
| `/api/catalog/products/page/1/pageSize/10` | 233 |
|
||||||
|
| `/api/me` | 259 |
|
||||||
|
| `/api/sales/retail/stats/days/7` | 228 |
|
||||||
|
|
||||||
|
_Регрессия = текущий p95 >50% от baseline. Baseline обновляется только когда регрессии нет (берёт min)._
|
||||||
|
|
||||||
|
## История за 7 дней
|
||||||
|
|
||||||
|
**Прогонов:** 14
|
||||||
|
**С красным:** 7
|
||||||
|
**Green-ratio:** 50%
|
||||||
|
|
||||||
|
### Прогоны с красным шагом
|
||||||
|
|
||||||
|
| Время | Красные шаги |
|
||||||
|
|---|---|
|
||||||
|
| `2026-06-08T12:16:05+05:00` | multi_tenant |
|
||||||
|
| `2026-06-08T12:16:52+05:00` | multi_tenant |
|
||||||
|
| `2026-06-08T12:18:01+05:00` | multi_tenant |
|
||||||
|
| `2026-06-08T12:18:35+05:00` | multi_tenant |
|
||||||
|
| `2026-06-08T12:22:49+05:00` | health, auth_me, products, metrics, signalr, multi_tenant, perf |
|
||||||
|
| `2026-06-08T12:23:00+05:00` | health, auth_me, products, metrics, signalr, multi_tenant, perf |
|
||||||
|
| `2026-06-08T12:23:18+05:00` | perf |
|
||||||
|
|
||||||
|
## Последние 24 прогона
|
||||||
|
|
||||||
|
`🔴🔴🔴🔴🟢🟢🔴🔴🔴🟢🟢🟢🟢🟢`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Скрипт: `~/quality-watchdog.sh` (cron `0 * * * *`).
|
||||||
|
Источник: `~/.fm-watchdog/quality-history.jsonl`.
|
||||||
|
Sprint 25 — autonomous continuous quality monitoring.
|
||||||
216
docs/sprint25-progress.md
Normal file
216
docs/sprint25-progress.md
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
# Sprint 25 — autonomous continuous quality monitoring
|
||||||
|
|
||||||
|
Цель: чтобы существующие фичи постоянно проверялись на работоспособность,
|
||||||
|
и при поломке — автоматическая попытка починить через инцидент-задачу в
|
||||||
|
очередь Server-Claude'а.
|
||||||
|
|
||||||
|
Старт: 2026-06-08. Исполнитель: Claude Opus 4.7.
|
||||||
|
|
||||||
|
## Чек-лист
|
||||||
|
|
||||||
|
- [x] **1. Hourly smoke watchdog** — `~/quality-watchdog.sh` запускается
|
||||||
|
cron'ом каждый час в минуту 5. 8 проверок (~60 сек): /health/ready,
|
||||||
|
signup→login→/api/me, GET /api/catalog/products, Playwright UI flow
|
||||||
|
(3.1 product create-list-get), /metrics, /hubs/notifications/negotiate,
|
||||||
|
multi-tenant изоляция, performance p95. Падение → Telegram + лог в
|
||||||
|
`~/.fm-watchdog/quality.log`.
|
||||||
|
|
||||||
|
- [x] **2. Auto-fix loop при двукратном падении** — при consecutive_fail≥2
|
||||||
|
watchdog создаёт `~/.fm-watchdog/incident-{ts}-{step}.txt` + копирует в
|
||||||
|
очередь Server-Claude'а как `queue/0000-incident-*` (префикс `0000-`
|
||||||
|
гарантирует сортировку в начало). После завершения spirinta Server-Claude
|
||||||
|
возвращается к текущему.
|
||||||
|
|
||||||
|
- [x] **3. Quality dashboard** — `docs/quality-status.md` рендерится
|
||||||
|
hourly через `scripts/quality-dashboard.py`: текущий статус-эмодзи,
|
||||||
|
список 8 шагов с last-green/last-red, perf baseline (median p95 по
|
||||||
|
последним 10 измерениям), история падений за 7 дней, sparkline 24
|
||||||
|
последних прогонов.
|
||||||
|
|
||||||
|
- [x] **4. Multi-tenant smoke 24/7** — создаёт TWO org с уникальными
|
||||||
|
именами `quality-{epoch}-A/B`, заводит товар в A, проверяет что B
|
||||||
|
не видит его ни через GET-by-id (404/403), ни через search (total=0).
|
||||||
|
Leak в multi-tenant изоляции — самый дорогой баг, поэтому ежечасный
|
||||||
|
контроль.
|
||||||
|
|
||||||
|
- [x] **5. Performance regression hourly** — p95 за 10 повторов для
|
||||||
|
`/api/me`, `/api/catalog/products`, `/api/sales/retail/stats`. Baseline
|
||||||
|
= median последних 10 измерений (robust к шуму). Регрессия = текущий
|
||||||
|
p95 >50% от baseline → alert + (при consecutive≥2) incident.
|
||||||
|
Первые 5 запусков всегда green (собираем стартовую выборку).
|
||||||
|
|
||||||
|
- [x] **6. Hangfire job: cleanup quality test orgs** —
|
||||||
|
`HousekeepingJobs.PruneQualityTestOrgsAsync` (recurring-id
|
||||||
|
`prune-quality-test-orgs`, default cron `30 2 * * *` UTC). Удаляет
|
||||||
|
org'и `quality-%` старше 24h: динамически по `information_schema`
|
||||||
|
находит все tenant-таблицы с `OrganizationId`, итеративно DELETE'ит
|
||||||
|
с обработкой FK-violation (до 10 проходов), затем чистит
|
||||||
|
`AspNetUserRoles/Tokens/Claims/Logins`, `OpenIddictTokens/Authorizations`
|
||||||
|
по email-шаблону `quality-%@test-fm.local`, и finally `users` +
|
||||||
|
`organizations`. Threshold переопределяется через
|
||||||
|
`Cleanup:QualityTestOrgHours`.
|
||||||
|
|
||||||
|
- [x] **7. Status badge в README** — `<!-- quality-badge -->` маркер с
|
||||||
|
эмодзи 🟢/🟡/🔴 + ссылкой на `docs/quality-status.md`. Обновляется
|
||||||
|
через `scripts/quality-dashboard.py`. 🟢 = последний run all green;
|
||||||
|
🟡 = красные шаги, но <2 consecutive (transient); 🔴 = 2+
|
||||||
|
consecutive (incident-уровень).
|
||||||
|
|
||||||
|
- [x] **8. Cron-валидация** — временно поменял на `*/3 * * * *`,
|
||||||
|
поймал 3 cron-запуска подряд (12:42 / 12:45 / 12:48 +05), все
|
||||||
|
8/8 green. Вернул на hourly (`5 * * * *`). См. `~/.fm-watchdog/quality-cron.log`.
|
||||||
|
|
||||||
|
## Журнал
|
||||||
|
|
||||||
|
### 2026-06-08 старт
|
||||||
|
Sprint 24 закрыт (8/8). Поехали по quality watchdog.
|
||||||
|
|
||||||
|
### 2026-06-08 quality-watchdog.sh (#1)
|
||||||
|
Главный скрипт ~16 КБ:
|
||||||
|
- shell-helpers `state_get`/`state_set`/`mark_red`/`mark_green`/`create_incident`.
|
||||||
|
- Все шаги логируют в `~/.fm-watchdog/quality.log` + state в
|
||||||
|
`~/.fm-watchdog/quality-state.json` (per-step `consecutive_fail`,
|
||||||
|
`last_green`, `last_red`, `last_detail`).
|
||||||
|
- Telegram-нотификация на каждый red-шаг. Если token/chat не настроены
|
||||||
|
(default на dev-vm) — просто лог без падения.
|
||||||
|
- Лог ротируется при превышении 1 МБ.
|
||||||
|
|
||||||
|
Подобранные через debug refs:
|
||||||
|
- `/api/catalog/units-of-measure` (не `/api/refs/units`)
|
||||||
|
- `/api/catalog/product-groups` (обязательное поле ProductGroupId)
|
||||||
|
- POST product требует `barcodes: [{code, type, isPrimary}]` + price
|
||||||
|
`[{priceTypeId, amount, currencyId}]` где priceTypeId = isRequired-row
|
||||||
|
из `/api/catalog/price-types`.
|
||||||
|
|
||||||
|
### 2026-06-08 auto-fix loop (#2)
|
||||||
|
`create_incident` пишет 2 файла: один в `~/.fm-watchdog/incident-{ts}-{step}.txt`
|
||||||
|
(для архива/обзора инженером), другой в `~/.fm-watchdog/queue/0000-incident-{ts}-{step}.txt`.
|
||||||
|
Префикс `0000-` критичен — `fm-watchdog.sh` ротирует очередь через
|
||||||
|
`ls | sort | head -1`, так что инцидент-задача рулит раньше любой
|
||||||
|
плановой работы. Проверил через симуляцию (STAGE_URL=https://nonexistent.example.invalid):
|
||||||
|
1-я итерация — yellow, 2-я — RED + 7 incident-файлов в очереди (по одному
|
||||||
|
на каждый упавший шаг, кроме ui_flow — там node_modules + playwright всё
|
||||||
|
равно работают).
|
||||||
|
|
||||||
|
### 2026-06-08 dashboard + history (#3)
|
||||||
|
`scripts/quality-dashboard.py` (Python 3.11+, no deps) рендерит markdown
|
||||||
|
из `quality-history.jsonl` (1 JSONL-строка на прогон, append-only,
|
||||||
|
trimmed до 400 последних = ~2 недели hourly). Содержит:
|
||||||
|
- Текущий статус-эмодзи.
|
||||||
|
- Таблицу 8 шагов: `статус | last-change | consecutive_fail`.
|
||||||
|
- Performance baseline (median p95 по 10 последним измерениям).
|
||||||
|
- История за 7 дней + green-ratio %.
|
||||||
|
- Sparkline последних 24 прогонов (🟢/🔴).
|
||||||
|
|
||||||
|
`updateReadmeBadge` инжектит `<!-- quality-badge --> ... <!-- /quality-badge -->`
|
||||||
|
маркер в README между h1 и existing badges.
|
||||||
|
|
||||||
|
### 2026-06-08 multi-tenant scenario (#4)
|
||||||
|
2 signup'a с unique `quality-{epoch}-A/B`, токены через `/connect/token`,
|
||||||
|
POST product в A → GET в B должен вернуть 404/403 + search в B должен
|
||||||
|
дать total=0. Хитрость: B видит "Все товары" (root group)
|
||||||
|
**своей** организации (создан bootstrap'ом при signup), не A's; это
|
||||||
|
правильно. Leak бы означал, что product создан вне tenant'a A.
|
||||||
|
|
||||||
|
### 2026-06-08 performance baseline (#5)
|
||||||
|
Первая версия использовала `min(old, new)` как baseline — после первого
|
||||||
|
быстрого замера (cold connection) baseline=200ms, и любой warm-replay
|
||||||
|
295ms давал +47% и срабатывал alert. Переделал на median последних 10
|
||||||
|
измерений + первые 5 запусков всегда green (warm-up). Это сглаживает
|
||||||
|
шум и держит alert на действительно sustained регрессиях.
|
||||||
|
|
||||||
|
Файл `~/.fm-watchdog/quality-perf-baseline.json` теперь содержит:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"median": { "_api_me": 245, ... },
|
||||||
|
"samples": { "_api_me": [216, 245, 297, ...], ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2026-06-08 PruneQualityTestOrgs job (#6)
|
||||||
|
Добавил метод в `HousekeepingJobs` + регистрация в
|
||||||
|
`HangfireJobsConfigurator` под id `prune-quality-test-orgs`,
|
||||||
|
cron `30 2 * * *` UTC.
|
||||||
|
|
||||||
|
Реализация:
|
||||||
|
1. `_db.Organizations.IgnoreQueryFilters().Where(Name LIKE 'quality-%' AND CreatedAt < threshold)` → candidate ids.
|
||||||
|
2. `DO $do$ ... END $do$` блок с `org_ids uuid[] := ARRAY[...]`.
|
||||||
|
3. Итеративный FOR pass IN 1..10 LOOP по
|
||||||
|
`information_schema.columns WHERE column_name = 'OrganizationId'`
|
||||||
|
— каждая итерация пытается DELETE из каждой tenant-таблицы; при
|
||||||
|
`foreign_key_violation` пропускает (следующий проход уберёт parent
|
||||||
|
запись). Реально хватает 2 проходов (нашёл employees ↔ employee_roles).
|
||||||
|
4. AspNetUserRoles/Tokens/Claims/Logins/OpenIddictTokens/Authorizations
|
||||||
|
через email-pattern `quality-%@test-fm.local`.
|
||||||
|
5. `users` + `organizations` отдельным шагом в конце.
|
||||||
|
|
||||||
|
Dry-run на stage (24 кандидата, ROLLBACK): pass 1 remaining=2, pass 2
|
||||||
|
remaining=0, итог 0 orgs в `quality-%` — работает.
|
||||||
|
|
||||||
|
### 2026-06-08 README badge (#7)
|
||||||
|
`updateReadmeBadge` инжектит маркированный блок:
|
||||||
|
```html
|
||||||
|
<!-- quality-badge --> 🟢 **Quality:** [`docs/quality-status.md`](docs/quality-status.md) <!-- /quality-badge -->
|
||||||
|
```
|
||||||
|
Каждый запуск watchdog'a обновляет эмодзи по результату последнего прогона.
|
||||||
|
|
||||||
|
### 2026-06-08 cron-валидация (#8)
|
||||||
|
Crontab: `5 * * * * /home/nns/quality-watchdog.sh >> ~/.fm-watchdog/quality-cron.log 2>&1`.
|
||||||
|
Для быстрого подтверждения временно поменял на `*/3 * * * *`, поймал
|
||||||
|
3 запуска подряд (12:42 / 12:45 / 12:48 +05), все 8/8 green. Восстановил
|
||||||
|
hourly.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
~/quality-watchdog.sh (cron 5 * * * *)
|
||||||
|
↓ запускает 8 проверок
|
||||||
|
├── HTTP curl (steps 1, 2, 3, 5, 6, 7, 8)
|
||||||
|
└── Playwright @smoke (step 4, использует tests/regression/flows/03)
|
||||||
|
↓ итог
|
||||||
|
├── ~/.fm-watchdog/quality.log (append-only)
|
||||||
|
├── ~/.fm-watchdog/quality-state.json (per-step consecutive_fail)
|
||||||
|
├── ~/.fm-watchdog/quality-history.jsonl (1 line/run, 400 lines max)
|
||||||
|
├── ~/.fm-watchdog/quality-perf-baseline.json (median + samples)
|
||||||
|
└── docs/quality-status.md + README badge (через quality-dashboard.py)
|
||||||
|
↓ если consecutive_fail ≥ 2
|
||||||
|
├── ~/.fm-watchdog/incident-{ts}-{step}.txt
|
||||||
|
└── ~/.fm-watchdog/queue/0000-incident-{ts}-{step}.txt (для fm-watchdog.sh ротации)
|
||||||
|
|
||||||
|
Hangfire job `prune-quality-test-orgs` (cron 30 2 * * * UTC):
|
||||||
|
- находит orgs `quality-%` старше 24h
|
||||||
|
- итеративно DELETE'ит из всех tenant-таблиц
|
||||||
|
- чистит Identity+OpenIddict по email-pattern
|
||||||
|
- DELETE users + organizations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что найдено и зафиксировано
|
||||||
|
|
||||||
|
- **Stage perf шумит на старте**: первые 1-2 запроса к /api/sales/retail/stats
|
||||||
|
доходят до 400ms (cold DB cache), затем 200-250ms steady. Поэтому
|
||||||
|
baseline стал median-based, не min-based.
|
||||||
|
- **POST product требует non-trivial payload**: barcodes (массив объектов
|
||||||
|
с code/type/isPrimary), prices (массив с priceTypeId/amount/currencyId),
|
||||||
|
productGroupId — все обязательные. Зафиксил в watchdog: вытягивает
|
||||||
|
refs из дефолтных значений orga через `/api/catalog/{units,product-groups,price-types,currencies}`.
|
||||||
|
- **FK dependency между employees и employee_roles**: первый DELETE на
|
||||||
|
`employee_roles` валится на employees.RoleId FK. Решил итеративным
|
||||||
|
ретраем (10 проходов) — гибкая стратегия, не привязанная к ручному
|
||||||
|
порядку, новые tenant-таблицы автоматом подцепятся.
|
||||||
|
|
||||||
|
## Метрики
|
||||||
|
|
||||||
|
| | До Sprint 25 | После | Δ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Watchdog scripts** | `fm-watchdog.sh` + `nightly-verify.sh` + `nightly-perf-check.sh` | + `quality-watchdog.sh` + `quality-dashboard.py` | +2 |
|
||||||
|
| **Cron entries** | 2 | 3 | +1 |
|
||||||
|
| **Hangfire recurring jobs** | 11 | 12 | +1 (prune-quality-test-orgs) |
|
||||||
|
| **Hourly smoke шаги** | (нет hourly smoke) | 8 шагов | new |
|
||||||
|
| **Docs (.md в `docs/`)** | 60 | 62 | +2 (quality-status.md, sprint25-progress.md) |
|
||||||
|
| **README badges** | 5 | 6 | +1 (quality 🟢/🟡/🔴) |
|
||||||
|
| **Auto-fix integration** | (нет) | incident → fm-watchdog queue | new |
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
8/8 ✓. 3 cron-trigger'd runs все green. `~/.fm-watchdog/DONE` создан
|
||||||
|
после ratiosit's финального прогона.
|
||||||
239
scripts/quality-dashboard.py
Executable file
239
scripts/quality-dashboard.py
Executable file
|
|
@ -0,0 +1,239 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Sprint 25: рендерер docs/quality-status.md из ~/.fm-watchdog/quality-history.jsonl.
|
||||||
|
|
||||||
|
Запускается из ~/quality-watchdog.sh после каждого прогона.
|
||||||
|
Также обновляет статус-бейдж в README.md (🟢/🟡/🔴).
|
||||||
|
|
||||||
|
Usage: python3 scripts/quality-dashboard.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
HISTORY = Path.home() / ".fm-watchdog" / "quality-history.jsonl"
|
||||||
|
STATE = Path.home() / ".fm-watchdog" / "quality-state.json"
|
||||||
|
BASELINE = Path.home() / ".fm-watchdog" / "quality-perf-baseline.json"
|
||||||
|
DASHBOARD = REPO / "docs" / "quality-status.md"
|
||||||
|
README = REPO / "README.md"
|
||||||
|
|
||||||
|
STEP_NAMES = {
|
||||||
|
"health": "/health/ready",
|
||||||
|
"auth_me": "signup→login→/api/me",
|
||||||
|
"products": "GET /api/catalog/products",
|
||||||
|
"ui_flow": "Playwright UI (product CRUD)",
|
||||||
|
"metrics": "/metrics (Prometheus)",
|
||||||
|
"signalr": "/hubs/notifications/negotiate",
|
||||||
|
"multi_tenant": "Multi-tenant isolation",
|
||||||
|
"perf": "Performance p95 vs baseline",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_history() -> list[dict]:
|
||||||
|
if not HISTORY.exists():
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for line in HISTORY.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out.append(json.loads(line))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_state() -> dict:
|
||||||
|
if not STATE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(STATE.read_text())
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_baseline() -> dict:
|
||||||
|
"""Возвращает dict {endpoint: median_ms} из нового формата с samples/median."""
|
||||||
|
if not BASELINE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
raw = json.loads(BASELINE.read_text())
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
if "median" in raw:
|
||||||
|
return raw["median"]
|
||||||
|
# Совместимость со старым форматом (просто {key: value}).
|
||||||
|
return {k: v for k, v in raw.items() if isinstance(v, (int, float))}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ts(s: str) -> datetime:
|
||||||
|
# python <3.11 не парсит +03:00 в datetime.fromisoformat для всех вариантов; чистим.
|
||||||
|
s = s.replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(s)
|
||||||
|
except Exception:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def determine_color(history: list[dict]) -> str:
|
||||||
|
"""🟢 если последний run all green; 🟡 если есть red но <consec 2;
|
||||||
|
🔴 если есть consecutive-2+ red (incident-уровень)."""
|
||||||
|
if not history:
|
||||||
|
return "🟡"
|
||||||
|
last = history[-1]
|
||||||
|
if not last.get("red"):
|
||||||
|
return "🟢"
|
||||||
|
# Считаем consecutive_fail из state.
|
||||||
|
state = load_state()
|
||||||
|
for step in last.get("red", []):
|
||||||
|
if int(state.get(step, {}).get("consecutive_fail", 0)) >= 2:
|
||||||
|
return "🔴"
|
||||||
|
return "🟡"
|
||||||
|
|
||||||
|
|
||||||
|
def render_dashboard(history: list[dict], state: dict, baseline: dict) -> str:
|
||||||
|
last = history[-1] if history else None
|
||||||
|
color = determine_color(history)
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
out = ["# Quality status", "", f"_Обновлено: {now} · auto-gen из `~/quality-watchdog.sh`_", ""]
|
||||||
|
out.append(f"## {color} Текущий статус")
|
||||||
|
out.append("")
|
||||||
|
if last:
|
||||||
|
out.append(f"**Последний прогон:** `{last['ts']}` ")
|
||||||
|
out.append(f"**Зелёных шагов:** {len(last.get('green', []))}/{len(STEP_NAMES)} ")
|
||||||
|
out.append(f"**Красных шагов:** {len(last.get('red', []))} ")
|
||||||
|
else:
|
||||||
|
out.append("_История пока пуста — quality-watchdog ещё не запускался cron'ом._")
|
||||||
|
out.append("")
|
||||||
|
|
||||||
|
# Step-by-step table.
|
||||||
|
out.append("## Шаги smoke-suite")
|
||||||
|
out.append("")
|
||||||
|
out.append("| Шаг | Статус | Последнее изменение | Consecutive fail |")
|
||||||
|
out.append("|---|---|---|---|")
|
||||||
|
last_green = set(last.get("green", [])) if last else set()
|
||||||
|
last_red = set(last.get("red", [])) if last else set()
|
||||||
|
for key, label in STEP_NAMES.items():
|
||||||
|
if key in last_green:
|
||||||
|
icon = "🟢"
|
||||||
|
elif key in last_red:
|
||||||
|
icon = "🔴"
|
||||||
|
else:
|
||||||
|
icon = "⚪"
|
||||||
|
s = state.get(key, {})
|
||||||
|
recent = s.get("last_green") if icon == "🟢" else s.get("last_red", "—")
|
||||||
|
cf = s.get("consecutive_fail", 0)
|
||||||
|
out.append(f"| {label} | {icon} | `{recent or '—'}` | {cf} |")
|
||||||
|
out.append("")
|
||||||
|
|
||||||
|
# Red detail для текущего прогона.
|
||||||
|
if last and last.get("details"):
|
||||||
|
out.append("## Детали падений (последний прогон)")
|
||||||
|
out.append("")
|
||||||
|
for d in last["details"]:
|
||||||
|
out.append(f"- `{d}`")
|
||||||
|
out.append("")
|
||||||
|
|
||||||
|
# Performance baseline.
|
||||||
|
if baseline:
|
||||||
|
out.append("## Performance baseline (p95, ms)")
|
||||||
|
out.append("")
|
||||||
|
out.append("| Endpoint | p95 (ms) |")
|
||||||
|
out.append("|---|---|")
|
||||||
|
for k, v in sorted(baseline.items()):
|
||||||
|
# _api_me → /api/me. Грубо, восстанавливаем читаемое имя.
|
||||||
|
pretty = k.replace("_", "/")
|
||||||
|
out.append(f"| `{pretty}` | {v} |")
|
||||||
|
out.append("")
|
||||||
|
out.append("_Регрессия = текущий p95 >50% от baseline. Baseline обновляется только когда регрессии нет (берёт min)._")
|
||||||
|
out.append("")
|
||||||
|
|
||||||
|
# История за неделю.
|
||||||
|
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
|
||||||
|
week_runs = []
|
||||||
|
for h in history:
|
||||||
|
ts = parse_ts(h["ts"])
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
if ts >= week_ago:
|
||||||
|
week_runs.append(h)
|
||||||
|
|
||||||
|
out.append("## История за 7 дней")
|
||||||
|
out.append("")
|
||||||
|
if not week_runs:
|
||||||
|
out.append("_Нет прогонов за последнюю неделю._")
|
||||||
|
else:
|
||||||
|
red_runs = [h for h in week_runs if h.get("red")]
|
||||||
|
out.append(f"**Прогонов:** {len(week_runs)} ")
|
||||||
|
out.append(f"**С красным:** {len(red_runs)} ")
|
||||||
|
out.append(f"**Green-ratio:** {((len(week_runs)-len(red_runs))*100)//max(1,len(week_runs))}% ")
|
||||||
|
out.append("")
|
||||||
|
if red_runs:
|
||||||
|
out.append("### Прогоны с красным шагом")
|
||||||
|
out.append("")
|
||||||
|
out.append("| Время | Красные шаги |")
|
||||||
|
out.append("|---|---|")
|
||||||
|
for h in red_runs[-20:]:
|
||||||
|
out.append(f"| `{h['ts']}` | {', '.join(h.get('red', []))} |")
|
||||||
|
out.append("")
|
||||||
|
|
||||||
|
# Последние 24 прогона как sparkline.
|
||||||
|
out.append("## Последние 24 прогона")
|
||||||
|
out.append("")
|
||||||
|
spark = "".join("🟢" if not h.get("red") else "🔴" for h in history[-24:])
|
||||||
|
out.append(f"`{spark or '— нет данных —'}`")
|
||||||
|
out.append("")
|
||||||
|
out.append("---")
|
||||||
|
out.append("")
|
||||||
|
out.append("Скрипт: `~/quality-watchdog.sh` (cron `0 * * * *`). ")
|
||||||
|
out.append("Источник: `~/.fm-watchdog/quality-history.jsonl`. ")
|
||||||
|
out.append("Sprint 25 — autonomous continuous quality monitoring.")
|
||||||
|
out.append("")
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
BADGE_RE = re.compile(r"<!-- quality-badge -->.*?<!-- /quality-badge -->", re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def update_readme_badge(color: str) -> None:
|
||||||
|
if not README.exists():
|
||||||
|
return
|
||||||
|
txt = README.read_text()
|
||||||
|
badge = f"<!-- quality-badge --> {color} **Quality:** [`docs/quality-status.md`](docs/quality-status.md) <!-- /quality-badge -->"
|
||||||
|
if BADGE_RE.search(txt):
|
||||||
|
new_txt = BADGE_RE.sub(badge, txt)
|
||||||
|
else:
|
||||||
|
# Вставляем после первого заголовка.
|
||||||
|
lines = txt.splitlines()
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
if ln.startswith("# "):
|
||||||
|
lines.insert(i + 1, "")
|
||||||
|
lines.insert(i + 2, badge)
|
||||||
|
break
|
||||||
|
new_txt = "\n".join(lines)
|
||||||
|
if new_txt != txt:
|
||||||
|
README.write_text(new_txt)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
history = load_history()
|
||||||
|
state = load_state()
|
||||||
|
baseline = load_baseline()
|
||||||
|
DASHBOARD.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
DASHBOARD.write_text(render_dashboard(history, state, baseline))
|
||||||
|
color = determine_color(history)
|
||||||
|
update_readme_badge(color)
|
||||||
|
print(f"dashboard updated: {DASHBOARD} (status={color})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -63,6 +63,15 @@ public Task StartAsync(CancellationToken ct)
|
||||||
cronExpression: cronTokens,
|
cronExpression: cronTokens,
|
||||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
|
// Sprint 25: чистка quality-{epoch}-* test-org, созданных
|
||||||
|
// ~/quality-watchdog.sh hourly. Default 02:30 UTC, до утреннего prune-блока.
|
||||||
|
var cronQualityOrgs = _cfg["Hangfire:Cron:PruneQualityTestOrgs"] ?? "30 2 * * *";
|
||||||
|
_jobs.AddOrUpdate<HousekeepingJobs>(
|
||||||
|
recurringJobId: "prune-quality-test-orgs",
|
||||||
|
methodCall: j => j.PruneQualityTestOrgsAsync(CancellationToken.None),
|
||||||
|
cronExpression: cronQualityOrgs,
|
||||||
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
// Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor.
|
// Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor.
|
||||||
var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC
|
var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC
|
||||||
var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час
|
var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час
|
||||||
|
|
|
||||||
|
|
@ -134,4 +134,124 @@ public async Task<int> PruneRevokedRefreshTokensAsync(CancellationToken ct = def
|
||||||
deleted, threshold);
|
deleted, threshold);
|
||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sprint 25: hard-delete тестовых организаций, которые
|
||||||
|
/// создаёт <c>~/quality-watchdog.sh</c> по конвенции
|
||||||
|
/// <c>quality-{epoch}-A/B</c>. Stage-БД иначе разрастается на
|
||||||
|
/// ~2 org/час = ~50/день. Делается строго по имени-шаблону, чтобы
|
||||||
|
/// никогда не задеть реальные орги (даже архивные).
|
||||||
|
///
|
||||||
|
/// Реализация: динамически находим все таблицы с колонкой
|
||||||
|
/// <c>OrganizationId</c> через information_schema, и DELETE'им из
|
||||||
|
/// них строки для match'ащих org-id. Заказ в одной транзакции через
|
||||||
|
/// CTE с DEFERRABLE FK не понадобится — PG умеет резолвить cascade
|
||||||
|
/// если делать DELETE в правильном порядке (сначала дочерние,
|
||||||
|
/// потом родительские); используем общий приём «удаляем по списку
|
||||||
|
/// org-id из всех таблиц, последний — Organizations».
|
||||||
|
///
|
||||||
|
/// Порог давности — <c>Cleanup:QualityTestOrgHours</c> (default 24)
|
||||||
|
/// чтобы свежие диагностические прогоны не зачищались до того, как
|
||||||
|
/// инженер посмотрит лог. Возвращает количество удалённых
|
||||||
|
/// организаций.</summary>
|
||||||
|
public async Task<int> PruneQualityTestOrgsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var minHours = _cfg.GetValue("Cleanup:QualityTestOrgHours", 24);
|
||||||
|
var threshold = DateTime.UtcNow.AddHours(-minHours);
|
||||||
|
|
||||||
|
var ids = await _db.Organizations
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(o => o.Name.StartsWith("quality-") && o.CreatedAt < threshold)
|
||||||
|
.Select(o => o.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (ids.Count == 0)
|
||||||
|
{
|
||||||
|
_log.LogInformation("Hangfire/PruneQualityTestOrgs: нет кандидатов старше {Threshold:O}", threshold);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Динамически собираем DELETE для каждой таблицы с колонкой "OrganizationId".
|
||||||
|
// Имена таблиц: snake_case (наши доменные) + PascalCase (Identity/OpenIddict).
|
||||||
|
// Идём по public-схеме, исключаем сам organizations (удаляем в конце).
|
||||||
|
// Тестовые юзеры распознаются по email LIKE 'quality-%@test-fm.local'.
|
||||||
|
//
|
||||||
|
// FK ordering: между tenant-таблицами есть ссылки (employees → employee_roles),
|
||||||
|
// поэтому делаем итеративные проходы: на каждом проходе пытаемся DELETE
|
||||||
|
// из каждой таблицы; если FK-violation — пропускаем, на след. проходе
|
||||||
|
// зависимые строки уже удалены и DELETE проходит. Максимум 10 проходов
|
||||||
|
// (depth FK графа гораздо меньше).
|
||||||
|
var sql = $@"
|
||||||
|
DO $do$
|
||||||
|
DECLARE
|
||||||
|
org_ids uuid[] := ARRAY[{string.Join(",", ids.Select(i => $"'{i}'::uuid"))}];
|
||||||
|
tbl text;
|
||||||
|
pass int := 0;
|
||||||
|
remaining int;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Итеративная очистка tenant-таблиц с OrganizationId.
|
||||||
|
-- Пропускаем organizations и users — отдельные шаги ниже.
|
||||||
|
FOR pass IN 1..10 LOOP
|
||||||
|
remaining := 0;
|
||||||
|
FOR tbl IN
|
||||||
|
SELECT c.table_name
|
||||||
|
FROM information_schema.columns c
|
||||||
|
JOIN information_schema.tables t
|
||||||
|
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
||||||
|
WHERE c.table_schema = 'public'
|
||||||
|
AND c.column_name = 'OrganizationId'
|
||||||
|
AND t.table_type = 'BASE TABLE'
|
||||||
|
AND c.table_name NOT IN ('organizations', 'users')
|
||||||
|
LOOP
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('DELETE FROM public.%I WHERE ""OrganizationId"" = ANY($1)', tbl) USING org_ids;
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN
|
||||||
|
remaining := remaining + 1;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
EXIT WHEN remaining = 0;
|
||||||
|
END LOOP;
|
||||||
|
IF remaining > 0 THEN
|
||||||
|
RAISE WARNING 'PruneQualityTestOrgs: % таблиц остались с FK-violation после 10 проходов', remaining;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Чистим AspNetUser* и OpenIddict-токены тестовых юзеров по email-шаблону.
|
||||||
|
-- Эти таблицы НЕ имеют OrganizationId, идут отдельным сегментом.
|
||||||
|
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u
|
||||||
|
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||||
|
);
|
||||||
|
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u
|
||||||
|
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||||
|
);
|
||||||
|
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u
|
||||||
|
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||||
|
);
|
||||||
|
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
|
||||||
|
SELECT u.""Id"" FROM public.users u
|
||||||
|
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||||
|
);
|
||||||
|
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
|
||||||
|
SELECT u.""Id""::text FROM public.users u
|
||||||
|
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||||
|
);
|
||||||
|
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
|
||||||
|
SELECT u.""Id""::text FROM public.users u
|
||||||
|
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. AppUser (public.users) — привязан через OrganizationId, чистим по нему.
|
||||||
|
DELETE FROM public.users WHERE ""OrganizationId"" = ANY(org_ids);
|
||||||
|
|
||||||
|
-- 4. Сама organizations.
|
||||||
|
DELETE FROM public.organizations WHERE ""Id"" = ANY(org_ids);
|
||||||
|
END $do$;
|
||||||
|
";
|
||||||
|
|
||||||
|
await _db.Database.ExecuteSqlRawAsync(sql, ct);
|
||||||
|
_log.LogInformation("Hangfire/PruneQualityTestOrgs: удалено {Count} тест-org (старше {Hours}ч)",
|
||||||
|
ids.Count, minHours);
|
||||||
|
return ids.Count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
tests/e2e/s23-setup.ts
Normal file
27
tests/e2e/s23-setup.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { login, makeClient } from './lib/api.js'
|
||||||
|
|
||||||
|
// Создаём 2 org'и с tokens, печатаем в формате shell env-vars.
|
||||||
|
async function main() {
|
||||||
|
const ts = Date.now()
|
||||||
|
const api = makeClient()
|
||||||
|
const orgs = []
|
||||||
|
for (const tag of ['A', 'B']) {
|
||||||
|
const email = `s23-${tag.toLowerCase()}-${ts}@food-market.local`
|
||||||
|
await api.post('/api/auth/signup', {
|
||||||
|
organizationName: `S23-${tag}-${ts}`, email, password: 'Stage12345!',
|
||||||
|
phone: '+77001234567',
|
||||||
|
})
|
||||||
|
const sess = await login(email, 'Stage12345!')
|
||||||
|
const t = makeClient(sess.accessToken)
|
||||||
|
const me = await t.get('/api/me')
|
||||||
|
orgs.push({ tag, token: sess.accessToken, orgId: me.data.orgId, email, userId: me.data.sub })
|
||||||
|
}
|
||||||
|
console.log('export S23_TS=' + ts)
|
||||||
|
for (const o of orgs) {
|
||||||
|
console.log(`export S23_${o.tag}_TOKEN='${o.token}'`)
|
||||||
|
console.log(`export S23_${o.tag}_ORG='${o.orgId}'`)
|
||||||
|
console.log(`export S23_${o.tag}_EMAIL='${o.email}'`)
|
||||||
|
console.log(`export S23_${o.tag}_USER='${o.userId}'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().catch(e => { console.error('FAIL:', e.response?.status, e.response?.data ?? e.message); process.exit(1) })
|
||||||
44
tests/e2e/scripts/screenshot-breadcrumbs.ts
Normal file
44
tests/e2e/scripts/screenshot-breadcrumbs.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* Sprint 7 item 6 — визуальная проверка breadcrumbs на edit-странице.
|
||||||
|
* Логинимся, seed-demo, открываем первый товар, скриншот шапки.
|
||||||
|
*/
|
||||||
|
import { chromium } from 'playwright'
|
||||||
|
import { makeClient, login } from '../lib/api.js'
|
||||||
|
|
||||||
|
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
|
||||||
|
const TS = Date.now()
|
||||||
|
const EMAIL = `crumb-shot-${TS}@food-market.local`
|
||||||
|
const PASS = 'CrumbShot12345!'
|
||||||
|
|
||||||
|
async function ensureSession() {
|
||||||
|
const api = makeClient()
|
||||||
|
const r = await api.post('/api/auth/signup', {
|
||||||
|
email: EMAIL, password: PASS,
|
||||||
|
organizationName: `CrumbShot ${TS}`, phone: '+77011190001', plan: 'start',
|
||||||
|
})
|
||||||
|
if (r.status !== 200) throw new Error(`signup ${r.status}: ${JSON.stringify(r.data)}`)
|
||||||
|
const sess = await login(EMAIL, PASS)
|
||||||
|
await makeClient(sess.accessToken).post('/api/admin/seed-demo', {})
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const sess = await ensureSession()
|
||||||
|
const browser = await chromium.launch({ headless: true })
|
||||||
|
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 800 } })
|
||||||
|
const page = await ctx.newPage()
|
||||||
|
await page.goto(`${BASE}/`)
|
||||||
|
await page.evaluate(({ token }) => localStorage.setItem('fm.access_token', token), { token: sess.accessToken })
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/catalog/products`, { waitUntil: 'domcontentloaded' })
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.locator('tbody tr').first().waitFor({ timeout: 12000 })
|
||||||
|
await page.locator('tbody tr').first().click()
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.waitForSelector('nav[aria-label="Хлебные крошки"]', { timeout: 8000 })
|
||||||
|
await page.screenshot({ path: `reports/breadcrumbs-product-${TS}.png`, fullPage: false })
|
||||||
|
console.log(`[shot] saved → reports/breadcrumbs-product-${TS}.png`)
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error(err); process.exit(1) })
|
||||||
Loading…
Reference in a new issue