Skip to content

Commit 5f71eb7

Browse files
committed
feat(ui): create reference/actual slider for toMatchScreenshot failures
1 parent b17e38d commit 5f71eb7

File tree

16 files changed

+405
-26
lines changed

16 files changed

+405
-26
lines changed

packages/browser/src/client/tester/expect/toMatchScreenshot.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { AsyncExpectationResult, MatcherState } from '@vitest/expect'
2+
import type { VisualRegressionArtifact } from '@vitest/runner'
23
import type { ScreenshotMatcherOptions } from '../../../../context'
34
import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types'
45
import type { Locator } from '../locators'
6+
import { recordArtifact } from '@vitest/runner'
57
import { getBrowserState } from '../../utils'
68
import { convertToSelector } from '../tester-utils'
79

@@ -65,23 +67,28 @@ export default async function toMatchScreenshot(
6567
)
6668

6769
if (result.pass === false) {
68-
const { annotate } = this.task.context
69-
70-
const annotations: ReturnType<typeof annotate>[] = []
70+
const attachments: VisualRegressionArtifact['attachments'] = []
7171

7272
if (result.reference) {
73-
annotations.push(annotate('Reference screenshot', { path: result.reference }))
73+
attachments.push({ name: 'reference', ...result.reference })
7474
}
7575

7676
if (result.actual) {
77-
annotations.push(annotate('Actual screenshot', { path: result.actual }))
77+
attachments.push({ name: 'actual', ...result.actual })
7878
}
7979

8080
if (result.diff) {
81-
annotations.push(annotate('Diff', { path: result.diff }))
81+
attachments.push({ name: 'diff', path: result.diff })
8282
}
8383

84-
await Promise.all(annotations)
84+
if (attachments.length > 0) {
85+
await recordArtifact(this.task, {
86+
type: 'internal:toMatchScreenshot',
87+
kind: 'visual-regression',
88+
message: result.message,
89+
attachments,
90+
})
91+
}
8592
}
8693

8794
return {
@@ -94,14 +101,15 @@ export default async function toMatchScreenshot(
94101
'',
95102
result.message,
96103
result.reference
97-
? `\nReference screenshot:\n ${this.utils.EXPECTED_COLOR(result.reference)}`
104+
? `\nReference screenshot:\n ${this.utils.EXPECTED_COLOR(result.reference.path)}`
98105
: null,
99106
result.actual
100-
? `\nActual screenshot:\n ${this.utils.RECEIVED_COLOR(result.actual)}`
107+
? `\nActual screenshot:\n ${this.utils.RECEIVED_COLOR(result.actual.path)}`
101108
: null,
102109
result.diff
103110
? this.utils.DIM_COLOR(`\nDiff image:\n ${result.diff}`)
104111
: null,
112+
'',
105113
]
106114
.filter(element => element !== null)
107115
.join('\n'),

packages/browser/src/node/commands/screenshotMatcher/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export const screenshotMatcher: BrowserCommand<
5555
if (value === null || value.actual === null) {
5656
return {
5757
pass: false,
58-
reference: referenceFile && paths.reference,
58+
reference: referenceFile && { path: paths.reference, width: reference!.metadata.width, height: reference!.metadata.height },
5959
actual: null,
6060
diff: null,
6161
message: `Could not capture a stable screenshot within ${timeout}ms.`,
@@ -80,7 +80,8 @@ export const screenshotMatcher: BrowserCommand<
8080
if (updateSnapshot !== 'all') {
8181
return {
8282
pass: false,
83-
reference: referencePath,
83+
// we use `actual`'s metadata because that's the screenshot we saved
84+
reference: { path: referencePath, width: value.actual.metadata.width, height: value.actual.metadata.height },
8485
actual: null,
8586
diff: null,
8687
message: `No existing reference screenshot found${
@@ -143,8 +144,8 @@ export const screenshotMatcher: BrowserCommand<
143144
// - fail
144145
return {
145146
pass: false,
146-
reference: paths.reference,
147-
actual: paths.diffs.actual,
147+
reference: { path: paths.reference, width: reference.metadata.width, height: reference.metadata.height },
148+
actual: { path: paths.diffs.actual, width: value.actual.metadata.width, height: value.actual.metadata.height },
148149
diff: finalResult.diff && paths.diffs.diff,
149150
message: `Screenshot does not match the stored reference.${
150151
finalResult.message === null

packages/browser/src/shared/screenshotMatcher/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ export type ScreenshotMatcherArguments<
1212
},
1313
]
1414

15+
interface ScreenshotData { path: string; width: number; height: number }
16+
1517
export type ScreenshotMatcherOutput = Promise<
1618
{
1719
pass: false
18-
reference: string | null
19-
actual: string | null
20+
reference: ScreenshotData | null
21+
actual: ScreenshotData | null
2022
diff: string | null
2123
message: string
2224
}

packages/runner/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,5 @@ export type {
5454
TestFunction,
5555
TestOptions,
5656
Use,
57+
VisualRegressionArtifact,
5758
} from './types/tasks'

packages/runner/src/types/tasks.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,24 @@ export interface TestAnnotationArtifact extends TestArtifactBase {
780780
annotation: TestAnnotation
781781
}
782782

783+
type VisualRegressionArtifactAttachment = TestAttachment & ({
784+
name: 'reference' | 'actual'
785+
width: number
786+
height: number
787+
} | { name: 'diff' })
788+
789+
/**
790+
* @experimental
791+
*
792+
* Artifact type for visual regressions.
793+
*/
794+
export interface VisualRegressionArtifact extends TestArtifactBase {
795+
type: 'internal:toMatchScreenshot'
796+
kind: 'visual-regression'
797+
message: string
798+
attachments: VisualRegressionArtifactAttachment[]
799+
}
800+
783801
/**
784802
* @experimental
785803
* @advanced
@@ -861,4 +879,4 @@ export interface TestArtifactRegistry {}
861879
*
862880
* This type automatically includes all artifacts registered via {@link TestArtifactRegistry}.
863881
*/
864-
export type TestArtifact = TestAnnotationArtifact | TestArtifactRegistry[keyof TestArtifactRegistry]
882+
export type TestArtifact = TestAnnotationArtifact | VisualRegressionArtifact | TestArtifactRegistry[keyof TestArtifactRegistry]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script setup lang="ts">
2+
import { useSlots } from 'vue'
3+
4+
const slots = useSlots()
5+
</script>
6+
7+
<template>
8+
<article class="flex flex-col gap-4">
9+
<h1>
10+
<slot name="title" />
11+
</h1>
12+
<p v-if="slots.message">
13+
<slot name="message" />
14+
</p>
15+
<slot />
16+
</article>
17+
</template>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<script setup lang="ts">
2+
import type { SmallTabsConfig } from '~/composables/small-tabs'
3+
import { provide, ref, useId } from 'vue'
4+
import { idFor, SMALL_TABS_CONTEXT } from '~/composables/small-tabs'
5+
6+
const activeTab = ref<string | null>(null)
7+
const tabs = ref<SmallTabsConfig[]>([])
8+
9+
function setActive(key: string) {
10+
activeTab.value = key
11+
}
12+
13+
const id = useId()
14+
15+
provide(SMALL_TABS_CONTEXT, {
16+
id,
17+
activeTab,
18+
registerTab: (tab) => {
19+
if (!tabs.value.some(({ id }) => id === tab.id)) {
20+
tabs.value.push(tab)
21+
}
22+
23+
if (tabs.value.length === 1) {
24+
setActive(tab.id)
25+
}
26+
},
27+
unregisterTab: (tab) => {
28+
const index = tabs.value.findIndex(({ id }) => id === tab.id)
29+
30+
if (index > -1) {
31+
tabs.value.splice(index, 1)
32+
}
33+
},
34+
})
35+
</script>
36+
37+
<template>
38+
<div
39+
class="flex flex-col items-center gap-6"
40+
>
41+
<div
42+
role="tablist"
43+
aria-orientation="horizontal"
44+
class="flex gap-4"
45+
>
46+
<button
47+
v-for="tab in tabs"
48+
:id="idFor.tab(tab.id, id)"
49+
:key="tab.id"
50+
role="tab"
51+
:aria-selected="activeTab === tab.id"
52+
:aria-controls="idFor.tabpanel(tab.id, id)"
53+
type="button"
54+
class="aria-[selected=true]:underline underline-offset-4"
55+
@click="setActive(tab.id)"
56+
>
57+
{{ tab.title }}
58+
</button>
59+
</div>
60+
<slot />
61+
</div>
62+
</template>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<script setup lang="ts">
2+
import type { SmallTabsConfig } from '~/composables/small-tabs'
3+
import { computed, inject, onMounted, onUnmounted, useId } from 'vue'
4+
import { idFor, SMALL_TABS_CONTEXT } from '~/composables/small-tabs'
5+
6+
interface TabPaneProps extends Omit<SmallTabsConfig, 'id'> {}
7+
8+
const props = defineProps<TabPaneProps>()
9+
10+
const context = inject(SMALL_TABS_CONTEXT)
11+
12+
if (!context) {
13+
throw new Error('TabPane must be used within Tabs')
14+
}
15+
16+
const id = useId()
17+
18+
const isActive = computed(() => context.activeTab.value === id)
19+
20+
onMounted(() => {
21+
context.registerTab({ ...props, id })
22+
})
23+
24+
onUnmounted(() => {
25+
context.unregisterTab({ ...props, id })
26+
})
27+
</script>
28+
29+
<template>
30+
<div
31+
:id="idFor.tabpanel(id, context.id)"
32+
role="tabpanel"
33+
:aria-labelledby="idFor.tab(id, context.id)"
34+
:hidden="!isActive"
35+
class="max-w-full"
36+
>
37+
<slot />
38+
</div>
39+
</template>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script setup lang="ts">
2+
import type { VisualRegressionArtifact } from '@vitest/runner'
3+
import { computed } from 'vue'
4+
import ArtifactTemplate from '../ArtifactTemplate.vue'
5+
import SmallTabs from './SmallTabs.vue'
6+
import SmallTabsPane from './SmallTabsPane.vue'
7+
import VisualRegressionImage from './VisualRegressionImage.vue'
8+
import VisualRegressionSlider from './VisualRegressionSlider.vue'
9+
10+
const { regression } = defineProps<{
11+
regression: VisualRegressionArtifact
12+
}>()
13+
14+
type AttachmentWithMeta = Exclude<VisualRegressionArtifact['attachments'][number], { name: 'diff' }>
15+
16+
const groups = computed(() => ({
17+
diff: regression.attachments.find(artifact => artifact.name === 'diff'),
18+
reference: regression.attachments.find((artifact): artifact is AttachmentWithMeta => artifact.name === 'reference'),
19+
actual: regression.attachments.find((artifact): artifact is AttachmentWithMeta => artifact.name === 'actual'),
20+
}))
21+
</script>
22+
23+
<template>
24+
<ArtifactTemplate>
25+
<template #title>
26+
Visual Regression
27+
</template>
28+
<template #message>
29+
{{ regression.message }}
30+
</template>
31+
<SmallTabs>
32+
<SmallTabsPane v-if="groups.diff" title="Diff">
33+
<VisualRegressionImage :attachment="groups.diff" />
34+
</SmallTabsPane>
35+
<SmallTabsPane v-if="groups.reference" title="Reference">
36+
<VisualRegressionImage :attachment="groups.reference" />
37+
</SmallTabsPane>
38+
<SmallTabsPane v-if="groups.actual" title="Actual">
39+
<VisualRegressionImage :attachment="groups.actual" />
40+
</SmallTabsPane>
41+
<SmallTabsPane v-if="groups.reference && groups.actual" title="Slider">
42+
<VisualRegressionSlider :actual="groups.actual" :reference="groups.reference" />
43+
</SmallTabsPane>
44+
</SmallTabs>
45+
</ArtifactTemplate>
46+
</template>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<script setup lang="ts">
2+
import type { TestAttachment } from '@vitest/runner'
3+
import { computed } from 'vue'
4+
import { internalOrExternalUrl, isExternalAttachment } from '~/composables/attachments'
5+
import VisualRegressionImageContainer from './VisualRegressionImageContainer.vue'
6+
7+
const { attachment } = defineProps<{
8+
attachment: TestAttachment
9+
}>()
10+
11+
const href = computed<string>(() => internalOrExternalUrl(attachment))
12+
</script>
13+
14+
<template>
15+
<VisualRegressionImageContainer>
16+
<a
17+
target="_blank"
18+
:href="href"
19+
:referrerPolicy="isExternalAttachment(attachment) ? 'no-referrer' : undefined"
20+
>
21+
<img
22+
:src="href"
23+
>
24+
</a>
25+
</VisualRegressionImageContainer>
26+
</template>

0 commit comments

Comments
 (0)