Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
4fd4e48
wip: add mergeReportsLabel
hi-ogawa Mar 24, 2026
31c89a0
fix: append label to projects
hi-ogawa Mar 24, 2026
8753200
wip: mergeReportsLabels?: string[]
hi-ogawa Mar 24, 2026
cf6df38
wip: cloneProject
hi-ogawa Mar 24, 2026
15ca046
wip: slop
hi-ogawa Mar 25, 2026
42a9779
wip: no slop
hi-ogawa Mar 25, 2026
19c1e02
chore: no slop
hi-ogawa Mar 25, 2026
a236b02
wip: TODO BlobOptions.label
hi-ogawa Mar 25, 2026
d4014bd
test: wip
hi-ogawa Mar 25, 2026
8b55122
test: wip
hi-ogawa Mar 25, 2026
50c32c3
refactor: do more on readBlobs
hi-ogawa Mar 25, 2026
490b4d2
refactor: remove mergeReportsLabels
hi-ogawa Mar 25, 2026
c8b3f8b
Merge branch 'main' into feat/merge-reports-label
hi-ogawa Mar 25, 2026
5428417
refactor: slop
hi-ogawa Mar 25, 2026
065a347
wip: move to BlobOptions.label
hi-ogawa Mar 25, 2026
f4838d5
fix: require all blobs to have labels when any blob has one
hi-ogawa Mar 25, 2026
64959b9
refactor: move resolveMergeReportProjects to blob.ts
hi-ogawa Mar 25, 2026
dd28398
refactor: slop
hi-ogawa Mar 25, 2026
cddd750
refactor: simple tuple
hi-ogawa Mar 25, 2026
6490840
refactor: simplify blob filename construction and flatten MergeReport…
hi-ogawa Mar 25, 2026
f5fb52e
chore: todo
hi-ogawa Mar 25, 2026
52f1927
chore: todo
hi-ogawa Mar 25, 2026
5d94076
wip: support VITEST_BLOB_LABEL
hi-ogawa Mar 25, 2026
a5f6c56
docs: add blob reporter label and cross-platform merge docs
hi-ogawa Mar 25, 2026
8d318a3
test: move workers-option unit test to test/core
hi-ogawa Mar 25, 2026
fd30d07
test: silly but for now
hi-ogawa Mar 25, 2026
44e2721
chore: lint
hi-ogawa Mar 25, 2026
747cf0f
refactor: minor
hi-ogawa Mar 25, 2026
31a6a04
test: more
hi-ogawa Mar 25, 2026
0503fd9
chore: cleanup
hi-ogawa Mar 25, 2026
7d89db0
chore: cleanup
hi-ogawa Mar 25, 2026
fe175fb
test: assert blob output
hi-ogawa Mar 25, 2026
0137190
ci: dogfood blob label
hi-ogawa Mar 26, 2026
d06fab4
Merge branch 'main' into feat/merge-reports-label
hi-ogawa Mar 26, 2026
fb4e8bc
ci: dogfood
hi-ogawa Mar 26, 2026
fde6ace
ci: test/cli too
hi-ogawa Mar 26, 2026
da49e6e
ci: simplify upload and download artifacts
hi-ogawa Mar 26, 2026
369b469
ci: fix merge reports
hi-ogawa Mar 26, 2026
87d6538
ci: merge merge reports
hi-ogawa Mar 26, 2026
36051a7
ci: fix upload
hi-ogawa Mar 26, 2026
7be50d9
ci: more glob
hi-ogawa Mar 26, 2026
88b5009
test: more realistic test
hi-ogawa Mar 26, 2026
8569516
ci: ugh include-hidden-files
hi-ogawa Mar 26, 2026
cc6543a
chore: lint
hi-ogawa Mar 26, 2026
3dbbf72
ci: merge-reports shouldn't be red
hi-ogawa Mar 26, 2026
5a4e63d
Merge branch 'main' into feat/merge-reports-label
hi-ogawa Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ jobs:

- name: Test
run: pnpm run test:ci
env:
VITEST_BLOB_LABEL_DOGFOOD: ${{ matrix.os }}-node-${{ matrix.node_version }}

- name: Test Examples
run: pnpm run test:examples
Expand All @@ -127,6 +129,17 @@ jobs:
path: test/ui/test-results/
retention-days: 30

- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: vitest-blob-${{ matrix.os }}-node-${{ matrix.node_version }}
path: |
README.md
test/core/.vitest-reports
test/cli/.vitest-reports
retention-days: 1
include-hidden-files: true

test-cached:
needs: changed
name: 'Cache&Test: node-${{ matrix.node_version }}, ${{ matrix.os }}'
Expand Down Expand Up @@ -248,3 +261,39 @@ jobs:
name: playwright-report-rolldown
path: rolldown/test/ui/test-results/
retention-days: 30

merge-reports:
needs: test
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
name: Merge Reports
timeout-minutes: 10
steps:
- uses: actions/checkout@v6

- uses: ./.github/actions/setup-and-cache

- name: Install
run: pnpm i

- name: Build
run: pnpm run build

- uses: actions/download-artifact@v4
with:
pattern: vitest-blob-*
merge-multiple: true

- name: Merge reports
continue-on-error: true
run: pnpm --filter=./test/core --filter=./test/cli --no-bail --sequential test --merge-reports --reporter=html

- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: vitest-merge-reports
path: |
README.md
test/core/html
test/cli/html
retention-days: 7
2 changes: 1 addition & 1 deletion docs/guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ You cannot use this option with `--watch` enabled (enabled in dev by default).
:::

::: tip
If `--reporter=blob` is used without an output file, the default path will include the current shard config to avoid collisions with other Vitest processes.
If `--reporter=blob` is used without an output file, the default path will include the current shard config and [label](/guide/reporters#labels) to avoid collisions with other Vitest processes.
:::

### merge-reports
Expand Down
9 changes: 6 additions & 3 deletions docs/guide/improving-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,10 @@ on:
- main
jobs:
tests:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
Expand All @@ -163,12 +164,14 @@ jobs:

- name: Run tests
run: pnpm run test --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
VITEST_BLOB_LABEL: ${{ matrix.os }}

- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shardIndex }}
name: blob-report-${{ matrix.os }}-${{ matrix.shardIndex }}
path: .vitest-reports/*
include-hidden-files: true
retention-days: 1
Expand All @@ -177,7 +180,7 @@ jobs:
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-attachments-${{ matrix.shardIndex }}
name: blob-attachments-${{ matrix.os }}-${{ matrix.shardIndex }}
path: .vitest-attachments/**
include-hidden-files: true
retention-days: 1
Expand Down
9 changes: 7 additions & 2 deletions docs/guide/reporters.md
Original file line number Diff line number Diff line change
Expand Up @@ -670,13 +670,18 @@ By default, stores all results in `.vitest-reports` folder, but can be overridde
npx vitest --reporter=blob --outputFile=reports/blob-1.json
```

We recommend using this reporter if you are running Vitest on different machines with the [`--shard`](/guide/cli#shard) flag.
All blob reports can be merged into any report by using `--merge-reports` command at the end of your CI pipeline:
We recommend using this reporter if you are running Vitest on different machines with the [`--shard`](/guide/cli#shard) flag or across multiple environments (e.g., linux/macos/windows). All blob reports can be merged into any report by using `--merge-reports` command at the end of your CI pipeline:

```bash
npx vitest --merge-reports=reports --reporter=json --reporter=default
```

When running the same tests across multiple environments, set the `label` option (or `VITEST_BLOB_LABEL` environment variable) to distinguish each environment's blob. Vitest reads labels at merge time and creates separate project entries automatically (e.g., `myproject [linux]`, `myproject [macos]`).

```bash
VITEST_BLOB_LABEL=linux vitest run --reporter=blob
```

Blob reporter output doesn't include file-based [attachments](/api/advanced/artifacts.html#testattachment).
Make sure to merge [`attachmentsDir`](/config/attachmentsdir) separately alongside blob reports on CI when using this feature.

Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ export class Vitest {
throw new Error('Cannot merge reports when `--reporter=blob` is used. Remove blob reporter from the config first.')
}

const { files, errors, coverages, executionTimes } = await readBlobs(this.version, directory || this.config.mergeReports, this.projects)
const { files, errors, coverages, executionTimes } = await readBlobs(this, directory || this.config.mergeReports)
this.state.blobs = { files, errors, coverages, executionTimes }

await this.report('onInit', this)
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/node/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ export class TestProject {
}

/** @internal */
static _cloneBrowserProject(parent: TestProject, config: ResolvedConfig): TestProject {
static _cloneProject(parent: TestProject, config: ResolvedConfig): TestProject {
const clone = new TestProject(parent.vitest, undefined, parent.tmpDir)
clone.runner = parent.runner
clone._vite = parent._vite
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/node/projects/resolveProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export async function resolveBrowserProjects(
names.add(name)
const clonedConfig = cloneConfig(project, config)
clonedConfig.name = name
const clone = TestProject._cloneBrowserProject(project, clonedConfig)
const clone = TestProject._cloneProject(project, clonedConfig)
resolvedProjects.push(clone)
})

Expand Down
107 changes: 98 additions & 9 deletions packages/vitest/src/node/reporters/blob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ import type { Reporter } from '../types/reporter'
import type { TestModule } from './reported-tasks'
import { existsSync } from 'node:fs'
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { calculateSuiteHash, generateFileHash } from '@vitest/runner/utils'
import { deepClone } from '@vitest/utils/helpers'
import { parse, stringify } from 'flatted'
import { dirname, resolve } from 'pathe'
import { getOutputFile } from '../../utils/config-helpers'

export interface BlobOptions {
outputFile?: string
/**
* Label for this environment's blob report (e.g., "linux", "node-22").
* Can also be set via `VITEST_BLOB_LABEL` environment variable.
*/
label?: string
}

export class BlobReporter implements Reporter {
Expand Down Expand Up @@ -46,13 +53,14 @@ export class BlobReporter implements Reporter {
const errors = [...unhandledErrors]
const coverage = this.coverage

const label = this.options.label ?? process.env.VITEST_BLOB_LABEL

let outputFile
= this.options.outputFile ?? getOutputFile(this.ctx.config, 'blob')
if (!outputFile) {
const shard = this.ctx.config.shard
outputFile = shard
? `.vitest-reports/blob-${shard.index}-${shard.count}.json`
: '.vitest-reports/blob.json'
const suffix = [label, shard && `${shard.index}-${shard.count}`].filter(Boolean).join('-')
outputFile = suffix ? `.vitest-reports/blob-${suffix}.json` : '.vitest-reports/blob.json'
}

const environmentModules: MergeReportEnvironmentModules = {}
Expand Down Expand Up @@ -89,6 +97,7 @@ export class BlobReporter implements Reporter {
coverage,
executionTime,
environmentModules,
label,
] satisfies MergeReport

const reportFile = resolve(this.ctx.config.root, outputFile)
Expand All @@ -110,9 +119,8 @@ export async function writeBlob(content: MergeReport, filename: string): Promise
}

export async function readBlobs(
currentVersion: string,
ctx: Vitest,
blobsDirectory: string,
projectsArray: TestProject[],
): Promise<MergedBlobs> {
// using process.cwd() because --merge-reports can only be used in CLI
const resolvedDir = resolve(process.cwd(), blobsDirectory)
Expand All @@ -126,15 +134,15 @@ export async function readBlobs(
)
}
const content = await readFile(fullPath, 'utf-8')
const [version, files, errors, coverage, executionTime, environmentModules] = parse(
const [version, files, errors, coverage, executionTime, environmentModules, label] = parse(
content,
) as MergeReport
if (!version) {
throw new TypeError(
`vitest.mergeReports() expects all paths in "${blobsDirectory}" to be files generated by the blob reporter, but "${filename}" is not a valid blob file`,
)
}
return { version, files, errors, coverage, file: filename, executionTime, environmentModules }
return { version, label, files, errors, coverage, file: filename, executionTime, environmentModules }
})
const blobs = await Promise.all(promises)

Expand All @@ -151,13 +159,41 @@ export async function readBlobs(
)
}

if (!versions.has(currentVersion)) {
if (!versions.has(ctx.version)) {
throw new Error(
`the blobs in "${blobsDirectory}" were generated by a different version of Vitest. Expected v${currentVersion}, but received v${blobs[0].version}`,
`the blobs in "${blobsDirectory}" were generated by a different version of Vitest. Expected v${ctx.version}, but received v${blobs[0].version}`,
)
}

// Auto-discover labels and duplicate projects if needed
const labels = discoverMergeReportLabels(blobs)
if (labels) {
ctx.projects = resolveMergeReportProjects(
ctx.projects,
labels,
)
}

// Rewrite blob data so they get attached to duplicated projects based on labels
for (const blob of blobs) {
const label = blob.label
if (!label) {
continue
}
for (const file of blob.files) {
file.projectName = suffixProjectName(file.projectName, label)
file.id = generateFileHash(file.name, file.projectName)
calculateSuiteHash(file)
}
const rewritten: MergeReportEnvironmentModules = {}
for (const [projectName, modules] of Object.entries(blob.environmentModules)) {
rewritten[suffixProjectName(projectName, label)] = modules
}
blob.environmentModules = rewritten
}

// Restore module graph
const projectsArray = ctx.projects
const projects = Object.fromEntries(
projectsArray.map(p => [p.name, p]),
)
Expand Down Expand Up @@ -211,6 +247,58 @@ export async function readBlobs(
}
}

function suffixProjectName(originalName: string | undefined, label: string): string {
return originalName ? `${originalName} [${label}]` : label
}

function resolveMergeReportProjects(
projects: TestProject[],
labels: string[],
): TestProject[] {
const names = new Set(projects.map(p => p.name))
const clonedProjects: TestProject[] = []

for (const project of projects) {
for (const label of labels) {
const name = suffixProjectName(project.name || undefined, label)

if (names.has(name)) {
throw new Error(
`Project name "${name}" is not unique. All projects should have unique names. Make sure your configuration is correct.`,
)
}

names.add(name)
const config = deepClone(project.config)
config.name = name
// TODO: importing `import "../project"` breaks some tests
clonedProjects.push((project.constructor as typeof TestProject)._cloneProject(project, config))
}
}

return clonedProjects
}

function discoverMergeReportLabels(blobs: { file: string; label?: string }[]): string[] | undefined {
const labeled = blobs.filter(b => b.label)

if (!labeled.length) {
return undefined
}

// TODO: support mix of labeled and unlabeled blobs?
const unlabeled = blobs.filter(b => !b.label)
if (unlabeled.length) {
throw new Error(
`vitest.mergeReports() requires all blob files to have a label when any blob has one. `
+ `Missing label in: ${unlabeled.map(b => `"${b.file}"`).join(', ')}`,
)
}

const labels = new Set(labeled.map(b => b.label!))
return [...labels]
}

export interface MergedBlobs {
files: File[]
errors: unknown[]
Expand All @@ -225,6 +313,7 @@ type MergeReport = [
coverage: unknown,
executionTime: number,
environmentModules: MergeReportEnvironmentModules,
label?: string,
]

interface MergeReportEnvironmentModules {
Expand Down
Loading
Loading