[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui)!: change html reporter default output to `.vitest` (#10620)

Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com>

authored by

Hiroshi Ogawa
Hiroshi Ogawa
and committed by
GitHub
(Jun 19, 2026, 6:24 PM +0900) 29c364d5 4c26d767

+91 -101
+6 -6
.github/workflows/ci.yml
··· 323 323 run: | 324 324 set +e 325 325 pnpm --filter=./packages/ui --no-bail test:ui --merge-reports 326 - cp ./packages/ui/.vitest/html/index.html ./packages/ui/.vitest/html/test-ui.html 326 + cp ./packages/ui/.vitest/index.html ./packages/ui/.vitest/test-ui.html 327 327 pnpm --filter=./test/unit --filter=./test/e2e --no-bail --sequential test --merge-reports 328 - cp -rf ./test/unit/.vitest/html/index.html ./test/unit/.vitest/html/test-unit.html 329 - cp -rf ./test/e2e/.vitest/html/index.html ./test/e2e/.vitest/html/test-e2e.html 328 + cp -rf ./test/unit/.vitest/index.html ./test/unit/.vitest/test-unit.html 329 + cp -rf ./test/e2e/.vitest/index.html ./test/e2e/.vitest/test-e2e.html 330 330 env: 331 331 VITEST_CI_MERGE_REPORTS: 'true' 332 332 ··· 334 334 id: upload-ui-report 335 335 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 336 336 with: 337 - path: ./packages/ui/.vitest/html/test-ui.html 337 + path: ./packages/ui/.vitest/test-ui.html 338 338 archive: false 339 339 retention-days: 7 340 340 ··· 342 342 id: upload-unit-report 343 343 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 344 344 with: 345 - path: ./test/unit/.vitest/html/test-unit.html 345 + path: ./test/unit/.vitest/test-unit.html 346 346 archive: false 347 347 retention-days: 7 348 348 ··· 350 350 id: upload-e2e-report 351 351 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 352 352 with: 353 - path: ./test/e2e/.vitest/html/test-e2e.html 353 + path: ./test/e2e/.vitest/test-e2e.html 354 354 archive: false 355 355 retention-days: 7 356 356
-3
.gitignore
··· 30 30 test/browser/fixtures/update-snapshot/basic.test.ts 31 31 test/e2e/fixtures/browser-multiple/basic-* 32 32 *.tsbuildinfo 33 - # exclude static html reporter folder 34 - test/browser/html/ 35 - test/unit/html/ 36 33 .vitest 37 34 explainFiles.txt 38 35 .vitest-dump
+1 -1
docs/config/outputfile.md
··· 8 8 - **Type:** `string | Record<string, string>` 9 9 - **CLI:** `--outputFile=<path>`, `--outputFile.json=./path` 10 10 11 - Write test results to a file when the `--reporter=json`, `--reporter=html` or `--reporter=junit` option is also specified. 11 + Write test results to a file when the `--reporter=json` or `--reporter=junit` option is also specified. 12 12 By providing an object instead of a string you can define individual outputs when using multiple reporters.
+8
docs/guide/migration.md
··· 174 174 175 175 If you manually opened the browser preview by copying the Vite server URL or visiting `/__vitest_test__/` directly, use the URL opened or printed by Vitest instead. 176 176 177 + ### Generated Reports and Artifacts Use the `.vitest` Directory 178 + 179 + Vitest now uses a single `.vitest` directory at the project root as the shared artifact root, so one `.vitest` entry in `.gitignore` is enough. Defaults that moved this major: 180 + 181 + - **Attachments** ([`attachmentsDir`](/config/attachmentsdir)): `.vitest-attachements/` → `.vitest/attachments/` 182 + - **Blob reporter** and `--merge-reports`: `.vitest-reports/blob-*.json` → `.vitest/blob/blob-*.json` 183 + - **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 + 177 185 ## Migrating to Vitest 4.0 {#vitest-4} 178 186 179 187 ::: warning Prerequisites
+2 -2
docs/guide/reporters.md
··· 60 60 61 61 ## Reporter Output 62 62 63 - By default, Vitest's reporters will print their output to the terminal. When using the `json`, `html` 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. 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. 64 64 65 65 :::code-group 66 66 ```bash [CLI] ··· 506 506 507 507 Generates an HTML file to view test results through an interactive [GUI](/guide/ui). After the file has been generated, Vitest will keep a local development server running and provide a link to view the report in a browser. 508 508 509 - Output file can be specified using the [`outputFile`](/config/outputfile) configuration option. If no `outputFile` option is provided, a new HTML file will be created. 509 + The report artifact root can be specified using the reporter's `outputDir` option. The report entry is written to `<outputDir>/index.html` and the UI assets files live under `<outputDir>/ui/`. By default `outputDir` is `.vitest`, the shared Vitest artifact directory, so attachments (`.vitest/attachments`) and coverage (`.vitest/coverage`) are reused without being copied. 510 510 511 511 :::code-group 512 512 ```bash [CLI]
+4 -4
docs/guide/ui.md
··· 51 51 To preview your HTML report, you can use the [vite preview](https://vitejs.dev/guide/cli.html#vite-preview) command: 52 52 53 53 ```sh 54 - npx vite preview --outDir ./html 54 + npx vite preview --outDir .vitest 55 55 ``` 56 56 57 - You can configure output with [`outputFile`](/config/outputfile) config option. You need to specify `.html` path there. For example, `./html/index.html` is the default value. 57 + You can configure the output location with the HTML reporter's `outputDir` option. It points to the report artifact root, and the report entry is written to `<outputDir>/index.html`. The default value is `.vitest`, the shared Vitest artifact directory. 58 58 ::: 59 59 60 60 If you need a portable report that can be opened or shared as one file, see [`singleFile`](/guide/reporters#html-reporter) in the HTML reporter documentation. ··· 67 67 id: upload-report 68 68 with: 69 69 name: vitest-report 70 - path: html/ 70 + path: .vitest/ 71 71 72 72 - name: Viewer link in summary 73 73 run: echo "[View HTML report](https://viewer.vitest.dev/?url=${{ steps.upload-report.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY ··· 80 80 ```yaml 81 81 - uses: actions/upload-artifact@v7 82 82 with: 83 - path: html/index.html 83 + path: .vitest/index.html 84 84 archive: false 85 85 ``` 86 86 :::
-1
examples/lit/.gitignore
··· 1 1 __traces__ 2 2 __screenshots__ 3 - html
-1
packages/ui/.gitignore
··· 1 1 __screenshots__ 2 - html/
+1 -2
packages/ui/client/composables/attachments.ts
··· 7 7 const contentType = attachment.contentType ?? 'application/octet-stream' 8 8 if (attachment.path) { 9 9 if (isReport) { 10 - // html reporter copies attachments to /data/ folder 11 - return `./data/${basename(attachment.path)}` 10 + return `./attachments/${basename(attachment.path)}` 12 11 } 13 12 return `/__vitest_attachment__?path=${encodeURIComponent(attachment.path)}&contentType=${contentType}&token=${(window as any).VITEST_API_TOKEN}` 14 13 }
+46 -40
packages/ui/node/reporter.ts
··· 1 1 import type { SerializedError, TestAttachment } from 'vitest' 2 - import type { HTMLOptions, Reporter, ResolvedConfig, RunnerTask, RunnerTestFile, TestModule, Vitest } from 'vitest/node' 2 + import type { HTMLOptions, Reporter, RunnerTask, RunnerTestFile, TestModule, Vitest } from 'vitest/node' 3 3 import type { HTMLReportMetadata } from '../client/composables/client/static' 4 4 import { existsSync, promises as fs, readFileSync } from 'node:fs' 5 5 import { promisify } from 'node:util' ··· 12 12 13 13 const gzipAsync = promisify(gzip) 14 14 15 - function getOutputFile(config: ResolvedConfig) { 16 - if (!config.outputFile) { 17 - return 18 - } 19 - 20 - if (typeof config.outputFile === 'string') { 21 - return config.outputFile 22 - } 23 - 24 - return config.outputFile.html 25 - } 26 - 27 15 export default class HTMLReporter implements Reporter { 28 16 ctx!: Vitest 29 17 options: HTMLOptions ··· 36 24 37 25 async onInit(ctx: Vitest): Promise<void> { 38 26 this.ctx = ctx 39 - const htmlFile 40 - = this.options.outputFile 41 - || getOutputFile(this.ctx.config) 42 - || 'html/index.html' 43 - const htmlFilePath = resolve(this.ctx.config.root, htmlFile) 44 - this.reporterDir = dirname(htmlFilePath) 27 + this.reporterDir = resolve( 28 + this.ctx.config.root, 29 + this.options.outputDir || '.vitest', 30 + ) 45 31 } 46 32 47 33 async onTestRunEnd( ··· 57 43 await inlineAttachments(result.files) 58 44 } 59 45 60 - // copy ui assets 61 - await fs.cp(distClientRoot, this.reporterDir, { recursive: true }) 62 - 63 - // create index.html and metadata 64 46 const rawData = stringify(result) 65 47 const data = await gzipAsync(rawData, { 66 48 level: zlibConstants.Z_BEST_COMPRESSION, 67 49 }) 68 - await handleIndexHtml({ 69 - srcDir: distClientRoot, 70 - dstDir: this.reporterDir, 71 - data, 72 - singleFile: this.options.singleFile, 73 - }) 50 + 51 + await fs.mkdir(this.reporterDir, { recursive: true }) 74 52 75 - // copy attachments 76 - // TODO: unify attachmentsDir and html outputFile, so both live together without extra copy 77 - if (!this.options.singleFile && existsSync(this.ctx.config.attachmentsDir)) { 78 - const destAttachmentsDir = resolve(this.reporterDir, 'data') 79 - await fs.rm(destAttachmentsDir, { recursive: true, force: true }) 80 - await fs.mkdir(destAttachmentsDir, { recursive: true }) 81 - await fs.cp(this.ctx.config.attachmentsDir, destAttachmentsDir, { recursive: true }) 53 + if (this.options.singleFile) { 54 + // write a single self-contained `<outputDir>/index.html` 55 + await handleIndexHtml({ 56 + srcDir: distClientRoot, 57 + dstDir: this.reporterDir, 58 + data, 59 + singleFile: true, 60 + }) 61 + } 62 + else { 63 + // copy ui assets into `<outputDir>/ui` 64 + const uiDir = resolve(this.reporterDir, 'ui') 65 + await fs.rm(uiDir, { recursive: true, force: true }) 66 + await fs.cp(distClientRoot, uiDir, { recursive: true }) 67 + // no need of ui/index.html 68 + await fs.rm(resolve(uiDir, 'index.html'), { force: true }) 69 + // create `<outputDir>/index.html` and `<outputDir>/ui/html.meta.json.gz` 70 + await handleIndexHtml({ 71 + srcDir: distClientRoot, 72 + dstDir: this.reporterDir, 73 + data, 74 + singleFile: false, 75 + }) 76 + 77 + // copy attachments into `<outputDir>/attachments` if needed. 78 + // the default location matches so no extra copy. 79 + const attachmentsDir = this.ctx.config.attachmentsDir 80 + const destAttachmentsDir = resolve(this.reporterDir, 'attachments') 81 + if (existsSync(attachmentsDir) && attachmentsDir !== destAttachmentsDir) { 82 + await fs.rm(destAttachmentsDir, { recursive: true, force: true }) 83 + await fs.mkdir(destAttachmentsDir, { recursive: true }) 84 + await fs.cp(attachmentsDir, destAttachmentsDir, { recursive: true }) 85 + } 82 86 } 83 87 84 88 this.ctx.logger.log( ··· 99 103 const destCoverageDir = resolve(this.reporterDir, 'coverage') 100 104 if (coverageHtmlDir === destCoverageDir) { 101 105 // skip and preserve already generated coverage report. 102 - // this can happen when users configures `outputFile` 103 - // next to `coverage.reportsDirectory`. 106 + // this can happen when the report `outputDir` resolves next to 107 + // `coverage.reportsDirectory` (e.g. default `.vitest/coverage`). 104 108 return 105 109 } 106 110 await fs.rm(destCoverageDir, { recursive: true, force: true }) ··· 204 208 } 205 209 else { 206 210 const dataFile = 'html.meta.json.gz' 207 - await fs.writeFile(resolve(options.dstDir, dataFile), options.data) 208 - metadataCode = `fetch(new URL("./${dataFile}", window.location.href)).then(async res => new Uint8Array(await res.arrayBuffer()))` 211 + await fs.writeFile(resolve(options.dstDir, 'ui', dataFile), options.data) 212 + metadataCode = `fetch(new URL("./ui/${dataFile}", window.location.href)).then(async res => new Uint8Array(await res.arrayBuffer()))` 213 + // rewrite the asset path from `./*` to `./ui/*` 214 + html = html.replace(/\b(href|src)="\.\//g, '$1="./ui/') 209 215 } 210 216 211 217 await fs.writeFile(
+1 -1
packages/ui/vite.config.ts
··· 130 130 server.middlewares.use(async (req, res, next) => { 131 131 const url = new URL(req.url || '', `http://localhost`) 132 132 if (url.pathname === `/${REPORT_FILE}`) { 133 - const data = fs.readFileSync(path.join(htmlDir, REPORT_FILE)) 133 + const data = fs.readFileSync(path.join(htmlDir, 'ui', REPORT_FILE)) 134 134 res.end(data) 135 135 return 136 136 }
+1 -7
packages/ui/vitest.config.ts
··· 31 31 ? ['blob', { label: process.env.VITEST_CI_BLOB_LABEL }] 32 32 : {}, 33 33 process.env.VITEST_CI_MERGE_REPORTS 34 - ? [ 35 - 'html', 36 - { 37 - outputFile: '.vitest/html/index.html', 38 - singleFile: true, 39 - }, 40 - ] 34 + ? ['html', { singleFile: true }] 41 35 : {}, 42 36 ...configDefaults.reporters, 43 37 ],
+7 -3
packages/vitest/src/node/reporters/html.ts
··· 1 1 export interface HTMLOptions { 2 2 /** 3 - * Path to the generated HTML report. 3 + * Directory used as the report artifact root. 4 4 * 5 - * @default 'html/index.html' 5 + * The report entry is written to `<outputDir>/index.html` and the UI 6 + * implementation files live under `<outputDir>/ui/`. By default this is the 7 + * shared `.vitest` artifact directory. 8 + * 9 + * @default '.vitest' 6 10 */ 7 - outputFile?: string 11 + outputDir?: string 8 12 /** 9 13 * Inline report assets, metadata, and attachments into the generated HTML file. 10 14 *
+1 -1
packages/vitest/src/node/reporters/utils.ts
··· 53 53 else if (reporterName in ReportersMap) { 54 54 const BuiltinReporter 55 55 = ReportersMap[reporterName as BuiltinReporters] 56 - return new BuiltinReporter(reporterOptions) 56 + return new BuiltinReporter(reporterOptions as any) 57 57 } 58 58 else { 59 59 const CustomReporter = await loadCustomReporterModule(
-1
test/browser/fixtures/trace/.gitignore
··· 1 - html/
+1 -1
test/browser/fixtures/trace/vitest.config.ts
··· 5 5 // TEST_BROWSER=chromium pnpm -C test/browser test-fixtures --root fixtures/trace 6 6 // TEST_BROWSER=chromium pnpm -C test/browser test-fixtures --root fixtures/trace --ui --browser.headless 7 7 // TEST_BROWSER=chromium pnpm -C test/browser test-fixtures --root fixtures/trace --reporter=html --browser.headless --run 8 - // pnpm dlx serve test/browser/fixtures/trace/html 8 + // pnpm dlx serve test/browser/fixtures/trace/.vitest 9 9 export default defineConfig({ 10 10 test: { 11 11 includeTaskLocation: true,
-1
test/browser/vitest.config.mts
··· 80 80 }, 81 81 diff: './custom-diff-config.ts', 82 82 outputFile: { 83 - html: './html/index.html', 84 83 json: './browser.json', 85 84 }, 86 85 onConsoleLog(log) {
-1
test/e2e/fixtures/reporters/.gitignore
··· 1 - html
+4 -4
test/e2e/test/reporters/html.test.ts
··· 18 18 } 19 19 `) 20 20 expect(result.exitCode).toBe(0) 21 - expect(result.fs.statFile('html/index.html').isFile()).toBe(true) 21 + expect(result.fs.statFile('.vitest/index.html').isFile()).toBe(true) 22 22 }) 23 23 24 24 it('singleFile', async () => { ··· 40 40 } 41 41 `) 42 42 expect(result.exitCode).toBe(0) 43 - expect(result.fs.statFile('html/index.html').isFile()).toBe(true) 43 + expect(result.fs.statFile('.vitest/index.html').isFile()).toBe(true) 44 44 }) 45 45 46 46 it('browser mode headless', async () => { ··· 68 68 }, 69 69 } 70 70 `) 71 - expect(result.fs.statFile('html/index.html').isFile()).toBe(true) 71 + expect(result.fs.statFile('.vitest/index.html').isFile()).toBe(true) 72 72 }) 73 73 74 74 it('html and coverage already next each other', async () => { ··· 86 86 }, { 87 87 reporters: [ 88 88 'default', 89 - ['html', { outputFile: './custom-dir/index.html' }], 89 + ['html', { outputDir: './custom-dir' }], 90 90 ], 91 91 coverage: { 92 92 enabled: true,
+1 -1
test/e2e/test/reporters/merge-reports.test.ts
··· 487 487 expect(result3.stderr).toMatchInlineSnapshot(`""`) 488 488 expect(result3.stdout).toMatchInlineSnapshot(` 489 489 " HTML Report is generated 490 - You can run npx vite preview --outDir html to see the test results. 490 + You can run npx vite preview --outDir .vitest to see the test results. 491 491 " 492 492 `) 493 493 })
+1 -7
test/e2e/vitest.config.ts
··· 10 10 ? ['blob', { label: process.env.VITEST_CI_BLOB_LABEL }] 11 11 : {}), 12 12 (process.env.VITEST_CI_MERGE_REPORTS 13 - ? [ 14 - 'html', 15 - { 16 - outputFile: '.vitest/html/index.html', 17 - singleFile: true, 18 - }, 19 - ] 13 + ? ['html', { singleFile: true }] 20 14 : {}), 21 15 ], 22 16 onConsoleLog(log) {
-1
test/ui/.gitignore
··· 1 - html 2 1 test-results
+1 -1
test/ui/fixtures/single-file/vitest.config.ts
··· 1 1 import { configDefaults, defineConfig } from 'vitest/config' 2 2 3 3 // pnpm -C test/ui test-fixtures --root fixtures/single-file --run 4 - // pnpm dlx serve test/ui/fixtures/single-file/html/ 4 + // pnpm dlx serve test/ui/fixtures/single-file/.vitest/ 5 5 6 6 export default defineConfig({ 7 7 test: {
+1 -1
test/ui/test/merge-reports.spec.ts
··· 45 45 }, 46 46 { 47 47 root: linuxRoot, 48 - build: { outDir: 'html' }, 48 + build: { outDir: '.vitest' }, 49 49 }, 50 50 ) 51 51
+1 -1
test/ui/test/single-file.spec.ts
··· 15 15 }, 16 16 { 17 17 root, 18 - build: { outDir: 'html' }, 18 + build: { outDir: '.vitest' }, 19 19 }, 20 20 ) 21 21 previewServer = server.previewServer
+1 -1
test/ui/test/trace.spec.ts
··· 83 83 }, 84 84 { 85 85 root, 86 - build: { outDir: 'html' }, 86 + build: { outDir: '.vitest' }, 87 87 }, 88 88 ) 89 89 previewServer = server.previewServer
+1 -1
test/ui/test/ui.spec.ts
··· 167 167 { 168 168 root: './fixtures/main', 169 169 base: '/custom/base/', 170 - build: { outDir: 'html' }, 170 + build: { outDir: '.vitest' }, 171 171 }, 172 172 ) 173 173 previewServer = server.previewServer
+1 -7
test/unit/vite.config.ts
··· 133 133 ? [['blob', { label: process.env.VITEST_CI_BLOB_LABEL }]] 134 134 : []), 135 135 process.env.VITEST_CI_MERGE_REPORTS 136 - ? [ 137 - 'html', 138 - { 139 - outputFile: '.vitest/html/index.html', 140 - singleFile: true, 141 - }, 142 - ] 136 + ? ['html', { singleFile: true }] 143 137 : {}, 144 138 ], 145 139 testNamePattern: '^((?!does not include test that).)*$',