/** * run-smoke — browser-and-nav.test.ts * * Covers the two runtime guards added for the "smoke was blind" gap: * - findNavMenuMismatches: the BUG A signature (a section whose route app-prefix * disagrees with its componentKey app-prefix — the cross-app nav merge). * - renderSmokeMarkdown: the on-disk report renders every failing axis (its * presence is what makes "did the gate run" verifiable). */ import { describe, it, expect } from 'vitest'; import { findNavMenuMismatches, renderSmokeMarkdown } from '../execute.js'; import type { SmokeReport } from '../types.js'; describe('run-smoke / findNavMenuMismatches (BUG A runtime guard)', () => { it('flags a section whose route app-prefix disagrees with its componentKey app', () => { // The exact reported shape: an RH section merged under the CLIENTS module. const menu = { applications: [ { route: '/clients', componentKey: 'clients', modules: [ { route: '/clients/configuration', componentKey: 'clients.configuration', sections: [ { route: '/rh/configuration/types-absence', componentKey: 'clients.configuration.types-absence' }, { route: '/clients/configuration/parametres', componentKey: 'clients.configuration.parametres' }, ], }, ], }, ], }; const mismatches = findNavMenuMismatches(menu); expect(mismatches).toHaveLength(1); expect(mismatches[0]).toContain('types-absence'); expect(mismatches[0]).toContain('app "rh"'); expect(mismatches[0]).toContain('app "clients"'); }); it('returns nothing for a well-scoped menu (each route app matches its key app)', () => { const menu = { applications: [ { route: '/rh', componentKey: 'rh', modules: [ { route: '/rh/configuration', componentKey: 'rh.configuration', sections: [{ route: '/rh/configuration/types-absence', componentKey: 'rh.configuration.types-absence' }], }, ], }, { route: '/clients', componentKey: 'clients', modules: [ { route: '/clients/configuration', componentKey: 'clients.configuration', sections: [{ route: '/clients/configuration/parametres', componentKey: 'clients.configuration.parametres' }], }, ], }, ], }; expect(findNavMenuMismatches(menu)).toHaveLength(0); }); it('tolerates the alternate `key` field name and odd shapes without throwing', () => { expect(findNavMenuMismatches({ items: [{ route: '/a/b/c', key: 'x.b.c' }] })).toHaveLength(1); expect(findNavMenuMismatches(null)).toHaveLength(0); expect(findNavMenuMismatches('not-an-object')).toHaveLength(0); }); }); describe('run-smoke / renderSmokeMarkdown', () => { function failReport(): SmokeReport { return { passed: false, backendBootMs: 1000, frontendBootMs: 2000, totalProbes: 2, passedProbes: 1, failedProbes: 1, skippedProbes: 0, probes: [], failures: [ { source: 'apiClient', url: '/api/x', verb: 'GET', status: 500, durationMs: 1, failed: true, failureReason: 'status-5xx' }, ], browser: { ran: true, available: true, pages: [], failures: [ { url: '/', ok: false, accessState: 'error', consoleErrors: ['pageerror: Absolute route path "/rh/configuration/types-absence" nested under "/clients" is not valid'], failedRequests: [{ url: '/src/index.css', status: 500 }], viteOverlay: true, }, ], }, navMenu: { ran: true, ok: false, mismatches: ['componentKey "clients.configuration.types-absence" (app "clients") carries route "/rh/configuration/types-absence" (app "rh") — cross-app nav merge (BUG A).'], }, interaction: { ran: false, authenticated: false, actions: [], failures: [] }, }; } it('renders every failing axis (HTTP, browser overlay, nav mismatch)', () => { const md = renderSmokeMarkdown(failReport(), 'configuration'); expect(md).toContain('# run-smoke — configuration'); expect(md).toContain('Result: **FAIL**'); expect(md).toContain('## Failed HTTP probes'); expect(md).toContain('## Failed browser pages'); expect(md).toContain('vite-error-overlay'); expect(md).toContain('/src/index.css'); expect(md).toContain('## Nav-menu app mismatches (BUG A)'); }); it('renders a browser-unavailable section as a blocker (never a silent pass)', () => { const r = failReport(); r.browser = { ran: true, available: false, reason: 'Playwright is not installed', pages: [], failures: [] }; const md = renderSmokeMarkdown(r, 'configuration'); expect(md).toContain('Browser unavailable (smoke.browser-unavailable)'); expect(md).toContain('Playwright is not installed'); }); it('renders a failed action-contract probe (axis 4b)', () => { const r = failReport(); r.interaction = { ran: true, authenticated: true, actions: [ { entity: 'Opportunity', module: 'crm', section: 'opportunites', code: 'archive', scope: 'row', httpMethod: 'POST', url: '/api/crm/opportunites/00000000-0000-0000-0000-000000000000/archive', body: {}, hasPayload: false, status: 415, verdict: 'fail', reason: 'smoke.action-body', durationMs: 3 }, ], failures: [ { entity: 'Opportunity', module: 'crm', section: 'opportunites', code: 'archive', scope: 'row', httpMethod: 'POST', url: '/api/crm/opportunites/00000000-0000-0000-0000-000000000000/archive', body: {}, hasPayload: false, status: 415, verdict: 'fail', reason: 'smoke.action-body', durationMs: 3 }, ], }; const md = renderSmokeMarkdown(r, 'configuration'); expect(md).toContain('## Failed action-contract probes (axis 4b)'); expect(md).toContain('/api/crm/opportunites'); expect(md).toContain('Opportunity.archive'); expect(md).toContain('smoke.action-body'); }); it('renders the not-authenticated note (smoke.interaction-unavailable)', () => { const r = failReport(); r.interaction = { ran: true, authenticated: false, reason: 'no admin token — …', actions: [], failures: [] }; const md = renderSmokeMarkdown(r, 'configuration'); expect(md).toContain('smoke.interaction-unavailable'); }); }); describe('run-smoke / findUnregisteredMenuKeys (registry↔menu coverage — the SCR-020 runtime net)', () => { const registered = new Set(['flotte.parametrage.alertes', 'flotte.parametrage.referentiels.cantons']) it('flags a clickable menu node whose componentKey has no registration', async () => { const { findUnregisteredMenuKeys } = await import('../execute.js') const menu = [{ key: 'flotte.parametrage', children: [ { componentKey: 'flotte.parametrage.alertes', route: '/flotte/parametrage/alertes' }, { componentKey: 'flotte.parametrage.referentiels', route: '/flotte/parametrage/referentiels' }, ], }] const missing = findUnregisteredMenuKeys(menu, registered) expect(missing).toHaveLength(1) expect(missing[0]).toContain('flotte.parametrage.referentiels') expect(missing[0]).toContain('SCR-020') }) it('a fully-registered menu passes; nodes without a route (pure groups) never count', async () => { const { findUnregisteredMenuKeys } = await import('../execute.js') const menu = [{ key: 'flotte.parametrage', children: [ { componentKey: 'flotte.parametrage.alertes', route: '/flotte/parametrage/alertes' }, { componentKey: 'flotte.parametrage.referentiels.cantons', route: '/flotte/parametrage/referentiels/cantons' }, ], }] expect(findUnregisteredMenuKeys(menu, registered)).toEqual([]) }) })