Skip to content

Commit 40afbe3

Browse files
authoredFeb 7, 2024
feat(vitest): add github actions reporter (#5093)
1 parent 0f2d9ff commit 40afbe3

File tree

9 files changed

+168
-10
lines changed

9 files changed

+168
-10
lines changed
 

‎docs/guide/reporters.md

+10
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,16 @@ export default defineConfig({
445445
```
446446
:::
447447

448+
### Github Actions Reporter <Badge type="info">1.3.0+</Badge>
449+
450+
Output [workflow commands](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message)
451+
to provide annotations for test failures. This reporter is automatically enabled when `process.env.GITHUB_ACTIONS === 'true'`, thus it doesn't require any configuration.
452+
453+
<img alt="Github Actions" img-dark src="https://github.com/vitest-dev/vitest/assets/4232207/336cddc2-df6b-4b8a-8e72-4d00010e37f5">
454+
<img alt="Github Actions" img-light src="https://github.com/vitest-dev/vitest/assets/4232207/ce8447c1-0eab-4fe1-abef-d0d322290dca">
455+
456+
457+
448458
## Custom Reporters
449459

450460
You can use third-party custom reporters installed from NPM by specifying their package name in the reporters' option:

‎packages/vitest/src/node/config.ts

+4
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,10 @@ export function resolveConfig(
422422
if (!resolved.reporters.length)
423423
resolved.reporters.push(['default', {}])
424424

425+
// automatically enable github-actions reporter
426+
if (process.env.GITHUB_ACTIONS === 'true' && !resolved.reporters.some(v => Array.isArray(v) && v[0] === 'github-actions'))
427+
resolved.reporters.push(['github-actions', {}])
428+
425429
if (resolved.changed)
426430
resolved.passWithNoTests ??= true
427431

‎packages/vitest/src/node/error.ts

+13-6
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ interface PrintErrorOptions {
2222
showCodeFrame?: boolean
2323
}
2424

25-
export async function printError(error: unknown, project: WorkspaceProject | undefined, options: PrintErrorOptions) {
25+
interface PrintErrorResult {
26+
nearest?: ParsedStack
27+
}
28+
29+
export async function printError(error: unknown, project: WorkspaceProject | undefined, options: PrintErrorOptions): Promise<PrintErrorResult | undefined> {
2630
const { showCodeFrame = true, fullStack = false, type } = options
2731
const logger = options.logger
2832
let e = error as ErrorWithDiff
@@ -43,8 +47,10 @@ export async function printError(error: unknown, project: WorkspaceProject | und
4347
}
4448

4549
// Error may have occured even before the configuration was resolved
46-
if (!project)
47-
return printErrorMessage(e, logger)
50+
if (!project) {
51+
printErrorMessage(e, logger)
52+
return
53+
}
4854

4955
const parserOptions: StackTraceParserOptions = {
5056
// only browser stack traces require remapping
@@ -85,7 +91,7 @@ export async function printError(error: unknown, project: WorkspaceProject | und
8591
logger.error(c.yellow(e.frame))
8692
}
8793
else {
88-
printStack(project, stacks, nearest, errorProperties, (s) => {
94+
printStack(logger, project, stacks, nearest, errorProperties, (s) => {
8995
if (showCodeFrame && s === nearest && nearest) {
9096
const sourceCode = readFileSync(nearest.file, 'utf-8')
9197
logger.error(generateCodeFrame(sourceCode.length > 100_000 ? sourceCode : logger.highlight(nearest.file, sourceCode), 4, s))
@@ -116,6 +122,8 @@ export async function printError(error: unknown, project: WorkspaceProject | und
116122
}
117123

118124
handleImportOutsideModuleError(e.stack || e.stackStr || '', logger)
125+
126+
return { nearest }
119127
}
120128

121129
function printErrorType(type: string, ctx: Vitest) {
@@ -229,14 +237,13 @@ function printErrorMessage(error: ErrorWithDiff, logger: Logger) {
229237
}
230238

231239
function printStack(
240+
logger: Logger,
232241
project: WorkspaceProject,
233242
stack: ParsedStack[],
234243
highlight: ParsedStack | undefined,
235244
errorProperties: Record<string, unknown>,
236245
onStack?: ((stack: ParsedStack) => void),
237246
) {
238-
const logger = project.ctx.logger
239-
240247
for (const frame of stack) {
241248
const color = frame === highlight ? c.cyan : c.gray
242249
const path = relative(project.config.root, frame.file)

‎packages/vitest/src/node/logger.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ export class Logger {
8282
this.console.log(`${CURSOR_TO_START}${ERASE_DOWN}${log}`)
8383
}
8484

85-
printError(err: unknown, options: ErrorOptions = {}) {
85+
async printError(err: unknown, options: ErrorOptions = {}) {
8686
const { fullStack = false, type } = options
8787
const project = options.project ?? this.ctx.getCoreWorkspaceProject() ?? this.ctx.projects[0]
88-
return printError(err, project, {
88+
await printError(err, project, {
8989
fullStack,
9090
type,
9191
showCodeFrame: true,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { Console } from 'node:console'
2+
import { Writable } from 'node:stream'
3+
import { getTasks } from '@vitest/runner/utils'
4+
import stripAnsi from 'strip-ansi'
5+
import type { File, Reporter, Vitest } from '../../types'
6+
import { getFullName } from '../../utils'
7+
import { printError } from '../error'
8+
import { Logger } from '../logger'
9+
import type { WorkspaceProject } from '../workspace'
10+
11+
export class GithubActionsReporter implements Reporter {
12+
ctx: Vitest = undefined!
13+
14+
onInit(ctx: Vitest) {
15+
this.ctx = ctx
16+
}
17+
18+
async onFinished(files: File[] = [], errors: unknown[] = []) {
19+
// collect all errors and associate them with projects
20+
const projectErrors = new Array<{ project: WorkspaceProject; title: string; error: unknown }>()
21+
for (const error of errors) {
22+
projectErrors.push({
23+
project: this.ctx.getCoreWorkspaceProject(),
24+
title: 'Unhandled error',
25+
error,
26+
})
27+
}
28+
for (const file of files) {
29+
const tasks = getTasks(file)
30+
const project = this.ctx.getProjectByTaskId(file.id)
31+
for (const task of tasks) {
32+
if (task.result?.state !== 'fail')
33+
continue
34+
35+
const title = getFullName(task, ' > ')
36+
for (const error of task.result?.errors ?? []) {
37+
projectErrors.push({
38+
project,
39+
title,
40+
error,
41+
})
42+
}
43+
}
44+
}
45+
46+
// format errors via `printError`
47+
for (const { project, title, error } of projectErrors) {
48+
const result = await printErrorWrapper(error, this.ctx, project)
49+
const stack = result?.nearest
50+
if (!stack)
51+
continue
52+
const formatted = formatMessage({
53+
command: 'error',
54+
properties: {
55+
file: stack.file,
56+
title,
57+
line: String(stack.line),
58+
column: String(stack.column),
59+
},
60+
message: stripAnsi(result.output),
61+
})
62+
this.ctx.logger.log(`\n${formatted}`)
63+
}
64+
}
65+
}
66+
67+
// use Logger with custom Console to extract messgage from `processError` util
68+
// TODO: maybe refactor `processError` to require single function `(message: string) => void` instead of full Logger?
69+
async function printErrorWrapper(error: unknown, ctx: Vitest, project: WorkspaceProject) {
70+
let output = ''
71+
const writable = new Writable({
72+
write(chunk, _encoding, callback) {
73+
output += String(chunk)
74+
callback()
75+
},
76+
})
77+
const result = await printError(error, project, {
78+
showCodeFrame: false,
79+
logger: new Logger(ctx, new Console(writable, writable)),
80+
})
81+
return { nearest: result?.nearest, output }
82+
}
83+
84+
// workflow command formatting based on
85+
// https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message
86+
// https://github.com/actions/toolkit/blob/f1d9b4b985e6f0f728b4b766db73498403fd5ca3/packages/core/src/command.ts#L80-L85
87+
function formatMessage({
88+
command,
89+
properties,
90+
message,
91+
}: {
92+
command: string
93+
properties: Record<string, string>
94+
message: string
95+
}): string {
96+
let result = `::${command}`
97+
Object.entries(properties).forEach(([k, v], i) => {
98+
result += i === 0 ? ' ' : ','
99+
result += `${k}=${escapeProperty(v)}`
100+
})
101+
result += `::${escapeData(message)}`
102+
return result
103+
}
104+
105+
function escapeData(s: string): string {
106+
return s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
107+
}
108+
109+
function escapeProperty(s: string): string {
110+
return s
111+
.replace(/%/g, '%25')
112+
.replace(/\r/g, '%0D')
113+
.replace(/\n/g, '%0A')
114+
.replace(/:/g, '%3A')
115+
.replace(/,/g, '%2C')
116+
}

‎packages/vitest/src/node/reporters/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { TapReporter } from './tap'
88
import { type JUnitOptions, JUnitReporter } from './junit'
99
import { TapFlatReporter } from './tap-flat'
1010
import { HangingProcessReporter } from './hanging-process'
11+
import { GithubActionsReporter } from './github-actions'
1112
import type { BaseReporter } from './base'
1213

1314
export {
@@ -20,6 +21,7 @@ export {
2021
JUnitReporter,
2122
TapFlatReporter,
2223
HangingProcessReporter,
24+
GithubActionsReporter,
2325
}
2426
export type { BaseReporter, Reporter }
2527

@@ -35,6 +37,7 @@ export const ReportersMap = {
3537
'tap-flat': TapFlatReporter,
3638
'junit': JUnitReporter,
3739
'hanging-process': HangingProcessReporter,
40+
'github-actions': GithubActionsReporter,
3841
}
3942

4043
export type BuiltinReporters = keyof typeof ReportersMap

‎test/reporters/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"type": "module",
44
"private": true,
55
"scripts": {
6-
"test": "NO_COLOR=1 vitest run"
6+
"test": "NO_COLOR=1 GITHUB_ACTIONS=false vitest run"
77
},
88
"devDependencies": {
99
"flatted": "^3.2.9",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { resolve } from 'pathe'
2+
import { expect, test } from 'vitest'
3+
import { runVitest } from '../../test-utils'
4+
import { GithubActionsReporter } from '../../../packages/vitest/src/node/reporters'
5+
6+
test(GithubActionsReporter, async () => {
7+
let { stdout, stderr } = await runVitest(
8+
{ reporters: new GithubActionsReporter(), root: './fixtures' },
9+
['some-failing.test.ts'],
10+
)
11+
stdout = stdout.replace(resolve(__dirname, '..').replace(/:/g, '%3A'), '__TEST_DIR__')
12+
expect(stdout).toMatchInlineSnapshot(`
13+
"
14+
::error file=__TEST_DIR__/fixtures/some-failing.test.ts,title=some-failing.test.ts > 3 + 3 = 7,line=8,column=17::AssertionError: expected 6 to be 7 // Object.is equality%0A%0A- Expected%0A+ Received%0A%0A- 7%0A+ 6%0A%0A ❯ some-failing.test.ts:8:17%0A%0A
15+
"
16+
`)
17+
expect(stderr).toBe('')
18+
})

‎test/ui/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"type": "module",
44
"private": true,
55
"scripts": {
6-
"test-e2e": "playwright test",
6+
"test-e2e": "GITHUB_ACTIONS=false playwright test",
77
"test-fixtures": "vitest"
88
},
99
"devDependencies": {

0 commit comments

Comments
 (0)
Please sign in to comment.