food-market/tests/e2e/lib/ui.ts
nns 8b6d139e3e
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
test(ui-deep): items 6-9 — Supply/RetailSale/InventoryDocs/Reports
Item 6 (3 specs): Supply UI + найден P2 баг lost-update (нет ETag).
Item 7 (4 specs): RetailSale + CustomerReturn — oversell/underpayment.
Item 8 (5 specs): 6 doc-форм Submit state, Transfer From≠To, CSV-import.
Item 9 (6 specs): Sales/Stock/Profit/ABC + CSV download через
waitForEvent + XLSX endpoint validation.

lib/ui.ts: signup timeout=60s + ignore network-flake console errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:37:01 +05:00

129 lines
6 KiB
TypeScript
Raw Permalink 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)
// 60s timeout — signup на холодный stage может задерживаться при первом
// обращении (cold cache), плюс ratelimit на signup 6/min.
const ctx = await apiRequest.newContext({ baseURL: BASE, ignoreHTTPSErrors: true, timeout: 60_000 })
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
// Сетевые flake'и (DNS / connection reset / network change) — внешний
// фактор, не баг приложения.
if (/^Failed to load resource: net::(ERR_NETWORK_CHANGED|ERR_INTERNET_DISCONNECTED|ERR_CONNECTION_RESET|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_REFUSED|ERR_TIMED_OUT|ERR_ABORTED)/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