/** * Статический экстрактор интерактивных элементов web-страниц → denominator для * измеримого UAT-покрытия. Читает web/src/App.tsx (route → компонент → файл) и * каждый pages/*.tsx, достаёт: поиск, фильтры (Select/даты/тумблеры), сортируемые * колонки, пагинацию, экспорт, кнопки действий. Пишет docs/uat-coverage.expected.json. * * Запуск: pnpm exec tsx tools/extract-controls.ts (из tests/e2e) */ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const HERE = dirname(fileURLToPath(import.meta.url)) const WEB = resolve(HERE, '../../../src/food-market.web/src') const APP = resolve(WEB, 'App.tsx') const PAGES = resolve(WEB, 'pages') const OUT = resolve(HERE, '../../../docs/uat-coverage.expected.json') // Рекурсивно: имя компонента (без .tsx) → путь файла (pages и подпапки, напр. money/). function buildFileMap(dir: string, map: Record = {}): Record { for (const name of readdirSync(dir)) { const p = join(dir, name) if (statSync(p).isDirectory()) buildFileMap(p, map) else if (name.endsWith('.tsx')) map[name.replace(/\.tsx$/, '')] = p } return map } const FILES = buildFileMap(PAGES) type El = { key: string; label: string; type: string } type Screen = { route: string; page: string; file: string; priority: string; title: string; listUrl: string; elements: El[] } // Приоритеты по категоризации владельца (UAT P1–P4). const PRIORITY: Record = { '/catalog/products': 'P1', '/inventory/stock': 'P1', '/sales/retail': 'P1', '/purchases/supplies': 'P2', '/money/shifts': 'P2', '/inventory/enters': 'P3', '/inventory/losses': 'P3', '/inventory/inventories': 'P3', '/inventory/movements': 'P3', '/purchases/supplier-returns': 'P3', '/purchases/invoices': 'P3', '/money/transactions': 'P3', '/money/supplier-debts': 'P3', '/money/report': 'P3', '/reports/sales': 'P3', '/reports/stock': 'P3', '/reports/profit': 'P3', '/catalog/counterparties': 'P3', '/catalog/data-quality': 'P3', '/settings/employees': 'P4', '/settings/employee-roles': 'P4', '/catalog/stores': 'P4', '/catalog/retail-points': 'P4', '/money/accounts': 'P4', '/settings/organization': 'P4', '/money/expense-items': 'P4', } // Карта: route (список) → компонент. Парсим App.tsx. element={...} может содержать // вложенные {} (RoleGuard roles={[…]}, AdvancedModeGuard) — балансируем скобки. function routeComponentMap(): Record { const src = readFileSync(APP, 'utf8') const map: Record = {} const re = / 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++ } const element = src.slice(re.lastIndex, i - 1) // Только app-списки: абсолютный путь, без параметров/new, не super-admin/онбординг. if (!path.startsWith('/') || path.includes(':') || path.endsWith('/new') || path === '*') continue if (path.startsWith('/super-admin') || path === '/' || path.startsWith('/onboarding') || path === '/help' || path === '/whats-new') continue if (['/login', '/forgot-password', '/reset-password', '/auth-bridge', '/no-organization'].includes(path)) continue const comp = [...element.matchAll(/([A-Z][A-Za-z0-9]+Page)\b/g)].map((x) => x[1]) .find((c) => !/Guard|Layout|Protected/.test(c)) if (comp) map[path] = comp } return map } function slug(s: string): string { return s.toLowerCase().replace(/[^a-zа-я0-9]+/gi, '-').replace(/^-+|-+$/g, '').slice(0, 24) } function extract(route: string, comp: string): Screen | null { const file = FILES[comp] if (!file || !existsSync(file)) return null const src = readFileSync(file, 'utf8') const els: El[] = [] const add = (key: string, label: string, type: string) => { if (!els.some((e) => e.key === key)) els.push({ key, label, type }) } if (/]*?url=["']([^"']+)["']/g)) add('export', `экспорт (${m[1]})`, 'export') if (!els.some((e) => e.key === 'export') && //