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>
73 lines
3.5 KiB
TypeScript
73 lines
3.5 KiB
TypeScript
/**
|
|
* Sprint 16 — flows 03 catalog (5 flows):
|
|
* 3.1 product create → list → get-by-id (CRUD smoke)
|
|
* 3.2 product update → изменения сохраняются
|
|
* 3.3 counterparty create (юрлицо) → list
|
|
* 3.4 store: дефолтный «Главный» существует, можно добавить ещё
|
|
* 3.5 price-type «Розничная» существует и помечена IsSystem+IsRetail
|
|
*/
|
|
import { expect, test } from '@playwright/test'
|
|
import { request, ApiError } from '../factories/api-client.js'
|
|
import { Endpoints } from '../factories/types.js'
|
|
import { OrgFactory } from '../factories/OrgFactory.js'
|
|
|
|
test.describe('flow 03 — catalog', () => {
|
|
test('3.1 product create → list → get-by-id @smoke', async () => {
|
|
const b = await OrgFactory.for('cat31').withProducts(1).build()
|
|
const p = b.products[0]!
|
|
const list = await request<{ items: Array<{ id: string; name: string }> }>(
|
|
Endpoints.products + '?pageSize=10', { token: b.session.accessToken },
|
|
)
|
|
expect(list.items.find(x => x.id === p.id)).toBeTruthy()
|
|
const single = await request<{ id: string; name: string }>(
|
|
`${Endpoints.products}/${p.id}`, { token: b.session.accessToken },
|
|
)
|
|
expect(single.name).toBe(p.name)
|
|
})
|
|
|
|
test('3.2 product update изменения сохраняются', async () => {
|
|
const b = await OrgFactory.for('cat32').withProducts(1).build()
|
|
const p = b.products[0]!
|
|
// Получаем полный объект для PUT (бекенд требует ImageUrl, Code, etc.)
|
|
const full = await request<any>(`${Endpoints.products}/${p.id}`, { token: b.session.accessToken })
|
|
const newName = full.name + ' UPDATED'
|
|
await request(`${Endpoints.products}/${p.id}`, {
|
|
token: b.session.accessToken, method: 'PUT',
|
|
body: { ...full, name: newName },
|
|
})
|
|
const after = await request<{ name: string }>(`${Endpoints.products}/${p.id}`, { token: b.session.accessToken })
|
|
expect(after.name).toBe(newName)
|
|
})
|
|
|
|
test('3.3 counterparty create + list @smoke', async () => {
|
|
const b = await OrgFactory.for('cat33').withCounterparties(2).build()
|
|
const list = await request<{ items: Array<{ id: string }> }>(
|
|
Endpoints.counterparties + '?pageSize=10', { token: b.session.accessToken },
|
|
)
|
|
expect(list.items.length).toBeGreaterThanOrEqual(2)
|
|
for (const c of b.counterparties) {
|
|
expect(list.items.find(x => x.id === c.id)).toBeTruthy()
|
|
}
|
|
})
|
|
|
|
test('3.4 store дефолтный «Главный» существует', async () => {
|
|
const b = await OrgFactory.for('cat34').build()
|
|
const list = await request<{ items: Array<{ id: string; name: string; isMain: boolean }> }>(
|
|
Endpoints.refs.stores, { token: b.session.accessToken },
|
|
)
|
|
const main = list.items.find(s => s.isMain)
|
|
expect(main, 'Главный склад должен быть создан при signup').toBeDefined()
|
|
expect(main!.id).toBe(b.refs.storeId)
|
|
})
|
|
|
|
test('3.5 price-type системный розничный есть', async () => {
|
|
const b = await OrgFactory.for('cat35').build()
|
|
const list = await request<{ items: Array<{ id: string; name: string; isRetail: boolean; isSystem: boolean }> }>(
|
|
Endpoints.refs.priceTypes, { token: b.session.accessToken },
|
|
)
|
|
const sys = list.items.find(p => p.isRetail && p.isSystem)
|
|
expect(sys, 'системный розничный price-type должен быть после signup').toBeDefined()
|
|
expect(sys!.id).toBe(b.refs.priceTypeId)
|
|
})
|
|
})
|