/** * Хелперы для 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 { 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 { 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