diff --git a/docs/sprint-ui-deep-progress.md b/docs/sprint-ui-deep-progress.md new file mode 100644 index 0000000..12af8b1 --- /dev/null +++ b/docs/sprint-ui-deep-progress.md @@ -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'ы. diff --git a/tests/e2e/lib/ui.ts b/tests/e2e/lib/ui.ts new file mode 100644 index 0000000..f2176ff --- /dev/null +++ b/tests/e2e/lib/ui.ts @@ -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 { + 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 { + 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 diff --git a/tests/e2e/package.json b/tests/e2e/package.json index ffcbabc..773620a 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -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" } diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..bfafe58 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -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 } } }, + ], +}) diff --git a/tests/e2e/pnpm-lock.yaml b/tests/e2e/pnpm-lock.yaml index 86ed3ea..8c73c4f 100644 --- a/tests/e2e/pnpm-lock.yaml +++ b/tests/e2e/pnpm-lock.yaml @@ -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 diff --git a/tests/e2e/scenarios/stage-ui-1-signup-flow.spec.ts b/tests/e2e/scenarios/stage-ui-1-signup-flow.spec.ts new file mode 100644 index 0000000..a9fa7bc --- /dev/null +++ b/tests/e2e/scenarios/stage-ui-1-signup-flow.spec.ts @@ -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 «Розничная …» — + // ; рядом внутри лежит input MoneyInput. + // У label в Field — есть {label}
...
, и сам 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 = { 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