food-market/tests/e2e/lib/ui.ts
nns 64cc5b0d10
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): items 2-3 — navigation + Products CRUD
Item 2 (4 specs): 27 sidebar-страниц последовательно открываются без
console-errors и без 5xx. Sidebar labels + active state проверены.

Item 3 (5 specs): Products full CRUD через UI — create+edit+delete с
ConfirmDialog, дубль артикула с понятным toast'ом, поиск, пагинация при
>50 товаров, загрузка картинки через setInputFiles.

watcher: фильтрует Chromium auto-сообщения «Failed to load resource: the
server responded with a status of N» — дубли network-обработчика.

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

124 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Хелперы для 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()
// «Failed to load resource: the server responded with a status of XXX» —
// авто-сообщение Chromium на каждый 4xx/5xx, дублирует network-обработчик.
// Не считаем это самостоятельной ошибкой.
if (/^Failed to load resource: the server responded with a status of \d+/i.test(t)) return
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