Skip to content

Commit 5857729

Browse files
hi-ogawaOpenCode (claude-opus-4-8)AriPerkkio
authored
feat(reporters)!: write json and junit reporter output files to .vitest by default (#10621)
Co-authored-by: Hiroshi Ogawa <[email protected]> Co-authored-by: OpenCode (claude-opus-4-8) <[email protected]> Co-authored-by: Ari Perkkiö <[email protected]>
1 parent a60ded0 commit 5857729

12 files changed

Lines changed: 210 additions & 107 deletions

File tree

docs/guide/migration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ Vitest now uses a single `.vitest` directory at the project root as the shared a
181181
- **Attachments** ([`attachmentsDir`](/config/attachmentsdir)): `.vitest-attachements/``.vitest/attachments/`
182182
- **Blob reporter** and `--merge-reports`: `.vitest-reports/blob-*.json``.vitest/blob/blob-*.json`
183183
- **HTML reporter** ([`html`](/guide/reporters#html-reporter)): `html/index.html``.vitest/index.html`, and its option changed from `outputFile` (a file) to `outputDir` (a directory)
184+
- **JSON reporter** ([`json`](/guide/reporters#json-reporter)): stdout → `.vitest/json/output.json`
185+
- **JUnit reporter** ([`junit`](/guide/reporters#junit-reporter)): stdout → `.vitest/junit/output.xml`
186+
187+
The `json` and `junit` reporters now write to a file by default instead of printing to stdout. If you previously relied on the report being printed to stdout (for example `vitest --reporter=json > out.json` or `vitest --reporter=json | jq`), either read the generated artifact file instead (for example `jq . .vitest/json/output.json`), or opt back into stdout with the reporter's `stdout` option (`reporters: [['json', { stdout: true }]]`). An explicit `outputFile` is still respected and unchanged.
184188

185189
### `toMatchScreenshot` Now Uses a Dedicated Screenshot Directory Config
186190

docs/guide/reporters.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@ export default defineConfig({
6060

6161
## Reporter Output
6262

63-
By default, Vitest's reporters will print their output to the terminal. When using the `json` or `junit` reporters, you can instead write your tests' output to a file by including an `outputFile` [configuration option](/config/outputfile) either in your Vite configuration file or via CLI. The `html` reporter writes a report directory instead; see its [`outputDir`](#html-reporter) option.
63+
By default, Vitest's reporters print their output to the terminal. The `json`, `junit` and `html` reporters instead write to a scoped location under `.vitest/`:
64+
65+
- `json` writes `.vitest/json/output.json`
66+
- `junit` writes `.vitest/junit/output.xml`
67+
- `html` writes `.vitest/index.html`
68+
69+
The `json` and `junit` locations can be overridden with the `outputFile` [configuration option](/config/outputfile) in your Vitest configuration file or via CLI. The `html` reporter uses its [`outputDir`](#html-reporter) option instead.
6470

6571
:::code-group
6672
```bash [CLI]
@@ -77,6 +83,30 @@ export default defineConfig({
7783
```
7884
:::
7985

86+
The `json` and `junit` reporters also accept `outputFile` as a reporter option, which takes precedence over the top-level `outputFile`:
87+
88+
```ts [vitest.config.ts]
89+
export default defineConfig({
90+
test: {
91+
reporters: [['json', { outputFile: './test-output.json' }]],
92+
},
93+
})
94+
```
95+
96+
To print the report to the terminal instead of writing it to a file, set the `stdout` option on the `json` or `junit` reporter. This is ignored when `outputFile` is set:
97+
98+
```ts [vitest.config.ts]
99+
export default defineConfig({
100+
test: {
101+
reporters: [['json', { stdout: true }]],
102+
},
103+
})
104+
```
105+
106+
::: warning
107+
When `stdout` is enabled, the report can be interleaved with other output written directly to the terminal — for example `process.stdout.write` in a test file, or logs from the main process such as a global setup file — which can make the JSON or XML unparsable. Prefer the default file output when you need to consume the report programmatically.
108+
:::
109+
80110
## Combining Reporters
81111

82112
You can use multiple reporters simultaneously to print your test results in different formats. For example:
@@ -316,7 +346,7 @@ Example terminal output for a passing test suite:
316346

317347
### JUnit Reporter
318348

319-
Outputs a report of the test results in JUnit XML format. Can either be printed to the terminal or written to an XML file using the [`outputFile`](/config/outputfile) configuration option.
349+
Outputs a report of the test results in JUnit XML format. By default it is written to `.vitest/junit/output.xml`. To write it elsewhere, use the [`outputFile`](/config/outputfile) configuration option or the reporter's own `outputFile` option. To print it to the terminal instead, set the reporter's [`stdout`](#reporter-output) option.
320350

321351
:::code-group
322352
```bash [CLI]
@@ -420,7 +450,7 @@ export default defineConfig({
420450

421451
### JSON Reporter
422452

423-
Generates a report of the test results in a JSON format compatible with Jest's `--json` option. Can either be printed to the terminal or written to a file using the [`outputFile`](/config/outputfile) configuration option.
453+
Generates a report of the test results in a JSON format compatible with Jest's `--json` option. By default it is written to `.vitest/json/output.json`. To write it elsewhere, use the [`outputFile`](/config/outputfile) configuration option or the reporter's own `outputFile` option. To print it to the terminal instead, set the reporter's [`stdout`](#reporter-output) option.
424454

425455
:::code-group
426456
```bash [CLI]

packages/vitest/src/node/reporters/json.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ export interface JsonTestResults {
7575

7676
export interface JsonOptions {
7777
outputFile?: string
78+
/**
79+
* Print the report to stdout instead of writing it to a file.
80+
* Ignored when {@link outputFile} is set.
81+
* @default false
82+
*/
83+
stdout?: boolean
7884
/** @experimental */
7985
filterMeta?: (key: string, value: unknown) => unknown
8086
}
@@ -220,15 +226,7 @@ export class JsonReporter implements Reporter {
220226
coverageMap: this.coverageMap,
221227
}
222228

223-
await this.writeReport(JSON.stringify(result))
224-
}
225-
226-
/**
227-
* Writes the report to an output file if specified in the config,
228-
* or logs it to the console otherwise.
229-
* @param report
230-
*/
231-
async writeReport(report: string): Promise<void> {
229+
const resultString = JSON.stringify(result)
232230
const outputFile
233231
= this.options.outputFile ?? getOutputFile(this.ctx.config, 'json')
234232

@@ -240,11 +238,16 @@ export class JsonReporter implements Reporter {
240238
await fs.mkdir(outputDirectory, { recursive: true })
241239
}
242240

243-
await fs.writeFile(reportFile, report, 'utf-8')
241+
await fs.writeFile(reportFile, resultString, 'utf-8')
244242
this.ctx.logger.log(`JSON report written to ${reportFile}`)
245243
}
244+
else if (this.options.stdout) {
245+
this.ctx.logger.log(resultString)
246+
}
246247
else {
247-
this.ctx.logger.log(report)
248+
const report = this.ctx.createReport('json')
249+
await report.writeFile('output.json', resultString)
250+
this.ctx.logger.log(`JSON report written to ${resolve(report.root, 'output.json')}`)
248251
}
249252
}
250253
}

packages/vitest/src/node/reporters/junit.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ export interface SuiteNameTemplateVariables {
5050
export interface JUnitOptions {
5151
outputFile?: string
5252

53+
/**
54+
* Print the report to stdout instead of writing it to a file.
55+
* Ignored when {@link outputFile} is set.
56+
* @default false
57+
*/
58+
stdout?: boolean
59+
5360
/**
5461
* Template for the `classname` attribute of `<testcase>`.
5562
*
@@ -256,7 +263,13 @@ export class JUnitReporter implements Reporter {
256263
if (!existsSync(outputDirectory)) {
257264
await fs.mkdir(outputDirectory, { recursive: true })
258265
}
266+
}
267+
else if (!this.options.stdout) {
268+
const report = this.ctx.createReport('junit')
269+
this.reportFile = resolve(report.root, 'output.xml')
270+
}
259271

272+
if (this.reportFile) {
260273
const fileFd = await fs.open(this.reportFile, 'w+')
261274
this.fileFd = fileFd
262275

test/e2e/test/annotations.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { TestAnnotation, TestArtifact } from 'vitest'
2-
import { readdirSync } from 'node:fs'
2+
import { readdirSync, readFileSync } from 'node:fs'
33
import path from 'node:path'
44
import { playwright } from '@vitest/browser-playwright'
5+
import { resolve } from 'pathe'
56
import { describe, expect, test } from 'vitest'
67
import { runInlineTests } from '../../test-utils'
78

@@ -371,7 +372,7 @@ describe('reporters', () => {
371372
})
372373

373374
test('junit reporter prints annotations', async () => {
374-
const { stdout } = await runInlineTests(
375+
const { root } = await runInlineTests(
375376
{
376377
'basic.test.ts': annotationTest,
377378
'test-3.js': test3Content,
@@ -380,7 +381,7 @@ describe('reporters', () => {
380381
{ reporters: ['junit'] },
381382
)
382383

383-
const result = stdout
384+
const result = readFileSync(resolve(root, '.vitest/junit/output.xml'), 'utf-8')
384385
.replace(/time="[\d.]+"/g, 'time="0"')
385386
.replace(/timestamp="[\w\-:.]+"/g, 'timestamp="0"')
386387
.replace(/hostname="[\w.\-]+"/g, 'hostname="CI"')

test/e2e/test/artifacts.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { TestAnnotation, TestArtifact } from 'vitest'
2+
import { readFileSync } from 'node:fs'
23
import { format } from 'node:util'
34
import { playwright } from '@vitest/browser-playwright'
5+
import { resolve } from 'pathe'
46
import { describe, expect, test } from 'vitest'
57
import { runInlineTests } from '../../test-utils'
68

@@ -311,7 +313,7 @@ describe('reporters', () => {
311313
})
312314

313315
test('junit', async () => {
314-
const { stdout } = await runInlineTests(
316+
const { root } = await runInlineTests(
315317
{
316318
'basic.test.ts': artifactsTest,
317319
'test-3.js': test3Content,
@@ -320,7 +322,7 @@ describe('reporters', () => {
320322
{ reporters: ['junit'] },
321323
)
322324

323-
const result = stdout
325+
const result = readFileSync(resolve(root, '.vitest/junit/output.xml'), 'utf-8')
324326
.replace(/time="[\d.]+"/g, 'time="0"')
325327
.replace(/timestamp="[\w\-:.]+"/g, 'timestamp="0"')
326328
.replace(/hostname="[\w.\-]+"/g, 'hostname="CI"')

test/e2e/test/benchmarking.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { BaselineData, BenchResult, TestBenchmark, TestBenchmarkTask } from 'vitest'
22
import type { JsonTestResults } from 'vitest/node'
3+
import { readFileSync } from 'node:fs'
4+
import { resolve } from 'pathe'
35
import { expect, test } from 'vitest'
46
import { runInlineTests } from '../../test-utils'
57

@@ -322,7 +324,7 @@ test('`bench(..., { perProject: true }, fn)` records a perProject task in the in
322324
})
323325

324326
test('junit reporter embeds the benchmark table inside <system-out>', async () => {
325-
const { stdout } = await runInlineTests(
327+
const { root } = await runInlineTests(
326328
{
327329
'junit.bench.ts': /* ts */`
328330
import { test, inject } from 'vitest'
@@ -343,10 +345,12 @@ test('junit reporter embeds the benchmark table inside <system-out>', async () =
343345
},
344346
)
345347

348+
const xml = readFileSync(resolve(root, '.vitest/junit/output.xml'), 'utf-8')
349+
346350
// extract the <system-out> block from the rendered XML
347351
// eslint-disable-next-line regexp/no-super-linear-backtracking
348-
const systemOut = stdout.match(/<system-out>\s*\n([\s\S]*?)<\/system-out>/)?.[1]
349-
expect(systemOut, stdout).toBeDefined()
352+
const systemOut = xml.match(/<system-out>\s*\n([\s\S]*?)<\/system-out>/)?.[1]
353+
expect(systemOut, xml).toBeDefined()
350354

351355
// a header + 2 data rows — reformat through the shared helper so digits
352356
// collapse to `d+` and widths become measurement-independent
@@ -908,7 +912,7 @@ test('`vitest bench` CLI invocation filters to the cloned benchmark project', as
908912
})
909913

910914
test('json reporter surfaces benchmarks on each assertion result', async () => {
911-
const { stderr, stdout } = await runInlineTests(
915+
const { stderr, root } = await runInlineTests(
912916
{
913917
'foo.bench.ts': /* ts */`
914918
import { test, inject } from 'vitest'
@@ -928,7 +932,7 @@ test('json reporter surfaces benchmarks on each assertion result', async () => {
928932
},
929933
)
930934
expect(stderr).toBe('')
931-
const parsed = JSON.parse(stdout) as JsonTestResults
935+
const parsed = JSON.parse(readFileSync(resolve(root, '.vitest/json/output.json'), 'utf-8')) as JsonTestResults
932936
const assertionResults = parsed.testResults.flatMap(tr => tr.assertionResults)
933937
const smoke = assertionResults.find(a => a.title === 'smoke')!
934938
expect(

test/e2e/test/reporters/json.test.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readFileSync } from 'node:fs'
12
import { runVitest } from '#test-utils'
23
import { resolve } from 'pathe'
34

@@ -7,15 +8,19 @@ describe('json reporter', async () => {
78
const root = resolve(import.meta.dirname, '..', '..', 'fixtures', 'reporters')
89
const projectRoot = resolve(import.meta.dirname, '..', '..', '..', '..')
910

11+
function readJsonReport() {
12+
return JSON.parse(readFileSync(resolve(root, '.vitest/json/output.json'), 'utf-8'))
13+
}
14+
1015
it('generates correct report', async () => {
11-
const { stdout } = await runVitest({
16+
await runVitest({
1217
reporters: 'json',
1318
root,
1419
include: ['**/json-fail-import.test.ts', '**/json-fail.test.ts'],
1520
includeTaskLocation: true,
1621
}, ['json-fail'])
1722

18-
const data = JSON.parse(stdout)
23+
const data = readJsonReport()
1924

2025
expect(data.testResults).toHaveLength(2)
2126

@@ -40,13 +45,13 @@ describe('json reporter', async () => {
4045
})
4146

4247
it('generates empty json with success: false', async () => {
43-
const { stdout } = await runVitest({
48+
await runVitest({
4449
reporters: 'json',
4550
root,
4651
includeTaskLocation: true,
4752
}, ['json-non-existing-files'])
4853

49-
const json = JSON.parse(stdout)
54+
const json = readJsonReport()
5055
json.startTime = 0
5156
expect(json).toMatchInlineSnapshot(`
5257
{
@@ -83,14 +88,14 @@ describe('json reporter', async () => {
8388
})
8489

8590
it('generates empty json with success: true', async () => {
86-
const { stdout } = await runVitest({
91+
await runVitest({
8792
reporters: 'json',
8893
root,
8994
includeTaskLocation: true,
9095
passWithNoTests: true,
9196
}, ['json-non-existing-files'])
9297

93-
const json = JSON.parse(stdout)
98+
const json = readJsonReport()
9499
json.startTime = 0
95100
expect(json).toMatchInlineSnapshot(`
96101
{
@@ -131,42 +136,56 @@ describe('json reporter', async () => {
131136
['passed', 'all-skipped.test.ts'],
132137
['failed', 'some-failing.test.ts'],
133138
])('resolves to "%s" status for test file "%s"', async (expected, file) => {
134-
const { stdout } = await runVitest({ reporters: 'json', root, include: [`**/${file}`] })
139+
await runVitest({ reporters: 'json', root, include: [`**/${file}`] })
135140

136-
const data = JSON.parse(stdout)
141+
const data = readJsonReport()
137142

138143
expect(data.testResults).toHaveLength(1)
139144
expect(data.testResults[0].status).toBe(expected)
140145
})
141146

142147
it('includes all meta fields when filterMeta is not set', async () => {
143-
const { stdout } = await runVitest({
148+
await runVitest({
144149
reporters: 'json',
145150
root,
146151
include: ['**/json-meta.test.ts'],
147152
})
148153

149-
const data = JSON.parse(stdout)
154+
const data = readJsonReport()
150155
const results = data.testResults[0].assertionResults
151156
const passing = results.find((r: any) => r.title === 'pass')
152157

153158
expect(passing.meta).toEqual({ custom: 'Passing test added this' })
154159
})
155160

156161
it('filterMeta filters meta fields by key', async () => {
157-
const { stdout } = await runVitest({
162+
await runVitest({
158163
reporters: [['json', {
159164
filterMeta: key => key !== 'custom',
160165
}]],
161166
root,
162167
include: ['**/json-meta.test.ts'],
163168
})
164169

165-
const data = JSON.parse(stdout)
170+
const data = readJsonReport()
166171
const results = data.testResults[0].assertionResults
167172

168173
for (const result of results) {
169174
expect(result.meta).toEqual({})
170175
}
171176
})
177+
178+
it('prints report to stdout when stdout option is set', async () => {
179+
const { stdout } = await runVitest({
180+
reporters: [['json', { stdout: true }]],
181+
root,
182+
include: ['**/ok.test.ts'],
183+
})
184+
const data = JSON.parse(stdout)
185+
expect(data).toMatchObject({
186+
numTotalTests: 1,
187+
numPassedTests: 1,
188+
success: true,
189+
})
190+
})
172191
})

0 commit comments

Comments
 (0)