Some checks are pending
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>
124 lines
5.4 KiB
TypeScript
124 lines
5.4 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()
|
||
// «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
|