Skip to content

Commit 9287be4

Browse files
committed
feat(ui): create reference/actual slider for toMatchScreenshot failures
1 parent 4a73870 commit 9287be4

File tree

14 files changed

+353
-41
lines changed

14 files changed

+353
-41
lines changed

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

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AsyncExpectationResult, MatcherState } from '@vitest/expect'
2+
import type { TestAnnotation } from 'vitest'
23
import type { ScreenshotMatcherOptions } from '../../../../context'
34
import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types'
45
import type { Locator } from '../locators'
@@ -69,21 +70,50 @@ export default async function toMatchScreenshot(
6970
if (result.pass === false && 'context' in currentTest) {
7071
const { annotate } = currentTest.context
7172

72-
const annotations: ReturnType<typeof annotate>[] = []
73+
const attachments: TestAnnotation['attachments'] = []
7374

7475
if (result.reference) {
75-
annotations.push(annotate('Reference screenshot', { path: result.reference }))
76+
attachments.push({
77+
name: 'reference',
78+
path: result.reference.path,
79+
metadata: {
80+
width: result.reference.metadata.width,
81+
height: result.reference.metadata.height,
82+
},
83+
})
7684
}
7785

7886
if (result.actual) {
79-
annotations.push(annotate('Actual screenshot', { path: result.actual }))
87+
attachments.push({
88+
name: 'actual',
89+
path: result.actual.path,
90+
metadata: {
91+
width: result.actual.metadata.width,
92+
height: result.actual.metadata.height,
93+
},
94+
})
8095
}
8196

8297
if (result.diff) {
83-
annotations.push(annotate('Diff', { path: result.diff }))
98+
attachments.push({
99+
name: 'diff',
100+
path: result.diff,
101+
})
84102
}
85103

86-
await Promise.all(annotations)
104+
if (attachments.length > 0) {
105+
await annotate({
106+
type: 'assertion-artifact',
107+
title: 'Visual Regression',
108+
message: result.message,
109+
attachments,
110+
metadata: {
111+
'internal:toMatchScreenshot': {
112+
kind: 'visual-regression',
113+
},
114+
},
115+
})
116+
}
87117
}
88118

89119
return {
@@ -96,14 +126,15 @@ export default async function toMatchScreenshot(
96126
'',
97127
result.message,
98128
result.reference
99-
? `\nReference screenshot:\n ${this.utils.EXPECTED_COLOR(result.reference)}`
129+
? `\nReference screenshot:\n ${this.utils.EXPECTED_COLOR(result.reference.path)}`
100130
: null,
101131
result.actual
102-
? `\nActual screenshot:\n ${this.utils.RECEIVED_COLOR(result.actual)}`
132+
? `\nActual screenshot:\n ${this.utils.RECEIVED_COLOR(result.actual.path)}`
103133
: null,
104134
result.diff
105135
? this.utils.DIM_COLOR(`\nDiff image:\n ${result.diff}`)
106136
: null,
137+
'',
107138
]
108139
.filter(element => element !== null)
109140
.join('\n'),

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

Lines changed: 4 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, metadata: reference!.metadata },
5959
actual: null,
6060
diff: null,
6161
message: `Could not capture a stable screenshot within ${timeout}ms.`,
@@ -80,7 +80,7 @@ export const screenshotMatcher: BrowserCommand<
8080
if (updateSnapshot !== 'all') {
8181
return {
8282
pass: false,
83-
reference: referencePath,
83+
reference: { path: referencePath, metadata: value.actual.metadata },
8484
actual: null,
8585
diff: null,
8686
message: `No existing reference screenshot found${
@@ -143,8 +143,8 @@ export const screenshotMatcher: BrowserCommand<
143143
// - fail
144144
return {
145145
pass: false,
146-
reference: paths.reference,
147-
actual: paths.diffs.actual,
146+
reference: { path: paths.reference, metadata: reference.metadata },
147+
actual: { path: paths.diffs.actual, metadata: value.actual.metadata },
148148
diff: finalResult.diff && paths.diffs.diff,
149149
message: `Screenshot does not match the stored reference.${
150150
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; metadata: { 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/ui/client/components/AnnotationAttachmentImage.vue

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,13 @@
11
<script setup lang="ts">
22
import type { TestAnnotation } from 'vitest'
33
import { computed } from 'vue'
4-
import { getAttachmentUrl, isExternalAttachment } from '~/composables/attachments'
4+
import { internalOrExternalUrl, isExternalAttachment } from '~/composables/attachments'
55
66
const props = defineProps<{
77
annotation: TestAnnotation
88
}>()
99
10-
const href = computed<string>(() => {
11-
const attachment = props.annotation.attachment!
12-
const potentialUrl = attachment.path || attachment.body
13-
if (typeof potentialUrl === 'string' && (potentialUrl.startsWith('http://') || potentialUrl.startsWith('https://'))) {
14-
return potentialUrl
15-
}
16-
else {
17-
return getAttachmentUrl(attachment)
18-
}
19-
})
10+
const href = computed<string>(() => internalOrExternalUrl(props.annotation.attachment!))
2011
</script>
2112

2213
<template>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<script setup lang="ts">
2+
import { provide, ref, useId } from 'vue'
3+
import { idFor, SMALL_TABS_CONTEXT } from '~/composables/small-tabs'
4+
5+
const activeTab = ref<string | null>(null)
6+
const tabs = ref<{ name: string; title: string }[]>([])
7+
8+
function setActive(key: string) {
9+
activeTab.value = key
10+
}
11+
12+
const id = useId()
13+
14+
provide(SMALL_TABS_CONTEXT, {
15+
id,
16+
activeTab,
17+
registerTab: (tab) => {
18+
if (!tabs.value.some(({ name }) => name === tab.name)) {
19+
tabs.value.push(tab)
20+
}
21+
22+
if (tabs.value.length === 1) {
23+
setActive(tab.name)
24+
}
25+
},
26+
unregisterTab: (tab) => {
27+
const index = tabs.value.findIndex(({ name }) => name === tab.name)
28+
29+
if (index > -1) {
30+
tabs.value.splice(index, 1)
31+
}
32+
},
33+
})
34+
</script>
35+
36+
<template>
37+
<div
38+
class="flex flex-col items-center gap-3"
39+
>
40+
<div
41+
role="tablist"
42+
aria-orientation="horizontal"
43+
class="flex gap-4"
44+
>
45+
<button
46+
v-for="tab in tabs"
47+
:id="idFor.tab(tab.name, id)"
48+
:key="tab.name"
49+
role="tab"
50+
:aria-selected="activeTab === tab.name"
51+
:aria-controls="idFor.tabpanel(tab.name, id)"
52+
type="button"
53+
class="aria-[selected=true]:underline underline-offset-4"
54+
@click="setActive(tab.name)"
55+
>
56+
{{ tab.title }}
57+
</button>
58+
</div>
59+
<slot />
60+
</div>
61+
</template>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<script setup lang="ts">
2+
import type { SmallTabsConfig } from '~/composables/small-tabs'
3+
import { computed, inject, onMounted, onUnmounted } from 'vue'
4+
import { idFor, SMALL_TABS_CONTEXT } from '~/composables/small-tabs'
5+
6+
interface TabPaneProps extends SmallTabsConfig {}
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 isActive = computed(() => context.activeTab.value === props.name)
17+
18+
onMounted(() => {
19+
context.registerTab(props)
20+
})
21+
22+
onUnmounted(() => {
23+
context.unregisterTab(props)
24+
})
25+
</script>
26+
27+
<template>
28+
<div
29+
:id="idFor.tabpanel(props.name, context.id)"
30+
role="tabpanel"
31+
:aria-labelledby="idFor.tab(props.name, context.id)"
32+
:hidden="!isActive" class="max-w-full"
33+
>
34+
<slot />
35+
</div>
36+
</template>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<script setup lang="ts">
2+
import type { TestAnnotation } from 'vitest'
3+
import { computed } from 'vue'
4+
import SmallTabs from './SmallTabs.vue'
5+
import SmallTabsPane from './SmallTabsPane.vue'
6+
import VisualRegressionImage from './VisualRegressionImage.vue'
7+
import VisualRegressionSlider from './VisualRegressionSlider.vue'
8+
9+
const { annotation } = defineProps<{
10+
annotation: TestAnnotation
11+
}>()
12+
13+
if (annotation.metadata?.['internal:toMatchScreenshot'] === undefined) {
14+
throw new Error('VisualRegression needs "internal:toMatchScreenshot" to work')
15+
}
16+
17+
if (!Array.isArray(annotation.attachments) || annotation.attachments.length === 0) {
18+
throw new Error('VisualRegression needs at least one attachment to work')
19+
}
20+
21+
const groups = computed(() => ({
22+
diff: annotation.attachments?.find(attachment => attachment.name === 'diff'),
23+
reference: annotation.attachments?.find(attachment => attachment.name === 'reference'),
24+
actual: annotation.attachments?.find(attachment => attachment.name === 'actual'),
25+
}))
26+
</script>
27+
28+
<template>
29+
<SmallTabs>
30+
<SmallTabsPane v-if="groups.diff" name="diff" title="Diff">
31+
<VisualRegressionImage :attachment="groups.diff" />
32+
</SmallTabsPane>
33+
<SmallTabsPane v-if="groups.reference" name="reference" title="Reference">
34+
<VisualRegressionImage :attachment="groups.reference" />
35+
</SmallTabsPane>
36+
<SmallTabsPane v-if="groups.actual" name="actual" title="Actual">
37+
<VisualRegressionImage :attachment="groups.actual" />
38+
</SmallTabsPane>
39+
<SmallTabsPane v-if="groups.reference && groups.actual" name="slider" title="Slider">
40+
<VisualRegressionSlider :actual="groups.actual" :reference="groups.reference" />
41+
</SmallTabsPane>
42+
</SmallTabs>
43+
</template>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script setup lang="ts">
2+
import type { TestArtifactAttachment } from '@vitest/runner/types/tasks'
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: TestArtifactAttachment
9+
}>()
10+
11+
const href = computed(() => 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 :src="href">
22+
</a>
23+
</VisualRegressionImageContainer>
24+
</template>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script setup lang="ts">
2+
const checkeredBackground = `url("${
3+
CSS.escape('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2"><path d="M1 2V0h1v1H0v1z" fill-opacity=".05"/></svg>')
4+
}")`
5+
</script>
6+
7+
<template>
8+
<div
9+
class="max-w-full w-fit mx-auto bg-[size:16px_16px] bg-[#fafafa] dark:bg-[#3a3a3a] bg-center p-4 rounded user-select-none outline-0 outline-black dark:outline-white outline-offset-4 outline-solid focus-within:has-focus-visible:outline-2"
10+
:style="{ backgroundImage: checkeredBackground }"
11+
>
12+
<slot />
13+
</div>
14+
</template>

0 commit comments

Comments
 (0)