[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(reporter): support html reporter single file output (#10235)

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

authored by

Hiroshi Ogawa
Codex
Claude Opus 4.7 (1M context)
and committed by
GitHub
(May 21, 2026, 3:22 PM +0200) f757ec5e 8f0932b3

+430 -45
+35 -17
.github/workflows/ci.yml
··· 129 129 130 130 - name: Unit Test UI 131 131 run: pnpm -C packages/ui test:ui 132 + env: 133 + VITEST_CI_BLOB_LABEL: ${{ matrix.os }}-node-${{ matrix.node_version }} 132 134 133 135 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 134 136 if: ${{ !cancelled() }} ··· 145 147 README.md 146 148 test/unit/.vitest 147 149 test/e2e/.vitest 150 + packages/ui/.vitest 148 151 retention-days: 1 149 152 include-hidden-files: true 150 153 ··· 308 311 309 312 - name: Merge reports 310 313 continue-on-error: true 311 - run: pnpm --filter=./test/unit --filter=./test/e2e --no-bail --sequential test --merge-reports --reporter=html 312 - 313 - - name: Merge reports html 314 - id: merge-html 315 314 run: | 316 - mkdir -p html-all 317 - cp -rf test/unit/html html-all/unit 318 - cp -rf test/e2e/html html-all/e2e 319 - echo "short_sha=${GITHUB_SHA:0:7}" >> $GITHUB_OUTPUT 315 + pnpm --filter=./packages/ui --no-bail test:ui --merge-reports 316 + cp ./packages/ui/.vitest/html/index.html ./packages/ui/.vitest/html/test-ui.html 317 + pnpm --filter=./test/unit --filter=./test/e2e --no-bail --sequential test --merge-reports 318 + cp -rf ./test/unit/.vitest/html/index.html ./test/unit/.vitest/html/test-unit.html 319 + cp -rf ./test/e2e/.vitest/html/index.html ./test/e2e/.vitest/html/test-e2e.html 320 + env: 321 + VITEST_CI_MERGE_REPORTS: 'true' 320 322 321 - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 322 - id: upload-report 323 + - name: Upload Test UI Report 324 + id: upload-ui-report 325 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 323 326 with: 324 - name: vitest-ci-report-${{ steps.merge-html.outputs.short_sha }} 325 - path: html-all 327 + path: ./packages/ui/.vitest/html/test-ui.html 328 + archive: false 326 329 retention-days: 7 327 330 328 - - name: Link report viewer 331 + - name: Upload Test Unit Report 332 + id: upload-unit-report 333 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 334 + with: 335 + path: ./test/unit/.vitest/html/test-unit.html 336 + archive: false 337 + retention-days: 7 338 + 339 + - name: Upload Test E2E Report 340 + id: upload-e2e-report 341 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 342 + with: 343 + path: ./test/e2e/.vitest/html/test-e2e.html 344 + archive: false 345 + retention-days: 7 346 + 347 + - name: Link CI reports 329 348 run: | 330 - echo "::notice title=Vitest HTML report::View HTML report: https://viewer.vitest.dev/?url=${STEPS_UPLOAD_REPORT_OUTPUTS_ARTIFACT_URL}" 331 - echo "[View HTML report](https://viewer.vitest.dev/?url=${STEPS_UPLOAD_REPORT_OUTPUTS_ARTIFACT_URL})" >> $GITHUB_STEP_SUMMARY 332 - env: 333 - STEPS_UPLOAD_REPORT_OUTPUTS_ARTIFACT_URL: ${{ steps.upload-report.outputs.artifact-url }} 349 + echo "::notice title=CI reports::test/unit: ${{ steps.upload-unit-report.outputs.artifact-url }}" 350 + echo "::notice title=CI reports::test/e2e: ${{ steps.upload-e2e-report.outputs.artifact-url }}" 351 + echo "::notice title=CI reports::packages/ui: ${{ steps.upload-ui-report.outputs.artifact-url }}"
+23
docs/guide/reporters.md
··· 522 522 ``` 523 523 ::: 524 524 525 + Set `singleFile` to generate a self-contained HTML report: 526 + 527 + ```ts [vitest.config.ts] 528 + export default defineConfig({ 529 + test: { 530 + reporters: [ 531 + ['html', { singleFile: true }], 532 + ], 533 + }, 534 + }) 535 + ``` 536 + 537 + When `singleFile` is enabled, Vitest inlines the UI assets, metadata, and test attachments into a single self-contained `index.html`. This makes the report easy to share, upload, or download as one artifact instead of preserving the whole `html` output directory. 538 + 539 + ::: warning 540 + `singleFile` has two caveats: 541 + 542 + - The file can grow very large because everything is embedded inline — slow to open, memory-hungry, and possibly over the size limits of artifact viewers or static hosts. 543 + - Coverage HTML reports are not inlined yet and remain as separate files. 544 + 545 + Prefer the default multi-file report when the suite has many or large attachments, or when you need coverage included in the bundle. 546 + ::: 547 + 525 548 ::: tip 526 549 This reporter requires installed [`@vitest/ui`](/guide/ui) package. 527 550 :::
+12 -1
docs/guide/ui.md
··· 53 53 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. 54 54 ::: 55 55 56 + 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. 57 + 56 58 ::: tip 57 59 To view the HTML report from CI, for example in GitHub Actions, upload the output directory as an artifact: 58 60 59 61 ```yaml 60 - - uses: actions/upload-artifact@v4 62 + - uses: actions/upload-artifact@v7 61 63 id: upload-report 62 64 with: 63 65 name: vitest-report ··· 68 70 ``` 69 71 70 72 This adds a link to the job summary. Click it to open the report in [Vitest Viewer](https://viewer.vitest.dev/) directly in the browser. You can also download the artifact manually and extract it, then run `vite preview` locally as above. 73 + 74 + When you use `singleFile: true`, you can upload the report as a single file and it will become viewable directly GitHub artifacts with `archive: false` option: 75 + 76 + ```yaml 77 + - uses: actions/upload-artifact@v7 78 + with: 79 + path: html/index.html 80 + archive: false 81 + ``` 71 82 ::: 72 83 73 84 ## Module Graph
+2 -1
packages/ui/vite.config.ts
··· 119 119 return !!htmlDir && env.command === 'serve' && env.mode !== 'test' 120 120 }, 121 121 async transformIndexHtml() { 122 + const metadataCode = `window.HTML_REPORT_METADATA=fetch(new URL("./${REPORT_FILE}", window.location.href)).then(async res => new Uint8Array(await res.arrayBuffer()))` 122 123 return [ 123 124 { 124 125 tag: 'script', 125 - children: `window.METADATA_PATH="${REPORT_FILE}"`, 126 + children: metadataCode, 126 127 }, 127 128 ] 128 129 },
+16 -1
packages/ui/vitest.config.ts
··· 2 2 import { preview } from '@vitest/browser-preview' 3 3 import { webdriverio } from '@vitest/browser-webdriverio' 4 4 import { mergeConfig } from 'vite' 5 - import { defineConfig } from 'vitest/config' 5 + import { configDefaults, defineConfig } from 'vitest/config' 6 6 import viteConfig from './vite.config' 7 7 8 8 // known working set of tests for providers ··· 26 26 ], 27 27 }, 28 28 test: { 29 + reporters: [ 30 + process.env.VITEST_CI_BLOB_LABEL 31 + ? ['blob', { label: process.env.VITEST_CI_BLOB_LABEL }] 32 + : {}, 33 + process.env.VITEST_CI_MERGE_REPORTS 34 + ? [ 35 + 'html', 36 + { 37 + outputFile: '.vitest/html/index.html', 38 + singleFile: true, 39 + }, 40 + ] 41 + : {}, 42 + ...configDefaults.reporters, 43 + ], 29 44 browser: { 30 45 enabled: true, 31 46 traceView: true,
+9
test/e2e/vitest.config.ts
··· 9 9 (process.env.VITEST_CI_BLOB_LABEL 10 10 ? ['blob', { label: process.env.VITEST_CI_BLOB_LABEL }] 11 11 : {}), 12 + (process.env.VITEST_CI_MERGE_REPORTS 13 + ? [ 14 + 'html', 15 + { 16 + outputFile: '.vitest/html/index.html', 17 + singleFile: true, 18 + }, 19 + ] 20 + : {}), 12 21 ], 13 22 onConsoleLog(log) { 14 23 if (log.includes('watcher is ready')) {
+9
test/unit/vite.config.ts
··· 132 132 ...(process.env.VITEST_CI_BLOB_LABEL 133 133 ? [['blob', { label: process.env.VITEST_CI_BLOB_LABEL }]] 134 134 : []), 135 + process.env.VITEST_CI_MERGE_REPORTS 136 + ? [ 137 + 'html', 138 + { 139 + outputFile: '.vitest/html/index.html', 140 + singleFile: true, 141 + }, 142 + ] 143 + : {}, 135 144 ], 136 145 testNamePattern: '^((?!does not include test that).)*$', 137 146 coverage: {
+4 -2
docs/api/advanced/artifacts.md
··· 78 78 export interface TestAttachment { 79 79 /** MIME type of the attachment (e.g., 'image/png', 'text/plain') */ 80 80 contentType?: string 81 - /** File system path to the attachment */ 81 + /** Local file path or external HTTP(S) URL to the attachment. Relative paths are resolved from the project root. */ 82 82 path?: string 83 83 /** Inline attachment content as a string or raw binary data */ 84 84 body?: string | Uint8Array ··· 94 94 95 95 The `TestAttachment` interface represents a file or data attachment associated with a test artifact. 96 96 97 - Attachments can be either file-based (via `path`) or inline content (via `body`). The `contentType` helps consumers understand how to interpret the attachment data. 97 + Attachments can be either path-based (via `path`) or inline content (via `body`). The `contentType` helps consumers understand how to interpret the attachment data. 98 + 99 + Attachment `path` can point to a local file or an external `http`/`https` URL. Relative local paths are resolved from the project root. Local files are copied into Vitest's attachments directory before reporters receive them. External URLs are preserved as-is. 98 100 99 101 If you pass a string `body`, Vitest assumes it is already base64-encoded unless you set `bodyEncoding: 'utf-8'`. When you pass `body` as a `Uint8Array`, Vitest automatically encodes it as base64. The `bodyEncoding` option only applies to inline `body` attachments, not `path` attachments. 100 102
+1 -1
packages/ui/client/constants.ts
··· 3 3 export const ENTRY_URL = `${ 4 4 location.protocol === 'https:' ? 'wss:' : 'ws:' 5 5 }//${HOST}/__vitest_api__?token=${(window as any).VITEST_API_TOKEN}` 6 - export const isReport = !!window.METADATA_PATH 6 + export const isReport = !!window.HTML_REPORT_METADATA
+1 -1
packages/ui/client/shim.d.ts
··· 1 1 /// <reference types="vite/client" /> 2 2 3 3 declare interface Window { 4 - METADATA_PATH?: string 4 + HTML_REPORT_METADATA?: Promise<Uint8Array> 5 5 } 6 6 7 7 declare interface Error {
+136 -15
packages/ui/node/reporter.ts
··· 1 + import type { TestAttachment } from '@vitest/runner' 1 2 import type { SerializedError } from 'vitest' 2 - import type { HTMLOptions, Reporter, TestModule, Vitest } from 'vitest/node' 3 + import type { HTMLOptions, Reporter, RunnerTask, RunnerTestFile, TestModule, Vitest } from 'vitest/node' 3 4 import type { HTMLReportMetadata } from '../client/composables/client/static' 4 5 import { existsSync, promises as fs, readFileSync } from 'node:fs' 5 6 import { fileURLToPath } from 'node:url' ··· 64 65 testModules, 65 66 unhandledErrors, 66 67 ) 68 + if (this.options.singleFile) { 69 + await inlineAttachments(result.files) 70 + } 71 + 67 72 const report = stringify(result) 68 - 69 - const metaFile = resolve(this.reporterDir, 'html.meta.json.gz') 70 - 71 73 const promiseGzip = promisify(gzip) 72 74 const data = await promiseGzip(report, { 73 75 level: zlibConstants.Z_BEST_COMPRESSION, 74 76 }) 75 - await fs.writeFile(metaFile, data, 'base64') 76 77 const ui = resolve(distDir, 'client') 77 78 // copy ui 78 79 const files = globSync(['**/*'], { cwd: ui, expandDirectories: false }) 79 80 await Promise.all( 80 81 files.map(async (f) => { 81 82 if (f === 'index.html') { 82 - const html = await fs.readFile(resolve(ui, f), 'utf-8') 83 - const filePath = relative(this.reporterDir, metaFile) 84 - await fs.writeFile( 85 - this.htmlFilePath, 86 - html.replace( 87 - '<!-- !LOAD_METADATA! -->', 88 - `<script>window.METADATA_PATH="${filePath}"</script>`, 89 - ), 90 - ) 83 + await handleIndexHtml({ 84 + srcDir: ui, 85 + dstDir: this.reporterDir, 86 + data, 87 + singleFile: this.options.singleFile, 88 + }) 91 89 } 92 90 else { 93 91 await fs.copyFile(resolve(ui, f), resolve(this.reporterDir, f)) ··· 97 95 98 96 // copy attachments 99 97 // TODO: unify attachmentsDir and html outputFile, so both live together without extra copy 100 - if (existsSync(this.ctx.config.attachmentsDir)) { 98 + if (!this.options.singleFile && existsSync(this.ctx.config.attachmentsDir)) { 101 99 const destAttachmentsDir = resolve(this.reporterDir, 'data') 102 100 await fs.rm(destAttachmentsDir, { recursive: true, force: true }) 103 101 await fs.mkdir(destAttachmentsDir, { recursive: true }) ··· 208 206 await Promise.all(promises) 209 207 210 208 return result 209 + } 210 + 211 + async function handleIndexHtml(options: { 212 + dstDir: string 213 + srcDir: string 214 + data: Buffer 215 + singleFile?: boolean 216 + }): Promise<void> { 217 + const indexHtmlFilePath = resolve(options.srcDir, 'index.html') 218 + let html = await fs.readFile(indexHtmlFilePath, 'utf-8') 219 + let metadataCode: string 220 + 221 + if (options.singleFile) { 222 + html = await inlineHtmlAssets(indexHtmlFilePath, html) 223 + const base64 = Buffer.from(options.data).toString('base64') 224 + metadataCode = `Promise.resolve((${uint8ArrayFromBase64.toString()})("${base64}"))` 225 + } 226 + else { 227 + const dataFile = `html.meta.json.gz` 228 + await fs.writeFile(resolve(options.dstDir, dataFile), options.data, 'base64') 229 + metadataCode = `fetch(new URL("./${dataFile}", window.location.href)).then(async res => new Uint8Array(await res.arrayBuffer()))` 230 + } 231 + 232 + await fs.writeFile( 233 + resolve(options.dstDir, 'index.html'), 234 + html.replace( 235 + '<!-- !LOAD_METADATA! -->', 236 + `<script>window.HTML_REPORT_METADATA=${metadataCode}</script>`, 237 + ), 238 + ) 239 + } 240 + 241 + async function inlineAttachments(files: RunnerTestFile[]): Promise<void> { 242 + for (const file of files) { 243 + await inlineTaskAttachments(file) 244 + } 245 + } 246 + 247 + async function inlineTaskAttachments(task: RunnerTask): Promise<void> { 248 + if (task.type === 'suite') { 249 + for (const child of task.tasks) { 250 + await inlineTaskAttachments(child) 251 + } 252 + } 253 + if (task.type === 'test') { 254 + for (const annotation of task.annotations) { 255 + if (annotation.attachment) { 256 + await inlineTestAttachment(annotation.attachment) 257 + } 258 + } 259 + for (const artifact of task.artifacts) { 260 + for (const attachment of artifact.attachments ?? []) { 261 + await inlineTestAttachment(attachment) 262 + } 263 + } 264 + } 265 + } 266 + 267 + async function inlineTestAttachment(attachment: TestAttachment): Promise<void> { 268 + if (attachment.path && !attachment.path.startsWith('http://') && !attachment.path.startsWith('https://')) { 269 + try { 270 + const buffer = await fs.readFile(attachment.path) 271 + attachment.body = buffer.toString('base64') 272 + attachment.bodyEncoding = 'base64' 273 + attachment.path = undefined 274 + } 275 + catch { 276 + // Keep the path so report generation does not fail when an attachment 277 + // cannot be embedded. 278 + } 279 + } 280 + } 281 + 282 + function uint8ArrayFromBase64(base64: string): Uint8Array { 283 + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/fromBase64 284 + if ('fromBase64' in Uint8Array && typeof Uint8Array.fromBase64 === 'function') { 285 + return Uint8Array.fromBase64(base64) 286 + } 287 + function stringToUint8Array(binary: string): Uint8Array { 288 + const len = binary.length 289 + const arr = new Uint8Array(len) 290 + for (let i = 0; i < len; i++) { 291 + arr[i] = binary.charCodeAt(i) 292 + } 293 + return arr 294 + } 295 + return stringToUint8Array(atob(base64)) 296 + } 297 + 298 + // regex based inlining for packages/ui/dist/client/index.html 299 + async function inlineHtmlAssets(file: string, content: string): Promise<string> { 300 + const baseDir = dirname(file) 301 + content = content.replace( 302 + /<link rel="icon" href="\.\/favicon\.ico" sizes="48x48">\n/, 303 + '', 304 + ) 305 + content = content.replace( 306 + /<link rel="icon" href="(\.\/favicon\.svg)" sizes="any" type="image\/svg\+xml">/, 307 + (_, asset: string) => { 308 + const icon = readFileSync(resolve(baseDir, asset)).toString('base64') 309 + return `<link rel="icon" href="data:image/svg+xml;base64,${icon}" sizes="any" type="image/svg+xml">` 310 + }, 311 + ) 312 + content = content.replace( 313 + /<script type="module" src="(\.\/assets\/[^"]+\.js)"><\/script>/, 314 + (_, asset: string) => `<script type="module">${escapeInlineScript(readFileSync(resolve(baseDir, asset), 'utf-8'))}</script>`, 315 + ) 316 + content = content.replace( 317 + /<link rel="stylesheet" href="(\.\/assets\/[^"]+\.css)">/, 318 + (_, asset: string) => `<style>${escapeInlineStyle(readFileSync(resolve(baseDir, asset), 'utf-8'))}</style>`, 319 + ) 320 + return content 321 + } 322 + 323 + function escapeInlineScript(content: string): string { 324 + // https://github.com/devongovett/rsc-html-stream/blob/9b858445f4f5817470f373ae266dea04d5fcfac3/server.js#L94-L102 325 + return content 326 + .replace(/<!--/g, '<\\!--') 327 + .replace(/<\/(script)/gi, '</\\$1') 328 + } 329 + 330 + function escapeInlineStyle(content: string): string { 331 + return content.replace(/<\/style/gi, '<\\/style') 211 332 }
+58
test/ui/test/single-file.spec.ts
··· 1 + import type { PreviewServer } from 'vite' 2 + import { expect, test } from '@playwright/test' 3 + import { assertDownloadAttachment, assertTestCounts, openExplorerItem, startHtmlReportPreview } from './helper' 4 + 5 + test.describe('html singleFile', () => { 6 + let previewServer: PreviewServer 7 + let baseURL: string 8 + 9 + test.beforeAll(async () => { 10 + const root = './fixtures/single-file' 11 + const server = await startHtmlReportPreview( 12 + { 13 + root, 14 + run: true, 15 + }, 16 + { 17 + root, 18 + build: { outDir: 'html' }, 19 + }, 20 + ) 21 + previewServer = server.previewServer 22 + baseURL = `${server.url}/` 23 + }) 24 + 25 + test.afterAll(async () => { 26 + await previewServer.close() 27 + }) 28 + 29 + test('basic', async ({ page }) => { 30 + const requestUrls: string[] = [] 31 + const IGNORED_URLS = ['https://fonts.googleapis.com/', 'https://fonts.gstatic.com/'] 32 + page.on('request', (request) => { 33 + const url = request.url() 34 + if (!IGNORED_URLS.some(ignored => url.startsWith(ignored))) { 35 + requestUrls.push(url) 36 + } 37 + }) 38 + 39 + await page.goto(baseURL) 40 + await assertTestCounts(page, { pass: 2, fail: 1 }) 41 + 42 + // test inlined attachments 43 + await openExplorerItem(page, 'annotation') 44 + await assertDownloadAttachment(page, { 45 + name: 'annotation-body', 46 + suggestedFilename: 'annotation-body.txt', 47 + content: 'test-body-content', 48 + }) 49 + await assertDownloadAttachment(page, { 50 + name: 'annotation-path', 51 + suggestedFilename: 'annotation-path.txt', 52 + content: 'test-path-content\n', 53 + }) 54 + 55 + // validate index.html is the only origin request 56 + expect(requestUrls).toEqual([baseURL]) 57 + }) 58 + })
-1
test/ui/test/trace.spec.ts
··· 96 96 }) 97 97 98 98 test('basic', async ({ page }) => { 99 - await page.goto(baseURL) 100 99 await testBasic(page) 101 100 }) 102 101
+2 -2
packages/runner/src/types/tasks.ts
··· 1263 1263 /** 1264 1264 * Represents a file or data attachment associated with a test artifact. 1265 1265 * 1266 - * Attachments can be either file-based (via `path`) or inline content (via `body`). 1266 + * Attachments can be either path-based (via `path`) or inline content (via `body`). 1267 1267 * The `contentType` helps consumers understand how to interpret the attachment data. 1268 1268 */ 1269 1269 export interface TestAttachment { 1270 1270 /** MIME type of the attachment (e.g., 'image/png', 'text/plain') */ 1271 1271 contentType?: string 1272 - /** File system path to the attachment */ 1272 + /** Local file path or external HTTP(S) URL to the attachment. Relative paths are resolved from the project root. */ 1273 1273 path?: string 1274 1274 /** Inline attachment content as a string or raw binary data */ 1275 1275 body?: string | Uint8Array | undefined
+50
packages/ui/client/assets/favicon.svg
··· 1 + <svg width="45" height="43" viewBox="0 0 45 43" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <path d="M21.2659 42.2067C20.9342 42.797 20.163 42.965 19.6137 42.5665L0.465308 28.6398C0.172341 28.4265 0 28.0862 0 27.7243V10.939C0 10.0148 1.04907 9.48061 1.79657 10.0235L13.5088 18.541C14.6031 19.3381 16.1476 18.9999 16.8111 17.8215L26.5113 0.577439C26.7117 0.222001 27.0887 0.00012207 27.498 0.00012207H43.0726C43.9385 0.00012207 44.4836 0.932876 44.0592 1.68683L21.2659 42.2046V42.2067Z" fill="#22FF84"/> 3 + <mask id="mask0_2002_17204" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="45" height="43"> 4 + <path d="M21.2659 42.2066C20.9342 42.7968 20.163 42.9649 19.6137 42.5663L0.465308 28.6397C0.172341 28.4264 0 28.086 0 27.7241V10.9389C0 10.0147 1.04907 9.48049 1.79657 10.0233L13.5088 18.5409C14.6031 19.338 16.1476 18.9998 16.8111 17.8214L26.5113 0.577317C26.7117 0.221879 27.0887 0 27.498 0H43.0726C43.9385 0 44.4836 0.932754 44.0592 1.68671L21.2659 42.2044V42.2066Z" fill="black"/> 5 + </mask> 6 + <g mask="url(#mask0_2002_17204)"> 7 + <g filter="url(#filter0_f_2002_17204)"> 8 + <ellipse cx="14.0313" cy="3.39062" rx="14.0313" ry="3.39062" transform="matrix(-0.650066 0.759878 0.759879 0.650065 32.5093 -3.61884)" fill="#00CCC6"/> 9 + </g> 10 + <g filter="url(#filter1_f_2002_17204)"> 11 + <ellipse cx="14.0313" cy="6.0868" rx="14.0313" ry="6.0868" transform="matrix(-0.588245 -0.808682 -0.808682 0.588246 16.1094 37.5068)" fill="#00CCC6"/> 12 + </g> 13 + <g filter="url(#filter2_f_2002_17204)"> 14 + <ellipse cx="6.44313" cy="12.1953" rx="6.37416" ry="5.55082" transform="rotate(23.1293 6.44313 12.1953)" fill="#77FF00"/> 15 + </g> 16 + <g filter="url(#filter3_f_2002_17204)"> 17 + <ellipse cx="40.7746" cy="15.9509" rx="5.61461" ry="24.5343" transform="rotate(23.1293 40.7746 15.9509)" fill="#77FF00"/> 18 + </g> 19 + <g filter="url(#filter4_f_2002_17204)"> 20 + <ellipse cx="4.98534" cy="10.0691" rx="4.98534" ry="10.0691" transform="matrix(0.707107 -0.707107 -0.707107 -0.707107 39.4482 10.9414)" fill="#97FFE2"/> 21 + </g> 22 + </g> 23 + <defs> 24 + <filter id="filter0_f_2002_17204" x="7.29277" y="-10.8334" width="37.3432" height="40.1616" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> 25 + <feFlood flood-opacity="0" result="BackgroundImageFix"/> 26 + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> 27 + <feGaussianBlur stdDeviation="4.59556" result="effect1_foregroundBlur_2002_17204"/> 28 + </filter> 29 + <filter id="filter1_f_2002_17204" x="-15.8693" y="8.64781" width="37.6054" height="42.1854" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> 30 + <feFlood flood-opacity="0" result="BackgroundImageFix"/> 31 + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> 32 + <feGaussianBlur stdDeviation="4.59556" result="effect1_foregroundBlur_2002_17204"/> 33 + </filter> 34 + <filter id="filter2_f_2002_17204" x="-9.0041" y="-2.68275" width="30.8944" height="29.7561" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> 35 + <feFlood flood-opacity="0" result="BackgroundImageFix"/> 36 + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> 37 + <feGaussianBlur stdDeviation="4.59556" result="effect1_foregroundBlur_2002_17204"/> 38 + </filter> 39 + <filter id="filter3_f_2002_17204" x="20.6482" y="-15.9117" width="40.2528" height="63.7251" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> 40 + <feFlood flood-opacity="0" result="BackgroundImageFix"/> 41 + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> 42 + <feGaussianBlur stdDeviation="4.59556" result="effect1_foregroundBlur_2002_17204"/> 43 + </filter> 44 + <filter id="filter4_f_2002_17204" x="18.7156" y="-16.8414" width="34.2758" height="34.2754" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> 45 + <feFlood flood-opacity="0" result="BackgroundImageFix"/> 46 + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> 47 + <feGaussianBlur stdDeviation="4.59556" result="effect1_foregroundBlur_2002_17204"/> 48 + </filter> 49 + </defs> 50 + </svg>
+5 -1
packages/ui/client/components/Navigation.vue
··· 16 16 showCoverage, 17 17 showDashboard, 18 18 } from '~/composables/navigation' 19 + import faviconSvg from '../assets/favicon.svg?raw' 19 20 import Explorer from './explorer/Explorer.vue' 20 21 import IconButton from './IconButton.vue' 22 + 23 + // inline icon to make singleFile html reporter simpler 24 + const logoUrl = `data:image/svg+xml,${encodeURIComponent(faviconSvg)}` 21 25 22 26 function updateSnapshot() { 23 27 return client.rpc.updateSnapshot() ··· 66 70 <!-- TODO: have test tree so the folders are also nested: test -> filename -> suite -> test --> 67 71 <Explorer border="r base" :on-item-click="clickOnTask" :nested="true" @run="onRunAll"> 68 72 <template #header="{ filteredFiles }"> 69 - <img w-6 h-6 src="/favicon.svg" alt="Vitest logo"> 73 + <img w-6 h-6 :src="logoUrl" alt="Vitest logo"> 70 74 <span font-light text-sm flex-1>Vitest</span> 71 75 <div class="flex text-lg"> 72 76 <IconButton
+22
test/e2e/test/reporters/html.test.ts
··· 21 21 expect(result.fs.statFile('html/index.html').isFile()).toBe(true) 22 22 }) 23 23 24 + it('singleFile', async () => { 25 + const result = await runInlineTests({ 26 + 'basic.test.ts': `test('basic', () => {});`, 27 + }, { 28 + globals: true, 29 + reporters: [ 30 + 'default', 31 + ['html', { singleFile: true }], 32 + ], 33 + }) 34 + expect(result.stderr).toMatchInlineSnapshot(`""`) 35 + expect(result.errorTree()).toMatchInlineSnapshot(` 36 + { 37 + "basic.test.ts": { 38 + "basic": "passed", 39 + }, 40 + } 41 + `) 42 + expect(result.exitCode).toBe(0) 43 + expect(result.fs.statFile('html/index.html').isFile()).toBe(true) 44 + }) 45 + 24 46 it('browser mode headless', async () => { 25 47 const result = await runInlineTests({ 26 48 'basic.test.ts': /* ts */`
+19
test/ui/fixtures/single-file/basic.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + 3 + test('pass', async () => { 4 + }) 5 + 6 + test('fail', async () => { 7 + expect(1).toBe(2) 8 + }) 9 + 10 + test('annotation', async ({ annotate, task }) => { 11 + await annotate('annotation-body', { 12 + body: 'test-body-content', 13 + bodyEncoding: 'utf-8', 14 + contentType: 'text/plain', 15 + }) 16 + await annotate('annotation-path', { 17 + path: `./resources/test.txt`, 18 + }) 19 + })
+13
test/ui/fixtures/single-file/vitest.config.ts
··· 1 + import { configDefaults, defineConfig } from 'vitest/config' 2 + 3 + // pnpm -C test/ui test-fixtures --root fixtures/single-file --run 4 + // pnpm dlx serve test/ui/fixtures/single-file/html/ 5 + 6 + export default defineConfig({ 7 + test: { 8 + reporters: [ 9 + ...configDefaults.reporters, 10 + ['html', { singleFile: true }], 11 + ], 12 + }, 13 + })
+1 -2
packages/ui/client/composables/client/static.ts
··· 76 76 ctx.state.idMap = reactive(ctx.state.idMap) 77 77 78 78 async function registerMetadata() { 79 - const res = await fetch(window.METADATA_PATH!) 80 - const content = new Uint8Array(await res.arrayBuffer()) 79 + const content = await window.HTML_REPORT_METADATA! 81 80 let metadata: HTMLReportMetadata 82 81 // Check for gzip magic numbers (0x1f 0x8b) to determine if content is compressed. 83 82 // This handles cases where a static server incorrectly sets Content-Encoding: gzip
+11
packages/vitest/src/node/reporters/html.ts
··· 1 1 export interface HTMLOptions { 2 + /** 3 + * Path to the generated HTML report. 4 + * 5 + * @default 'html/index.html' 6 + */ 2 7 outputFile?: string 8 + /** 9 + * Inline report assets, metadata, and attachments into the generated HTML file. 10 + * 11 + * @default false 12 + */ 13 + singleFile?: boolean 3 14 }
+1
test/ui/fixtures/single-file/resources/test.txt
··· 1 + test-path-content