/** * uat-report/render-html.ts — PURE: ReportModel → one self-contained HTML string. * * No CDN, no external assets: inline CSS (system fonts, design tokens with a * dark-scheme override + print rules), inline SVG charts (render-svg.ts) and a * tiny inline script (role/status/search filters + column sort with aria-sort + * matrix expand/collapse). Failure screenshots arrive as data URIs through * `model.screenshots` (collected by the caller — this module does NO I/O); * everything else links relatively. Every dynamic string is HTML-escaped — * including response-body excerpts, which are attacker-controlled. */ import { classifyDuration, humanBytes, type ApiRunResult, type UiRunResult } from '../lib/run-results.js'; import type { FailureGroup, MatrixCell, RbacMatrix, ReportModel } from './build-model.js'; import { barsSvg, donutSvg, type BandBar, type DonutSegment } from './render-svg.js'; export function escapeHtml(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } const e = escapeHtml; const STATUS_BADGE: Record = { pass: 'badge badge-pass', fail: 'badge badge-fail', skip: 'badge badge-skip', indeterminate: 'badge badge-ind', }; function apiRowStatus(r: ApiRunResult): { key: string; label: string } { if (!r.executed) return { key: 'skip', label: `skipped (${r.reason ?? '—'})` }; return r.ok ? { key: 'pass', label: 'pass' } : { key: 'fail', label: 'FAIL' }; } function uiRowStatus(r: UiRunResult): { key: string; label: string } { if (!r.executed) return { key: 'skip', label: `skipped (${r.reason ?? '—'})` }; if (r.actual === 'indeterminate') return { key: 'indeterminate', label: 'indeterminate' }; return r.ok ? { key: 'pass', label: 'pass' } : { key: 'fail', label: 'FAIL' }; } function durationCell(ms: number, warnMs: number, slowMs: number): string { const band = classifyDuration(ms, warnMs, slowMs); return `${ms} ms`; } /** A "no metric here" cell (write-flow rows are not page observations). */ const dashCell = '—'; /** Human run duration: "850 ms", "42s", "4m 12s", "1h 03m". */ export function formatDurationMs(ms: number): string { if (ms < 1000) return `${ms} ms`; const totalSeconds = Math.round(ms / 1000); if (totalSeconds < 60) return `${totalSeconds}s`; const totalMinutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (totalMinutes < 60) return `${totalMinutes}m ${String(seconds).padStart(2, '0')}s`; const hours = Math.floor(totalMinutes / 60); return `${hours}h ${String(totalMinutes % 60).padStart(2, '0')}m`; } function kpiCard(label: string, value: string, sub = ''): string { return `
${value}
${label}
${ sub ? `
${sub}
` : '' }
`; } // ── Executive summary ─────────────────────────────────────────────────────── function coverageCard(axis: string, cov: { executed: number; skipped: number; skipReasons: { reason: string; count: number }[] }): string { const chips = cov.skipReasons .map((s) => `${e(s.reason)} × ${s.count}`) .join(' '); return `
${e(axis)} coverage ${cov.executed} executed · ${cov.skipped} skipped${ chips ? `
${chips}
` : '' }
`; } function renderExecSummary(model: ReportModel): string { const api = model.api; const ui = model.ui; const charts: string[] = []; if (api) { const segments: DonutSegment[] = [ { label: 'passed', count: api.passed, cssClass: 'seg-pass' }, { label: 'failed', count: api.failed, cssClass: 'seg-fail' }, { label: 'skipped', count: api.skipped, cssClass: 'seg-skip' }, ]; const rate = api.executed > 0 ? `${Math.round((api.passed / api.executed) * 100)}%` : '—'; charts.push(`
API checks
${donutSvg(segments, rate, 'API checks')}
`); } if (ui) { const determinate = ui.executed - ui.indeterminate; const segments: DonutSegment[] = [ { label: 'passed', count: ui.passed, cssClass: 'seg-pass' }, { label: 'failed', count: ui.failed, cssClass: 'seg-fail' }, { label: 'indeterminate', count: ui.indeterminate, cssClass: 'seg-ind' }, { label: 'skipped', count: ui.skipped, cssClass: 'seg-skip' }, ]; const rate = determinate > 0 ? `${Math.round((ui.passed / determinate) * 100)}%` : '—'; charts.push(`
UI checks
${donutSvg(segments, rate, 'UI checks')}
`); } if (model.latency.api) { const bands: BandBar[] = model.latency.api.map((b) => ({ label: b.label, count: b.count, cssClass: `band-${b.band}` })); charts.push(`
API latency
${barsSvg(bands, 'API latency distribution')}
`); } if (model.latency.ui) { const bands: BandBar[] = model.latency.ui.map((b) => ({ label: b.label, count: b.count, cssClass: `band-${b.band}` })); charts.push(`
Page readiness
${barsSvg(bands, 'Page readiness distribution')}
`); } const covCards: string[] = []; if (model.coverage.api) covCards.push(coverageCard('API', model.coverage.api)); if (model.coverage.ui) covCards.push(coverageCard('UI', model.coverage.ui)); const passed = (api?.passed ?? 0) + (ui?.passed ?? 0); const failed = (api?.failed ?? 0) + (ui?.failed ?? 0); const skipped = (api?.skipped ?? 0) + (ui?.skipped ?? 0); const subParts = [`${passed} passed`, `${failed} failed`, `${skipped} skipped`]; if (model.durationMs !== undefined) subParts.push(`run ${formatDurationMs(model.durationMs)}`); return `
${model.verdict}
${subParts.map((p) => e(p)).join(' · ')}
${charts.join('')}
${covCards.length ? `
${covCards.join('')}
` : ''}
`; } // ── Failures first ────────────────────────────────────────────────────────── function apiFailureRow(r: ApiRunResult): string { const expected = r.mode === 'authz_only' ? 'authz gate' : String(r.expected); const details = [r.note, r.error].filter(Boolean).map(String); const excerpt = r.bodyExcerpt ? `
response
${e(r.bodyExcerpt)}
` : ''; return ` API ${e(r.method)} ${e(r.route)} ${e(r.role)} ${r.permission ? e(r.permission) : ''} ${e(expected)} → ${r.actual ?? '—'} ${details.map((d) => e(d)).join(' · ')}${excerpt} `; } function uiFailureRow(r: UiRunResult): string { const details = [...r.warnings, r.error, r.flowDetail].filter(Boolean).map(String); return ` UI ${e(r.routeId)}${r.kind !== 'page' ? ` ${e(r.kind)}` : ''} ${e(r.role)} ${r.permission ? e(r.permission) : ''} ${e(r.expected)} → ${e(r.actual)} ${details.map((d) => e(d)).join(' · ')} `; } function renderFailuresFirst(model: ReportModel): string { const totalFailed = (model.api?.failed ?? 0) + (model.ui?.failed ?? 0); if (totalFailed === 0) { return `

Failures

All green — no failed check in this run.

`; } const groups = model.failureGroups .map((g: FailureGroup) => { const count = g.api.length + g.ui.length; const rows = [...g.api.map(apiFailureRow), ...g.ui.map(uiFailureRow)].join('\n'); return `
${e(g.label)} ${count} ${rows}
AxisUnitRolePermissionExpected → actualDetail
`; }) .join('\n'); return `

Failures first ${totalFailed} failed check${totalFailed > 1 ? 's' : ''}, grouped by cause

${groups}
`; } function renderPerfAdvisories(model: ReportModel): string { const { api, ui } = model.perfOffenders; const count = api.length + ui.length; if (count === 0) return ''; const rows = [ ...api.map( (r) => `API${e(r.method)} ${e(r.route)}${e(r.role)}${durationCell(r.durationMs, model.thresholds.apiWarnMs, model.thresholds.apiSlowMs)}`, ), ...ui.map( (r) => `UI${e(r.routeId)}${e(r.role)}${durationCell(r.perf.fullyReadyMs, model.thresholds.uiWarnMs, model.thresholds.uiSlowMs)}`, ), ].join('\n'); return `

Performance advisories ${count} over the warn budget (never a failure)

${rows}
AxisUnitRoleDuration
`; } // ── RBAC matrices ─────────────────────────────────────────────────────────── function matrixCellHtml(cell: MatrixCell, role: string): string { switch (cell.kind) { case 'pass': return `✓`; case 'fail': return `✗`; case 'skip': return `–`; case 'absent': return `·`; } } function renderMatrix(matrix: RbacMatrix, title: string, unitHeader: string): string { const roleHeads = matrix.roles.map((r) => `${e(r)}`).join(''); const groups = matrix.groups .map((g) => { const rows = g.rows .map( (row) => `${e(row.label)}${ row.permission ? `${e(row.permission)}` : '' }${row.cells.map((cell, i) => matrixCellHtml(cell, matrix.roles[i] ?? '')).join('')}`, ) .join('\n'); return `
0 ? ' open' : ''}> ${e(g.group)} ✓ ${g.passed} ✗ ${g.failed} – ${g.skipped}
${roleHeads}${rows}
${e(unitHeader)}
`; }) .join('\n'); return `

${e(title)} every cell = one check (hover for detail)

${groups}
`; } // ── Axis tables ───────────────────────────────────────────────────────────── function renderApiSection(model: ReportModel): string { const api = model.api; if (!api) return '

Backend (API)

No API run in this report.

'; const { apiWarnMs, apiSlowMs } = model.thresholds; const rows = api.rows .map((r) => { const status = apiRowStatus(r); const details = [r.note, r.error].filter(Boolean).map(String); const excerpt = r.bodyExcerpt ? `
response
${e(r.bodyExcerpt)}
` : ''; return ` ${e(r.method)} ${e(r.route)} ${e(r.role)} ${r.permission ? e(r.permission) : ''} ${e(r.mode)} ${r.expected} ${r.actual ?? '—'} ${e(status.label)} ${durationCell(r.durationMs, apiWarnMs, apiSlowMs)} ${humanBytes(r.sizeBytes)} ${details.length ? e(details.join(' · ')) : ''}${excerpt} `; }) .join('\n'); return `

Backend (API) ${api.passed}/${api.executed} passed · ${api.skipped} skipped

${rows}
MethodEndpointRolePermissionModeExpectedActualStatusDurationSizeNotes
`; } function screenshotCell(r: UiRunResult, model: ReportModel): string { if (!r.screenshot) return ''; const label = `screenshot ${r.routeId} (${r.role})`; const embed = model.screenshots[r.screenshot]; if (embed?.dataUri) { return `
📷${e(label)}
`; } const link = `📷`; if (embed?.note) return `${link} ${e(embed.note)}`; return `${link}`; } function renderUiSection(model: ReportModel): string { const ui = model.ui; if (!ui) return '

Frontend (UI)

No UI run in this report.

'; const { uiWarnMs, uiSlowMs } = model.thresholds; const rows = ui.rows .map((r) => { const status = uiRowStatus(r); const issues: string[] = [...r.warnings]; if (r.error) issues.push(r.error); if (r.flowDetail) issues.push(r.flowDetail); const errorsBlock = r.consoleErrors.length || r.network.failed.length ? `
${r.consoleErrors.length} console · ${r.network.failed.length} requests
` : ''; const isPage = r.kind === 'page'; const navCell = isPage ? durationCell(r.perf.navMs, uiWarnMs, uiSlowMs) : dashCell; const ttfpCell = isPage && r.perf.ttfpMs !== undefined ? `${r.perf.ttfpMs} ms` : dashCell; const readyCell = isPage ? durationCell(r.perf.fullyReadyMs, uiWarnMs, uiSlowMs) : dashCell; const weightCell = isPage ? `${humanBytes(r.network.transferredBytes)}` : dashCell; const reqCell = isPage ? `${r.network.requestCount}` : dashCell; return ` ${e(r.routeId)}${r.kind !== 'page' ? `${e(r.kind)}` : ''} ${e(r.url)} ${e(r.role)} ${r.permission ? e(r.permission) : ''} ${e(r.navigationUsed)} ${e(r.expected)} ${e(r.actual)} ${e(status.label)} ${navCell} ${ttfpCell} ${readyCell} ${weightCell} ${reqCell} ${issues.map((w) => e(w)).join('
')}${errorsBlock} ${screenshotCell(r, model)} `; }) .join('\n'); return `

Frontend (UI) ${ui.passed}/${ui.executed - ui.indeterminate} passed · ${ui.indeterminate} indeterminate · ${ui.skipped} skipped

${rows}
RouteURLRolePermissionNavExpectedActualStatusNav msTTFPReady msWeightReqIssuesShot
`; } function renderRollups(model: ReportModel): string { const roleRows = model.roleRollups .map((r) => { const total = r.apiPassed + r.apiFailed + r.uiPassed + r.uiFailed; const passed = r.apiPassed + r.uiPassed; const rate = total > 0 ? Math.round((passed / total) * 100) : null; return `${e(r.role)}${r.apiPassed}${r.apiFailed}${r.uiPassed}${r.uiFailed}${ rate === null ? '' : `
${rate}%
` }`; }) .join('\n'); const groupRows = model.groupRollups .map( (g) => `${e(g.group)}${g.apiPassed}${g.apiFailed}${g.uiPassed}${g.uiFailed}`, ) .join('\n'); return `

By role

${roleRows}
RoleAPI ✓API ✗UI ✓UI ✗Pass rate

By module

${groupRows}
GroupAPI ✓API ✗UI ✓UI ✗
`; } const STYLE = ` :root{--bg:#f6f7f9;--card:#fff;--ink:#1c2430;--muted:#68727f;--line:#e3e7ec;--pass:#16803c;--pass-bg:#e8f6ee;--fail:#b42318;--fail-bg:#fdebe9;--skip:#68727f;--skip-bg:#eef0f3;--ind:#955d00;--ind-bg:#fdf3e0;--warn:#955d00;--slow:#b42318;--accent:#2456d6} @media (prefers-color-scheme: dark){:root{--bg:#12161c;--card:#1a2029;--ink:#e6eaef;--muted:#8b96a3;--line:#2a3341;--pass:#4ade80;--pass-bg:#12301e;--fail:#f87171;--fail-bg:#3a1715;--skip:#8b96a3;--skip-bg:#232a35;--ind:#fbbf24;--ind-bg:#33270e;--warn:#fbbf24;--slow:#f87171;--accent:#7ba3f7}} *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,Segoe UI,Roboto,sans-serif;padding:24px} h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:0 0 10px}section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin:14px 0;overflow-x:auto} .meta{color:var(--muted);font-size:12.5px;display:flex;gap:14px;flex-wrap:wrap;margin-bottom:10px} .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}.kpi{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px} .kpi-v{font-size:22px;font-weight:650}.kpi-l{color:var(--muted);font-size:12px;margin-top:2px}.kpi-s{color:var(--muted);font-size:11.5px;margin-top:4px} table.grid{border-collapse:collapse;width:100%;font-size:13px}.grid th,.grid td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top} .grid thead th{position:sticky;top:0;background:var(--card);cursor:default;white-space:nowrap}.grid th[data-sort]{cursor:pointer}.grid th[data-sort]:hover{color:var(--accent)} .num{text-align:right;white-space:nowrap}.mono{font-family:ui-monospace,Consolas,monospace;font-size:12.5px}.small{font-size:12px}.muted{color:var(--muted);font-weight:400;font-size:12.5px} .badge{display:inline-block;padding:1px 8px;border-radius:99px;font-size:11.5px;font-weight:600;white-space:nowrap} .badge-pass{color:var(--pass);background:var(--pass-bg)}.badge-fail{color:var(--fail);background:var(--fail-bg)}.badge-skip{color:var(--skip);background:var(--skip-bg)}.badge-ind{color:var(--ind);background:var(--ind-bg)} .dur-warn{color:var(--warn);font-weight:600}.dur-slow{color:var(--slow);font-weight:700} .flow{margin-left:6px;font-size:10.5px;color:var(--accent);border:1px solid currentColor;border-radius:4px;padding:0 4px;vertical-align:1px} .cols{display:grid;grid-template-columns:1fr 1fr;gap:18px}@media(max-width:900px){.cols{grid-template-columns:1fr}} .bar{position:relative;background:var(--skip-bg);border-radius:6px;height:18px;min-width:120px}.bar-fill{background:var(--pass);height:100%;border-radius:6px}.bar-warn{background:var(--ind)}.bar span{position:absolute;inset:0;text-align:center;font-size:11px;line-height:18px} .filters{display:flex;gap:10px;align-items:center;margin:6px 0 12px;flex-wrap:wrap}.filters select,.filters button,.filters input{font:inherit;padding:4px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--ink);cursor:pointer} .filters input{cursor:text;min-width:220px}.filters button.on{border-color:var(--accent);color:var(--accent);font-weight:600} details{margin-top:4px}details summary{cursor:pointer;color:var(--accent);font-size:12px}details ul{margin:6px 0 0;padding-left:16px} .exec .verdict-row{display:flex;gap:16px;align-items:center;flex-wrap:wrap} .verdict{font-size:26px;font-weight:800;letter-spacing:1px;padding:6px 22px;border-radius:10px} .verdict-pass{color:var(--pass);background:var(--pass-bg)}.verdict-fail{color:var(--fail);background:var(--fail-bg)} .exec-sub{color:var(--muted);font-size:14px} .charts{display:flex;gap:26px;flex-wrap:wrap;margin-top:14px;align-items:flex-end} .charts figure{margin:0}.charts figcaption{color:var(--muted);font-size:12px;margin-bottom:6px} .seg-track{stroke:var(--skip-bg)}.seg-pass{stroke:var(--pass)}.seg-fail{stroke:var(--fail)}.seg-skip{stroke:var(--skip)}.seg-ind{stroke:var(--ind)} .donut-center{fill:var(--ink);font-size:19px;font-weight:650} .band-fast{fill:var(--pass)}.band-ok{fill:var(--accent)}.band-warn{fill:var(--ind)}.band-slow{fill:var(--fail)} .bar-label,.bar-count{fill:var(--muted);font-size:11px} .cov{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}.cov-card{border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:12.5px} .cov-card strong{margin-right:8px}.chips{margin-top:6px}.chip{display:inline-block;background:var(--skip-bg);color:var(--muted);border-radius:99px;padding:1px 8px;font-size:11px;margin:0 4px 4px 0} .failures details.fgroup summary{font-size:13.5px;font-weight:600} .excerpt{white-space:pre-wrap;word-break:break-word;background:var(--skip-bg);border-radius:6px;padding:8px;max-width:640px;max-height:220px;overflow:auto} .perf-adv summary h2{display:inline;font-size:14px} .mx-tools{display:flex;gap:8px;margin-bottom:8px}.mx-tools button{font:inherit;font-size:12px;padding:3px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--ink);cursor:pointer} details.mx{border:1px solid var(--line);border-radius:8px;padding:4px 10px;margin:6px 0} details.mx summary{display:flex;justify-content:space-between;gap:12px;align-items:center} .mx-counts{font-size:12px}.c-pass{color:var(--pass);margin-right:8px}.c-fail{color:var(--fail);margin-right:8px}.c-skip{color:var(--skip)} .mx-scroll{overflow-x:auto;margin:8px 0 4px} table.mx-table{border-collapse:collapse;font-size:12px} .mx-table th,.mx-table td{border:1px solid var(--line);padding:2px 6px} .mx-table thead th{background:var(--card);position:sticky;top:0} .mx-table .mx-unit{position:sticky;left:0;background:var(--card);text-align:left;max-width:420px;font-weight:400} .mx-perm{display:block;color:var(--muted);font-size:10.5px} .mx-table td.cell{width:26px;min-width:26px;text-align:center;font-weight:700} .cell-pass{color:var(--pass);background:var(--pass-bg)}.cell-fail{color:var(--fail);background:var(--fail-bg)}.cell-skip{color:var(--skip);background:var(--skip-bg)}.cell-absent{color:var(--muted)} footer{color:var(--muted);font-size:12px;margin-top:18px} @media print{.filters,.mx-tools{display:none}body{background:#fff;padding:0}section{break-inside:avoid;border:none}details.mx,details.fgroup{border:none}details:not([open])>*:not(summary){display:revert}} `; const SCRIPT = ` (function(){ var role=document.getElementById('f-role'),search=document.getElementById('f-search'),buttons=[].slice.call(document.querySelectorAll('.filters button[data-f]')),status='all'; function apply(){var r=role?role.value:'all';var q=search?search.value.toLowerCase():''; [].slice.call(document.querySelectorAll('table.grid tbody tr')).forEach(function(tr){ if(!tr.hasAttribute('data-role'))return;var okR=r==='all'||tr.getAttribute('data-role')===r;var s=tr.getAttribute('data-status'); var okS=status==='all'||(status==='fail'?s==='fail':status==='pass'?s==='pass':s==='skip'||s==='indeterminate'); var okQ=q===''||((tr.children[0]?tr.children[0].textContent:'')+' '+(tr.children[1]?tr.children[1].textContent:'')).toLowerCase().indexOf(q)>-1; tr.style.display=okR&&okS&&okQ?'':'none';});} if(role)role.addEventListener('change',apply); if(search)search.addEventListener('input',apply); buttons.forEach(function(b){b.addEventListener('click',function(){status=b.getAttribute('data-f');buttons.forEach(function(x){x.classList.toggle('on',x===b)});apply();});}); [].slice.call(document.querySelectorAll('th[data-sort]')).forEach(function(th){th.addEventListener('click',function(){ var table=th.closest('table'),tbody=table.querySelector('tbody'),idx=[].indexOf.call(th.parentNode.children,th); var dir=th._d=th._d===1?-1:1; th.setAttribute('aria-sort',dir===1?'ascending':'descending'); [].slice.call(th.parentNode.children).forEach(function(x){if(x!==th)x.removeAttribute('aria-sort')}); [].slice.call(tbody.querySelectorAll('tr')).sort(function(a,b){ var ca=a.children[idx],cb=b.children[idx];var va=ca&&ca.getAttribute('data-v'),vb=cb&&cb.getAttribute('data-v'); if(va!==null&&va!==undefined&&vb!==null&&vb!==undefined)return(Number(va)-Number(vb))*dir; return String(ca?ca.textContent:'').localeCompare(String(cb?cb.textContent:''))*dir; }).forEach(function(tr){tbody.appendChild(tr)});});}); [].slice.call(document.querySelectorAll('.mx-tools button[data-mx]')).forEach(function(b){b.addEventListener('click',function(){ var open=b.getAttribute('data-mx')==='open';var sec=b.closest('section'); [].slice.call(sec.querySelectorAll('details.mx')).forEach(function(d){d.open=open});});}); })(); `; export function renderHtml(model: ReportModel): string { const api = model.api; const ui = model.ui; const kpis: string[] = []; kpis.push( kpiCard( 'Overall pass rate', model.overallPassRate === null ? '—' : `${model.overallPassRate}%`, `${(api?.passed ?? 0) + (ui?.passed ?? 0)} passed · ${(api?.failed ?? 0) + (ui?.failed ?? 0)} failed`, ), ); if (api) { kpis.push(kpiCard('API checks', `${api.passed}/${api.executed}`, `${api.skipped} skipped`)); kpis.push(kpiCard('API latency', `${api.avgDurationMs} ms avg`, `p95 ${api.p95DurationMs} ms`)); kpis.push(kpiCard('API payload', humanBytes(api.totalBytes), 'total received')); } if (ui) { kpis.push(kpiCard('UI checks', `${ui.passed}/${ui.executed - ui.indeterminate}`, `${ui.indeterminate} indeterminate · ${ui.skipped} skipped`)); kpis.push(kpiCard('Page readiness', `${ui.avgFullyReadyMs} ms avg`, `p95 ${ui.p95FullyReadyMs} ms · ${ui.perfWarnings} over budget`)); kpis.push(kpiCard('Page weight', humanBytes(ui.totalBytes), 'total transferred')); kpis.push(kpiCard('Console errors', String(ui.consoleErrorCount), 'on executed pages')); } const roleOptions = ['all', ...model.roles].map((r) => ``).join(''); const warningsBlock = model.runWarnings.length ? `

Run warnings

` : ''; function renderSecurityFindings(m: ReportModel): string { const sf = m.securityFindings; if (!sf) return ''; const ungatedRows = sf.ungated .map( (u) => `
  • ${e(u.method)} ${e(u.route)}${u.controller ? ` (${e(u.controller)})` : ''} — no [RequirePermission], no [AllowAnonymous]: every authenticated role passes. The green rows above are the DEPLOYED behaviour, not a certification — fix the controller (DEV-API-033).
  • `, ) .join(''); const unseededRows = sf.unseeded .map( (u) => `
  • ${e(u.method)} ${e(u.route)} — declared permission ${u.permission ? `${e(u.permission)} ` : ''}exists in NO live permission row: every role is denied (lost-seed drift — check the seed / DEV-CORE-011).
  • `, ) .join(''); return `

    ⚠ Security surface — findings the run cannot certify

    ${ungatedRows ? `

    Ungated endpoints (${sf.ungated.length})

    ` : ''} ${unseededRows ? `

    Declared but unseeded permissions (${sf.unseeded.length})

    ` : ''}
    `; } return ` ${e(model.title)}

    ${e(model.title)}

    run ${e(model.runId)} ${model.startedAt ? `started ${e(model.startedAt)}` : ''} ${model.finishedAt ? `finished ${e(model.finishedAt)}` : ''} ${model.durationMs !== undefined ? `duration ${e(formatDurationMs(model.durationMs))}` : ''} ${model.apiUrl ? `API ${e(model.apiUrl)}` : ''} ${model.frontendUrl ? `frontend ${e(model.frontendUrl)}` : ''} ${model.planPath ? `plan ${e(model.planPath)}` : ''} ${model.planSignatureShort ? `signature ${e(model.planSignatureShort)}` : ''}
    ${renderExecSummary(model)}
    ${kpis.join('')}
    ${renderSecurityFindings(model)} ${renderFailuresFirst(model)}
    ${renderRollups(model)} ${model.apiMatrix ? renderMatrix(model.apiMatrix, 'RBAC matrix — API', 'Endpoint') : ''} ${model.uiMatrix ? renderMatrix(model.uiMatrix, 'RBAC matrix — UI', 'Route') : ''} ${renderApiSection(model)} ${renderUiSection(model)} ${renderPerfAdvisories(model)} ${warningsBlock} `; }