Skip to content

Commit c37c2eb

Browse files
authored
feat(ui): add tabbed failure view for toMatchScreenshot with comparison slider (#8813)
1 parent 1256b5c commit c37c2eb

File tree

20 files changed

+842
-32
lines changed

20 files changed

+842
-32
lines changed

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

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
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'
5-
import { getBrowserState, getWorkerState } from '../../utils'
6+
import { recordArtifact } from '@vitest/runner'
7+
import { getBrowserState } from '../../utils'
68
import { convertToSelector } from '../tester-utils'
79

810
const counters = new Map<string, { current: number }>([])
@@ -19,13 +21,11 @@ export default async function toMatchScreenshot(
1921
throw new Error('\'toMatchScreenshot\' cannot be used with "not"')
2022
}
2123

22-
const currentTest = getWorkerState().current
23-
24-
if (currentTest === undefined || this.currentTestName === undefined) {
24+
if (this.task === undefined || this.currentTestName === undefined) {
2525
throw new Error('\'toMatchScreenshot\' cannot be used without test context')
2626
}
2727

28-
const counterName = `${currentTest.result?.repeatCount ?? 0}${this.testPath}${this.currentTestName}`
28+
const counterName = `${this.task.result?.repeatCount ?? 0}${this.testPath}${this.currentTestName}`
2929
let counter = counters.get(counterName)
3030

3131
if (counter === undefined) {
@@ -66,24 +66,29 @@ export default async function toMatchScreenshot(
6666
] satisfies ScreenshotMatcherArguments,
6767
)
6868

69-
if (result.pass === false && 'context' in currentTest) {
70-
const { annotate } = currentTest.context
71-
72-
const annotations: ReturnType<typeof annotate>[] = []
69+
if (result.pass === false) {
70+
const attachments: VisualRegressionArtifact['attachments'] = []
7371

7472
if (result.reference) {
75-
annotations.push(annotate('Reference screenshot', { path: result.reference }))
73+
attachments.push({ name: 'reference', ...result.reference })
7674
}
7775

7876
if (result.actual) {
79-
annotations.push(annotate('Actual screenshot', { path: result.actual }))
77+
attachments.push({ name: 'actual', ...result.actual })
8078
}
8179

8280
if (result.diff) {
83-
annotations.push(annotate('Diff', { path: result.diff }))
81+
attachments.push({ name: 'diff', path: result.diff })
8482
}
8583

86-
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+
}
8792
}
8893

8994
return {
@@ -96,14 +101,15 @@ export default async function toMatchScreenshot(
96101
'',
97102
result.message,
98103
result.reference
99-
? `\nReference screenshot:\n ${this.utils.EXPECTED_COLOR(result.reference)}`
104+
? `\nReference screenshot:\n ${this.utils.EXPECTED_COLOR(result.reference.path)}`
100105
: null,
101106
result.actual
102-
? `\nActual screenshot:\n ${this.utils.RECEIVED_COLOR(result.actual)}`
107+
? `\nActual screenshot:\n ${this.utils.RECEIVED_COLOR(result.actual.path)}`
103108
: null,
104109
result.diff
105110
? this.utils.DIM_COLOR(`\nDiff image:\n ${result.diff}`)
106111
: null,
112+
'',
107113
]
108114
.filter(element => element !== null)
109115
.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: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { faker } from '@faker-js/faker'
2+
import { describe, expect, it } from 'vitest'
3+
import { userEvent } from 'vitest/browser'
4+
import { defineComponent, h } from 'vue'
5+
import { page, render } from '~/test'
6+
import SmallTabs from './SmallTabs.vue'
7+
import SmallTabsPane from './SmallTabsPane.vue'
8+
9+
function createSmallTabs(children: number) {
10+
return defineComponent({
11+
setup: () =>
12+
() =>
13+
h(
14+
SmallTabs,
15+
null,
16+
{
17+
default: () => Array.from({ length: children }, () => h(
18+
SmallTabsPane,
19+
{ title: faker.lorem.word() },
20+
() => faker.lorem.words(2),
21+
)),
22+
},
23+
),
24+
})
25+
}
26+
27+
describe('SmallTabs', () => {
28+
it('has accessible elements', async () => {
29+
render(createSmallTabs(2))
30+
31+
// a tablist with two elements inside
32+
const tablist = page.getByRole('tablist')
33+
const tabs = tablist.getByRole('tab')
34+
const firstTab = tabs.first()
35+
const secondTab = tabs.last()
36+
37+
await expect.element(tablist).toBeInTheDocument()
38+
expect(tabs.all()).toHaveLength(2)
39+
40+
await expect.element(firstTab).toHaveAttribute('aria-selected', 'true')
41+
await expect.element(secondTab).toHaveAttribute('aria-selected', 'false')
42+
43+
// two tab panels, with one hidden
44+
const panels = page.getByRole('tabpanel', { includeHidden: true })
45+
const firstPanel = panels.first()
46+
const secondPanel = panels.last()
47+
48+
expect(panels.all()).toHaveLength(2)
49+
50+
await expect.element(firstPanel).not.toHaveAttribute('hidden')
51+
await expect.element(secondPanel).toHaveAttribute('hidden')
52+
53+
// panels should be labelled by their tab button
54+
await expect.element(firstPanel).toHaveAttribute(
55+
'aria-labelledby',
56+
firstTab.element().getAttribute('id'),
57+
)
58+
await expect.element(secondPanel).toHaveAttribute(
59+
'aria-labelledby',
60+
secondTab.element().getAttribute('id'),
61+
)
62+
63+
await expect.element(firstTab).toHaveAttribute(
64+
'aria-controls',
65+
firstPanel.element().getAttribute('id'),
66+
)
67+
await expect.element(secondTab).toHaveAttribute(
68+
'aria-controls',
69+
secondPanel.element().getAttribute('id'),
70+
)
71+
})
72+
73+
it('opens one panel at a time', async () => {
74+
const tabsLimit = 5
75+
76+
render(createSmallTabs(tabsLimit))
77+
78+
const tabs = page.getByRole('tablist').getByRole('tab')
79+
const panels = page.getByRole('tabpanel', { includeHidden: true })
80+
81+
for (let tabIndex = 0; tabIndex < tabsLimit; tabIndex += 1) {
82+
const activeTab = tabs.nth(tabIndex)
83+
const activePanel = panels.nth(tabIndex)
84+
85+
await userEvent.click(activeTab)
86+
await expect.element(
87+
tabs.and(page.getByRole('tab', { selected: true })),
88+
).toBe(activeTab.element())
89+
await expect.element(
90+
page.getByRole('tabpanel'),
91+
).toBe(activePanel.element())
92+
}
93+
})
94+
})
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+
const id = useId()
10+
11+
provide(SMALL_TABS_CONTEXT, {
12+
id,
13+
activeTab,
14+
registerTab: (tab) => {
15+
if (!tabs.value.some(({ id }) => id === tab.id)) {
16+
tabs.value.push(tab)
17+
}
18+
19+
if (tabs.value.length === 1) {
20+
activeTab.value = tab.id
21+
}
22+
},
23+
unregisterTab: (tab) => {
24+
const index = tabs.value.findIndex(({ id }) => id === tab.id)
25+
26+
if (index > -1) {
27+
tabs.value.splice(index, 1)
28+
}
29+
30+
if (activeTab.value === tab.id) {
31+
activeTab.value = tabs.value[0]?.id ?? null
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="activeTab = 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>

0 commit comments

Comments
 (0)