From 772923645f250674e937dd887572e76e971524b9 Mon Sep 17 00:00:00 2001 From: Raul Macarie Date: Tue, 18 Nov 2025 10:56:21 +0100 Subject: [PATCH] feat(api): add extensible test artifact API (#8987) --- docs/.vitepress/components.d.ts | 1 + docs/.vitepress/components/Advanced.vue | 5 + docs/.vitepress/config.ts | 4 + docs/api/advanced/artifacts.md | 176 ++++++++ docs/api/advanced/reporters.md | 16 + .../browser-playwright/src/commands/trace.ts | 31 +- packages/browser/src/client/tester/runner.ts | 30 +- packages/browser/src/node/rpc.ts | 4 +- packages/browser/src/types.ts | 4 +- packages/runner/src/artifact.ts | 184 ++++++++ packages/runner/src/context.ts | 156 +------ packages/runner/src/index.ts | 1 + packages/runner/src/suite.ts | 1 + packages/runner/src/types.ts | 5 + packages/runner/src/types/runner.ts | 8 + packages/runner/src/types/tasks.ts | 179 +++++++- .../ui/client/composables/client/index.ts | 5 +- .../client/composables/explorer/collector.ts | 14 +- .../ui/client/composables/explorer/tree.ts | 8 +- packages/vitest/src/api/setup.ts | 12 +- packages/vitest/src/api/types.ts | 3 +- packages/vitest/src/node/ast-collect.ts | 1 + packages/vitest/src/node/pools/rpc.ts | 4 +- packages/vitest/src/node/reporters/junit.ts | 1 + .../src/node/reporters/reported-tasks.ts | 10 + packages/vitest/src/node/test-run.ts | 36 +- packages/vitest/src/node/types/reporter.ts | 7 +- packages/vitest/src/public/index.ts | 7 + packages/vitest/src/runtime/runners/index.ts | 12 +- packages/vitest/src/typecheck/collect.ts | 1 + packages/vitest/src/types/rpc.ts | 4 +- packages/vitest/src/utils/tasks.ts | 3 + packages/ws-client/src/index.ts | 3 + .../fixtures/custom-pool/pool/custom-pool.ts | 1 + test/cli/test/annotations.test.ts | 13 + test/cli/test/artifacts.test.ts | 420 ++++++++++++++++++ test/core/test/exports.test.ts | 2 + test/reporters/src/data.ts | 10 + .../tests/__snapshots__/html.test.ts.snap | 3 + test/reporters/tests/junit.test.ts | 1 + test/reporters/tests/merge-reports.test.ts | 1 + 41 files changed, 1181 insertions(+), 206 deletions(-) create mode 100644 docs/.vitepress/components/Advanced.vue create mode 100644 docs/api/advanced/artifacts.md create mode 100644 packages/runner/src/artifact.ts create mode 100644 test/cli/test/artifacts.test.ts diff --git a/docs/.vitepress/components.d.ts b/docs/.vitepress/components.d.ts index 6d19e0f7d..04e66fb93 100644 --- a/docs/.vitepress/components.d.ts +++ b/docs/.vitepress/components.d.ts @@ -7,6 +7,7 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + Advanced: typeof import('./components/Advanced.vue')['default'] ArrowDown: typeof import('./components/ArrowDown.vue')['default'] BlogIndex: typeof import('./components/BlogIndex.vue')['default'] Box: typeof import('./components/Box.vue')['default'] diff --git a/docs/.vitepress/components/Advanced.vue b/docs/.vitepress/components/Advanced.vue new file mode 100644 index 000000000..7bc97edeb --- /dev/null +++ b/docs/.vitepress/components/Advanced.vue @@ -0,0 +1,5 @@ + diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 10e7648b7..ee388f272 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -993,6 +993,10 @@ export default ({ mode }: { mode: string }) => { text: 'TaskMeta', link: '/api/advanced/metadata', }, + { + text: 'TestArtifact', + link: '/api/advanced/artifacts', + }, ], }, // { diff --git a/docs/api/advanced/artifacts.md b/docs/api/advanced/artifacts.md new file mode 100644 index 000000000..c5393b2b3 --- /dev/null +++ b/docs/api/advanced/artifacts.md @@ -0,0 +1,176 @@ +--- +outline: deep +title: Test Artifacts +--- + +# Test Artifacts 4.0.11 + +::: warning +This is an advanced API. As a user, you most likely want to use [test annotations](/guide/test-annotations) to add notes or context to your tests instead. This is primarily used internally and by library authors. +::: + +Test artifacts allow attaching or recording structured data, files, or metadata during test execution. This is a low-level feature primarily designed for: + +- Internal use ([`annotate`](/guide/test-annotations) is built on top of the artifact system) +- Framework authors creating custom testing tools on top of Vitest + +Each artifact includes: + +- A type discriminator which is a unique identifier for the artifact type +- Custom data, can be any relevant information +- Optional attachments, either files or inline content associated with the artifact +- A source code location indicating where the artifact was created + +Vitest automatically manages attachment serialization (files are copied to [`attachmentsDir`](/config/#attachmentsdir)) and injects source location metadata, so you can focus on the data you want to record. All artifacts **must** extend from [`TestArtifactBase`](#testartifactbase) and all attachments from [`TestAttachment`](#testattachment) to be correctly handled internally. + +## API + +### `recordArtifact` {#recordartifact} + +::: warning +`recordArtifact` is an experimental API. Breaking changes might not follow SemVer, please pin Vitest's version when using it. + +The API surface may change based on feedback. We encourage you to try it out and share your experience with the team. +::: + +```ts +function recordArtifact(task: Test, artifact: Artifact): Promise +``` + +The `recordArtifact` function records an artifact during test execution and returns it. It expects a [task](/api/advanced/runner#tasks) as the first parameter and an object assignable to [`TestArtifact`](#testartifact) as the second. + +This function has to be used within a test, and the test has to still be running. Recording after test completion will throw an error. + +When an artifact is recorded on a test, it emits an `onTestArtifactRecord` runner event and a [`onTestCaseArtifactRecord` reporter event](/api/advanced/reporters#ontestcaseartifactrecord). + +Note: annotations, [even though they're built on top of this feature](#relationship-with-annotations), won't appear in the `task.artifacts` array for backwards compatibility reasons until the next major version. + +### `TestArtifact` + +The `TestArtifact` type is a union containing all artifacts Vitest can produce, including custom ones. All artifacts extend from [`TestArtifactBase`](#testartifactbase) + +### `TestArtifactBase` {#testartifactbase} + +```ts +export interface TestArtifactBase { + /** File or data attachments associated with this artifact */ + attachments?: TestAttachment[] + /** Source location where this artifact was created */ + location?: TestArtifactLocation +} +``` + +The `TestArtifactBase` interface is the base for all test artifacts. + +Extend this interface when creating custom test artifacts. Vitest automatically manages the `attachments` array and injects the `location` property to indicate where the artifact was created in your test code. + +### `TestAttachment` + +```ts +export interface TestAttachment { + /** MIME type of the attachment (e.g., 'image/png', 'text/plain') */ + contentType?: string + /** File system path to the attachment */ + path?: string + /** Inline attachment content as a string or raw binary data */ + body?: string | Uint8Array +} +``` + +The `TestAttachment` interface represents a file or data attachment associated with a test artifact. + +Attachments can be either file-based (via `path`) or inline content (via `body`). The `contentType` helps consumers understand how to interpret the attachment data. + +### `TestArtifactLocation` + +```ts +export interface TestArtifactLocation { + /** Line number in the source file (1-indexed) */ + line: number + /** Column number in the line (1-indexed) */ + column: number + /** Path to the source file */ + file: string +} +``` + +The `TestArtifactLocation` interface represents the source code location information for a test artifact. It indicates where in the source code the artifact originated from. + +### `TestArtifactRegistry` + +The `TestArtifactRegistry` interface is a registry for custom test artifact types. + +Augmenting this interface using [TypeScript's module augmentation feature](https://typescriptlang.org/docs/handbook/declaration-merging#module-augmentation) allows registering custom artifact types that tests can produce. + +Each custom artifact should extend [`TestArtifactBase`](#testartifactbase) and include a unique `type` discriminator property. + +Here are a few guidelines or best practices to follow: + +- Try using a `Symbol` as the **registry key** to guarantee uniqueness +- The `type` property should follow the pattern `'package-name:artifact-name'`, **`'internal:'` is a reserved prefix** +- Use `attachments` to include files or data; extend [`TestAttachment`](#testattachment) for custom metadata +- `location` property is automatically injected + +## Custom Artifacts + +To use and manage artifacts in a type-safe manner, you need to create its type and register it: + +```ts +import type { TestArtifactBase, TestAttachment } from 'vitest' + +interface A11yReportAttachment extends TestAttachment { + contentType: 'text/html' + path: string +} + +interface AccessibilityArtifact extends TestArtifactBase { + type: 'a11y:report' + passed: boolean + wcagLevel: 'A' | 'AA' | 'AAA' + attachments: [A11yReportAttachment] +} + +const a11yReportKey = Symbol('report') + +declare module 'vitest' { + interface TestArtifactRegistry { + [a11yReportKey]: AccessibilityArtifact + } +} +``` + +As long as the types are assignable to their bases and don't have errors, everything should work fine and you should be able to record artifacts using [`recordArtifact`](#recordartifact): + +```ts +async function toBeAccessible( + this: MatcherState, + actual: Element, + wcagLevel: 'A' | 'AA' | 'AAA' = 'AA' +): AsyncExpectationResult { + const report = await runAccessibilityAudit(actual, wcagLevel) + + await recordArtifact(this.task, { + type: 'a11y:report', + passed: report.violations.length === 0, + wcagLevel, + attachments: [{ + contentType: 'text/html', + path: report.path, + }], + }) + + return { + pass: violations.length === 0, + message: () => `Found ${report.violations.length} accessibility violation(s)` + } +} +``` + +## Relationship with Annotations + +Test annotations are built on top of the artifact system. When using annotations in tests, they create `internal:annotation` artifacts under the hood. However, annotations are: + +- Simpler to use +- Designed for end-users, not developers + +Use annotations if you just want to add notes to your tests. Use artifacts if you need custom data. diff --git a/docs/api/advanced/reporters.md b/docs/api/advanced/reporters.md index 90f4ddc39..9060c9b8d 100644 --- a/docs/api/advanced/reporters.md +++ b/docs/api/advanced/reporters.md @@ -16,6 +16,7 @@ Vitest has its own test run lifecycle. These are represented by reporter's metho - [`onHookEnd(beforeAll)`](#onhookend) - [`onTestCaseReady`](#ontestcaseready) - [`onTestAnnotate`](#ontestannotate) 3.2.0 + - [`onTestCaseArtifactRecord`](#ontestcaseartifactrecord) 4.0.11 - [`onHookStart(beforeEach)`](#onhookstart) - [`onHookEnd(beforeEach)`](#onhookend) - [`onHookStart(afterEach)`](#onhookstart) @@ -332,3 +333,18 @@ function onTestAnnotate( The `onTestAnnotate` hook is associated with the [`context.annotate`](/guide/test-context#annotate) method. When `annotate` is invoked, Vitest serialises it and sends the same attachment to the main thread where reporter can interact with it. If the path is specified, Vitest stores it in a separate directory (configured by [`attachmentsDir`](/config/#attachmentsdir)) and modifies the `path` property to reference it. + +## onTestCaseArtifactRecord 4.0.11 {#ontestcaseartifactrecord} + +```ts +function onTestCaseArtifactRecord( + testCase: TestCase, + artifact: TestArtifact, +): Awaitable +``` + +The `onTestCaseArtifactRecord` hook is associated with the [`recordArtifact`](/api/advanced/artifacts#recordartifact) utility. When `recordArtifact` is invoked, Vitest serialises it and sends the same attachment to the main thread where reporter can interact with it. + +If the path is specified, Vitest stores it in a separate directory (configured by [`attachmentsDir`](/config/#attachmentsdir)) and modifies the `path` property to reference it. + +Note: annotations, [even though they're built on top of this feature](/api/advanced/artifacts#relationship-with-annotations), won't hit this hook and won't appear in the `task.artifacts` array for backwards compatibility reasons until the next major version. diff --git a/packages/browser-playwright/src/commands/trace.ts b/packages/browser-playwright/src/commands/trace.ts index ca54ec006..55b26453d 100644 --- a/packages/browser-playwright/src/commands/trace.ts +++ b/packages/browser-playwright/src/commands/trace.ts @@ -106,20 +106,25 @@ export const annotateTraces: BrowserCommand<[{ traces: string[]; testId: string const vitest = project.vitest await Promise.all(traces.map((trace) => { const entity = vitest.state.getReportedEntityById(testId) - return vitest._testRun.annotate(testId, { - message: relative(project.config.root, trace), - type: 'traces', - attachment: { - path: trace, - contentType: 'application/octet-stream', + const location = entity?.location + ? { + file: entity.module.moduleId, + line: entity.location.line, + column: entity.location.column, + } + : undefined + return vitest._testRun.recordArtifact(testId, { + type: 'internal:annotation', + annotation: { + message: relative(project.config.root, trace), + type: 'traces', + attachment: { + path: trace, + contentType: 'application/octet-stream', + }, + location, }, - location: entity?.location - ? { - file: entity.module.moduleId, - line: entity.location.line, - column: entity.location.column, - } - : undefined, + location, }) })) } diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index c328736e4..00da160aa 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -7,6 +7,7 @@ import type { TaskResultPack, Test, TestAnnotation, + TestArtifact, VitestRunner, } from '@vitest/runner' import type { SerializedConfig, TestExecutionMethod, WorkerGlobalState } from 'vitest' @@ -227,28 +228,41 @@ export function createBrowserRunner( } onTestAnnotate = (test: Test, annotation: TestAnnotation): Promise => { - if (annotation.location) { + const artifact: TestArtifact = { type: 'internal:annotation', annotation, location: annotation.location } + + return this.onTestArtifactRecord(test, artifact).then(({ annotation }) => annotation) + } + + onTestArtifactRecord = (test: Test, artifact: Artifact): Promise => { + if (artifact.location) { // the file should be the test file // tests from other files are not supported - const map = this.sourceMapCache.get(annotation.location.file) + const map = this.sourceMapCache.get(artifact.location.file) + if (!map) { - return rpc().onTaskAnnotate(test.id, annotation) + return rpc().onTaskArtifactRecord(test.id, artifact) } - const traceMap = new DecodedMap(map as any, annotation.location.file) - const position = getOriginalPosition(traceMap, annotation.location) + const traceMap = new DecodedMap(map as any, artifact.location.file) + const position = getOriginalPosition(traceMap, artifact.location) + if (position) { const { source, column, line } = position - const file = source || annotation.location.file - annotation.location = { + const file = source || artifact.location.file + artifact.location = { line, column: column + 1, // if the file path is on windows, we need to remove the starting slash file: file.match(/\/\w:\//) ? file.slice(1) : file, } + + if (artifact.type === 'internal:annotation') { + artifact.annotation.location = artifact.location + } } } - return rpc().onTaskAnnotate(test.id, annotation) + + return rpc().onTaskArtifactRecord(test.id, artifact) } onTaskUpdate = (task: TaskResultPack[], events: TaskEventPack[]): Promise => { diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index f3235dd79..5fe2b740b 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -150,8 +150,8 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke await vitest._testRun.collected(project, files) } }, - async onTaskAnnotate(id, annotation) { - return vitest._testRun.annotate(id, annotation) + async onTaskArtifactRecord(id, artifact) { + return vitest._testRun.recordArtifact(id, artifact) }, async onTaskUpdate(method, packs, events) { if (method === 'collect') { diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 0b06d0d26..3265d94c8 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -1,5 +1,5 @@ import type { MockedModuleSerialized, ServerIdResolution, ServerMockResolution } from '@vitest/mocker' -import type { TaskEventPack, TaskResultPack, TestAnnotation } from '@vitest/runner' +import type { TaskEventPack, TaskResultPack, TestArtifact } from '@vitest/runner' import type { BirpcReturn } from 'birpc' import type { AfterSuiteRunMeta, @@ -18,7 +18,7 @@ export interface WebSocketBrowserHandlers { onUnhandledError: (error: unknown, type: string) => Promise onQueued: (method: TestExecutionMethod, file: RunnerTestFile) => void onCollected: (method: TestExecutionMethod, files: RunnerTestFile[]) => Promise - onTaskAnnotate: (testId: string, annotation: TestAnnotation) => Promise + onTaskArtifactRecord: (testId: string, artifact: Artifact) => Promise onTaskUpdate: (method: TestExecutionMethod, packs: TaskResultPack[], events: TaskEventPack[]) => void onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void cancelCurrentRun: (reason: CancelReason) => void diff --git a/packages/runner/src/artifact.ts b/packages/runner/src/artifact.ts new file mode 100644 index 000000000..2b0c2b797 --- /dev/null +++ b/packages/runner/src/artifact.ts @@ -0,0 +1,184 @@ +import type { Test, TestArtifact, TestArtifactBase /* used in JSDoc */, TestAttachment } from './types' +import { finishSendTasksUpdate } from './run' +import { getRunner } from './suite' +import { findTestFileStackTrace } from './utils/collect' + +/** + * @experimental + * @advanced + * + * Records a custom test artifact during test execution. + * + * This function allows you to attach structured data, files, or metadata to a test. + * + * Vitest automatically injects the source location where the artifact was created and manages any attachments you include. + * + * @param task - The test task context, typically accessed via `this.task` in custom matchers or `context.task` in tests + * @param artifact - The artifact to record. Must extend {@linkcode TestArtifactBase} + * + * @returns A promise that resolves to the recorded artifact with location injected + * + * @throws {Error} If called after the test has finished running + * @throws {Error} If the test runner doesn't support artifacts + * + * @example + * ```ts + * // In a custom assertion + * async function toHaveValidSchema(this: MatcherState, actual: unknown) { + * const validation = validateSchema(actual) + * + * await recordArtifact(this.task, { + * type: 'my-plugin:schema-validation', + * passed: validation.valid, + * errors: validation.errors, + * }) + * + * return { pass: validation.valid, message: () => '...' } + * } + * ``` + */ +export async function recordArtifact(task: Test, artifact: Artifact): Promise { + const runner = getRunner() + + if (task.result && task.result.state !== 'run') { + throw new Error(`Cannot record a test artifact outside of the test run. The test "${task.name}" finished running with the "${task.result.state}" state already.`) + } + + const stack = findTestFileStackTrace( + task.file.filepath, + new Error('STACK_TRACE').stack!, + ) + + if (stack) { + artifact.location = { + file: stack.file, + line: stack.line, + column: stack.column, + } + + if (artifact.type === 'internal:annotation') { + artifact.annotation.location = artifact.location + } + } + + if (Array.isArray(artifact.attachments)) { + for (const attachment of artifact.attachments) { + manageArtifactAttachment(attachment) + } + } + + // annotations won't resolve as artifacts for backwards compatibility until next major + if (artifact.type === 'internal:annotation') { + return artifact + } + + if (!runner.onTestArtifactRecord) { + throw new Error(`Test runner doesn't support test artifacts.`) + } + + await finishSendTasksUpdate(runner) + + const resolvedArtifact = await runner.onTestArtifactRecord(task, artifact) + + task.artifacts.push(resolvedArtifact) + + return resolvedArtifact as typeof artifact +} + +const table: string[] = [] +for (let i = 65; i < 91; i++) { + table.push(String.fromCharCode(i)) // A-Z +} +for (let i = 97; i < 123; i++) { + table.push(String.fromCharCode(i)) // a-z +} +for (let i = 0; i < 10; i++) { + table.push(i.toString(10)) // 0-9 +} +table.push('+', '/') + +function encodeUint8Array(bytes: Uint8Array): string { + let base64 = '' + const len = bytes.byteLength + for (let i = 0; i < len; i += 3) { + if (len === i + 1) { // last 1 byte + const a = (bytes[i] & 0xFC) >> 2 + const b = ((bytes[i] & 0x03) << 4) + base64 += table[a] + base64 += table[b] + base64 += '==' + } + else if (len === i + 2) { // last 2 bytes + const a = (bytes[i] & 0xFC) >> 2 + const b = ((bytes[i] & 0x03) << 4) | ((bytes[i + 1] & 0xF0) >> 4) + const c = ((bytes[i + 1] & 0x0F) << 2) + base64 += table[a] + base64 += table[b] + base64 += table[c] + base64 += '=' + } + else { + const a = (bytes[i] & 0xFC) >> 2 + const b = ((bytes[i] & 0x03) << 4) | ((bytes[i + 1] & 0xF0) >> 4) + const c = ((bytes[i + 1] & 0x0F) << 2) | ((bytes[i + 2] & 0xC0) >> 6) + const d = bytes[i + 2] & 0x3F + base64 += table[a] + base64 += table[b] + base64 += table[c] + base64 += table[d] + } + } + return base64 +} + +/** + * Records an async operation associated with a test task. + * + * This function tracks promises that should be awaited before a test completes. + * The promise is automatically removed from the test's promise list once it settles. + */ +export function recordAsyncOperation( + test: Test, + promise: Promise, +): Promise { + // if promise is explicitly awaited, remove it from the list + promise = promise.finally(() => { + if (!test.promises) { + return + } + const index = test.promises.indexOf(promise) + if (index !== -1) { + test.promises.splice(index, 1) + } + }) + + // record promise + if (!test.promises) { + test.promises = [] + } + test.promises.push(promise) + return promise +} + +/** + * Validates and prepares a test attachment for serialization. + * + * This function ensures attachments have either `body` or `path` set (but not both), and converts `Uint8Array` bodies to base64-encoded strings for easier serialization. + * + * @param attachment - The attachment to validate and prepare + * + * @throws {TypeError} If neither `body` nor `path` is provided + * @throws {TypeError} If both `body` and `path` are provided + */ +export function manageArtifactAttachment(attachment: TestAttachment): void { + if (attachment.body == null && !attachment.path) { + throw new TypeError(`Test attachment requires "body" or "path" to be set. Both are missing.`) + } + if (attachment.body && attachment.path) { + throw new TypeError(`Test attachment requires only one of "body" or "path" to be set. Both are specified.`) + } + // convert to a string so it's easier to serialise + if (attachment.body instanceof Uint8Array) { + attachment.body = encodeUint8Array(attachment.body) + } +} diff --git a/packages/runner/src/context.ts b/packages/runner/src/context.ts index 73e421497..c42d49db7 100644 --- a/packages/runner/src/context.ts +++ b/packages/runner/src/context.ts @@ -6,16 +6,14 @@ import type { SuiteCollector, Test, TestAnnotation, - TestAnnotationLocation, - TestAttachment, TestContext, WriteableTestContext, } from './types/tasks' import { getSafeTimers } from '@vitest/utils/timers' +import { manageArtifactAttachment, recordArtifact, recordAsyncOperation } from './artifact' import { PendingError } from './errors' import { finishSendTasksUpdate } from './run' import { getRunner } from './suite' -import { findTestFileStackTrace } from './utils' const now = Date.now @@ -157,76 +155,37 @@ export function createTestContext( ) } - async function annotate( - message: string, - location?: TestAnnotationLocation, - type?: string, - attachment?: TestAttachment, - ) { - const annotation: TestAnnotation = { - message, - type: type || 'notice', - } - if (attachment) { - if (attachment.body == null && !attachment.path) { - throw new TypeError(`Test attachment requires "body" or "path" to be set. Both are missing.`) - } - if (attachment.body && attachment.path) { - throw new TypeError(`Test attachment requires only one of "body" or "path" to be set. Both are specified.`) - } - annotation.attachment = attachment - // convert to a string so it's easier to serialise - if (attachment.body instanceof Uint8Array) { - attachment.body = encodeUint8Array(attachment.body) - } - } - if (location) { - annotation.location = location - } - - if (!runner.onTestAnnotate) { - throw new Error(`Test runner doesn't support test annotations.`) - } - - await finishSendTasksUpdate(runner) - - const resolvedAnnotation = await runner.onTestAnnotate(test, annotation) - test.annotations.push(resolvedAnnotation) - return resolvedAnnotation - } - context.annotate = ((message, type, attachment) => { if (test.result && test.result.state !== 'run') { throw new Error(`Cannot annotate tests outside of the test run. The test "${test.name}" finished running with the "${test.result.state}" state already.`) } - const stack = findTestFileStackTrace( - test.file.filepath, - new Error('STACK_TRACE').stack!, - ) + const annotation: TestAnnotation = { + message, + type: typeof type === 'object' || type === undefined ? 'notice' : type, + } + const annotationAttachment = typeof type === 'object' ? type : attachment - let location: undefined | TestAnnotationLocation + if (annotationAttachment) { + annotation.attachment = annotationAttachment - if (stack) { - location = { - file: stack.file, - line: stack.line, - column: stack.column, - } + manageArtifactAttachment(annotation.attachment) } - if (typeof type === 'object') { - return recordAsyncAnnotation( - test, - annotate(message, location, undefined, type), - ) - } - else { - return recordAsyncAnnotation( - test, - annotate(message, location, type, attachment), - ) - } + return recordAsyncOperation( + test, + recordArtifact(test, { type: 'internal:annotation', annotation }).then(async ({ annotation }) => { + if (!runner.onTestAnnotate) { + throw new Error(`Test runner doesn't support test annotations.`) + } + + await finishSendTasksUpdate(runner) + + const resolvedAnnotation = await runner.onTestAnnotate(test, annotation) + test.annotations.push(resolvedAnnotation) + return resolvedAnnotation + }), + ) }) as TestContext['annotate'] context.onTestFailed = (handler, timeout) => { @@ -286,72 +245,3 @@ export function getFileContext(file: File): Record { export function setFileContext(file: File, context: Record): void { fileContexts.set(file, context) } - -const table: string[] = [] -for (let i = 65; i < 91; i++) { - table.push(String.fromCharCode(i)) // A-Z -} -for (let i = 97; i < 123; i++) { - table.push(String.fromCharCode(i)) // a-z -} -for (let i = 0; i < 10; i++) { - table.push(i.toString(10)) // 0-9 -} -table.push('+', '/') - -function encodeUint8Array(bytes: Uint8Array): string { - let base64 = '' - const len = bytes.byteLength - for (let i = 0; i < len; i += 3) { - if (len === i + 1) { // last 1 byte - const a = (bytes[i] & 0xFC) >> 2 - const b = ((bytes[i] & 0x03) << 4) - base64 += table[a] - base64 += table[b] - base64 += '==' - } - else if (len === i + 2) { // last 2 bytes - const a = (bytes[i] & 0xFC) >> 2 - const b = ((bytes[i] & 0x03) << 4) | ((bytes[i + 1] & 0xF0) >> 4) - const c = ((bytes[i + 1] & 0x0F) << 2) - base64 += table[a] - base64 += table[b] - base64 += table[c] - base64 += '=' - } - else { - const a = (bytes[i] & 0xFC) >> 2 - const b = ((bytes[i] & 0x03) << 4) | ((bytes[i + 1] & 0xF0) >> 4) - const c = ((bytes[i + 1] & 0x0F) << 2) | ((bytes[i + 2] & 0xC0) >> 6) - const d = bytes[i + 2] & 0x3F - base64 += table[a] - base64 += table[b] - base64 += table[c] - base64 += table[d] - } - } - return base64 -} - -function recordAsyncAnnotation( - test: Test, - promise: Promise, -): Promise { - // if promise is explicitly awaited, remove it from the list - promise = promise.finally(() => { - if (!test.promises) { - return - } - const index = test.promises.indexOf(promise) - if (index !== -1) { - test.promises.splice(index, 1) - } - }) - - // record promise - if (!test.promises) { - test.promises = [] - } - test.promises.push(promise) - return promise -} diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts index 3886a4db3..39a91ee3e 100644 --- a/packages/runner/src/index.ts +++ b/packages/runner/src/index.ts @@ -1,3 +1,4 @@ +export { recordArtifact } from './artifact' export { afterAll, afterEach, diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index f8e73c7d4..601f48063 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -318,6 +318,7 @@ function createSuiteCollector( : 'run', meta: options.meta ?? Object.create(null), annotations: [], + artifacts: [], } const handler = options.handler if (task.mode === 'run' && !handler) { diff --git a/packages/runner/src/types.ts b/packages/runner/src/types.ts index 177e18521..eaccf8148 100644 --- a/packages/runner/src/types.ts +++ b/packages/runner/src/types.ts @@ -42,8 +42,13 @@ export type { TaskUpdateEvent, Test, TestAnnotation, + TestAnnotationArtifact, TestAnnotationLocation, TestAPI, + TestArtifact, + TestArtifactBase, + TestArtifactLocation, + TestArtifactRegistry, TestAttachment, TestContext, TestFunction, diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index 266de0493..51695e8c5 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -9,6 +9,7 @@ import type { TaskResultPack, Test, TestAnnotation, + TestArtifact, TestContext, } from './tasks' @@ -145,6 +146,13 @@ export interface VitestRunner { */ onTestAnnotate?: (test: Test, annotation: TestAnnotation) => Promise + /** + * @experimental + * + * Called when artifacts are recorded on tests via the `recordArtifact` utility. + */ + onTestArtifactRecord?: (test: Test, artifact: Artifact) => Promise + /** * Called before running all tests in collected paths. */ diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index d0afc9ca0..b726e0829 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -192,6 +192,7 @@ export type TaskResultPack = [ export interface TaskEventData { annotation?: TestAnnotation | undefined + artifact?: TestArtifact | undefined } export type TaskEventPack = [ @@ -222,6 +223,7 @@ export type TaskUpdateEvent | 'after-hook-start' | 'after-hook-end' | 'test-annotation' + | 'test-artifact' export interface Suite extends TaskBase { type: 'suite' @@ -283,25 +285,12 @@ export interface Test extends TaskPopulated { * An array of custom annotations. */ annotations: TestAnnotation[] -} - -export interface TestAttachment { - contentType?: string - path?: string - body?: string | Uint8Array -} - -export interface TestAnnotationLocation { - line: number - column: number - file: string -} - -export interface TestAnnotation { - message: string - type: string - location?: TestAnnotationLocation - attachment?: TestAttachment + /** + * An array of artifacts produced by the test. + * + * @experimental + */ + artifacts: TestArtifact[] } export type Task = Test | Suite | File @@ -721,3 +710,155 @@ export type SequenceSetupFiles = 'list' | 'parallel' export type WriteableTestContext = { -readonly [P in keyof TestContext]: TestContext[P] } + +// test artifacts + +/** + * Represents a file or data attachment associated with a test artifact. + * + * Attachments can be either file-based (via `path`) or inline content (via `body`). + * The `contentType` helps consumers understand how to interpret the attachment data. + */ +export interface TestAttachment { + /** MIME type of the attachment (e.g., 'image/png', 'text/plain') */ + contentType?: string + /** File system path to the attachment */ + path?: string + /** Inline attachment content as a string or raw binary data */ + body?: string | Uint8Array +} + +/** + * Source code location information for a test artifact. + * + * Indicates where in the source code the artifact originated from. + */ +export interface TestArtifactLocation { + /** Line number in the source file (1-indexed) */ + line: number + /** Column number in the line (1-indexed) */ + column: number + /** Path to the source file */ + file: string +} + +/** + * @experimental + * + * Base interface for all test artifacts. + * + * Extend this interface when creating custom test artifacts. Vitest automatically manages the `attachments` array and injects the `location` property to indicate where the artifact was created in your test code. + */ +export interface TestArtifactBase { + /** File or data attachments associated with this artifact */ + attachments?: TestAttachment[] + /** Source location where this artifact was created */ + location?: TestArtifactLocation +} + +/** + * @deprecated Use {@linkcode TestArtifactLocation} instead. + * + * Kept for backwards compatibility. + */ +export type TestAnnotationLocation = TestArtifactLocation + +export interface TestAnnotation { + message: string + type: string + location?: TestArtifactLocation + attachment?: TestAttachment +} + +/** + * @experimental + * + * Artifact type for test annotations. + */ +export interface TestAnnotationArtifact extends TestArtifactBase { + type: 'internal:annotation' + annotation: TestAnnotation +} + +/** + * @experimental + * @advanced + * + * Registry for custom test artifact types. + * + * Augment this interface to register custom artifact types that your tests can produce. + * + * Each custom artifact should extend {@linkcode TestArtifactBase} and include a unique `type` discriminator property. + * + * @remarks + * - Use a `Symbol` as the **registry key** to guarantee uniqueness + * - The `type` property should follow the pattern `'package-name:artifact-name'`, `'internal:'` is a reserved prefix + * - Use `attachments` to include files or data; extend {@linkcode TestAttachment} for custom metadata + * - `location` property is automatically injected to indicate where the artifact was created + * + * @example + * ```ts + * // Define custom attachment type for generated PDF + * interface PDFAttachment extends TestAttachment { + * contentType: 'application/pdf' + * body: Uint8Array + * pageCount: number + * fileSize: number + * } + * + * interface PDFGenerationArtifact extends TestArtifactBase { + * type: 'my-plugin:pdf-generation' + * templateName: string + * isValid: boolean + * attachments: [PDFAttachment] + * } + * + * // Use a symbol to guarantee key uniqueness + * const pdfKey = Symbol('pdf-generation') + * + * declare module 'vitest' { + * interface TestArtifactRegistry { + * [pdfKey]: PDFGenerationArtifact + * } + * } + * + * // Custom assertion for PDF generation + * async function toGenerateValidPDF( + * this: MatcherState, + * actual: PDFTemplate, + * data: Record + * ): AsyncExpectationResult { + * const pdfBuffer = await actual.render(data) + * const validation = await validatePDF(pdfBuffer) + * + * await recordArtifact(this.task, { + * type: 'my-plugin:pdf-generation', + * templateName: actual.name, + * isValid: validation.success, + * attachments: [{ + * contentType: 'application/pdf', + * body: pdfBuffer, + * pageCount: validation.pageCount, + * fileSize: pdfBuffer.byteLength + * }] + * }) + * + * return { + * pass: validation.success, + * message: () => validation.success + * ? `Generated valid PDF with ${validation.pageCount} pages` + * : `Invalid PDF: ${validation.error}` + * } + * } + * ``` + */ +export interface TestArtifactRegistry {} + +/** + * @experimental + * + * Union type of all test artifacts, including built-in and custom registered artifacts. + * + * This type automatically includes all artifacts registered via {@link TestArtifactRegistry}. + */ +export type TestArtifact = TestAnnotationArtifact | TestArtifactRegistry[keyof TestArtifactRegistry] diff --git a/packages/ui/client/composables/client/index.ts b/packages/ui/client/composables/client/index.ts index a08f86292..d29a40f41 100644 --- a/packages/ui/client/composables/client/index.ts +++ b/packages/ui/client/composables/client/index.ts @@ -34,7 +34,10 @@ export const client = (function createVitestClient() { }, handlers: { onTestAnnotate(testId: string, annotation: TestAnnotation) { - explorerTree.annotateTest(testId, annotation) + explorerTree.recordTestArtifact(testId, { type: 'internal:annotation', annotation, location: annotation.location }) + }, + onTestArtifactRecord(testId, artifact) { + explorerTree.recordTestArtifact(testId, artifact) }, onTaskUpdate(packs: RunnerTaskResultPack[], events: RunnerTaskEventPack[]) { explorerTree.resumeRun(packs, events) diff --git a/packages/ui/client/composables/explorer/collector.ts b/packages/ui/client/composables/explorer/collector.ts index e6f3898dc..87a604883 100644 --- a/packages/ui/client/composables/explorer/collector.ts +++ b/packages/ui/client/composables/explorer/collector.ts @@ -1,4 +1,4 @@ -import type { File, Task, TaskResultPack, Test, TestAnnotation } from '@vitest/runner' +import type { File, Task, TaskResultPack, Test, TestArtifact } from '@vitest/runner' import type { Arrayable } from '@vitest/utils' import type { CollectFilteredTests, CollectorInfo, Filter, FilteredTests } from '~/composables/explorer/types' import { isTestCase } from '@vitest/runner/utils' @@ -66,9 +66,9 @@ export function preparePendingTasks(packs: TaskResultPack[]) { }) } -export function annotateTest( +export function recordTestArtifact( id: string, - annotation: TestAnnotation, + artifact: TestArtifact, ) { const pending = explorerTree.pendingTasks const idMap = client.state.idMap @@ -80,7 +80,13 @@ export function annotateTest( pending.set(test.file.id, file) } file.add(test.id) - test.annotations.push(annotation) + + if (artifact.type === 'internal:annotation') { + test.annotations.push(artifact.annotation) + } + else { + test.artifacts.push(artifact) + } } } diff --git a/packages/ui/client/composables/explorer/tree.ts b/packages/ui/client/composables/explorer/tree.ts index 746710267..faa1d698c 100644 --- a/packages/ui/client/composables/explorer/tree.ts +++ b/packages/ui/client/composables/explorer/tree.ts @@ -1,4 +1,4 @@ -import type { File, TaskResultPack, TestAnnotation } from '@vitest/runner' +import type { File, TaskResultPack, TestArtifact } from '@vitest/runner' import type { RunnerTaskEventPack } from 'vitest' import type { CollectorInfo, @@ -9,7 +9,7 @@ import type { import { useRafFn } from '@vueuse/core' import { reactive } from 'vue' import { runCollapseAllTask, runCollapseNode } from '~/composables/explorer/collapse' -import { annotateTest, collectTestsTotalData, preparePendingTasks, runCollect, runLoadFiles } from '~/composables/explorer/collector' +import { collectTestsTotalData, preparePendingTasks, recordTestArtifact, runCollect, runLoadFiles } from '~/composables/explorer/collector' import { runExpandAll, runExpandNode } from '~/composables/explorer/expand' import { runFilter } from '~/composables/explorer/filter' import { @@ -83,8 +83,8 @@ export class ExplorerTree { this.collect(true, false) } - annotateTest(testId: string, annotation: TestAnnotation) { - annotateTest(testId, annotation) + recordTestArtifact(testId: string, artifact: TestArtifact) { + recordTestArtifact(testId, artifact) if (!this.onTaskUpdateCalled) { clearTimeout(this.resumeEndRunId) this.onTaskUpdateCalled = true diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 8af491347..821dc0ca0 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -1,4 +1,4 @@ -import type { File, TaskEventPack, TaskResultPack, TestAnnotation } from '@vitest/runner' +import type { File, TaskEventPack, TaskResultPack, TestAnnotation, TestArtifact } from '@vitest/runner' import type { SerializedError } from '@vitest/utils' import type { IncomingMessage } from 'node:http' import type { ViteDevServer } from 'vite' @@ -198,6 +198,16 @@ export class WebSocketReporter implements Reporter { }) } + async onTestCaseArtifactRecord(testCase: TestCase, artifact: TestArtifact): Promise { + if (this.clients.size === 0) { + return + } + + this.clients.forEach((client) => { + client.onTestArtifactRecord?.(testCase.id, artifact)?.catch?.(noop) + }) + } + async onTaskUpdate(packs: TaskResultPack[], events: TaskEventPack[]): Promise { if (this.clients.size === 0) { return diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index e6f4503f4..2d029d365 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -1,4 +1,4 @@ -import type { File, TaskEventPack, TaskResultPack, TestAnnotation } from '@vitest/runner' +import type { File, TaskEventPack, TaskResultPack, TestAnnotation, TestArtifact } from '@vitest/runner' import type { Awaitable } from '@vitest/utils' import type { BirpcReturn } from 'birpc' import type { SerializedConfig } from '../runtime/config' @@ -61,6 +61,7 @@ export interface WebSocketEvents { executionTime?: number, ) => Awaitable onTestAnnotate?: (testId: string, annotation: TestAnnotation) => Awaitable + onTestArtifactRecord?: (testId: string, artifact: TestArtifact) => Awaitable onTaskUpdate?: (packs: TaskResultPack[], events: TaskEventPack[]) => Awaitable onUserConsoleLog?: (log: UserConsoleLog) => Awaitable onPathsCollected?: (paths?: string[]) => Awaitable diff --git a/packages/vitest/src/node/ast-collect.ts b/packages/vitest/src/node/ast-collect.ts index d8ca00882..827d9cc82 100644 --- a/packages/vitest/src/node/ast-collect.ts +++ b/packages/vitest/src/node/ast-collect.ts @@ -350,6 +350,7 @@ function createFileTask( meta: {}, timeout: 0, annotations: [], + artifacts: [], } definition.task = task latestSuite.tasks.push(task) diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts index d9dd51c33..302345e34 100644 --- a/packages/vitest/src/node/pools/rpc.ts +++ b/packages/vitest/src/node/pools/rpc.ts @@ -120,8 +120,8 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp onAfterSuiteRun(meta) { vitest.coverageProvider?.onAfterSuiteRun(meta) }, - async onTaskAnnotate(testId, annotation) { - return vitest._testRun.annotate(testId, annotation) + async onTaskArtifactRecord(testId, artifact) { + return vitest._testRun.recordArtifact(testId, artifact) }, async onTaskUpdate(packs, events) { if (methodsOptions.collect) { diff --git a/packages/vitest/src/node/reporters/junit.ts b/packages/vitest/src/node/reporters/junit.ts index ae97c8666..7b504775b 100644 --- a/packages/vitest/src/node/reporters/junit.ts +++ b/packages/vitest/src/node/reporters/junit.ts @@ -336,6 +336,7 @@ export class JUnitReporter implements Reporter { suite: null as any, file: null as any, annotations: [], + artifacts: [], } satisfies Task) } diff --git a/packages/vitest/src/node/reporters/reported-tasks.ts b/packages/vitest/src/node/reporters/reported-tasks.ts index f1df17ff4..374aefee5 100644 --- a/packages/vitest/src/node/reporters/reported-tasks.ts +++ b/packages/vitest/src/node/reporters/reported-tasks.ts @@ -6,6 +6,7 @@ import type { Suite as RunnerTestSuite, TaskMeta, TestAnnotation, + TestArtifact, } from '@vitest/runner' import type { SerializedError, TestError } from '@vitest/utils' import type { TestProject } from '../project' @@ -186,6 +187,15 @@ export class TestCase extends ReportedTaskImplementation { return [...this.task.annotations] } + /** + * @experimental + * + * Test artifacts recorded via the `recordArtifact` API during the test execution. + */ + public artifacts(): ReadonlyArray { + return [...this.task.artifacts] + } + /** * Useful information about the test like duration, memory usage, etc. * Diagnostic is only available after the test has finished. diff --git a/packages/vitest/src/node/test-run.ts b/packages/vitest/src/node/test-run.ts index 109e7c901..e9a334540 100644 --- a/packages/vitest/src/node/test-run.ts +++ b/packages/vitest/src/node/test-run.ts @@ -3,10 +3,9 @@ import type { TaskEventPack, TaskResultPack, TaskUpdateEvent, - TestAnnotation, TestAttachment, } from '@vitest/runner' -import type { TaskEventData } from '@vitest/runner/types/tasks' +import type { TaskEventData, TestArtifact } from '@vitest/runner/types/tasks' import type { SerializedError } from '@vitest/utils' import type { UserConsoleLog } from '../types/general' import type { Vitest } from './core' @@ -55,19 +54,35 @@ export class TestRun { await this.vitest.report('onUserConsoleLog', log) } - async annotate(testId: string, annotation: TestAnnotation): Promise { + async recordArtifact(testId: string, artifact: Artifact): Promise { const task = this.vitest.state.idMap.get(testId) const entity = task && this.vitest.state.getReportedEntity(task) assert(task && entity, `Entity must be found for task ${task?.name || testId}`) - assert(entity.type === 'test', `Annotation can only be added to a test, instead got ${entity.type}`) + assert(entity.type === 'test', `Artifacts can only be recorded on a test, instead got ${entity.type}`) - await this.resolveTestAttachment(entity, annotation) + // annotations won't resolve as artifacts for backwards compatibility until next major + if (artifact.type === 'internal:annotation') { + await this.resolveTestAttachment(entity, artifact.annotation.attachment, artifact.annotation.message) - entity.task.annotations.push(annotation) + entity.task.annotations.push(artifact.annotation) - await this.vitest.report('onTestCaseAnnotate', entity, annotation) - return annotation + await this.vitest.report('onTestCaseAnnotate', entity, artifact.annotation) + + return artifact + } + + if (Array.isArray(artifact.attachments)) { + await Promise.all( + artifact.attachments.map(attachment => this.resolveTestAttachment(entity, attachment)), + ) + } + + entity.task.artifacts.push(artifact) + + await this.vitest.report('onTestCaseArtifactRecord', entity, artifact) + + return artifact } async updated(update: TaskResultPack[], events: TaskEventPack[]): Promise { @@ -232,9 +247,8 @@ export class TestRun { } } - private async resolveTestAttachment(test: TestCase, annotation: TestAnnotation): Promise { + private async resolveTestAttachment(test: TestCase, attachment: TestAttachment | undefined, filename?: string): Promise { const project = test.project - const attachment = annotation.attachment if (!attachment) { return attachment } @@ -244,7 +258,7 @@ export class TestRun { const hash = createHash('sha1').update(currentPath).digest('hex') const newPath = resolve( project.config.attachmentsDir, - `${sanitizeFilePath(annotation.message)}-${hash}${extname(currentPath)}`, + `${filename ? `${sanitizeFilePath(filename)}-` : ''}${hash}${extname(currentPath)}`, ) if (!existsSync(project.config.attachmentsDir)) { await mkdir(project.config.attachmentsDir, { recursive: true }) diff --git a/packages/vitest/src/node/types/reporter.ts b/packages/vitest/src/node/types/reporter.ts index 3f5d4b530..9785c3110 100644 --- a/packages/vitest/src/node/types/reporter.ts +++ b/packages/vitest/src/node/types/reporter.ts @@ -1,4 +1,4 @@ -import type { File, TaskEventPack, TaskResultPack, TestAnnotation } from '@vitest/runner' +import type { File, TaskEventPack, TaskResultPack, TestAnnotation, TestArtifact } from '@vitest/runner' import type { Awaitable, SerializedError } from '@vitest/utils' import type { UserConsoleLog } from '../../types/general' import type { Vitest } from '../core' @@ -71,6 +71,11 @@ export interface Reporter { */ onTestCaseAnnotate?: (testCase: TestCase, annotation: TestAnnotation) => Awaitable + /** + * Called when artifacts are recorded on tests via the `recordArtifact` utility. + */ + onTestCaseArtifactRecord?: (testCase: TestCase, artifact: TestArtifact) => Awaitable + /** * Called when test suite is ready to run. * Called before the `beforeAll` hooks for the test are run. diff --git a/packages/vitest/src/public/index.ts b/packages/vitest/src/public/index.ts index ff71f5830..d1655a64b 100644 --- a/packages/vitest/src/public/index.ts +++ b/packages/vitest/src/public/index.ts @@ -80,6 +80,7 @@ export { it, onTestFailed, onTestFinished, + recordArtifact, suite, test, } from '@vitest/runner' @@ -103,7 +104,13 @@ export type { TaskMeta, TaskState, TestAnnotation, + TestAnnotationArtifact, TestAPI, + TestArtifact, + TestArtifactBase, + TestArtifactLocation, + TestArtifactRegistry, + TestAttachment, TestContext, TestFunction, TestOptions, diff --git a/packages/vitest/src/runtime/runners/index.ts b/packages/vitest/src/runtime/runners/index.ts index 0fa87474d..056e4fc8f 100644 --- a/packages/vitest/src/runtime/runners/index.ts +++ b/packages/vitest/src/runtime/runners/index.ts @@ -73,10 +73,18 @@ export async function resolveTestRunner( // patch some methods, so custom runners don't need to call RPC const originalOnTestAnnotate = testRunner.onTestAnnotate testRunner.onTestAnnotate = async (test, annotation) => { - const p = rpc().onTaskAnnotate(test.id, annotation) + const p = rpc().onTaskArtifactRecord(test.id, { type: 'internal:annotation', location: annotation.location, annotation }) const overriddenResult = await originalOnTestAnnotate?.call(testRunner, test, annotation) const vitestResult = await p - return overriddenResult || vitestResult + return overriddenResult || vitestResult.annotation + } + + const originalOnTestArtifactRecord = testRunner.onTestArtifactRecord + testRunner.onTestArtifactRecord = async (test, artifact) => { + const p = rpc().onTaskArtifactRecord(test.id, artifact) + const overriddenResult = await originalOnTestArtifactRecord?.call(testRunner, test, artifact) + const vitestResult = await p + return overriddenResult as typeof artifact || vitestResult } const originalOnCollectStart = testRunner.onCollectStart diff --git a/packages/vitest/src/typecheck/collect.ts b/packages/vitest/src/typecheck/collect.ts index cf81e781d..392a07ef7 100644 --- a/packages/vitest/src/typecheck/collect.ts +++ b/packages/vitest/src/typecheck/collect.ts @@ -208,6 +208,7 @@ export async function collectTests( end: definition.end, start: definition.start, annotations: [], + artifacts: [], meta: { typecheck: true, }, diff --git a/packages/vitest/src/types/rpc.ts b/packages/vitest/src/types/rpc.ts index fa5d3e68e..caf16e628 100644 --- a/packages/vitest/src/types/rpc.ts +++ b/packages/vitest/src/types/rpc.ts @@ -1,4 +1,4 @@ -import type { CancelReason, File, TaskEventPack, TaskResultPack, TestAnnotation } from '@vitest/runner' +import type { CancelReason, File, TaskEventPack, TaskResultPack, TestArtifact } from '@vitest/runner' import type { SnapshotResult } from '@vitest/snapshot' import type { FetchFunctionOptions, FetchResult } from 'vite/module-runner' import type { OTELCarrier } from '../utils/traces' @@ -20,7 +20,7 @@ export interface RuntimeRPC { onQueued: (file: File) => void onCollected: (files: File[]) => Promise onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void - onTaskAnnotate: (testId: string, annotation: TestAnnotation) => Promise + onTaskArtifactRecord: (testId: string, artifact: Artifact) => Promise onTaskUpdate: (pack: TaskResultPack[], events: TaskEventPack[]) => Promise onCancel: (reason: CancelReason) => void getCountOfFailedTests: () => number diff --git a/packages/vitest/src/utils/tasks.ts b/packages/vitest/src/utils/tasks.ts index 712d7c486..7e4e69daf 100644 --- a/packages/vitest/src/utils/tasks.ts +++ b/packages/vitest/src/utils/tasks.ts @@ -43,6 +43,9 @@ export function convertTasksToEvents(file: File, onTask?: (task: Task) => void): task.annotations.forEach((annotation) => { events.push([task.id, 'test-annotation', { annotation }]) }) + task.artifacts.forEach((artifact) => { + events.push([task.id, 'test-artifact', { artifact }]) + }) events.push([task.id, 'test-finished', undefined]) } } diff --git a/packages/ws-client/src/index.ts b/packages/ws-client/src/index.ts index 9c7b212a1..70919a59a 100644 --- a/packages/ws-client/src/index.ts +++ b/packages/ws-client/src/index.ts @@ -54,6 +54,9 @@ export function createClient(url: string, options: VitestClientOptions = {}): Vi onTestAnnotate(testId, annotation) { handlers.onTestAnnotate?.(testId, annotation) }, + onTestArtifactRecord(testId, artifact) { + handlers.onTestArtifactRecord?.(testId, artifact) + }, onSpecsCollected(specs, startTime) { specs?.forEach(([config, file]) => { ctx.state.clearFiles({ config }, [file]) diff --git a/test/cli/fixtures/custom-pool/pool/custom-pool.ts b/test/cli/fixtures/custom-pool/pool/custom-pool.ts index 2e241cb3b..cd2ad64bf 100644 --- a/test/cli/fixtures/custom-pool/pool/custom-pool.ts +++ b/test/cli/fixtures/custom-pool/pool/custom-pool.ts @@ -92,6 +92,7 @@ async function onMessage(message: WorkerRequest, project: TestProject, options: mode: 'run', meta: {}, annotations: [], + artifacts: [], timeout: 0, file: taskFile, result: { diff --git a/test/cli/test/annotations.test.ts b/test/cli/test/annotations.test.ts index fa956486f..05dac499e 100644 --- a/test/cli/test/annotations.test.ts +++ b/test/cli/test/annotations.test.ts @@ -1,3 +1,4 @@ +import type { TestArtifact } from '@vitest/runner' import type { TestAnnotation } from 'vitest' import { playwright } from '@vitest/browser-playwright' import { describe, expect, test } from 'vitest' @@ -50,6 +51,7 @@ describe('API', () => { ])('annotations are exposed correctly in $name', async (options) => { const events: string[] = [] const annotations: Record> = {} + const artifacts: Record> = {} const { stderr } = await runInlineTests( { @@ -66,6 +68,9 @@ describe('API', () => { const path = annotation.attachment?.path?.replace(testCase.project.config.root, '').replace(/\w+\.js$/, '.js') events.push(`[annotate] ${testCase.name} ${annotation.message} ${annotation.type} path=${path} contentType=${annotation.attachment?.contentType} body=${annotation.attachment?.body}`) }, + onTestCaseArtifactRecord() { + events.push('[artifact]') + }, onTestCaseReady(testCase) { events.push(`[ready] ${testCase.name}`) }, @@ -86,6 +91,8 @@ describe('API', () => { } return annotation }) + // artifacts should be empty until next major so no handling needed + artifacts[testCase.name] = testCase.artifacts() }, }, ], @@ -116,6 +123,12 @@ describe('API', () => { ] `) + expect(artifacts).toMatchInlineSnapshot(` + { + "second": [], + "simple": [], + } + `) expect(annotations).toMatchInlineSnapshot(` { "second": [ diff --git a/test/cli/test/artifacts.test.ts b/test/cli/test/artifacts.test.ts new file mode 100644 index 000000000..d6ccb2802 --- /dev/null +++ b/test/cli/test/artifacts.test.ts @@ -0,0 +1,420 @@ +import type { TestAnnotation, TestArtifact } from 'vitest' +import { format } from 'node:util' +import { playwright } from '@vitest/browser-playwright' +import { describe, expect, test } from 'vitest' +import { runInlineTests } from '../../test-utils' + +const test3Content = /* ts */` +export async function externalArtifactRecord(recordArtifact, task) { + await recordArtifact(task, { type: 'external' }) +} +` + +const artifactsTest = /* ts */` +import { test, describe, recordArtifact } from 'vitest' +import { externalArtifactRecord } from './test-3.js' + +test('simple', async ({ task }) => { + await recordArtifact(task, { type: 'with-no-attachments' }) + await recordArtifact(task, { type: 'with-one-attachment', attachments: [{ path: './test-3.js' }] }) + await recordArtifact(task, { type: 'with-multiple-attachments', attachments: [{ path: './test-3.js' }, { path: './test-4.js' }] }) + await externalArtifactRecord(recordArtifact, task) + await recordArtifact(task, { type: 'with-base64', attachments: [{ body: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' }]}) + await recordArtifact(task, { type: 'with-bytes', attachments: [{ body: new Uint8Array(Array.from({ length: 256 }).map((_, i) => i)) }] }) + await recordArtifact(task, { type: 'with-contentType', attachments: [{ body: '', contentType: 'text/plain' }] }) +}) + +describe('suite', () => { + test('second', async ({ task }) => { + await recordArtifact(task, { type: 'with-external-link', attachments: [{ path: 'https://absolute-path.com' }] }) + }) +}) +` + +describe('API', () => { + test.for([ + { name: 'forks', pool: 'forks' }, + { name: 'threads', pool: 'threads' }, + { + name: 'browser', + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [ + { browser: 'chromium' as const }, + ], + }, + }, + ])('artifacts are exposed correctly in $name', async (options) => { + const events: string[] = [] + const annotations: Record> = {} + const artifacts: Record> = {} + + const { stderr } = await runInlineTests( + { + 'basic.test.ts': artifactsTest, + 'test-3.js': test3Content, + 'test-4.js': '', + }, + { + includeTaskLocation: true, + reporters: [ + 'default', + { + onTestCaseAnnotate() { + events.push('[annotate]') + }, + onTestCaseArtifactRecord(testCase, artifact) { + const path = artifact.attachments?.map( + attachment => attachment.path?.replace(testCase.project.config.root, '').replace(/\w+\.js$/, '.js'), + ) + events.push(`[artifact] ${testCase.name} ${artifact.type} path=${format(path)} contentType=${format(artifact.attachments?.map(attachment => attachment.contentType))} body=${format(artifact.attachments?.map(attachment => attachment.body))}`) + }, + onTestCaseReady(testCase) { + events.push(`[ready] ${testCase.name}`) + }, + onTestCaseResult(testCase) { + events.push(`[result] ${testCase.name}`) + annotations[testCase.name] = testCase.annotations() + artifacts[testCase.name] = testCase.artifacts().map((artifact) => { + if (Array.isArray(artifact.attachments)) { + for (const attachment of artifact.attachments) { + if (attachment?.path) { + attachment.path = attachment.path.replace( + testCase.project.config.root, + '', + ).replace(/\w+\.js$/, '.js') + } + } + } + if (artifact.location) { + artifact.location.file = artifact.location.file.replace( + testCase.project.config.root, + '', + ) + } + return artifact + }) + }, + }, + ], + }, + {}, + { + test: options, + }, + ) + + expect(stderr).toBe('') + expect(events).toMatchInlineSnapshot(` + [ + "[ready] simple", + "[artifact] simple with-no-attachments path=undefined contentType=undefined body=undefined", + "[artifact] simple with-one-attachment path=[ '/.vitest-attachments/.js' ] contentType=[ 'text/javascript' ] body=[ undefined ]", + "[artifact] simple with-multiple-attachments path=[ + '/.vitest-attachments/.js', + '/.vitest-attachments/.js' + ] contentType=[ 'text/javascript', 'text/javascript' ] body=[ undefined, undefined ]", + "[artifact] simple external path=undefined contentType=undefined body=undefined", + "[artifact] simple with-base64 path=[ undefined ] contentType=[ undefined ] body=[ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' ]", + "[artifact] simple with-bytes path=[ undefined ] contentType=[ undefined ] body=[ + 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==' + ]", + "[artifact] simple with-contentType path=[ undefined ] contentType=[ 'text/plain' ] body=[ '' ]", + "[result] simple", + "[ready] second", + "[artifact] second with-external-link path=[ 'https://absolute-path.com' ] contentType=[ undefined ] body=[ undefined ]", + "[result] second", + ] + `) + + expect(artifacts).toMatchInlineSnapshot(` + { + "second": [ + { + "attachments": [ + { + "path": "https://absolute-path.com", + }, + ], + "location": { + "column": 11, + "file": "/basic.test.ts", + "line": 17, + }, + "type": "with-external-link", + }, + ], + "simple": [ + { + "location": { + "column": 9, + "file": "/basic.test.ts", + "line": 6, + }, + "type": "with-no-attachments", + }, + { + "attachments": [ + { + "contentType": "text/javascript", + "path": "/.vitest-attachments/.js", + }, + ], + "location": { + "column": 9, + "file": "/basic.test.ts", + "line": 7, + }, + "type": "with-one-attachment", + }, + { + "attachments": [ + { + "contentType": "text/javascript", + "path": "/.vitest-attachments/.js", + }, + { + "contentType": "text/javascript", + "path": "/.vitest-attachments/.js", + }, + ], + "location": { + "column": 9, + "file": "/basic.test.ts", + "line": 8, + }, + "type": "with-multiple-attachments", + }, + { + "location": { + "column": 9, + "file": "/basic.test.ts", + "line": 9, + }, + "type": "external", + }, + { + "attachments": [ + { + "body": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", + }, + ], + "location": { + "column": 9, + "file": "/basic.test.ts", + "line": 10, + }, + "type": "with-base64", + }, + { + "attachments": [ + { + "body": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==", + }, + ], + "location": { + "column": 9, + "file": "/basic.test.ts", + "line": 11, + }, + "type": "with-bytes", + }, + { + "attachments": [ + { + "body": "", + "contentType": "text/plain", + }, + ], + "location": { + "column": 9, + "file": "/basic.test.ts", + "line": 12, + }, + "type": "with-contentType", + }, + ], + } + `) + expect(annotations).toMatchInlineSnapshot(` + { + "second": [], + "simple": [], + } + `) + }) + + test('cannot record artifacts when the test finished running', async () => { + const { stderr } = await runInlineTests({ + 'basic.test.ts': ` + import { recordArtifact } from 'vitest' + test('finished early', ({ task }) => { + setTimeout(() => { + recordArtifact(task, { type: 'invalid-artifact' }) + }, 50) + }) + + test('long running test ', async () => { + await new Promise(r => setTimeout(() => r(), 100)) + }) + `, + }, { globals: true }) + expect(stderr).toContain('Cannot record a test artifact outside of the test run. The test "finished early" finished running with the "pass" state already.') + }) +}) + +describe('reporters', () => { + test('tap', async () => { + const { stdout } = await runInlineTests( + { + 'basic.test.ts': artifactsTest, + 'test-3.js': test3Content, + 'test-4.js': '', + }, + { reporters: ['tap'] }, + ) + + expect(stdout.replace(/[\d.]+ms/g, '