Sprint 16 — постоянный regression-контур: flows + visual + nightly +
CI workflow + README badges.
Ключевые цифры:
- 35 flow-тестов: 35/35 ✓ за ~30 секунд (workers=2 локально).
- 60 visual snapshot'ов (15 страниц × 2 темы × 2 viewport'a):
60/60 ✓ за ~4 минуты с retries=1.
- Полный регресс прогон: ~5 минут (цель была < 15).
Что сделано:
1. tests/regression/ — Playwright + factories + 8 spec-файлов.
OrgFactory builder создаёт org через API за O(N) HTTP вызовов
(signup → token → refs → products → counterparties → posted supplies).
Каждый flow независим, использует свой fresh-org.
2. tests/regression/visual/ — 15 страниц × 2 темы × 2 viewport'a.
Маски на динамический контент (артикулы с Date.now, KPI'ы,
delta-стрелки) чтобы 0.2% threshold не флакал. snapshotPathTemplate
c {projectName} — desktop+mobile не затирают друг друга.
3. tests/regression/factories/OrgFactory.ts — builder с .withProducts
.withCounterparties .withSupplies. Retry signup'a на 429.
4. .forgejo/workflows/regression.yml — on workflow_run после
Docker API/Web; cache на pnpm-store + Playwright-browsers;
артефакты при failure; Telegram-уведомление в обоих случаях.
5. ~/nightly-verify.sh + cron `0 4 * * *`: health → redeploy если
нужно → smoke flows; в воскресенье полный flows+visual. Логи с
ротацией 14 дней. Telegram на провал (~/.fm-watchdog/telegram-*).
6. scripts/generate-badges.sh — coverage из cobertura.xml в SVG через
shields.io (offline fallback). 4 CI-status badge + coverage badge в
README; CI step «Update coverage badge» авто-коммитит обновлённый
SVG на push в main.
Локальное число flake'ов: 1/60 visual на retry=1 (product-new light) —
случайная гонка маски, retry'ит и проходит.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
62 lines
3 KiB
TypeScript
62 lines
3 KiB
TypeScript
/**
|
||
* Sprint 16 — flows 08 realtime + misc (5 flows):
|
||
* 8.1 SignalR connect → message на /hubs/notifications не падает
|
||
* 8.2 dashboard live-status = on после wsConnect
|
||
* 8.3 search global: 'товар' возвращает результат
|
||
* 8.4 retail-points список содержит дефолтный «Касса 1»
|
||
* 8.5 health/ready возвращает Healthy при работающей БД
|
||
*/
|
||
import { expect, test } from '@playwright/test'
|
||
import { request } from '../factories/api-client.js'
|
||
import { OrgFactory } from '../factories/OrgFactory.js'
|
||
import { attachSession } from '../lib/ui.js'
|
||
|
||
test.describe('flow 08 — realtime + misc', () => {
|
||
test('8.1 dashboard рендерится без console-ошибок (SignalR опц.)', async ({ page }) => {
|
||
const b = await OrgFactory.for('rt81').build()
|
||
const errs: string[] = []
|
||
page.on('console', (m) => {
|
||
if (m.type() !== 'error') return
|
||
const t = m.text()
|
||
if (/Failed to load resource|net::ERR_/.test(t)) return
|
||
errs.push(t)
|
||
})
|
||
await attachSession(page, b.session, '/dashboard')
|
||
await page.waitForLoadState('networkidle')
|
||
expect(errs, 'console-ошибок на /dashboard быть не должно').toEqual([])
|
||
})
|
||
|
||
test('8.2 live-status элемент отображается на dashboard', async ({ page }) => {
|
||
const b = await OrgFactory.for('rt82').build()
|
||
await attachSession(page, b.session, '/dashboard')
|
||
// Sidebar показывает Wifi/WifiOff title — поэтому проверяем по title через
|
||
// accessible name на иконке (Live on / Live off).
|
||
const live = page.locator('[title*="live" i], [title*="реальн" i]').first()
|
||
await expect(live).toBeVisible({ timeout: 10_000 })
|
||
})
|
||
|
||
test('8.3 search global возвращает товар по части имени @smoke', async () => {
|
||
const b = await OrgFactory.for('rt83').withProducts(2).build()
|
||
// Берём первое слово первого товара (например, "Товар").
|
||
const needle = b.products[0]!.name.split(' ')[0] ?? 'Товар'
|
||
const r = await request<{ products: Array<{ id: string; name: string }> }>(
|
||
`/api/search/global?q=${encodeURIComponent(needle)}`, { token: b.session.accessToken },
|
||
)
|
||
expect(r.products.length).toBeGreaterThan(0)
|
||
})
|
||
|
||
test('8.4 retail-points список содержит дефолтную «Касса 1»', async () => {
|
||
const b = await OrgFactory.for('rt84').build()
|
||
const r = await request<{ items: Array<{ id: string; name: string }> }>(
|
||
'/api/catalog/retail-points', { token: b.session.accessToken },
|
||
)
|
||
expect(r.items.length).toBeGreaterThanOrEqual(1)
|
||
expect(r.items.find(x => /касса 1/i.test(x.name)), 'Касса 1 должна быть посеена').toBeDefined()
|
||
})
|
||
|
||
test('8.5 health/ready возвращает Healthy', async () => {
|
||
const r = await request<{ status: string }>('/health/ready')
|
||
expect(r.status).toBe('Healthy')
|
||
})
|
||
})
|