food-market/tests/scripts/menu-orphans-audit.mjs
nns b82adc414c feat(menu): новые экраны s31 в sidebar+Cmd+K+Cmd+J + split-кнопки, orphan-audit
Три новых роута из 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>
2026-10-04 22:50:14 +05:00

164 lines
7.4 KiB
JavaScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()