Compare commits

..

No commits in common. "eb867697d07201678b9d8859f7561ebb487566ed" and "64af42167b2f40f5f727ccacbe479a53ae980a80" have entirely different histories.

9 changed files with 23 additions and 516 deletions

View file

@ -1,47 +0,0 @@
# Sprint UI-deep — глубокое браузерное тестирование stage
Цель: пройти `https://test.admin.food-market.kz` через **реальный Chromium**
(Playwright Test) и найти UX-баги, которые axios-проверки не видят:
console errors, network 5xx/4xx, layout breaks, missing loading states,
проблемы responsive, отсутствие confirm/validation/disabled-state и
multi-tenant утечки через URL.
Старт: 2026-05-30. Исполнитель: Claude Opus 4.7 (автономный режим).
## Стек
- `@playwright/test` runner — параллельные специ, trace-on-failure, screenshot-on-failure.
- `otplib` — генерация TOTP-кодов для item 11 (2FA flow).
- Все спецы лежат в `tests/e2e/scenarios/stage-ui-*.spec.ts`.
- `tests/e2e/playwright.config.ts` — конфиг с `BASE`, `headless: true`,
`screenshot: 'only-on-failure'`, `trace: 'retain-on-failure'`.
## Принципы
- Каждый пункт = отдельный spec-файл (.spec.ts).
- Каждый баг: воспроизвести в test() → починить код → `dotnet build` + локальные тесты → `~/deploy-stage.sh` → retest spec на стейдже зелёный → коммит фикса → коммит spec → `[x]` в этом доке.
- НЕ трогать: `global.json`, прод-стек, POS WPF.
## Чек-лист
- [ ] **1. Signup → onboarding → первая работа** — реальный browser signup, создание товара/контрагента/приёмки через клики, остаток виден на товаре.
- [ ] **2. Дашборд + навигация** — клик каждый пункт sidebar, страницы грузятся без console-ошибок и 5xx.
- [ ] **3. Каталог (товары) full CRUD** — создание с ценой+картинкой+штрихкодом, редактирование, дубль артикула → ошибка, удаление через confirm, поиск, пагинация.
- [ ] **4. Контрагенты / Группы / Единицы / Типы цен** — те же CRUD-проверки.
- [ ] **5. Сотрудники + Роли** — создание, role assignment, смена пароля, удаление активного.
- [ ] **6. Приёмка (Supply)** — Draft→Post через UI, кнопка disabled без строк, остаток обновлён, Unpost, конкурентность (2 вкладки → 409).
- [ ] **7. RetailSale + CustomerReturn** — payment-валидация, oversell-ошибка читаемая, возврат из проведённой продажи кнопкой.
- [ ] **8. Складские документы** — Enter/Loss/Transfer/Inventory/SupplierReturn/Demand: создать→провести→остаток. Transfer запрет From==To. Inventory CSV-import.
- [ ] **9. Отчёты — Sales/Stock/Profit/ABC** — фильтры через UI, числа сходятся, CSV/XLSX скачивается через page.waitForEvent('download').
- [ ] **10. OrgAuditLog UI** — записи видны, diff раскрывается, фильтры работают.
- [ ] **11. 2FA flow** — Enroll, QR, otplib код, Verify, login требует 2FA, Disable.
- [ ] **12. Login edge** — неверный пароль (читаемая ошибка), rate-limit 429, forgot-password.
- [ ] **13. Multi-tenant изоляция через URL** — 2 контекста, A создаёт товар, B пытается /products/{id-A} → 404.
- [ ] **14. Mobile viewport 375x667** — шаги 1-6 на мобильном, найти что ломается.
## Журнал
### 2026-05-30 — старт
- Создан этот файл. Sprint 7 (UX-полировка) закрыт ранее — теперь смотрим уже на «улучшенный» UI и ищем оставшиеся дыры.
- Подготовка: устанавливаю `@playwright/test`, `otplib`. Конфиг + helper'ы.

View file

@ -21,20 +21,14 @@ export function Modal({ open, onClose, title, children, footer, width = 'max-w-l
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-stretch sm:items-start justify-center sm:p-4 overflow-y-auto bg-slate-900/50 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className="fixed inset-0 z-50 flex items-stretch sm:items-start justify-center sm:p-4 overflow-y-auto bg-slate-900/50 backdrop-blur-sm" onClick={onClose}>
<div
className={`w-full ${width} min-h-full sm:min-h-0 sm:mt-16 bg-white dark:bg-slate-900 sm:rounded-xl shadow-xl flex flex-col`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3.5 border-b border-slate-200 dark:border-slate-800">
<h2 id="modal-title" className="font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600" aria-label="Закрыть">
<h2 className="font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
<X className="w-5 h-5" />
</button>
</div>

View file

@ -96,7 +96,7 @@ api.interceptors.response.use(
// если страница не отображала mutation.error явно.
const status = error.response?.status
if (status && status >= 400 && status !== 401 && !original.__silent) {
toast.error(humanizeAxios(error), { title: errorTitle(status) })
toast.error(humanizeError(error), { title: errorTitle(status) })
}
return Promise.reject(error)
},
@ -115,18 +115,8 @@ function errorTitle(status: number): string | undefined {
/** Извлекает человеко-читаемый текст из ответа API. Бэкенд использует
* ProblemDetails (RFC 7807) с `title`/`detail`/`errors`; иногда отдаёт
* простой `message`/`error`/`error_description`. Падать в крайнем случае
* на статус-текст («Internal Server Error»). Экспортируется, чтобы
* страницы с form-level error display'ем (ProductEditPage и др.) могли
* показывать ту же подсказку, что и toast. */
export function humanizeError(err: AxiosError | Error): string {
if (!('isAxiosError' in err) && !((err as AxiosError).response)) {
// обычный Error (не axios) — возвращаем message
return err.message
}
return humanizeAxios(err as AxiosError)
}
function humanizeAxios(err: AxiosError): string {
* на статус-текст («Internal Server Error»). */
function humanizeError(err: AxiosError): string {
const data = err.response?.data as Record<string, unknown> | undefined
if (data && typeof data === 'object') {
// ASP.NET validation errors: { errors: { Field: ['msg', ...] } }

View file

@ -2,7 +2,7 @@ import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'
import { api, humanizeError } from '@/lib/api'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
import {
@ -177,7 +177,10 @@ export function ProductEditPage() {
qc.invalidateQueries({ queryKey: ['/api/catalog/products'] })
navigate(created ? `/catalog/products/${created.id}` : '/catalog/products')
},
onError: (e: Error) => setError(humanizeError(e)),
onError: (e: Error) => {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg)
},
meta: { successMessage: 'Сохранено' },
})
@ -210,9 +213,7 @@ export function ProductEditPage() {
.filter((pt) => pt.isRequired)
.filter((pt) => {
const row = form.prices.find((p) => p.priceTypeId === pt.id)
// !row.currencyId — гонка currencies.data: если попало пустое значение,
// canSave должен остаться disabled пока currencies не подгрузятся.
return !row || row.amount <= 0 || !row.currencyId
return !row || row.amount <= 0
})
const canSave = form.name.trim().length > 0
@ -446,7 +447,6 @@ export function ProductEditPage() {
<Field key={pt.id} label={`${pt.name}${required ? ' *' : ''}`}>
<MoneyInput
value={row?.amount ?? null}
disabled={!currencies.data}
onChange={(n) => {
if (n == null) {
setForm({ ...form, prices: form.prices.filter(x => x.priceTypeId !== pt.id) })
@ -455,12 +455,7 @@ export function ProductEditPage() {
if (idx >= 0) {
updatePrice(idx, { amount: n })
} else {
// Race-guard: пока currencies.data не загрузились — не
// создаём строку с пустым currencyId (сервер вернёт
// невнятный JSON-validation 400). Поле disabled до
// загрузки, эта ветка — двойная страховка.
const cur = currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id
if (!cur) return
const cur = currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? ''
setForm({ ...form, prices: [...form.prices, { priceTypeId: pt.id, amount: n, currencyId: cur }] })
}
}}

View file

@ -1,119 +0,0 @@
/**
* Хелперы для 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

View file

@ -10,14 +10,12 @@
"axios": "^1.7.9",
"js-yaml": "^4.1.0",
"pg": "^8.13.1",
"playwright": "^1.60.0"
"playwright": "^1.59.1"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.17.10",
"@types/pg": "^8.11.10",
"otplib": "^13.4.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}

View file

@ -1,39 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Конфиг Playwright Test для UI-deep тестирования стейджа.
* Запуск: pnpm exec playwright test (или `npx playwright test`).
*
* Env:
* E2E_ADMIN_URL базовый URL (default https://test.admin.food-market.kz)
* CI=1 включает workers=1, full retry off
*/
const baseURL = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
export default defineConfig({
testDir: './scenarios',
testMatch: /stage-ui-.*\.spec\.ts$/,
fullyParallel: false, // тесты делят tenant-данные через API, серий безопаснее
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: [
['list'],
['html', { outputFolder: 'reports/playwright-html', open: 'never' }],
],
use: {
baseURL,
headless: true,
ignoreHTTPSErrors: true,
viewport: { width: 1280, height: 800 },
actionTimeout: 15_000,
navigationTimeout: 30_000,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
outputDir: 'reports/playwright-artifacts',
projects: [
{ name: 'chromium-desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } } },
],
})

View file

@ -18,12 +18,9 @@ importers:
specifier: ^8.13.1
version: 8.20.0
playwright:
specifier: ^1.60.0
version: 1.60.0
specifier: ^1.59.1
version: 1.59.1
devDependencies:
'@playwright/test':
specifier: ^1.60.0
version: 1.60.0
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
@ -33,9 +30,6 @@ importers:
'@types/pg':
specifier: ^8.11.10
version: 8.20.0
otplib:
specifier: ^13.4.0
version: 13.4.0
tsx:
specifier: ^4.19.2
version: 4.21.0
@ -201,36 +195,6 @@ packages:
cpu: [x64]
os: [win32]
'@noble/hashes@2.2.0':
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
'@otplib/core@13.4.0':
resolution: {integrity: sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==}
'@otplib/hotp@13.4.0':
resolution: {integrity: sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==}
'@otplib/plugin-base32-scure@13.4.0':
resolution: {integrity: sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==}
'@otplib/plugin-crypto-noble@13.4.0':
resolution: {integrity: sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==}
'@otplib/totp@13.4.0':
resolution: {integrity: sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==}
'@otplib/uri@13.4.0':
resolution: {integrity: sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==}
'@playwright/test@1.60.0':
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
engines: {node: '>=18'}
hasBin: true
'@scure/base@2.2.0':
resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==}
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
@ -355,9 +319,6 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
otplib@13.4.0:
resolution: {integrity: sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
@ -392,13 +353,13 @@ packages:
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
playwright-core@1.59.1:
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.60.0:
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
playwright@1.59.1:
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
engines: {node: '>=18'}
hasBin: true
@ -526,41 +487,6 @@ snapshots:
'@esbuild/win32-x64@0.27.7':
optional: true
'@noble/hashes@2.2.0': {}
'@otplib/core@13.4.0': {}
'@otplib/hotp@13.4.0':
dependencies:
'@otplib/core': 13.4.0
'@otplib/uri': 13.4.0
'@otplib/plugin-base32-scure@13.4.0':
dependencies:
'@otplib/core': 13.4.0
'@scure/base': 2.2.0
'@otplib/plugin-crypto-noble@13.4.0':
dependencies:
'@noble/hashes': 2.2.0
'@otplib/core': 13.4.0
'@otplib/totp@13.4.0':
dependencies:
'@otplib/core': 13.4.0
'@otplib/hotp': 13.4.0
'@otplib/uri': 13.4.0
'@otplib/uri@13.4.0':
dependencies:
'@otplib/core': 13.4.0
'@playwright/test@1.60.0':
dependencies:
playwright: 1.60.0
'@scure/base@2.2.0': {}
'@types/js-yaml@4.0.9': {}
'@types/node@20.19.39':
@ -710,15 +636,6 @@ snapshots:
dependencies:
mime-db: 1.52.0
otplib@13.4.0:
dependencies:
'@otplib/core': 13.4.0
'@otplib/hotp': 13.4.0
'@otplib/plugin-base32-scure': 13.4.0
'@otplib/plugin-crypto-noble': 13.4.0
'@otplib/totp': 13.4.0
'@otplib/uri': 13.4.0
pg-cloudflare@1.3.0:
optional: true
@ -754,11 +671,11 @@ snapshots:
dependencies:
split2: 4.2.0
playwright-core@1.60.0: {}
playwright-core@1.59.1: {}
playwright@1.60.0:
playwright@1.59.1:
dependencies:
playwright-core: 1.60.0
playwright-core: 1.59.1
optionalDependencies:
fsevents: 2.3.2

View file

@ -1,182 +0,0 @@
/**
* Sprint UI-deep, пункт 1: signup создание первого товара / контрагента /
* приёмки проверка остатка через UI.
*
* Не используем форму /signup (она на marketing-сайте food-market.kz), для
* скорости берём apiSignup() и грузим админку с access_token'ом в
* localStorage. Сам smoke маркетинга-signup'a (по требованию item-12) в
* отдельном spec'е (stage-ui-12-login-edge).
*/
import { test, expect } from '@playwright/test'
import { apiSignup, attachSession, watchPage, expectNoErrors } from '../lib/ui.js'
test.describe('UI-1 signup & first work', () => {
test.describe.configure({ mode: 'serial' })
test('1.1 attach session → /dashboard рендерится без console-ошибок', async ({ page }) => {
const sess = await apiSignup('item1')
const errs = watchPage(page)
await attachSession(page, sess, '/dashboard')
await expect(page.getByRole('heading', { name: /главная|обзор|dashboard/i }).first()).toBeVisible({ timeout: 10_000 })
await page.waitForLoadState('networkidle')
expectNoErrors(errs, '/dashboard freshly logged-in')
// Сохраняем для следующих тестов
test.info().annotations.push({ type: 'session', description: JSON.stringify(sess) })
})
test('1.2 создание первого товара через UI: Каталог → Товары → Создать', async ({ page }) => {
const sess = await apiSignup('item12')
const errs = watchPage(page)
await attachSession(page, sess, '/catalog/products')
// Empty state с CTA «Создать первый товар»
const emptyCta = page.getByRole('button', { name: /создать первый товар/i })
await expect(emptyCta).toBeVisible({ timeout: 8_000 })
await emptyCta.click()
await page.waitForURL(/\/catalog\/products\/new/, { timeout: 10_000 })
// Заполняем форму
const name = `BrowserProd ${Date.now()}`
await page.getByLabel('Название *').fill(name)
// Артикул — сгенерирован автодефолтом, оставляем.
// Розничная цена обязательна. Поле находим по label «Розничная …» —
// <Field label="Розничная *">; рядом внутри лежит input MoneyInput.
// У label в Field — есть <span>{label}</span><div>...</div>, и сам label.
// getByLabel должен найти input ассоциированный с этим label-обёрткой.
await page.getByLabel(/Розничная/).first().fill('999')
// Кнопка Сохранить должна стать enabled.
const saveBtn = page.getByRole('button', { name: /^сохранить$|^сохранить/i }).last()
await expect(saveBtn).toBeEnabled({ timeout: 5_000 })
await saveBtn.click()
// Toast «Сохранено» или переход на список
await Promise.race([
page.waitForURL(/\/catalog\/products(?!\/new)/, { timeout: 10_000 }),
page.getByRole('alert').filter({ hasText: /сохранено|создано/i }).waitFor({ timeout: 10_000 }),
])
// На списке должна появиться запись
await page.goto('/catalog/products')
await expect(page.locator('tbody').getByText(name)).toBeVisible({ timeout: 10_000 })
expectNoErrors(errs, 'create product flow')
})
test('1.3 первый контрагент: Каталог → Контрагенты → Добавить (modal)', async ({ page }) => {
const sess = await apiSignup('item13')
const errs = watchPage(page)
await attachSession(page, sess, '/catalog/counterparties')
// CTA «Добавить контрагента» (от Empty state)
const cta = page.getByRole('button', { name: /добавить контрагента|добавить$/i }).first()
await expect(cta).toBeVisible({ timeout: 8_000 })
await cta.click()
// Modal: ищем по h2-заголовку (после fix'a — role=dialog, но оставляем
// двойной локатор для backward-compat пока стейдж не обновлён).
const dialog = page.locator('[role="dialog"]').or(page.locator('div.fixed.inset-0').filter({ has: page.locator('h2') })).first()
await expect(dialog).toBeVisible()
await dialog.getByLabel(/название|наименование/i).first().fill(`КонтрагентUI ${Date.now()}`)
await dialog.getByRole('button', { name: /создать|сохранить/i }).click()
// Toast и модалка закрылась
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
// Запись появилась
await expect(page.locator('tbody tr').first()).toBeVisible()
expectNoErrors(errs, 'create counterparty modal')
})
test('1.4 создать приёмку с этим товаром, провести, проверить остаток', async ({ page }) => {
const sess = await apiSignup('item14')
const errs = watchPage(page)
// 1) seed catalog: создаём один товар + один поставщик через API чтобы не
// повторять UI clicks (это уже покрыто в 1.2/1.3).
const { request } = await import('@playwright/test')
const apiCtx = await request.newContext({
baseURL: process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz',
ignoreHTTPSErrors: true,
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
})
// unit/group/priceType: бери дефолтные из bootstrap'a. Все list-endpoints
// отдают PagedResult { items: [...] } — единая форма.
type Paged<T> = { items: T[] }
const units = await (await apiCtx.get('/api/catalog/units-of-measure?pageSize=200')).json() as Paged<{ id: string; code: string }>
const groups = await (await apiCtx.get('/api/catalog/product-groups')).json() as Paged<{ id: string; name: string }>
const pts = await (await apiCtx.get('/api/catalog/price-types')).json() as Paged<{ id: string; isRetail: boolean }>
const cur = await (await apiCtx.get('/api/catalog/currencies')).json() as Paged<{ id: string; code: string }>
const stores = await (await apiCtx.get('/api/catalog/stores')).json() as Paged<{ id: string; isMain: boolean }>
const unit = units.items.find(u => u.code === '796') ?? units.items[0]
const grp = groups.items[0]
const retail = pts.items.find(p => p.isRetail) ?? pts.items[0]
const kzt = cur.items.find(c => c.code === 'KZT') ?? cur.items[0]
const mainStore = stores.items.find(s => s.isMain) ?? stores.items[0]
const prodResp = await apiCtx.post('/api/catalog/products', {
data: {
name: `SupplyTest ${Date.now()}`,
article: `STS-${Date.now()}`,
unitOfMeasureId: unit.id,
vat: 12, vatEnabled: true,
productGroupId: grp.id,
packaging: 1, // Piece
prices: [{ priceTypeId: retail.id, amount: 500, currencyId: kzt.id }],
barcodes: [{ code: '2000000000019', type: 1, isPrimary: true }],
},
})
expect([200, 201]).toContain(prodResp.status())
const prod = await prodResp.json() as { id: string; name: string }
const cpResp = await apiCtx.post('/api/catalog/counterparties', {
data: { name: `Поставщик UI ${Date.now()}`, type: 2 /* LegalEntity */ },
})
expect([200, 201]).toContain(cpResp.status())
// 2) Идём через UI создавать приёмку
await attachSession(page, sess, '/purchases/supplies')
// EmptyState CTA
const cta = page.getByRole('button', { name: /создать приёмку|создать первый/i }).first()
await expect(cta).toBeVisible({ timeout: 8_000 })
await cta.click()
await page.waitForURL(/\/purchases\/supplies\/new/, { timeout: 10_000 })
// Контрагент select
const supplierField = page.locator('label').filter({ hasText: /поставщик/i }).locator('..')
await supplierField.locator('select, input').first().click().catch(() => {})
// Открываем AsyncSelect — у него input для поиска
const supplierInput = page.locator('input').filter({ has: page.locator(':scope') }).first()
// Попробуем найти select с опциями
const supplierSelect = page.locator('select').filter({ has: page.locator('option') }).first()
// Поскольку компонент AsyncSelect — кликаем на видимый текст «Выберите контрагента»
const selectTrigger = page.getByText(/выберите контрагент|поставщик/i).first()
// ProductPicker: добавить строку
const addLine = page.getByRole('button', { name: /добавить.*товар|добавить строку|\+ товар/i }).first()
// Проверка: до добавления строк кнопка Провести должна быть disabled/недоступна.
const postCheckbox = page.getByRole('checkbox', { name: /проведено/i })
if (await postCheckbox.count()) {
await expect(postCheckbox).toBeDisabled()
}
expectNoErrors(errs, 'supply create page initial load')
// Здесь нам важно сначала проверить что страница хотя бы рендерится без
// ошибок. Полный E2E через UI с AsyncSelect + ProductPicker — на следующих
// шагах (item 6), а тут только smoke + перехват console-ошибок.
await apiCtx.dispose()
})
test('1.5 OnboardingPage (/) не падает на свежей орге', async ({ page }) => {
const sess = await apiSignup('item15')
const errs = watchPage(page)
await attachSession(page, sess, '/')
await page.waitForLoadState('networkidle')
// OnboardingPage есть на /. Проверяем что что-то вообще отрендерилось.
await expect(page.locator('body')).toBeVisible()
// Не должно быть пустого экрана:
const html = await page.locator('body').innerHTML()
expect(html.length).toBeGreaterThan(200)
expectNoErrors(errs, 'onboarding /')
})
}) // describe