Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions docs/guide/improving-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,15 @@ jobs:
include-hidden-files: true
retention-days: 1

- name: Upload attachments to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-attachments-${{ matrix.shardIndex }}
path: .vitest-attachments/**
include-hidden-files: true
retention-days: 1

merge-reports:
if: ${{ !cancelled() }}
needs: [tests]
Expand All @@ -183,10 +192,19 @@ jobs:
pattern: blob-report-*
merge-multiple: true

- name: Download attachments from GitHub Actions Artifacts
uses: actions/download-artifact@v4
with:
path: .vitest-attachments
pattern: blob-attachments-*
merge-multiple: true

- name: Merge reports
run: npx vitest --merge-reports
```

If your tests create file-based attachments (for example via `context.annotate` or custom artifacts), upload and restore [`attachmentsDir`](/config/attachmentsdir) in the merge job as shown above.

:::

:::tip
Expand Down
3 changes: 3 additions & 0 deletions docs/guide/reporters.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,9 @@ All blob reports can be merged into any report by using `--merge-reports` comman
npx vitest --merge-reports=reports --reporter=json --reporter=default
```

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.

::: tip
Both `--reporter=blob` and `--merge-reports` do not work in watch mode.
:::
Expand Down
9 changes: 5 additions & 4 deletions packages/ui/client/composables/attachments.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { TestAttachment } from '@vitest/runner'
import mime from 'mime/lite'
import { basename } from 'pathe'
import { isReport } from '~/constants'

export function getAttachmentUrl(attachment: TestAttachment): string {
// html reporter always saves files into /data/ folder
if (isReport) {
return `/data/${attachment.path}`
}
const contentType = attachment.contentType ?? 'application/octet-stream'
if (attachment.path) {
if (isReport) {
// html reporter copies attachments to /data/ folder
return `/data/${basename(attachment.path)}`
}
return `/__vitest_attachment__?path=${encodeURIComponent(attachment.path)}&contentType=${contentType}&token=${(window as any).VITEST_API_TOKEN}`
}
// attachment.body is always a string outside of the test frame
Expand Down
77 changes: 11 additions & 66 deletions packages/ui/node/reporter.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import type { Task, TestAttachment } from '@vitest/runner'
import type { ModuleGraphData, RunnerTestFile, SerializedConfig } from 'vitest'
import type { HTMLOptions, Reporter, Vitest } from 'vitest/node'
import crypto from 'node:crypto'
import { promises as fs } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
import { existsSync, promises as fs } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { gzip, constants as zlibConstants } from 'node:zlib'
import { stringify } from 'flatted'
import mime from 'mime/lite'
import { dirname, extname, relative, resolve } from 'pathe'
import { dirname, relative, resolve } from 'pathe'
import { globSync } from 'tinyglobby'
import c from 'tinyrainbow'
import { getModuleGraph } from '../../vitest/src/utils/graph'
Expand Down Expand Up @@ -66,7 +62,6 @@ export default class HTMLReporter implements Reporter {
this.reporterDir = dirname(htmlFilePath)
this.htmlFilePath = htmlFilePath

await fs.mkdir(resolve(this.reporterDir, 'data'), { recursive: true })
await fs.mkdir(resolve(this.reporterDir, 'assets'), { recursive: true })
}

Expand All @@ -82,30 +77,7 @@ export default class HTMLReporter implements Reporter {
}
const promises: Promise<void>[] = []

const processAttachments = (task: Task) => {
if (task.type === 'test') {
task.annotations.forEach((annotation) => {
const attachment = annotation.attachment
if (attachment) {
promises.push(this.processAttachment(attachment))
}
})
task.artifacts.forEach((artifact) => {
const attachments = artifact.attachments
if (attachments) {
attachments.forEach((attachment) => {
promises.push(this.processAttachment(attachment))
})
}
})
}
else {
task.tasks.forEach(processAttachments)
}
}

promises.push(...result.files.map(async (file) => {
processAttachments(file)
const projectName = file.projectName || ''
const resolvedConfig = this.ctx.getProjectByName(projectName).config
const browser = resolvedConfig.browser.enabled
Expand All @@ -132,42 +104,6 @@ export default class HTMLReporter implements Reporter {
await this.writeReport(stringify(result))
}

async processAttachment(attachment: TestAttachment): Promise<void> {
if (attachment.path) {
// keep external resource as is, but remove body if it's set somehow
if (
attachment.path.startsWith('http://')
|| attachment.path.startsWith('https://')
) {
attachment.body = undefined
return
}

const buffer = await readFile(attachment.path)
const hash = crypto.createHash('sha1').update(buffer).digest('hex')
const filename = hash + extname(attachment.path)
// move the file into an html directory to make access/publishing UI easier
await writeFile(resolve(this.reporterDir, 'data', filename), buffer)
attachment.path = filename
attachment.body = undefined
return
}

if (attachment.body) {
const buffer = typeof attachment.body === 'string'
? Buffer.from(attachment.body, 'base64')
: Buffer.from(attachment.body)

const hash = crypto.createHash('sha1').update(buffer).digest('hex')
const extension = mime.getExtension(attachment.contentType || 'application/octet-stream') || 'dat'
const filename = `${hash}.${extension}`
// store the file in html directory instead of passing down as a body
await writeFile(resolve(this.reporterDir, 'data', filename), buffer)
attachment.path = filename
attachment.body = undefined
}
}

async writeReport(report: string): Promise<void> {
const metaFile = resolve(this.reporterDir, 'html.meta.json.gz')

Expand Down Expand Up @@ -198,6 +134,15 @@ export default class HTMLReporter implements Reporter {
}),
)

// copy attachments
// TODO: unify attachmentsDir and html outputFile, so both live together without extra copy
if (existsSync(this.ctx.config.attachmentsDir)) {
const destAttachmentsDir = resolve(this.reporterDir, 'data')
await fs.rm(destAttachmentsDir, { recursive: true, force: true })
await fs.mkdir(destAttachmentsDir, { recursive: true })
await fs.cp(this.ctx.config.attachmentsDir, destAttachmentsDir, { recursive: true })
}

this.ctx.logger.log(
`${c.bold(c.inverse(c.magenta(' HTML ')))} ${c.magenta(
'Report is generated',
Expand Down
44 changes: 29 additions & 15 deletions packages/vitest/src/node/reporters/base.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { File, Task, TestAnnotation } from '@vitest/runner'
import type { SerializedError } from '@vitest/utils'
import type { TestError, UserConsoleLog } from '../../types/general'
import type { ParsedStack, SerializedError } from '@vitest/utils'
import type { AsyncLeak, TestError, UserConsoleLog } from '../../types/general'
import type { Vitest } from '../core'
import type { TestSpecification } from '../test-specification'
import type { Reporter, TestRunEndReason } from '../types/reporter'
Expand Down Expand Up @@ -521,17 +521,18 @@ export abstract class BaseReporter implements Reporter {

reportSummary(files: File[], errors: unknown[]): void {
this.printErrorsSummary(files, errors)
this.printLeaksSummary()

const leakCount = this.printLeaksSummary()

if (this.ctx.config.mode === 'benchmark') {
this.reportBenchmarkSummary(files)
}
else {
this.reportTestSummary(files, errors)
this.reportTestSummary(files, errors, leakCount)
}
}

reportTestSummary(files: File[], errors: unknown[]): void {
reportTestSummary(files: File[], errors: unknown[], leakCount: number): void {
this.log()

const affectedFiles = [
Expand Down Expand Up @@ -575,10 +576,8 @@ export abstract class BaseReporter implements Reporter {
)
}

const leaks = this.ctx.state.leakSet.size

if (leaks) {
this.log(padSummaryTitle('Leaks'), c.bold(c.red(`${leaks} leak${leaks > 1 ? 's' : ''}`)))
if (leakCount) {
this.log(padSummaryTitle('Leaks'), c.bold(c.red(`${leakCount} leak${leakCount > 1 ? 's' : ''}`)))
}

this.log(padSummaryTitle('Start at'), this._timeStart)
Expand Down Expand Up @@ -789,22 +788,35 @@ export abstract class BaseReporter implements Reporter {
const leaks = this.ctx.state.leakSet

if (leaks.size === 0) {
return
return 0
}

this.error(`\n${errorBanner(`Async Leaks ${leaks.size}`)}\n`)
const leakWithStacks = new Map<string, { leak: AsyncLeak; stacks: ParsedStack[] }>()

// Leaks can be duplicate, where type and position are identical
for (const leak of leaks) {
const filename = this.relative(leak.filename)

this.ctx.logger.error(c.red(`${leak.type} leaking in ${filename}`))

const stacks = parseStacktrace(leak.stack)

if (stacks.length === 0) {
continue
}

const filename = this.relative(leak.filename)
const key = `${filename}:${stacks[0].line}:${stacks[0].column}:${leak.type}`

if (leakWithStacks.has(key)) {
continue
}

leakWithStacks.set(key, { leak, stacks })
}

this.error(`\n${errorBanner(`Async Leaks ${leakWithStacks.size}`)}\n`)

for (const { leak, stacks } of leakWithStacks.values()) {
const filename = this.relative(leak.filename)
this.ctx.logger.error(c.red(`${leak.type} leaking in ${filename}`))

try {
const sourceCode = readFileSync(stacks[0].file, 'utf-8')

Expand All @@ -828,6 +840,8 @@ export abstract class BaseReporter implements Reporter {
{},
)
}

return leakWithStacks.size
}

reportBenchmarkSummary(files: File[]): void {
Expand Down
5 changes: 5 additions & 0 deletions packages/vitest/src/runtime/detect-async-leaks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,20 @@ export function detectAsyncLeaks(testFile: string, projectName: string): () => P
}

let stack = ''
const limit = Error.stackTraceLimit

// VitestModuleEvaluator's async wrapper of node:vm causes out-of-bound stack traces, simply skip it.
// Crash fixed in https://github.com/vitejs/vite/pull/21585
try {
Error.stackTraceLimit = 100
stack = new Error('VITEST_DETECT_ASYNC_LEAKS').stack || ''
}
catch {
return
}
finally {
Error.stackTraceLimit = limit
}

if (!stack.includes(testFile)) {
const trigger = resources.get(triggerAsyncId)
Expand Down
39 changes: 37 additions & 2 deletions test/cli/test/detect-async-leaks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,46 @@ test('fetch', async () => {
await fetch('https://vitest.dev').then(response => response.text())
})
`,

'packages/example/test/example-2.test.ts': `
import { createServer } from "node:http";

let setConnected = () => {}
let waitConnection = new Promise(resolve => (setConnected = resolve))

beforeAll(async () => {
const server = createServer((_, res) => {
setConnected();
setTimeout(() => res.end("Hello after 10 seconds!"), 10_000).unref();
});
await new Promise((resolve) => server.listen(5179, resolve));
return () => server.close();
});

test("is a leak", async () => {
fetch('http://localhost:5179');
await waitConnection;
});
`,
})

expect.soft(stdout).not.toContain('Leak')
expect.soft(stdout).toContain('Leaks 1 leak')

expect(stderr).toBe('')
expect(stderr).toMatchInlineSnapshot(`
"
⎯⎯⎯⎯⎯⎯⎯ Async Leaks 1 ⎯⎯⎯⎯⎯⎯⎯⎯

PROMISE leaking in packages/example/test/example-2.test.ts
15|
16| test("is a leak", async () => {
17| fetch('http://localhost:5179');
| ^
18| await waitConnection;
19| });
❯ packages/example/test/example-2.test.ts:17:9

"
`)
})

test('fs handle', async () => {
Expand Down
9 changes: 9 additions & 0 deletions test/ui/fixtures/annotated.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,12 @@ test('annotated image test', async ({ annotate }) => {
path: './fixtures/cute-puppy.jpg'
})
})

test('annotated with body', async ({ annotate }) => {
await annotate('body annotation', {
contentType: 'text/markdown',
// requires pre-encoded base64 for raw string
// https://github.com/vitest-dev/vitest/issues/9633
body: btoa('Hello **markdown**'),
})
})
3 changes: 2 additions & 1 deletion test/ui/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export default defineConfig({
projects: [
{
name: 'chromium',
use: devices['Desktop Chrome'],
// increase viewport height so virtual scroller renders all explorer items
use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 900 } },
},
],
use: {
Expand Down
Loading
Loading