Skip to content

Commit

Permalink
feat: introduce custom node:test reporter
Browse files Browse the repository at this point in the history
 * Compact, clean
 * Do not repeat errors twice
 * Display file names
 * Do not print .matcherResult for expect
 * Colors under --colors or FORCE_COLOR even when not a tty
 * Road to GH support in the future
 * Coherence with :pure engines
  • Loading branch information
ChALkeR committed Sep 4, 2024
1 parent 89bec44 commit f9178ef
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 36 deletions.
45 changes: 9 additions & 36 deletions bin/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

import { spawn, execFile as execFileCallback } from 'node:child_process'
import { promisify, inspect } from 'node:util'
import { promisify } from 'node:util'
import { once } from 'node:events'
import { fileURLToPath } from 'node:url'
import { basename, dirname, resolve, join } from 'node:path'
Expand Down Expand Up @@ -206,7 +206,8 @@ if (options.pure) {
assert(!options.watch, `Can not use --watch with with ${options.engine} engine`)
assert(options.testNamePattern.length === 0, '--test-name-pattern requires node:test engine now')
} else if (options.engine === 'node:test') {
args.push('--test', '--no-warnings=ExperimentalWarning', '--test-reporter=spec')
const reporter = resolveRequire('./reporter.js')
args.push('--test', '--no-warnings=ExperimentalWarning', '--test-reporter', reporter)

if (haveSnapshots) args.push('--experimental-test-snapshots')

Expand Down Expand Up @@ -519,50 +520,22 @@ if (options.pure) {
}
}

const haveColors = process.stdout.hasColors?.() || process.env.FORCE_COLOR === '1' // 0 is already handled by hasColors()
const colors = new Map(Object.entries(inspect.colors))
const color = (text, color) => {
if (!haveColors || text === '') return text
if (!colors.has(color)) throw new Error(`Unknown color: ${color}`)
const [start, end] = colors.get(color)
return `\x1B[${start}m${text}\x1B[${end}m`
}

const format = (chunk) => {
if (!haveColors) return chunk
return chunk
.replaceAll(/^✔ PASS /gmu, color('✔ PASS ', 'green'))
.replaceAll(/^⏭ SKIP /gmu, color('⏭ SKIP ', 'dim'))
.replaceAll(/^✖ FAIL /gmu, color('✖ FAIL ', 'red'))
.replaceAll(/^⚠ WARN /gmu, color('⚠ WARN ', 'blue'))
.replaceAll(/^‼ FATAL /gmu, `${color('‼', 'red')} ${color(' FATAL ', 'bgRed')} `)
}
const { format, header, timeLabel, printSummary } = await import('./reporter.js')

const failures = []
const tasks = files.map((file) => ({ file, task: runConcurrent(file) }))
const timeString = color('Total time', 'dim')
console.time(timeString)
console.time(timeLabel)
for (const { file, task } of tasks) {
console.log(color(`# ${file}`, 'bold'))
console.log(header(file))
const { ok, output } = await task
for (const chunk of output.map((x) => x.trimEnd()).filter(Boolean)) console.log(format(chunk))
if (!ok) failures.push(file)
}

if (failures.length > 0) {
process.exitCode = 1
const [total, passed, failed] = [files.length, files.length - failures.length, failures.length]
const failLine = color(`${failed} / ${total}`, 'red')
const passLine = color(`${passed} / ${total}`, 'green')
const suffix = passed > 0 ? color(` (passed: ${passLine})`, 'dim') : ''
console.log(`${color('Test suites failed:', 'bold')} ${failLine}${suffix}`)
console.log(color('Failed test suites:', 'red'))
for (const file of failures) console.log(` ${file}`) // joining with \n can get truncated, too big
} else {
console.log(color(`All ${files.length} test suites passed`, 'green'))
}
if (failures.length > 0) process.exitCode = 1
printSummary(files, failures)

console.timeEnd(timeString)
console.timeEnd(timeLabel)
} else {
assert(!buildFile)
assert(['node', c8].includes(options.binary), `Unexpected native engine: ${options.binary}`)
Expand Down
114 changes: 114 additions & 0 deletions bin/reporter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict'
import { inspect } from 'node:util'
import { relative } from 'node:path'
import { spec as SpecReporter } from 'node:test/reporters'

const haveColors = process.stdout.hasColors?.() || process.env.FORCE_COLOR === '1' // 0 is already handled by hasColors()
const colors = new Map(Object.entries(inspect.colors))

export const color = (text, color) => {
if (!haveColors || text === '') return text
if (!colors.has(color)) throw new Error(`Unknown color: ${color}`)
const [start, end] = colors.get(color)
return `\x1B[${start}m${text}\x1B[${end}m`
}

// Used for pure engine output formatting
export const format = (chunk) => {
if (!haveColors) return chunk
return chunk
.replaceAll(/^✔ PASS /gmu, color('✔ PASS ', 'green'))
.replaceAll(/^⏭ SKIP /gmu, color('⏭ SKIP ', 'dim'))
.replaceAll(/^✖ FAIL /gmu, color('✖ FAIL ', 'red'))
.replaceAll(/^⚠ WARN /gmu, color('⚠ WARN ', 'blue'))
.replaceAll(/^‼ FATAL /gmu, `${color('‼', 'red')} ${color(' FATAL ', 'bgRed')} `)
}

export const printSummary = (files, failures) => {
if (failures.length > 0) {
const [total, passed, failed] = [files.length, files.length - failures.length, failures.length]
const failLine = color(`${failed} / ${total}`, 'red')
const passLine = color(`${passed} / ${total}`, 'green')
const suffix = passed > 0 ? color(` (passed: ${passLine})`, 'dim') : ''
console.log(`${color('Test suites failed:', 'bold')} ${failLine}${suffix}`)
console.log(color('Failed test suites:', 'red'))
for (const file of failures) console.log(` ${file}`) // joining with \n can get truncated, too big
} else {
console.log(color(`All ${files.length} test suites passed`, 'green'))
}
}

export const timeLabel = color('Total time', 'dim')
export const header = (file) => color(`# ${file}`, 'bold')

export default async function nodeTestReporterExodus(source) {
const spec = new SpecReporter()
spec.on('data', (data) => {
console.log(data.toString('utf8'))
})

const files = new Set()
const failedFiles = new Set()
const cwd = process.cwd()
const path = []
let lastFile
const formatTime = ({ duration_ms: ms }) => color(` (${ms}ms)`, 'dim')
const formatSuffix = (data) => `${formatTime(data.details)}${data.todo ? ' # TODO' : ''}`
const printHead = (data) => {
const file = relative(cwd, data.file) // some events have data.file resolved, some not
if (file === lastFile) return
lastFile = file
files.add(file)
console.log(header(file))
}

for await (const { type, data } of source) {
// Ignored: test:complete (no support on older Node.js), test:plan, test:dequeue, test:enqueue
switch (type) {
case 'test:start':
printHead(data)
path.push(data.name)
break
case 'test:pass':
if (data.skip) {
console.log(`${color('⏭ SKIP ', 'dim')}${path.join(' > ')}${formatSuffix(data)}`)
} else {
console.log(`${color('✔ PASS ', 'green')}${path.join(' > ')}${formatSuffix(data)}`)
}

assert(path.pop() === data.name)
break
case 'test:fail':
console.log(`${color('✖ FAIL ', 'red')}${path.join(' > ')}${formatSuffix(data)}`)
assert(path.pop() === data.name)
if (data.details.error) {
if (data.details.error.cause) delete data.details.error.cause.matcherResult
const err = inspect(data.details.error.cause || data.details.error, {
colors: haveColors,
})
console.log(err.replace(/^/gmu, ' '))
console.log('')
}

if (!data.todo) failedFiles.add(relative(cwd, data.file))
break
case 'test:watch:drained':
console.log(color(`ℹ waiting for changes as we are in ---watch mode`, 'blue'))
break
case 'test:diagnostic':
if (/^suites \d+$/.test(data.message)) break // we count suites = files
console.log(color(`ℹ ${data.message}`, 'blue'))
break
case 'test:stderr':
case 'test:stdout':
printHead(data)
console.log(data.message.replace(/\n$/, ''))
break
case 'test:coverage':
spec.write({ type, data }) // let spec reporter handle that
break
}
}

printSummary([...files], [...failedFiles])
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"exodus-test": "bin/index.js"
},
"exports": {
"./node-test-reporter": "./bin/reporter.js",
"./jest": "./src/jest.js",
"./node": "./src/node.js",
"./tape": {
Expand All @@ -36,6 +37,7 @@
"prettier": "@exodus/prettier",
"files": [
"bin/jest.js",
"bin/reporter.js",
"bundler/babel-worker.cjs",
"bundler/bundle.js",
"bundler/modules/empty/function-throw.cjs",
Expand Down

0 comments on commit f9178ef

Please sign in to comment.