food-market/tests/e2e/lib/ui.ts
nns eb867697d0
Some checks are pending
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 Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
test(ui-deep): setup + Item 1 — signup flow (5 specs)
Sprint UI-deep, пункт 1: реальный Chromium через Playwright Test.
Установлены @playwright/test 1.60.0 и otplib (для item 11).
Конфиг tests/e2e/playwright.config.ts — workers=1, traces+screenshots
on-failure, screenshot dir reports/playwright-artifacts/.

Хелперы tests/e2e/lib/ui.ts:
- apiSignup() — быстрый signup через API + login
- attachSession() — кладёт access_token в localStorage, грузит путь
- watchPage() — listener console-errors и network 4xx/5xx
- expectNoErrors() — assert после flow'a

Item 1 (5 specs, все ✓ на стейдже):
- 1.1 attach session → /dashboard, без console-ошибок
- 1.2 создание товара через UI (Empty CTA → форма → Сохранить)
- 1.3 первый контрагент через Modal
- 1.4 создать товар + контрагент через API, открыть форму приёмки,
       smoke на компоненты страницы
- 1.5 OnboardingPage (/) рендерится

Найден 1 реальный баг → починен:
- ProductEditPage: race на currencies.data — если быстро Сохранить,
  цена-MoneyInput добавляет строку с currencyId='' → server 400 с
  криптичным JSON validation. Фикс: MoneyInput disabled пока
  !currencies.data + canSave проверяет row.currencyId.
- Form error display показывал "Request failed with status code 400";
  теперь использует общий humanizeError() (exporting из @/lib/api).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:33:10 +05:00

120 lines
5.1 KiB
TypeScript

/**
* Хелперы для UI-deep тестов: signup через API (быстрее чем форма), затем
* подкладываем access_token в localStorage веба и грузим страницу.
*
* Также — common-listeners на console-error и failed responses: тесты
* провалятся, если страница пишет ошибки в DevTools или возвращает 5xx.
*/
import { expect, type Page, type BrowserContext, type APIRequestContext, type ConsoleMessage } from '@playwright/test'
import { request as apiRequest } from '@playwright/test'
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
export interface Session {
email: string
password: string
orgName: string
accessToken: string
orgId: string
}
/** Signup через API (быстрее чем форма). Возвращает токен. */
export async function apiSignup(prefix = 'ui'): Promise<Session> {
const ts = Date.now() + Math.floor(Math.random() * 1000)
const ctx = await apiRequest.newContext({ baseURL: BASE, ignoreHTTPSErrors: true })
const email = `${prefix}-${ts}@food-market.local`
const password = 'UiTest12345!'
const orgName = `UI-${prefix}-${ts}`
let r = await ctx.post('/api/auth/signup', {
data: { email, password, organizationName: orgName, phone: '+77011190001', plan: 'start' },
failOnStatusCode: false,
})
// ratelimit на signup: 6/min; ждём и повторяем
for (let i = 0; i < 5 && r.status() === 429; i++) {
await new Promise(res => setTimeout(res, 15_000))
r = await ctx.post('/api/auth/signup', {
data: { email, password, organizationName: orgName, phone: '+77011190001', plan: 'start' },
failOnStatusCode: false,
})
}
expect(r.status(), `signup для ${email}`).toBe(200)
const tok = await loginToken(ctx, email, password)
const me = await ctx.get('/api/me', { headers: { Authorization: `Bearer ${tok}` } })
expect(me.status()).toBe(200)
const meBody = await me.json() as { orgId: string }
await ctx.dispose()
return { email, password, orgName, accessToken: tok, orgId: meBody.orgId }
}
export async function loginToken(ctx: APIRequestContext, email: string, password: string): Promise<string> {
const body = new URLSearchParams({
grant_type: 'password',
username: email,
password,
client_id: 'food-market-web',
scope: 'openid profile email roles api offline_access',
})
const r = await ctx.post('/connect/token', {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: body.toString(),
failOnStatusCode: false,
})
expect(r.status(), `connect/token ${email}`).toBe(200)
const j = await r.json() as { access_token: string }
return j.access_token
}
/** Логинит браузер: устанавливает fm.access_token в localStorage и идёт на /dashboard. */
export async function attachSession(page: Page, sess: Session, gotoPath = '/dashboard') {
await page.goto('/login')
await page.evaluate((tok) => localStorage.setItem('fm.access_token', tok), sess.accessToken)
await page.goto(gotoPath, { waitUntil: 'domcontentloaded' })
}
/** Слушатель console-ошибок и сетевых 5xx/неожиданных 4xx. Возвращает накопленные
* проблемы, чтобы тест мог отдельно assert на пустоту в конце. */
export interface CollectedErrors {
console: string[]
network: string[]
}
export function watchPage(page: Page, opts?: {
/** Список URL substring — ожидаемые 4xx, не считаются ошибкой (например 404 на проверку дубля артикула). */
expected4xxContains?: string[]
/** Список console-substr — ожидаемые ошибки (например React strict-mode dev-warn). */
expectedConsoleContains?: string[]
}): CollectedErrors {
const acc: CollectedErrors = { console: [], network: [] }
page.on('console', (msg: ConsoleMessage) => {
if (msg.type() !== 'error') return
const t = msg.text()
if ((opts?.expectedConsoleContains ?? []).some(s => t.includes(s))) return
acc.console.push(t)
})
page.on('response', async (resp) => {
const status = resp.status()
if (status < 400) return
const url = resp.url()
// 401 без токена на API — нормально для не-залогиненных страниц
if (status === 401 && /\/(api|connect)\//.test(url)) return
if ((opts?.expected4xxContains ?? []).some(s => url.includes(s))) return
if (status >= 400) {
acc.network.push(`${status} ${resp.request().method()} ${url}`)
}
})
return acc
}
/** Удобный assert-helper для пост-проверки ошибок. */
export function expectNoErrors(acc: CollectedErrors, where: string) {
if (acc.console.length || acc.network.length) {
const msg = [
`Found errors on ${where}:`,
...acc.console.map(c => ` CONSOLE: ${c}`),
...acc.network.map(n => ` NET: ${n}`),
].join('\n')
throw new Error(msg)
}
}
export const STAGE_URL = BASE