Три новых роута из Sprint 31 были достижимы только по прямому URL — в sidebar/командной палитре их не было. Добавлено: - AppLayout: «Накладные (по фото)» (/purchases/invoices) в разделе «Закупки», «Инвентаризация с телефона» (/inventory/mobile) в «Остатках» — для Admin и Storekeeper, видно в простом режиме; i18n ru + en. - SplitButtonMenu — переиспользуемая dropdown-кнопка «Новая…». SuppliesPage: вручную / по фото / сканированием. InventoriesPage: вручную / с телефона. - CommandPalette (Cmd+K): новые static-pages invoices, supply-scan, inventories (был пропущен), inventory-mobile. - QuickActionsPalette (Cmd+J): invoice-new, supply-scan, inventory-mobile. - Параллельно всплыл сирота /admin/diagnostic — добавили ссылку в sidebar под Admin'ом. Playwright stage-ui-menu-links.spec.ts: demo-keeper + demo-admin, desktop 1280×800 + mobile 375×667 — кликами с главной до всех трёх новых экранов, скриншоты в reports/. Негатив для demo-cashier проверяет, что ему эти пункты не видны. Orphan-audit: tests/scripts/menu-orphans-audit.mjs парсит App.tsx и диффит против фактических точек входа (sidebar/Cmd+K/Cmd+J/split- кнопок). После всех правок — 0 сирот (отчёт docs/menu-orphans-report.md). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
164 lines
7.4 KiB
JavaScript
Executable file
164 lines
7.4 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
||
/**
|
||
* Аудит «сиротских» tenant-маршрутов: читает App.tsx, достаёт все
|
||
* <Route path="/..."> внутри tenant-блока, сравнивает со всеми строками,
|
||
* до которых реально можно добраться кликами из UI — через sidebar
|
||
* (AppLayout.tsx), командную палитру (CommandPalette.tsx), палитру
|
||
* быстрых действий (QuickActionsPalette.tsx), SplitButtonMenu-экшены
|
||
* на list-страницах (SuppliesPage.tsx, InventoriesPage.tsx).
|
||
*
|
||
* Сирота = tenant-маршрут, который объявлен в App.tsx, но не упомянут
|
||
* ни в одном из входов и не относится к служебным (login/auth-bridge),
|
||
* detail-страницам с :id (на них ведут клики по строкам таблицы),
|
||
* страницам создания /new (на них ведут кнопки «Создать…»).
|
||
*
|
||
* Запуск: node tests/scripts/menu-orphans-audit.mjs
|
||
* Опции: --json — машинный вывод для CI
|
||
* --out=<file.md> — вместо stdout пишет markdown-отчёт
|
||
*/
|
||
import { readFileSync, writeFileSync } from 'node:fs'
|
||
import { resolve, dirname } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
const __filename = fileURLToPath(import.meta.url)
|
||
const ROOT = resolve(dirname(__filename), '..', '..')
|
||
const WEB_SRC = resolve(ROOT, 'src/food-market.web/src')
|
||
|
||
const files = {
|
||
app: resolve(WEB_SRC, 'App.tsx'),
|
||
layout: resolve(WEB_SRC, 'components/AppLayout.tsx'),
|
||
cmdK: resolve(WEB_SRC, 'components/CommandPalette.tsx'),
|
||
cmdJ: resolve(WEB_SRC, 'components/QuickActionsPalette.tsx'),
|
||
supplies: resolve(WEB_SRC, 'pages/SuppliesPage.tsx'),
|
||
inventories: resolve(WEB_SRC, 'pages/InventoriesPage.tsx'),
|
||
}
|
||
|
||
/** Все `path="…"` внутри tenant-ветки (под TenantRouteGuard). */
|
||
function extractTenantRoutes(appSrc) {
|
||
const i = appSrc.indexOf('<TenantRouteGuard>')
|
||
const j = appSrc.indexOf('</Route>', appSrc.indexOf('<Route element={<TenantRouteGuard', i))
|
||
// Проще — ограничиться концом файла; TenantRouteGuard — последний
|
||
// крупный блок перед закрывающим */}. Берём от TenantRouteGuard до
|
||
// конца файла и парсим path'ы — любой "/..." до пробела/>.
|
||
const slice = appSrc.slice(i)
|
||
const paths = new Set()
|
||
const re = /path="([^"]+)"/g
|
||
let m
|
||
while ((m = re.exec(slice))) {
|
||
const p = m[1]
|
||
if (p.startsWith('/')) paths.add(p)
|
||
}
|
||
return [...paths].sort()
|
||
}
|
||
|
||
/** Пути-строки из любого исходника. Regex-ы допускают пустую «часть
|
||
* после слэша», чтобы ловить и голый `/` (корневой онбординг). */
|
||
function extractReachablePaths(src) {
|
||
const out = new Set()
|
||
// navigate('/foo') / navigate(`/foo`)
|
||
for (const m of src.matchAll(/navigate\(\s*['"`](\/[^'"`\n]*)['"`]/g)) out.add(m[1])
|
||
// to="/foo"
|
||
for (const m of src.matchAll(/\bto="(\/[^"]*)"/g)) out.add(m[1])
|
||
for (const m of src.matchAll(/\bto=\{\s*['"`](\/[^'"`]*)['"`]\s*\}/g)) out.add(m[1])
|
||
// href="/foo"
|
||
for (const m of src.matchAll(/\bhref="(\/[^"]*)"/g)) out.add(m[1])
|
||
// Поля объектов (SplitButtonMenu / STATIC_PAGES / NavItem).
|
||
for (const m of src.matchAll(/\bto:\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1])
|
||
for (const m of src.matchAll(/\bpath:\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1])
|
||
// run: (n) => n('/foo')
|
||
for (const m of src.matchAll(/\bn\(\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1])
|
||
return out
|
||
}
|
||
|
||
/** Служебные / «вспомогательные» маршруты, которые заведомо не должны
|
||
* быть пунктом меню. */
|
||
function isReservedRoute(p) {
|
||
// Детали редактирования — туда ведут клики по строкам таблиц.
|
||
if (/\/:[^/]+(\?)?$/.test(p)) return true
|
||
// «Создать новое …» — туда ведут кнопки «Новый …», это ок.
|
||
if (p.endsWith('/new')) return true
|
||
// Явный служебный набор.
|
||
if (['/login', '/forgot-password', '/reset-password', '/auth-bridge',
|
||
'/no-organization', '/onboarding-wizard'].includes(p)) return true
|
||
// SuperAdmin консоль — отдельный layout, у неё свой sidebar.
|
||
if (p.startsWith('/super-admin')) return true
|
||
return false
|
||
}
|
||
|
||
function main() {
|
||
const appSrc = readFileSync(files.app, 'utf8')
|
||
const tenantRoutes = extractTenantRoutes(appSrc)
|
||
|
||
const reachable = new Set()
|
||
for (const key of ['layout', 'cmdK', 'cmdJ', 'supplies', 'inventories']) {
|
||
const src = readFileSync(files[key], 'utf8')
|
||
for (const p of extractReachablePaths(src)) reachable.add(p)
|
||
}
|
||
|
||
const orphans = []
|
||
const covered = []
|
||
const reserved = []
|
||
|
||
for (const p of tenantRoutes) {
|
||
if (isReservedRoute(p)) { reserved.push(p); continue }
|
||
if (reachable.has(p)) { covered.push(p); continue }
|
||
// Также засчитываем, если это list-страница, которая достижима через
|
||
// /... → /.../new (например, когда есть «Создать» на пустой странице,
|
||
// но нет точки входа на сам список) — это ВСЁ РАВНО сирота: пустую
|
||
// кнопку создать можно, но вернуться к списку нельзя.
|
||
orphans.push(p)
|
||
}
|
||
|
||
const argv = process.argv.slice(2)
|
||
const asJson = argv.includes('--json')
|
||
const outArg = argv.find((a) => a.startsWith('--out='))
|
||
const outFile = outArg ? outArg.slice('--out='.length) : null
|
||
|
||
if (asJson) {
|
||
const payload = { tenantRoutes, reachable: [...reachable].sort(), orphans, covered, reserved }
|
||
process.stdout.write(JSON.stringify(payload, null, 2) + '\n')
|
||
process.exit(orphans.length > 0 ? 2 : 0)
|
||
}
|
||
|
||
const lines = []
|
||
lines.push('# Menu orphans audit')
|
||
lines.push('')
|
||
lines.push(`Generated: ${new Date().toISOString()}`)
|
||
lines.push('')
|
||
lines.push(`- tenant-маршрутов всего: **${tenantRoutes.length}**`)
|
||
lines.push(`- служебных (login / :id / /new / super-admin): **${reserved.length}**`)
|
||
lines.push(`- достижимо кликами: **${covered.length}**`)
|
||
lines.push(`- сирот (не достижимо из UI): **${orphans.length}**`)
|
||
lines.push('')
|
||
if (orphans.length) {
|
||
lines.push('## Сироты')
|
||
lines.push('')
|
||
for (const p of orphans) lines.push(`- \`${p}\``)
|
||
lines.push('')
|
||
} else {
|
||
lines.push('## Сироты')
|
||
lines.push('')
|
||
lines.push('_Нет._ Все не-служебные tenant-маршруты достижимы через sidebar / Cmd+K / Cmd+J / кнопки на list-страницах.')
|
||
lines.push('')
|
||
}
|
||
lines.push('## Достижимо')
|
||
lines.push('')
|
||
for (const p of covered) lines.push(`- \`${p}\``)
|
||
lines.push('')
|
||
lines.push('## Служебное (не требует точки входа)')
|
||
lines.push('')
|
||
for (const p of reserved) lines.push(`- \`${p}\``)
|
||
lines.push('')
|
||
|
||
const out = lines.join('\n')
|
||
if (outFile) {
|
||
writeFileSync(outFile, out, 'utf8')
|
||
process.stderr.write(`[orphans] wrote ${outFile} — ${orphans.length} orphan(s)\n`)
|
||
} else {
|
||
process.stdout.write(out)
|
||
}
|
||
process.exit(orphans.length > 0 ? 2 : 0)
|
||
}
|
||
|
||
main()
|