From 24603e3c462631602be154472815c84f99e262b2 Mon Sep 17 00:00:00 2001 From: Raul Macarie Date: Mon, 9 Feb 2026 11:41:55 +0100 Subject: [PATCH] feat: store failure screenshots using artifacts API (#9588) --- docs/api/advanced/artifacts.md | 4 +- packages/browser/src/client/tester/runner.ts | 7 +- packages/browser/src/node/plugin.ts | 60 +--------- packages/runner/src/artifact.ts | 7 +- packages/runner/src/types.ts | 1 + packages/runner/src/types/tasks.ts | 23 +++- .../client/components/FailureScreenshot.vue | 70 ++++++++++++ .../client/components/artifacts/Artifacts.vue | 78 +++++++++++++ .../ui/client/components/views/ViewReport.vue | 45 +------- .../components/views/ViewTestReport.vue | 105 ++---------------- packages/ui/client/composables/location.ts | 15 +++ packages/ui/client/composables/screenshot.ts | 35 ------ packages/vitest/src/node/reporters/base.ts | 14 ++- packages/vitest/src/types/global.ts | 1 - test/cli/test/artifacts.test.ts | 4 +- 15 files changed, 223 insertions(+), 246 deletions(-) create mode 100644 packages/ui/client/components/FailureScreenshot.vue create mode 100644 packages/ui/client/components/artifacts/Artifacts.vue create mode 100644 packages/ui/client/composables/location.ts delete mode 100644 packages/ui/client/composables/screenshot.ts diff --git a/docs/api/advanced/artifacts.md b/docs/api/advanced/artifacts.md index 8d9ae9f54..b1db9f49d 100644 --- a/docs/api/advanced/artifacts.md +++ b/docs/api/advanced/artifacts.md @@ -39,7 +39,9 @@ function recordArtifact(task: Test, artifact: Art 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. +::: info +Artifacts must be recorded before the task is reported. Any artifacts recorded after that will not be included in the task. +::: When an artifact is recorded on a test, it emits an `onTestArtifactRecord` runner event and a [`onTestCaseArtifactRecord` reporter event](/api/advanced/reporters#ontestcaseartifactrecord). To retrieve recorded artifacts from a test case, use the [`artifacts()`](/api/advanced/test-case#artifacts) method. diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 31e66e000..cebf35858 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -18,7 +18,7 @@ import type { VitestBrowserClientMocker } from './mocker' import type { CommandsManager } from './tester-utils' import { globalChannel, onCancel } from '@vitest/browser/client' import { getTestName } from '@vitest/runner/utils' -import { BenchmarkRunner, TestRunner } from 'vitest' +import { BenchmarkRunner, recordArtifact, TestRunner } from 'vitest' import { page, userEvent } from 'vitest/browser' import { DecodedMap, @@ -175,7 +175,10 @@ export function createBrowserRunner( console.error('[vitest] Failed to take a screenshot', err) }) if (screenshot) { - task.meta.failScreenshotPath = screenshot + await recordArtifact(task, { + type: 'internal:failureScreenshot', + attachments: [{ contentType: 'image/png', path: screenshot, originalPath: screenshot }], + } as const) } } } diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index 0b23b6dd4..5380a76e7 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -1,14 +1,13 @@ -import type { Stats } from 'node:fs' import type { HtmlTagDescriptor } from 'vite' import type { Plugin } from 'vitest/config' import type { Vitest } from 'vitest/node' import type { ParentBrowserProject } from './projectParent' -import { createReadStream, lstatSync, readFileSync } from 'node:fs' +import { createReadStream, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dynamicImportPlugin } from '@vitest/mocker/node' import { toArray } from '@vitest/utils/helpers' import MagicString from 'magic-string' -import { basename, dirname, extname, join, resolve } from 'pathe' +import { basename, dirname, join, resolve } from 'pathe' import sirv from 'sirv' import { coverageConfigDefaults } from 'vitest/config' import { @@ -97,61 +96,6 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => { ) } - const uiEnabled = parentServer.config.browser.ui - - if (uiEnabled) { - // eslint-disable-next-line prefer-arrow-callback - server.middlewares.use(`${base}__screenshot-error`, function vitestBrowserScreenshotError(req, res) { - if (!req.url) { - res.statusCode = 404 - res.end() - return - } - - const url = new URL(req.url, 'http://localhost') - const id = url.searchParams.get('id') - if (!id) { - res.statusCode = 404 - res.end() - return - } - - const task = parentServer.vitest.state.idMap.get(id) - const file = task?.meta.failScreenshotPath - if (!file) { - res.statusCode = 404 - res.end() - return - } - - let stat: Stats | undefined - try { - stat = lstatSync(file) - } - catch { - } - - if (!stat?.isFile()) { - res.statusCode = 404 - res.end() - return - } - - const ext = extname(file) - const buffer = readFileSync(file) - res.setHeader( - 'Cache-Control', - 'public,max-age=0,must-revalidate', - ) - res.setHeader('Content-Length', buffer.length) - res.setHeader('Content-Type', ext === 'jpeg' || ext === 'jpg' - ? 'image/jpeg' - : ext === 'webp' - ? 'image/webp' - : 'image/png') - res.end(buffer) - }) - } server.middlewares.use((req, res, next) => { // 9000 mega head move // Vite always caches optimized dependencies, but users might mock diff --git a/packages/runner/src/artifact.ts b/packages/runner/src/artifact.ts index 125df7590..6f64d8567 100644 --- a/packages/runner/src/artifact.ts +++ b/packages/runner/src/artifact.ts @@ -13,12 +13,13 @@ import { findTestFileStackTrace } from './utils/collect' * * Vitest automatically injects the source location where the artifact was created and manages any attachments you include. * + * **Note:** artifacts must be recorded before the task is reported. Any artifacts recorded after that will not be included in the task. + * * @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 @@ -40,10 +41,6 @@ import { findTestFileStackTrace } from './utils/collect' 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!, diff --git a/packages/runner/src/types.ts b/packages/runner/src/types.ts index 304023fa2..4b79bd876 100644 --- a/packages/runner/src/types.ts +++ b/packages/runner/src/types.ts @@ -14,6 +14,7 @@ export type { AroundEachListener, BeforeAllListener, BeforeEachListener, + FailureScreenshotArtifact, File, Fixture, FixtureFn, diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 1c231c255..c1f9f4c2b 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -1345,6 +1345,23 @@ export interface VisualRegressionArtifact extends TestArtifactBase { attachments: VisualRegressionArtifactAttachment[] } +interface FailureScreenshotArtifactAttachment extends TestAttachment { + path: string + /** Original file system path to the screenshot, before attachment resolution */ + originalPath: string + body?: undefined +} + +/** + * @experimental + * + * Artifact type for failure screenshots. + */ +export interface FailureScreenshotArtifact extends TestArtifactBase { + type: 'internal:failureScreenshot' + attachments: [FailureScreenshotArtifactAttachment] | [] +} + /** * @experimental * @advanced @@ -1426,4 +1443,8 @@ export interface TestArtifactRegistry {} * * This type automatically includes all artifacts registered via {@link TestArtifactRegistry}. */ -export type TestArtifact = TestAnnotationArtifact | VisualRegressionArtifact | TestArtifactRegistry[keyof TestArtifactRegistry] +export type TestArtifact + = | FailureScreenshotArtifact + | TestAnnotationArtifact + | VisualRegressionArtifact + | TestArtifactRegistry[keyof TestArtifactRegistry] diff --git a/packages/ui/client/components/FailureScreenshot.vue b/packages/ui/client/components/FailureScreenshot.vue new file mode 100644 index 000000000..78cf18eae --- /dev/null +++ b/packages/ui/client/components/FailureScreenshot.vue @@ -0,0 +1,70 @@ + + + diff --git a/packages/ui/client/components/artifacts/Artifacts.vue b/packages/ui/client/components/artifacts/Artifacts.vue new file mode 100644 index 000000000..4dc1da6f3 --- /dev/null +++ b/packages/ui/client/components/artifacts/Artifacts.vue @@ -0,0 +1,78 @@ + + + diff --git a/packages/ui/client/components/views/ViewReport.vue b/packages/ui/client/components/views/ViewReport.vue index 33cdd436e..e75652145 100644 --- a/packages/ui/client/components/views/ViewReport.vue +++ b/packages/ui/client/components/views/ViewReport.vue @@ -1,13 +1,10 @@ - diff --git a/packages/ui/client/components/views/ViewTestReport.vue b/packages/ui/client/components/views/ViewTestReport.vue index 0f0f249fc..19e841bf6 100644 --- a/packages/ui/client/components/views/ViewTestReport.vue +++ b/packages/ui/client/components/views/ViewTestReport.vue @@ -1,19 +1,14 @@