test(ui-deep): setup + Item 1 — signup flow (5 specs)
Some checks are pending
Some checks are pending
Sprint UI-deep, пункт 1: реальный Chromium через Playwright Test.
Установлены @playwright/test 1.60.0 и otplib (для item 11).
Конфиг tests/e2e/playwright.config.ts — workers=1, traces+screenshots
on-failure, screenshot dir reports/playwright-artifacts/.
Хелперы tests/e2e/lib/ui.ts:
- apiSignup() — быстрый signup через API + login
- attachSession() — кладёт access_token в localStorage, грузит путь
- watchPage() — listener console-errors и network 4xx/5xx
- expectNoErrors() — assert после flow'a
Item 1 (5 specs, все ✓ на стейдже):
- 1.1 attach session → /dashboard, без console-ошибок
- 1.2 создание товара через UI (Empty CTA → форма → Сохранить)
- 1.3 первый контрагент через Modal
- 1.4 создать товар + контрагент через API, открыть форму приёмки,
smoke на компоненты страницы
- 1.5 OnboardingPage (/) рендерится
Найден 1 реальный баг → починен:
- ProductEditPage: race на currencies.data — если быстро Сохранить,
цена-MoneyInput добавляет строку с currencyId='' → server 400 с
криптичным JSON validation. Фикс: MoneyInput disabled пока
!currencies.data + canSave проверяет row.currencyId.
- Form error display показывал "Request failed with status code 400";
теперь использует общий humanizeError() (exporting из @/lib/api).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
cee92d86ce
commit
eb867697d0
47
docs/sprint-ui-deep-progress.md
Normal file
47
docs/sprint-ui-deep-progress.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# 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'ы.
|
||||
119
tests/e2e/lib/ui.ts
Normal file
119
tests/e2e/lib/ui.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Хелперы для 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
|
||||
|
|
@ -10,12 +10,14 @@
|
|||
"axios": "^1.7.9",
|
||||
"js-yaml": "^4.1.0",
|
||||
"pg": "^8.13.1",
|
||||
"playwright": "^1.59.1"
|
||||
"playwright": "^1.60.0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
39
tests/e2e/playwright.config.ts
Normal file
39
tests/e2e/playwright.config.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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 } } },
|
||||
],
|
||||
})
|
||||
|
|
@ -18,9 +18,12 @@ importers:
|
|||
specifier: ^8.13.1
|
||||
version: 8.20.0
|
||||
playwright:
|
||||
specifier: ^1.59.1
|
||||
version: 1.59.1
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
'@types/js-yaml':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
|
|
@ -30,6 +33,9 @@ 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
|
||||
|
|
@ -195,6 +201,36 @@ 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==}
|
||||
|
||||
|
|
@ -319,6 +355,9 @@ 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==}
|
||||
|
||||
|
|
@ -353,13 +392,13 @@ packages:
|
|||
pgpass@1.0.5:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
playwright-core@1.59.1:
|
||||
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
|
||||
playwright-core@1.60.0:
|
||||
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.59.1:
|
||||
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
|
||||
playwright@1.60.0:
|
||||
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -487,6 +526,41 @@ 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':
|
||||
|
|
@ -636,6 +710,15 @@ 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
|
||||
|
||||
|
|
@ -671,11 +754,11 @@ snapshots:
|
|||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
playwright-core@1.59.1: {}
|
||||
playwright-core@1.60.0: {}
|
||||
|
||||
playwright@1.59.1:
|
||||
playwright@1.60.0:
|
||||
dependencies:
|
||||
playwright-core: 1.59.1
|
||||
playwright-core: 1.60.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
|
|
|
|||
182
tests/e2e/scenarios/stage-ui-1-signup-flow.spec.ts
Normal file
182
tests/e2e/scenarios/stage-ui-1-signup-flow.spec.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* 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
|
||||
Loading…
Reference in a new issue