Some checks are pending
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>
120 lines
5.1 KiB
TypeScript
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
|