feat(ops96): motor de métricas baratas + @vitest/coverage-v8 atrás de flag

This commit is contained in:
Francisco Solla 2026-08-25 00:53:59 -03:00
parent b271d83f6f
commit c13174b8aa
6 changed files with 397 additions and 1 deletions

View file

@ -19,7 +19,7 @@
"server-only": ["tests/helpers/serverOnly.ts"]
},
"ignoreBinaries": [],
"ignoreDependencies": ["tailwindcss-animate", "tw-animate-css", "shadcn", "eslint-config-next"],
"ignoreDependencies": ["tailwindcss-animate", "tw-animate-css", "shadcn", "eslint-config-next", "@vitest/coverage-v8"],
"rules": {
"exports": "error",
"types": "error",

View file

@ -160,6 +160,7 @@
"@types/web-push": "^3.6.4",
"@types/yauzl": "^3.4.0",
"@vitejs/plugin-react": "4.5.2",
"@vitest/coverage-v8": "4.0.18",
"csv-parse": "^7.0.1",
"eslint": "^9.39.2",
"eslint-config-next": "15.4.11",

View file

@ -219,6 +219,9 @@ importers:
'@vitejs/plugin-react':
specifier: 4.5.2
version: 4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.7.0)(lightningcss@1.30.2)(sass@1.77.4)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/coverage-v8':
specifier: 4.0.18
version: 4.0.18(vitest@4.0.18(@types/node@22.19.9)(happy-dom@20.8.3)(jiti@2.7.0)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.9)(@typescript/typescript6@6.0.2))(sass@1.77.4)(tsx@4.21.0)(yaml@2.9.0))
csv-parse:
specifier: ^7.0.1
version: 7.0.1
@ -630,6 +633,10 @@ packages:
'@types/react':
optional: true
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
@ -3794,6 +3801,15 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0
'@vitest/coverage-v8@4.0.18':
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
peerDependencies:
'@vitest/browser': 4.0.18
vitest: 4.0.18
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.0.18':
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
@ -3992,6 +4008,9 @@ packages:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
ast-v8-to-istanbul@0.3.12:
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@ -5376,6 +5395,9 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@ -5699,6 +5721,18 @@ packages:
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
@ -5721,6 +5755,9 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -5963,6 +6000,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.4:
resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@ -8583,6 +8627,8 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@bcoe/v8-coverage@1.0.2': {}
'@borewit/text-codec@0.2.1': {}
'@csstools/color-helpers@6.0.1': {}
@ -11656,6 +11702,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.19.9)(happy-dom@20.8.3)(jiti@2.7.0)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.9)(@typescript/typescript6@6.0.2))(sass@1.77.4)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18
ast-v8-to-istanbul: 0.3.12
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.4
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.18(@types/node@22.19.9)(happy-dom@20.8.3)(jiti@2.7.0)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.9)(@typescript/typescript6@6.0.2))(sass@1.77.4)(tsx@4.21.0)(yaml@2.9.0)
'@vitest/expect@4.0.18':
dependencies:
'@standard-schema/spec': 1.1.0
@ -11909,6 +11969,12 @@ snapshots:
dependencies:
tslib: 2.8.1
ast-v8-to-istanbul@0.3.12:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
async-function@1.0.0: {}
atomic-sleep@1.0.0: {}
@ -13435,6 +13501,8 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
html-escaper@2.0.2: {}
html-url-attributes@3.0.1: {}
http-errors@2.0.1:
@ -13699,6 +13767,19 @@ snapshots:
isomorphic.js@0.2.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
@ -13718,6 +13799,8 @@ snapshots:
joycon@3.1.1: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@ -13967,6 +14050,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.4:
dependencies:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.4
markdown-table@3.0.4: {}
marked@14.0.0: {}

View file

@ -0,0 +1,105 @@
/**
* 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.
*/
const DEFAULT_TOP = 15
const ms = (value) => `${(value / 1000).toFixed(1)}s`
/** @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<typeof summarizeVitestReport>} 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.
*
* @param {{ file: string, content: string }} sources
* @returns {Array<{ 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')
}

View file

@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* testing-audit-metrics (OPS96) — the cheap-metrics tool behind /testing-audit:
* per-layer portrait of the suite from native reporters, nothing third-party.
*
* node scripts/testing-audit-metrics.mjs unit|int [--top N]
* node scripts/testing-audit-metrics.mjs e2e-inventory
*
* unit/int: spawns `pnpm test:<layer> --reporter=json` (the package scripts
* carry their own env guards — invalid DATABASE_URL for unit, .env.test.local
* worktree DB for int), parses the JSON payload and prints totals plus a
* slowest-files markdown table for the audit report. The runner's exit code
* propagates: a red suite still prints its portrait, then fails.
* e2e-inventory: static count of describe/test blocks per spec file — durations
* are deliberately absent because running Playwright overnight is forbidden.
*/
import { readdirSync, readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
inventoryE2ESource,
renderE2EInventoryTable,
renderSlowestFilesTable,
summarizeVitestReport,
} from './lib/testing-audit-metrics-core.mjs'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const E2E_DIR = 'tests/e2e'
const DEFAULT_TOP = 15
const usage = () => {
console.error('usage: node scripts/testing-audit-metrics.mjs <unit|int|e2e-inventory> [--top N]')
process.exit(2)
}
const args = process.argv.slice(2)
const mode = args.find((arg) => !arg.startsWith('--'))
if (mode !== 'unit' && mode !== 'int' && mode !== 'e2e-inventory') usage()
const topFlag = args.indexOf('--top')
const top = topFlag === -1 ? DEFAULT_TOP : Number(args[topFlag + 1])
if (!Number.isInteger(top) || top <= 0) usage()
/** Vitest's jest-compatible JSON payload starts with this key. */
const VITEST_JSON_ANCHOR = '{"numTotal'
const runVitestLayer = async (layer) => {
console.log(`[metrics] rodando pnpm test:${layer} --reporter=json …`)
const result = spawnSync('pnpm', [`test:${layer}`, '--reporter=json'], {
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
})
const jsonStart = (result.stdout ?? '').indexOf(VITEST_JSON_ANCHOR)
if (jsonStart === -1) {
console.error(`[metrics] vitest não produziu relatório JSON no stdout (exit ${result.status})`)
console.error((result.stderr ?? '').slice(-4000))
process.exit(result.status ?? 2)
}
let report
try {
report = JSON.parse(result.stdout.slice(jsonStart))
} catch (error) {
console.error(`[metrics] relatório JSON inválido: ${error instanceof Error ? error.message : error}`)
process.exit(2)
}
const summary = summarizeVitestReport(report)
console.log(
`\n## ${layer} — ${summary.totals.files} arquivos / ${summary.totals.tests} testes` +
` (${summary.totals.failedTests} falhas) / parede dos arquivos ${ms(summary.totals.durationMs)}\n`,
)
console.log(renderSlowestFilesTable(summary, { top }))
if (result.status !== 0) process.exit(result.status)
}
const ms = (value) => `${(value / 1000).toFixed(1)}s`
const runE2EInventory = () => {
const dir = path.join(repoRoot, E2E_DIR)
const rows = readdirSync(dir)
.filter((name) => name.endsWith('.spec.ts'))
.sort()
.map((name) =>
inventoryE2ESource({
file: `${E2E_DIR}/${name}`,
content: readFileSync(path.join(dir, name), 'utf8'),
}),
)
const totalTests = rows.reduce((sum, row) => sum + row.tests, 0)
console.log(`\n## e2e (inventário estático) — ${rows.length} arquivos / ${totalTests} testes\n`)
console.log(renderE2EInventoryTable(rows))
}
if (mode === 'e2e-inventory') runE2EInventory()
else await runVitestLayer(mode)

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
inventoryE2ESource,
renderE2EInventoryTable,
renderSlowestFilesTable,
summarizeVitestReport,
} from '../../scripts/lib/testing-audit-metrics-core.mjs'
const reportFixture = () => ({
numTotalTests: 3,
numFailedTests: 0,
testResults: [
{
name: '/abs/repo/tests/unit/b.unit.spec.ts',
endTime: 1600,
startTime: 500,
assertionResults: [
{ title: 'slow one', status: 'passed', duration: 400 },
{ title: 'fast one', status: 'passed', duration: 10 },
],
},
{
name: '/abs/repo/tests/unit/a.unit.spec.ts',
endTime: 2000,
startTime: 1000,
assertionResults: [{ title: 'only one', status: 'passed', duration: 50 }],
},
],
})
describe('summarizeVitestReport', () => {
it('totals files and tests and sorts rows by duration desc', () => {
const summary = summarizeVitestReport(reportFixture())
expect(summary.totals).toEqual({ files: 2, tests: 3, failedTests: 0, durationMs: 2100 })
expect(summary.rows.map((row) => row.file)).toEqual([
'tests/unit/b.unit.spec.ts',
'tests/unit/a.unit.spec.ts',
])
})
it('keeps the slowest assertion per file', () => {
const summary = summarizeVitestReport(reportFixture())
expect(summary.rows[0].slowestTest).toBe('slow one')
expect(summary.rows[0].slowestMs).toBe(400)
})
it('falls back to summed durations when the payload lacks timestamps', () => {
const payload = {
testResults: [{ name: '/x/y.unit.spec.ts', assertionResults: [{ title: 't', duration: 120 }] }],
}
expect(summarizeVitestReport(payload).rows[0].durationMs).toBe(120)
})
it('rejects payloads without testResults', () => {
expect(() => summarizeVitestReport({})).toThrow('missing testResults')
})
})
describe('renderSlowestFilesTable', () => {
it('renders a markdown table limited to top N rows', () => {
const table = renderSlowestFilesTable(summarizeVitestReport(reportFixture()), { top: 1 })
expect(table.split('\n')).toHaveLength(3)
expect(table).toContain('| tests/unit/b.unit.spec.ts | 2 | 1.1s | slow one (0.4s) |')
})
})
describe('inventoryE2ESource', () => {
it('counts describe and test blocks statically', () => {
const row = inventoryE2ESource({
file: 'tests/e2e/x.e2e.spec.ts',
content: "test('a', () => {})\n describe('d', () => { test('b', () => {}) })",
})
expect(row).toEqual({ file: 'tests/e2e/x.e2e.spec.ts', describes: 1, tests: 2 })
})
it('does not count words merely containing the keywords', () => {
const row = inventoryE2ESource({
file: 'tests/e2e/x.e2e.spec.ts',
content: '// contest( and described( are not blocks',
})
expect(row.tests).toBe(0)
expect(row.describes).toBe(0)
})
})
describe('renderE2EInventoryTable', () => {
it('marks durations as not measured overnight', () => {
const table = renderE2EInventoryTable([
{ file: 'tests/e2e/x.e2e.spec.ts', describes: 1, tests: 4 },
])
expect(table).toContain('| tests/e2e/x.e2e.spec.ts | 1 | 4 | não medido — execução noturna proibida |')
})
})