Compare commits
No commits in common. "eb867697d07201678b9d8859f7561ebb487566ed" and "64af42167b2f40f5f727ccacbe479a53ae980a80" have entirely different histories.
eb867697d0
...
64af42167b
|
|
@ -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'ы.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', ...] } }
|
||||
|
|
|
|||
|
|
@ -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 }] })
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } } },
|
||||
],
|
||||
})
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in a new issue