test(ui-deep): items 2-3 — navigation + Products CRUD
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Item 2 (4 specs): 27 sidebar-страниц последовательно открываются без
console-errors и без 5xx. Sidebar labels + active state проверены.

Item 3 (5 specs): Products full CRUD через UI — create+edit+delete с
ConfirmDialog, дубль артикула с понятным toast'ом, поиск, пагинация при
>50 товаров, загрузка картинки через setInputFiles.

watcher: фильтрует Chromium auto-сообщения «Failed to load resource: the
server responded with a status of N» — дубли network-обработчика.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-30 12:52:10 +05:00
parent 3cdb819331
commit 64cc5b0d10
3 changed files with 376 additions and 0 deletions

View file

@ -87,6 +87,10 @@ export function watchPage(page: Page, opts?: {
page.on('console', (msg: ConsoleMessage) => {
if (msg.type() !== 'error') return
const t = msg.text()
// «Failed to load resource: the server responded with a status of XXX» —
// авто-сообщение Chromium на каждый 4xx/5xx, дублирует network-обработчик.
// Не считаем это самостоятельной ошибкой.
if (/^Failed to load resource: the server responded with a status of \d+/i.test(t)) return
if ((opts?.expectedConsoleContains ?? []).some(s => t.includes(s))) return
acc.console.push(t)
})

View file

@ -0,0 +1,118 @@
/**
* Sprint UI-deep, пункт 2: дашборд + навигация.
* Прокликиваем каждый sidebar-пункт под Owner-Admin'ом, проверяем что
* страница рендерится без console-errors и без 5xx/4xx ответов.
*/
import { test, expect } from '@playwright/test'
import { apiSignup, attachSession, watchPage, expectNoErrors } from '../lib/ui.js'
const NAV_PATHS = [
{ path: '/', heading: /быстрый старт|онбординг|главная|начать/i },
{ path: '/dashboard', heading: /выручка|обзор|аналитика|главная/i },
{ path: '/catalog/products', heading: /товары/i },
{ path: '/catalog/product-groups', heading: /группы/i },
{ path: '/catalog/units', heading: /ед\.|единиц/i },
{ path: '/catalog/price-types', heading: /типы цен/i },
{ path: '/catalog/counterparties', heading: /контрагент/i },
{ path: '/inventory/stock', heading: /остатк/i },
{ path: '/inventory/movements', heading: /движени/i },
{ path: '/inventory/enters', heading: /оприходован/i },
{ path: '/inventory/losses', heading: /списан/i },
{ path: '/inventory/transfers', heading: /перемещен/i },
{ path: '/inventory/inventories', heading: /инвентар/i },
{ path: '/purchases/supplies', heading: /приёмк|приемк/i },
{ path: '/purchases/supplier-returns', heading: /возврат/i },
{ path: '/sales/retail', heading: /рознич|чек/i },
{ path: '/sales/demands', heading: /оптов|отгрузк/i },
{ path: '/reports/sales', heading: /продаж/i },
{ path: '/reports/stock', heading: /остатк/i },
{ path: '/reports/profit', heading: /прибыл/i },
{ path: '/reports/abc', heading: /abc|анализ/i },
{ path: '/audit-log', heading: /журнал/i },
{ path: '/settings/organization', heading: /настройк/i },
{ path: '/catalog/stores', heading: /склад/i },
{ path: '/catalog/retail-points', heading: /касс/i },
{ path: '/settings/employees', heading: /сотрудник/i },
{ path: '/settings/employee-roles', heading: /рол/i },
] as const
test.describe('UI-2 dashboard + navigation', () => {
test.describe.configure({ mode: 'serial' })
test('2.1 каждая страница sidebar открывается без console-errors / 5xx', async ({ page }) => {
test.setTimeout(180_000) // 27 страниц по ~5с = 135с
const sess = await apiSignup('nav')
const errs = watchPage(page, {
// Stock/Reports на свежей орге могут отдавать 404 на отчёт если нет данных,
// но это API уровень и сейчас не должно быть. Если запись «404 /api/...»
// появится — это баг и тест упадёт.
})
await attachSession(page, sess, '/dashboard')
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 })
for (const { path } of NAV_PATHS) {
await page.goto(path, { waitUntil: 'domcontentloaded' })
// ждём первого heading на странице — это значит React-роут отработал
await page.waitForLoadState('networkidle', { timeout: 20_000 })
// <body> непустой
const bodyText = await page.locator('body').innerText()
expect(bodyText.length, `body не пустой на ${path}`).toBeGreaterThan(100)
}
expectNoErrors(errs, 'sidebar walk')
})
test('2.2 sidebar содержит все ожидаемые пункты', async ({ page }) => {
const sess = await apiSignup('nav2')
const errs = watchPage(page)
await attachSession(page, sess, '/dashboard')
const expectedLabels = [
'Главная', 'Аналитика', 'Товары', 'Группы', 'Ед. измерения', 'Типы цен',
'Контрагенты', 'Остатки', 'Движения', 'Оприходования', 'Списания',
'Перемещения', 'Инвентаризации', 'Приёмки', 'Возвраты поставщикам',
'Розничные чеки', 'Оптовые отгрузки', 'Продажи', 'Прибыль', 'ABC-анализ',
'Журнал изменений', 'Общие', 'Склады', 'Кассы', 'Сотрудники', 'Роли',
]
for (const lbl of expectedLabels) {
// в sidebar nav (aside)
await expect(
page.locator('aside').getByRole('link', { name: new RegExp(`^${lbl}$`) }).first(),
`пункт «${lbl}»`,
).toBeVisible({ timeout: 5_000 })
}
expectNoErrors(errs, 'sidebar labels')
})
test('2.3 клик по пункту меняет URL и активный стейт', async ({ page }) => {
const sess = await apiSignup('nav3')
const errs = watchPage(page)
await attachSession(page, sess, '/dashboard')
// Клик на «Товары»
await page.locator('aside').getByRole('link', { name: /^Товары$/ }).first().click()
await page.waitForURL(/\/catalog\/products/, { timeout: 10_000 })
// Активный (текущий) пункт в sidebar — NavLink из react-router-dom добавляет
// active class. Проверим через aria-current="page".
const active = page.locator('aside a[aria-current="page"]')
await expect(active).toHaveText(/Товары/, { timeout: 5_000 })
expectNoErrors(errs, 'nav click')
})
test('2.4 несуществующий tenant-роут → редирект на онбординг / 404 без белого экрана', async ({ page }) => {
const sess = await apiSignup('nav4')
const errs = watchPage(page, {
// 404 ожидаем — это и есть проверка
expected4xxContains: ['/api/'],
})
await attachSession(page, sess, '/garbage-path-' + Date.now())
await page.waitForLoadState('networkidle', { timeout: 10_000 })
// body не должен быть пустым
const bodyText = await page.locator('body').innerText()
expect(bodyText.length).toBeGreaterThan(100)
// sidebar всё ещё виден (приложение не упало)
await expect(page.locator('aside').first()).toBeVisible()
})
})

View file

@ -0,0 +1,254 @@
/**
* Sprint UI-deep, пункт 3: Products full CRUD через UI.
* - Создание с разными типами цен и штрихкодом
* - Загрузка изображения через page.setInputFiles()
* - Редактирование
* - Дубль артикула ошибка с понятным сообщением
* - Удаление через ConfirmDialog подтвердить запись пропала
* - Поиск по списку
* - Пагинация (создаём >50 товаров и проверяем)
*/
import { test, expect, request as apiRequest } from '@playwright/test'
import { apiSignup, attachSession, watchPage, expectNoErrors } from '../lib/ui.js'
import { promises as fs } from 'node:fs'
import path from 'node:path'
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
test.describe('UI-3 products full CRUD', () => {
test.describe.configure({ mode: 'serial' })
test('3.1 create → edit → delete через UI с confirm-диалогом', async ({ page }) => {
test.setTimeout(120_000)
const sess = await apiSignup('crud31')
const errs = watchPage(page)
await attachSession(page, sess, '/catalog/products')
// CREATE
await page.getByRole('button', { name: /создать первый товар/i }).click()
await page.waitForURL(/\/catalog\/products\/new/, { timeout: 10_000 })
const name = `CRUD ${Date.now()}`
await page.getByLabel('Название *').fill(name)
await page.getByLabel(/Розничная/).first().fill('1500')
// Дожидаемся currencies (MoneyInput должен стать enabled)
await expect(page.getByLabel(/Розничная/).first()).toBeEnabled()
const saveBtn = page.getByRole('button', { name: /^сохранить$/i }).last()
await expect(saveBtn).toBeEnabled({ timeout: 8_000 })
await saveBtn.click()
// После save переходим на /catalog/products/<id>
await page.waitForURL(/\/catalog\/products\/[0-9a-f-]{36}$/, { timeout: 10_000 })
// EDIT — меняем цену
await page.getByLabel(/Розничная/).first().fill('2000')
await saveBtn.click()
// PUT → navigate('/catalog/products') (на список). Toast «Сохранено» может
// мелькнуть/исчезнуть до waitForURL, поэтому не настаиваем на нём.
await page.waitForURL(/\/catalog\/products$/, { timeout: 10_000 })
// На списке наш товар с обновлённой ценой «2 000»
await expect(page.locator('tbody').getByText(/2[\s ]?000/).first())
.toBeVisible({ timeout: 5_000 })
// Возвращаемся в карточку для DELETE
await page.locator('tbody').getByText(name).click()
await page.waitForURL(/\/catalog\/products\/[0-9a-f-]{36}$/, { timeout: 10_000 })
// DELETE — кнопка Удалить → ConfirmDialog → подтвердить
await page.getByRole('button', { name: /удалить/i }).click()
const dialog = page.locator('[aria-labelledby="confirm-dialog-title"]').first()
await expect(dialog).toBeVisible({ timeout: 5_000 })
await expect(dialog).toContainText(/удалить товар/i)
await dialog.getByRole('button', { name: /удалить/i }).click()
// Редирект на список
await page.waitForURL(/\/catalog\/products$/, { timeout: 10_000 })
// Запись исчезла
await expect(page.locator('tbody').getByText(name)).not.toBeVisible({ timeout: 5_000 })
expectNoErrors(errs, 'crud product')
})
test('3.2 дубль артикула → 409 с понятной ошибкой через toast', async ({ page }) => {
const sess = await apiSignup('crud32')
const errs = watchPage(page, {
// ожидаем 409 на дубликат — это и есть проверка
expected4xxContains: ['/api/catalog/products'],
})
// Создаём первый товар через API
const ctx = await apiRequest.newContext({
baseURL: BASE, ignoreHTTPSErrors: true,
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
})
type Paged<T> = { items: T[] }
const units = await (await ctx.get('/api/catalog/units-of-measure?pageSize=200')).json() as Paged<{ id: string; code: string }>
const groups = await (await ctx.get('/api/catalog/product-groups')).json() as Paged<{ id: string }>
const pts = await (await ctx.get('/api/catalog/price-types')).json() as Paged<{ id: string; isRetail: boolean }>
const curs = await (await ctx.get('/api/catalog/currencies')).json() as Paged<{ id: string; code: string }>
const unit = units.items.find(u => u.code === '796')!
const grp = groups.items[0]
const retail = pts.items.find(p => p.isRetail)!
const kzt = curs.items.find(c => c.code === 'KZT')!
const article = `DUP-${Date.now()}`
const first = await ctx.post('/api/catalog/products', {
data: {
name: 'Первый', article,
unitOfMeasureId: unit.id, vat: 12, vatEnabled: true,
productGroupId: grp.id, packaging: 1,
prices: [{ priceTypeId: retail.id, amount: 100, currencyId: kzt.id }],
barcodes: [{ code: '4000000000016', type: 1, isPrimary: true }],
},
})
expect([200, 201]).toContain(first.status())
await ctx.dispose()
// Через UI пытаемся создать второй с тем же артикулом
await attachSession(page, sess, '/catalog/products/new')
await page.getByLabel('Название *').fill('Дубль')
// Перебиваем сгенерированный артикул на дубликат
await page.getByLabel('Артикул *').fill(article)
await page.getByLabel(/Розничная/).first().fill('200')
await expect(page.getByLabel(/Розничная/).first()).toBeEnabled()
const saveBtn = page.getByRole('button', { name: /^сохранить$/i }).last()
await expect(saveBtn).toBeEnabled({ timeout: 8_000 })
await saveBtn.click()
// Должен появиться toast с человеко-читаемым сообщением (не «Request failed»)
const alertText = await page.getByRole('alert').first().textContent({ timeout: 8_000 })
expect(alertText, 'toast не должен содержать generic axios message')
.not.toMatch(/Request failed with status code/i)
// должен содержать ключевые слова про артикул/уникальность
expect(alertText?.toLowerCase() ?? '').toMatch(/артикул|уже существует|already|уник/i)
expectNoErrors(errs, 'dup article')
})
test('3.3 поиск по списку', async ({ page }) => {
const sess = await apiSignup('crud33')
const errs = watchPage(page)
// Seed-demo для наполнения
const ctx = await apiRequest.newContext({
baseURL: BASE, ignoreHTTPSErrors: true,
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
})
await ctx.post('/api/admin/seed-demo', { data: {} })
await ctx.dispose()
await attachSession(page, sess, '/catalog/products')
// Жде первую строку
await page.locator('tbody tr').first().waitFor({ timeout: 10_000 })
// Поиск «Молоко»
await page.getByPlaceholder(/поиск/i).fill('Молоко')
// Дебаунс ~300ms + сетевой запрос
await page.waitForLoadState('networkidle')
// В таблице должны остаться только Молоко-records
const rows = await page.locator('tbody tr').count()
expect(rows).toBeGreaterThan(0)
const allCellsText = await page.locator('tbody').innerText()
expect(allCellsText.toLowerCase()).toContain('молок')
// Без других категорий
expect(allCellsText.toLowerCase()).not.toContain('хлеб ржан')
expectNoErrors(errs, 'search')
})
test('3.4 пагинация при >50 товаров', async ({ page }) => {
test.setTimeout(120_000)
const sess = await apiSignup('crud34')
const errs = watchPage(page)
// Seed-demo даёт 50; добавим ещё немного через API
const ctx = await apiRequest.newContext({
baseURL: BASE, ignoreHTTPSErrors: true,
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
})
await ctx.post('/api/admin/seed-demo', { data: {} })
type Paged<T> = { items: T[] }
const units = await (await ctx.get('/api/catalog/units-of-measure?pageSize=200')).json() as Paged<{ id: string; code: string }>
const groups = await (await ctx.get('/api/catalog/product-groups?pageSize=200')).json() as Paged<{ id: string }>
const pts = await (await ctx.get('/api/catalog/price-types?pageSize=50')).json() as Paged<{ id: string; isRetail: boolean }>
const curs = await (await ctx.get('/api/catalog/currencies')).json() as Paged<{ id: string; code: string }>
const unit = units.items.find(u => u.code === '796')!
const grp = groups.items[0]
const retail = pts.items.find(p => p.isRetail)!
const kzt = curs.items.find(c => c.code === 'KZT')!
// Дозабиваем до 55 (нужно >50 чтобы пагинация была)
for (let i = 0; i < 5; i++) {
const r = await ctx.post('/api/catalog/products', {
data: {
name: `Extra${i}`, article: `EXTRA-${Date.now()}-${i}`,
unitOfMeasureId: unit.id, vat: 12, vatEnabled: true,
productGroupId: grp.id, packaging: 1,
prices: [{ priceTypeId: retail.id, amount: 100 + i, currencyId: kzt.id }],
barcodes: [{ code: `40000000${(50000 + i).toString().padStart(5, '0')}`.slice(0, 13), type: 1, isPrimary: true }],
},
})
expect([200, 201]).toContain(r.status())
}
await ctx.dispose()
await attachSession(page, sess, '/catalog/products')
await page.locator('tbody tr').first().waitFor({ timeout: 10_000 })
// Pagination footer показывает «Стр X из Y» / номера страниц
// 55 / 50 = 2 страницы
await expect(page.locator('text=/2|следую|next/i').first()).toBeVisible({ timeout: 5_000 })
expectNoErrors(errs, 'pagination')
})
test('3.5 загрузка изображения в карточку товара', async ({ page }) => {
test.setTimeout(90_000)
const sess = await apiSignup('crud35')
const errs = watchPage(page)
// Сначала создаём товар через API чтобы открыть его edit
const ctx = await apiRequest.newContext({
baseURL: BASE, ignoreHTTPSErrors: true,
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
})
type Paged<T> = { items: T[] }
const units = await (await ctx.get('/api/catalog/units-of-measure?pageSize=200')).json() as Paged<{ id: string; code: string }>
const groups = await (await ctx.get('/api/catalog/product-groups')).json() as Paged<{ id: string }>
const pts = await (await ctx.get('/api/catalog/price-types')).json() as Paged<{ id: string; isRetail: boolean }>
const curs = await (await ctx.get('/api/catalog/currencies')).json() as Paged<{ id: string; code: string }>
const r = await ctx.post('/api/catalog/products', {
data: {
name: 'PhotoTest', article: `PHOTO-${Date.now()}`,
unitOfMeasureId: units.items.find(u => u.code === '796')!.id, vat: 12, vatEnabled: true,
productGroupId: groups.items[0].id, packaging: 1,
prices: [{ priceTypeId: pts.items.find(p => p.isRetail)!.id, amount: 100, currencyId: curs.items.find(c => c.code === 'KZT')!.id }],
barcodes: [{ code: '5000000000010', type: 1, isPrimary: true }],
},
})
expect([200, 201]).toContain(r.status())
const created = await r.json() as { id: string }
await ctx.dispose()
// Генерируем 1x1 PNG (минимальный валидный)
const pngPath = path.join('/tmp', `test-img-${Date.now()}.png`)
const oneByOnePng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=',
'base64',
)
await fs.writeFile(pngPath, oneByOnePng)
await attachSession(page, sess, `/catalog/products/${created.id}`)
await page.waitForLoadState('networkidle')
// Скролл до секции «Изображения»
await page.getByText('Изображения').first().scrollIntoViewIfNeeded()
const fileInput = page.locator('input[type="file"]').first()
await expect(fileInput).toBeAttached({ timeout: 5_000 })
// Слушаем upload response чтобы понять, что произошло
const uploadResp = page.waitForResponse(
(r) => r.url().includes(`/api/catalog/products/${created.id}/images`) && r.request().method() === 'POST',
{ timeout: 15_000 },
)
await fileInput.setInputFiles(pngPath)
const resp = await uploadResp
const status = resp.status()
// 200/201 — success; 415 mime — баг; 413 — слишком большой; 400 — что-то ещё
expect(status, `image upload response (${await resp.text().catch(() => '')})`).toBeLessThan(400)
expectNoErrors(errs, 'upload image')
await fs.unlink(pngPath).catch(() => {})
})
})