Skip to content

Commit 24603e3

Browse files
authored
feat: store failure screenshots using artifacts API (#9588)
1 parent 4873984 commit 24603e3

File tree

15 files changed

+223
-246
lines changed

15 files changed

+223
-246
lines changed

docs/api/advanced/artifacts.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ function recordArtifact<Artifact extends TestArtifact>(task: Test, artifact: Art
3939

4040
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.
4141

42-
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.
42+
::: info
43+
Artifacts must be recorded before the task is reported. Any artifacts recorded after that will not be included in the task.
44+
:::
4345

4446
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.
4547

packages/browser/src/client/tester/runner.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type { VitestBrowserClientMocker } from './mocker'
1818
import type { CommandsManager } from './tester-utils'
1919
import { globalChannel, onCancel } from '@vitest/browser/client'
2020
import { getTestName } from '@vitest/runner/utils'
21-
import { BenchmarkRunner, TestRunner } from 'vitest'
21+
import { BenchmarkRunner, recordArtifact, TestRunner } from 'vitest'
2222
import { page, userEvent } from 'vitest/browser'
2323
import {
2424
DecodedMap,
@@ -175,7 +175,10 @@ export function createBrowserRunner(
175175
console.error('[vitest] Failed to take a screenshot', err)
176176
})
177177
if (screenshot) {
178-
task.meta.failScreenshotPath = screenshot
178+
await recordArtifact(task, {
179+
type: 'internal:failureScreenshot',
180+
attachments: [{ contentType: 'image/png', path: screenshot, originalPath: screenshot }],
181+
} as const)
179182
}
180183
}
181184
}

packages/browser/src/node/plugin.ts

Lines changed: 2 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import type { Stats } from 'node:fs'
21
import type { HtmlTagDescriptor } from 'vite'
32
import type { Plugin } from 'vitest/config'
43
import type { Vitest } from 'vitest/node'
54
import type { ParentBrowserProject } from './projectParent'
6-
import { createReadStream, lstatSync, readFileSync } from 'node:fs'
5+
import { createReadStream, readFileSync } from 'node:fs'
76
import { createRequire } from 'node:module'
87
import { dynamicImportPlugin } from '@vitest/mocker/node'
98
import { toArray } from '@vitest/utils/helpers'
109
import MagicString from 'magic-string'
11-
import { basename, dirname, extname, join, resolve } from 'pathe'
10+
import { basename, dirname, join, resolve } from 'pathe'
1211
import sirv from 'sirv'
1312
import { coverageConfigDefaults } from 'vitest/config'
1413
import {
@@ -97,61 +96,6 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => {
9796
)
9897
}
9998

100-
const uiEnabled = parentServer.config.browser.ui
101-
102-
if (uiEnabled) {
103-
// eslint-disable-next-line prefer-arrow-callback
104-
server.middlewares.use(`${base}__screenshot-error`, function vitestBrowserScreenshotError(req, res) {
105-
if (!req.url) {
106-
res.statusCode = 404
107-
res.end()
108-
return
109-
}
110-
111-
const url = new URL(req.url, 'http://localhost')
112-
const id = url.searchParams.get('id')
113-
if (!id) {
114-
res.statusCode = 404
115-
res.end()
116-
return
117-
}
118-
119-
const task = parentServer.vitest.state.idMap.get(id)
120-
const file = task?.meta.failScreenshotPath
121-
if (!file) {
122-
res.statusCode = 404
123-
res.end()
124-
return
125-
}
126-
127-
let stat: Stats | undefined
128-
try {
129-
stat = lstatSync(file)
130-
}
131-
catch {
132-
}
133-
134-
if (!stat?.isFile()) {
135-
res.statusCode = 404
136-
res.end()
137-
return
138-
}
139-
140-
const ext = extname(file)
141-
const buffer = readFileSync(file)
142-
res.setHeader(
143-
'Cache-Control',
144-
'public,max-age=0,must-revalidate',
145-
)
146-
res.setHeader('Content-Length', buffer.length)
147-
res.setHeader('Content-Type', ext === 'jpeg' || ext === 'jpg'
148-
? 'image/jpeg'
149-
: ext === 'webp'
150-
? 'image/webp'
151-
: 'image/png')
152-
res.end(buffer)
153-
})
154-
}
15599
server.middlewares.use((req, res, next) => {
156100
// 9000 mega head move
157101
// Vite always caches optimized dependencies, but users might mock

packages/runner/src/artifact.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ import { findTestFileStackTrace } from './utils/collect'
1313
*
1414
* Vitest automatically injects the source location where the artifact was created and manages any attachments you include.
1515
*
16+
* **Note:** artifacts must be recorded before the task is reported. Any artifacts recorded after that will not be included in the task.
17+
*
1618
* @param task - The test task context, typically accessed via `this.task` in custom matchers or `context.task` in tests
1719
* @param artifact - The artifact to record. Must extend {@linkcode TestArtifactBase}
1820
*
1921
* @returns A promise that resolves to the recorded artifact with location injected
2022
*
21-
* @throws {Error} If called after the test has finished running
2223
* @throws {Error} If the test runner doesn't support artifacts
2324
*
2425
* @example
@@ -40,10 +41,6 @@ import { findTestFileStackTrace } from './utils/collect'
4041
export async function recordArtifact<Artifact extends TestArtifact>(task: Test, artifact: Artifact): Promise<Artifact> {
4142
const runner = getRunner()
4243

43-
if (task.result && task.result.state !== 'run') {
44-
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.`)
45-
}
46-
4744
const stack = findTestFileStackTrace(
4845
task.file.filepath,
4946
new Error('STACK_TRACE').stack!,

packages/runner/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type {
1414
AroundEachListener,
1515
BeforeAllListener,
1616
BeforeEachListener,
17+
FailureScreenshotArtifact,
1718
File,
1819
Fixture,
1920
FixtureFn,

packages/runner/src/types/tasks.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1345,6 +1345,23 @@ export interface VisualRegressionArtifact extends TestArtifactBase {
13451345
attachments: VisualRegressionArtifactAttachment[]
13461346
}
13471347

1348+
interface FailureScreenshotArtifactAttachment extends TestAttachment {
1349+
path: string
1350+
/** Original file system path to the screenshot, before attachment resolution */
1351+
originalPath: string
1352+
body?: undefined
1353+
}
1354+
1355+
/**
1356+
* @experimental
1357+
*
1358+
* Artifact type for failure screenshots.
1359+
*/
1360+
export interface FailureScreenshotArtifact extends TestArtifactBase {
1361+
type: 'internal:failureScreenshot'
1362+
attachments: [FailureScreenshotArtifactAttachment] | []
1363+
}
1364+
13481365
/**
13491366
* @experimental
13501367
* @advanced
@@ -1426,4 +1443,8 @@ export interface TestArtifactRegistry {}
14261443
*
14271444
* This type automatically includes all artifacts registered via {@link TestArtifactRegistry}.
14281445
*/
1429-
export type TestArtifact = TestAnnotationArtifact | VisualRegressionArtifact | TestArtifactRegistry[keyof TestArtifactRegistry]
1446+
export type TestArtifact
1447+
= | FailureScreenshotArtifact
1448+
| TestAnnotationArtifact
1449+
| VisualRegressionArtifact
1450+
| TestArtifactRegistry[keyof TestArtifactRegistry]
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<script setup lang="ts">
2+
import type { RunnerTask } from 'vitest'
3+
import { computed, ref } from 'vue'
4+
import { getAttachmentUrl } from '~/composables/attachments'
5+
import { isReport } from '~/constants'
6+
import IconButton from './IconButton.vue'
7+
import Modal from './Modal.vue'
8+
import ScreenshotError from './views/ScreenshotError.vue'
9+
10+
const { task } = defineProps<{
11+
task: RunnerTask
12+
}>()
13+
14+
const showScreenshot = ref(false)
15+
const artifact = computed(() => {
16+
if (task.type === 'test') {
17+
const artifact = task.artifacts.find(artifact => artifact.type === 'internal:failureScreenshot')
18+
19+
if (artifact !== undefined) {
20+
return artifact
21+
}
22+
}
23+
24+
return null
25+
})
26+
const screenshotUrl = computed(() =>
27+
artifact.value && artifact.value.attachments.length && getAttachmentUrl(artifact.value.attachments[0]),
28+
)
29+
30+
function openScreenshot() {
31+
if (artifact.value === null || artifact.value.attachments.length === 0) {
32+
return
33+
}
34+
35+
const filePath = artifact.value.attachments[0].originalPath
36+
37+
fetch(`/__open-in-editor?file=${encodeURIComponent(filePath)}`)
38+
}
39+
</script>
40+
41+
<template>
42+
<template v-if="screenshotUrl">
43+
<div flex="~ gap-2 items-center">
44+
<IconButton
45+
v-tooltip.bottom="'View screenshot error'"
46+
class="!op-100"
47+
icon="i-carbon:image"
48+
title="View screenshot error"
49+
@click="showScreenshot = true"
50+
/>
51+
<!-- in a report there is no dev server to handle the action -->
52+
<IconButton
53+
v-if="!isReport"
54+
v-tooltip.bottom="'Open screenshot error in editor'"
55+
class="!op-100"
56+
icon="i-carbon:image-reference"
57+
title="Open screenshot error in editor"
58+
@click="openScreenshot"
59+
/>
60+
</div>
61+
<Modal :key="screenshotUrl" v-model="showScreenshot" direction="right">
62+
<ScreenshotError
63+
:file="task.file.filepath"
64+
:name="task.name"
65+
:url="screenshotUrl"
66+
@close="showScreenshot = false"
67+
/>
68+
</Modal>
69+
</template>
70+
</template>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<script setup lang="ts">
2+
import type { RunnerTestCase, TestArtifact } from 'vitest'
3+
import type { Component } from 'vue'
4+
import { computed } from 'vue'
5+
import { getLocationString, openLocation } from '~/composables/location'
6+
import VisualRegression from './visual-regression/VisualRegression.vue'
7+
8+
const { test } = defineProps<{ test: RunnerTestCase }>()
9+
10+
interface HandledArtifact { artifact: TestArtifact; component: Component; props: object }
11+
12+
type ComponentProps<T> = T extends new(...args: any) => { $props: infer P } ? NonNullable<P>
13+
: T extends (props: infer P, ...args: any) => any ? P
14+
: object
15+
16+
const handledArtifacts = computed<readonly HandledArtifact[]>(() => {
17+
const handledArtifacts: HandledArtifact[] = []
18+
19+
for (const artifact of test.artifacts) {
20+
switch (artifact.type) {
21+
case 'internal:toMatchScreenshot': {
22+
if (artifact.kind === 'visual-regression') {
23+
handledArtifacts.push({
24+
artifact,
25+
component: VisualRegression,
26+
props: { regression: artifact } satisfies ComponentProps<typeof VisualRegression>,
27+
})
28+
}
29+
30+
continue
31+
}
32+
}
33+
}
34+
35+
return handledArtifacts
36+
})
37+
</script>
38+
39+
<template>
40+
<template v-if="handledArtifacts.length">
41+
<h1 m-2>
42+
Test Artifacts
43+
</h1>
44+
<div
45+
v-for="{ artifact, component, props }, index of handledArtifacts"
46+
:key="artifact.type + index"
47+
bg="yellow-500/10"
48+
text="yellow-500 sm"
49+
p="x3 y2"
50+
m-2
51+
rounded
52+
role="note"
53+
>
54+
<div flex="~ gap-2 items-center justify-between" overflow-hidden>
55+
<div>
56+
<span
57+
v-if="artifact.location && artifact.location.file === test.file.filepath"
58+
v-tooltip.bottom="'Open in Editor'"
59+
title="Open in Editor"
60+
class="flex gap-1 text-yellow-500/80 cursor-pointer"
61+
ws-nowrap
62+
@click="openLocation(test, artifact.location)"
63+
>
64+
{{ getLocationString(artifact.location) }}
65+
</span>
66+
<span
67+
v-else-if="artifact.location && artifact.location.file !== test.file.filepath"
68+
class="flex gap-1 text-yellow-500/80"
69+
ws-nowrap
70+
>
71+
{{ getLocationString(artifact.location) }}
72+
</span>
73+
</div>
74+
</div>
75+
<component :is="component" v-bind="props" />
76+
</div>
77+
</template>
78+
</template>

0 commit comments

Comments
 (0)