/** * Pure core for testing-audit metrics (OPS96) — normalization of native * runner reports into the audit's per-layer portrait. No I/O here: the CLI * (scripts/testing-audit-metrics.mjs) spawns the suites and feeds these * functions; the unit spec pins their behavior against fixture payloads. */ export const DEFAULT_TOP = 15 export const ms = (value) => `${(value / 1000).toFixed(1)}s` /** Vitest's jest-compatible JSON payload starts with this key. */ const VITEST_JSON_ANCHOR = '{"numTotal' /** * Extract the Vitest JSON report from the runner's raw stdout. pnpm echoes the * script line BEFORE the JSON and appends its own noise (e.g. the `ELIFECYCLE` * notice) AFTER it when the suite is red; tests may also print console blobs * with braces around the payload. Walking back from the last `}` finds the * payload boundary, so a red suite still yields its portrait. * * @param {string} stdout raw stdout of `vitest run --reporter=json` * @returns {Record} parsed JSON report */ export const parseVitestStdout = (stdout) => { if (typeof stdout !== 'string' || stdout.length === 0) { throw new Error('vitest não produziu relatório JSON no stdout') } const jsonStart = stdout.indexOf(VITEST_JSON_ANCHOR) if (jsonStart === -1) { throw new Error('vitest não produziu relatório JSON no stdout') } let end = stdout.length while (end > jsonStart) { end = stdout.lastIndexOf('}', end - 1) if (end < jsonStart) break try { return JSON.parse(stdout.slice(jsonStart, end + 1)) } catch { // not the payload boundary yet — keep walking back } } throw new Error('relatório JSON inválido') } /** @returns {number} */ const fileDurationMs = (entry) => { if (typeof entry.endTime === 'number' && typeof entry.startTime === 'number') { return Math.max(0, entry.endTime - entry.startTime) } return entry.assertionResults?.reduce((sum, result) => sum + (result.duration ?? 0), 0) ?? 0 } const slowestAssertion = (entry) => (entry.assertionResults ?? []) .filter((result) => typeof result.duration === 'number') .reduce( (slowest, result) => slowest === null || result.duration > slowest.duration ? result : slowest, null, ) /** * Normalize a Vitest JSON reporter payload (jest-compatible shape) into a * layer summary: totals plus per-file rows sorted by duration desc. * * @param {unknown} report parsed JSON from `vitest run --reporter=json` * @returns {{ * totals: { files: number, tests: number, failedTests: number, durationMs: number }, * rows: Array<{ file: string, tests: number, durationMs: number, slowestTest: string | null, slowestMs: number | null }>, * }} */ export const summarizeVitestReport = (report) => { if (!report || !Array.isArray(report.testResults)) { throw new Error('invalid vitest JSON report: missing testResults') } const rows = report.testResults.map((entry) => { const slowest = slowestAssertion(entry) return { file: String(entry.name ?? '(desconhecido)').replace(/^.*tests\//, 'tests/'), tests: entry.assertionResults?.length ?? 0, durationMs: fileDurationMs(entry), slowestTest: slowest ? slowest.title : null, slowestMs: slowest ? slowest.duration : null, } }) rows.sort((a, b) => b.durationMs - a.durationMs) return { totals: { files: rows.length, tests: Number(report.numTotalTests ?? rows.reduce((sum, row) => sum + row.tests, 0)), failedTests: Number(report.numFailedTests ?? 0), durationMs: rows.reduce((sum, row) => sum + row.durationMs, 0), }, rows, } } /** * Markdown table of the slowest spec files of a layer. * * @param {ReturnType} summary * @param {{ top?: number }} [options] */ export const renderSlowestFilesTable = (summary, { top = DEFAULT_TOP } = {}) => { const lines = ['| arquivo | testes | duração | teste mais lento |', '|---|---:|---:|---|'] for (const row of summary.rows.slice(0, Math.max(0, top))) { const slowest = row.slowestTest === null ? '—' : `${row.slowestTest} (${ms(row.slowestMs ?? 0)})` lines.push(`| ${row.file} | ${row.tests} | ${ms(row.durationMs)} | ${slowest} |`) } return lines.join('\n') } /** * Static inventory of an e2e spec source — counting only, never execution. * Deliberate approximation: `test.each`/`test.skip` variants are not counted * (the audit's portrait is structural, not exhaustive). * * @param {{ file: string, content: string }} source * @returns {{ file: string, describes: number, tests: number }} */ export const inventoryE2ESource = ({ file, content }) => ({ file, describes: (content.match(/\bdescribe\s*\(/g) ?? []).length, tests: (content.match(/\btest\s*\(/g) ?? []).length, }) /** Markdown table of the static e2e inventory (durations deliberately absent). */ export const renderE2EInventoryTable = (rows) => { const lines = [ '| arquivo | describes | testes | duração |', '|---|---:|---:|---|', ...rows.map( (row) => `| ${row.file} | ${row.describes} | ${row.tests} | não medido — execução noturna proibida |`, ), ] return lines.join('\n') }