From 8399a66d803fb47f4f0479b7675dcde3aa5b5543 Mon Sep 17 00:00:00 2001 From: Raul Macarie Date: Sun, 9 Nov 2025 14:08:35 +0100 Subject: [PATCH 1/5] refactor(browser): get `annotate` from matcher state in `toMatchScreenshot` --- .../src/client/tester/expect/toMatchScreenshot.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts index e30898f365a3..ffd8ad0e3ac0 100644 --- a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts +++ b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts @@ -2,7 +2,7 @@ import type { AsyncExpectationResult, MatcherState } from '@vitest/expect' import type { ScreenshotMatcherOptions } from '../../../../context' import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types' import type { Locator } from '../locators' -import { getBrowserState, getWorkerState } from '../../utils' +import { getBrowserState } from '../../utils' import { convertToSelector } from '../tester-utils' const counters = new Map([]) @@ -19,13 +19,11 @@ export default async function toMatchScreenshot( throw new Error('\'toMatchScreenshot\' cannot be used with "not"') } - const currentTest = getWorkerState().current - - if (currentTest === undefined || this.currentTestName === undefined) { + if (this.task === undefined || this.currentTestName === undefined) { throw new Error('\'toMatchScreenshot\' cannot be used without test context') } - const counterName = `${currentTest.result?.repeatCount ?? 0}${this.testPath}${this.currentTestName}` + const counterName = `${this.task.result?.repeatCount ?? 0}${this.testPath}${this.currentTestName}` let counter = counters.get(counterName) if (counter === undefined) { @@ -66,8 +64,8 @@ export default async function toMatchScreenshot( ] satisfies ScreenshotMatcherArguments, ) - if (result.pass === false && 'context' in currentTest) { - const { annotate } = currentTest.context + if (result.pass === false) { + const { annotate } = this.task.context const annotations: ReturnType[] = [] From 2fc025c8b98781be6d2872baa3fee1ccd2a3d085 Mon Sep 17 00:00:00 2001 From: Raul Macarie Date: Wed, 19 Nov 2025 23:42:25 +0100 Subject: [PATCH 2/5] feat(ui): create reference/actual slider for `toMatchScreenshot` failures --- .../client/tester/expect/toMatchScreenshot.ts | 26 ++++--- .../node/commands/screenshotMatcher/index.ts | 9 ++- .../src/shared/screenshotMatcher/types.ts | 6 +- packages/runner/src/types.ts | 1 + packages/runner/src/types/tasks.ts | 20 ++++- .../components/artifacts/ArtifactTemplate.vue | 17 +++++ .../artifacts/visual-regression/SmallTabs.vue | 62 ++++++++++++++++ .../visual-regression/SmallTabsPane.vue | 39 ++++++++++ .../visual-regression/VisualRegression.vue | 46 ++++++++++++ .../VisualRegressionImage.vue | 26 +++++++ .../VisualRegressionImageContainer.vue | 14 ++++ .../VisualRegressionSlider.vue | 74 +++++++++++++++++++ .../components/views/ViewTestReport.vue | 51 +++++++++++-- packages/ui/client/composables/attachments.ts | 10 +++ packages/ui/client/composables/codemirror.ts | 8 +- packages/ui/client/composables/small-tabs.ts | 22 ++++++ 16 files changed, 405 insertions(+), 26 deletions(-) create mode 100644 packages/ui/client/components/artifacts/ArtifactTemplate.vue create mode 100644 packages/ui/client/components/artifacts/visual-regression/SmallTabs.vue create mode 100644 packages/ui/client/components/artifacts/visual-regression/SmallTabsPane.vue create mode 100644 packages/ui/client/components/artifacts/visual-regression/VisualRegression.vue create mode 100644 packages/ui/client/components/artifacts/visual-regression/VisualRegressionImage.vue create mode 100644 packages/ui/client/components/artifacts/visual-regression/VisualRegressionImageContainer.vue create mode 100644 packages/ui/client/components/artifacts/visual-regression/VisualRegressionSlider.vue create mode 100644 packages/ui/client/composables/small-tabs.ts diff --git a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts index ffd8ad0e3ac0..91567c330eec 100644 --- a/packages/browser/src/client/tester/expect/toMatchScreenshot.ts +++ b/packages/browser/src/client/tester/expect/toMatchScreenshot.ts @@ -1,7 +1,9 @@ import type { AsyncExpectationResult, MatcherState } from '@vitest/expect' +import type { VisualRegressionArtifact } from '@vitest/runner' import type { ScreenshotMatcherOptions } from '../../../../context' import type { ScreenshotMatcherArguments, ScreenshotMatcherOutput } from '../../../shared/screenshotMatcher/types' import type { Locator } from '../locators' +import { recordArtifact } from '@vitest/runner' import { getBrowserState } from '../../utils' import { convertToSelector } from '../tester-utils' @@ -65,23 +67,28 @@ export default async function toMatchScreenshot( ) if (result.pass === false) { - const { annotate } = this.task.context - - const annotations: ReturnType[] = [] + const attachments: VisualRegressionArtifact['attachments'] = [] if (result.reference) { - annotations.push(annotate('Reference screenshot', { path: result.reference })) + attachments.push({ name: 'reference', ...result.reference }) } if (result.actual) { - annotations.push(annotate('Actual screenshot', { path: result.actual })) + attachments.push({ name: 'actual', ...result.actual }) } if (result.diff) { - annotations.push(annotate('Diff', { path: result.diff })) + attachments.push({ name: 'diff', path: result.diff }) } - await Promise.all(annotations) + if (attachments.length > 0) { + await recordArtifact(this.task, { + type: 'internal:toMatchScreenshot', + kind: 'visual-regression', + message: result.message, + attachments, + }) + } } return { @@ -94,14 +101,15 @@ export default async function toMatchScreenshot( '', result.message, result.reference - ? `\nReference screenshot:\n ${this.utils.EXPECTED_COLOR(result.reference)}` + ? `\nReference screenshot:\n ${this.utils.EXPECTED_COLOR(result.reference.path)}` : null, result.actual - ? `\nActual screenshot:\n ${this.utils.RECEIVED_COLOR(result.actual)}` + ? `\nActual screenshot:\n ${this.utils.RECEIVED_COLOR(result.actual.path)}` : null, result.diff ? this.utils.DIM_COLOR(`\nDiff image:\n ${result.diff}`) : null, + '', ] .filter(element => element !== null) .join('\n'), diff --git a/packages/browser/src/node/commands/screenshotMatcher/index.ts b/packages/browser/src/node/commands/screenshotMatcher/index.ts index 200ed96a3bae..1d35054d4aa9 100644 --- a/packages/browser/src/node/commands/screenshotMatcher/index.ts +++ b/packages/browser/src/node/commands/screenshotMatcher/index.ts @@ -55,7 +55,7 @@ export const screenshotMatcher: BrowserCommand< if (value === null || value.actual === null) { return { pass: false, - reference: referenceFile && paths.reference, + reference: referenceFile && { path: paths.reference, width: reference!.metadata.width, height: reference!.metadata.height }, actual: null, diff: null, message: `Could not capture a stable screenshot within ${timeout}ms.`, @@ -80,7 +80,8 @@ export const screenshotMatcher: BrowserCommand< if (updateSnapshot !== 'all') { return { pass: false, - reference: referencePath, + // we use `actual`'s metadata because that's the screenshot we saved + reference: { path: referencePath, width: value.actual.metadata.width, height: value.actual.metadata.height }, actual: null, diff: null, message: `No existing reference screenshot found${ @@ -143,8 +144,8 @@ export const screenshotMatcher: BrowserCommand< // - fail return { pass: false, - reference: paths.reference, - actual: paths.diffs.actual, + reference: { path: paths.reference, width: reference.metadata.width, height: reference.metadata.height }, + actual: { path: paths.diffs.actual, width: value.actual.metadata.width, height: value.actual.metadata.height }, diff: finalResult.diff && paths.diffs.diff, message: `Screenshot does not match the stored reference.${ finalResult.message === null diff --git a/packages/browser/src/shared/screenshotMatcher/types.ts b/packages/browser/src/shared/screenshotMatcher/types.ts index 1bc124de30b7..2af6c2e02238 100644 --- a/packages/browser/src/shared/screenshotMatcher/types.ts +++ b/packages/browser/src/shared/screenshotMatcher/types.ts @@ -12,11 +12,13 @@ export type ScreenshotMatcherArguments< }, ] +interface ScreenshotData { path: string; width: number; height: number } + export type ScreenshotMatcherOutput = Promise< { pass: false - reference: string | null - actual: string | null + reference: ScreenshotData | null + actual: ScreenshotData | null diff: string | null message: string } diff --git a/packages/runner/src/types.ts b/packages/runner/src/types.ts index eaccf81481b8..84e2379eef8f 100644 --- a/packages/runner/src/types.ts +++ b/packages/runner/src/types.ts @@ -54,4 +54,5 @@ export type { TestFunction, TestOptions, Use, + VisualRegressionArtifact, } from './types/tasks' diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index b726e0829d2c..3ae8f199f6b8 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -780,6 +780,24 @@ export interface TestAnnotationArtifact extends TestArtifactBase { annotation: TestAnnotation } +type VisualRegressionArtifactAttachment = TestAttachment & ({ + name: 'reference' | 'actual' + width: number + height: number +} | { name: 'diff' }) + +/** + * @experimental + * + * Artifact type for visual regressions. + */ +export interface VisualRegressionArtifact extends TestArtifactBase { + type: 'internal:toMatchScreenshot' + kind: 'visual-regression' + message: string + attachments: VisualRegressionArtifactAttachment[] +} + /** * @experimental * @advanced @@ -861,4 +879,4 @@ export interface TestArtifactRegistry {} * * This type automatically includes all artifacts registered via {@link TestArtifactRegistry}. */ -export type TestArtifact = TestAnnotationArtifact | TestArtifactRegistry[keyof TestArtifactRegistry] +export type TestArtifact = TestAnnotationArtifact | VisualRegressionArtifact | TestArtifactRegistry[keyof TestArtifactRegistry] diff --git a/packages/ui/client/components/artifacts/ArtifactTemplate.vue b/packages/ui/client/components/artifacts/ArtifactTemplate.vue new file mode 100644 index 000000000000..0b5ab742e643 --- /dev/null +++ b/packages/ui/client/components/artifacts/ArtifactTemplate.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/ui/client/components/artifacts/visual-regression/SmallTabs.vue b/packages/ui/client/components/artifacts/visual-regression/SmallTabs.vue new file mode 100644 index 000000000000..32ecbc68e51f --- /dev/null +++ b/packages/ui/client/components/artifacts/visual-regression/SmallTabs.vue @@ -0,0 +1,62 @@ + + + diff --git a/packages/ui/client/components/artifacts/visual-regression/SmallTabsPane.vue b/packages/ui/client/components/artifacts/visual-regression/SmallTabsPane.vue new file mode 100644 index 000000000000..3477af76a0b5 --- /dev/null +++ b/packages/ui/client/components/artifacts/visual-regression/SmallTabsPane.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/ui/client/components/artifacts/visual-regression/VisualRegression.vue b/packages/ui/client/components/artifacts/visual-regression/VisualRegression.vue new file mode 100644 index 000000000000..bda7a3869991 --- /dev/null +++ b/packages/ui/client/components/artifacts/visual-regression/VisualRegression.vue @@ -0,0 +1,46 @@ + + + diff --git a/packages/ui/client/components/artifacts/visual-regression/VisualRegressionImage.vue b/packages/ui/client/components/artifacts/visual-regression/VisualRegressionImage.vue new file mode 100644 index 000000000000..b238497a6060 --- /dev/null +++ b/packages/ui/client/components/artifacts/visual-regression/VisualRegressionImage.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/ui/client/components/artifacts/visual-regression/VisualRegressionImageContainer.vue b/packages/ui/client/components/artifacts/visual-regression/VisualRegressionImageContainer.vue new file mode 100644 index 000000000000..1dcadc41ad12 --- /dev/null +++ b/packages/ui/client/components/artifacts/visual-regression/VisualRegressionImageContainer.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/ui/client/components/artifacts/visual-regression/VisualRegressionSlider.vue b/packages/ui/client/components/artifacts/visual-regression/VisualRegressionSlider.vue new file mode 100644 index 000000000000..76101543d17c --- /dev/null +++ b/packages/ui/client/components/artifacts/visual-regression/VisualRegressionSlider.vue @@ -0,0 +1,74 @@ + + + diff --git a/packages/ui/client/components/views/ViewTestReport.vue b/packages/ui/client/components/views/ViewTestReport.vue index c20043bb5338..13530a71f840 100644 --- a/packages/ui/client/components/views/ViewTestReport.vue +++ b/packages/ui/client/components/views/ViewTestReport.vue @@ -1,15 +1,16 @@ @@ -52,7 +52,7 @@ provide(SMALL_TABS_CONTEXT, { :aria-controls="idFor.tabpanel(tab.id, id)" type="button" class="aria-[selected=true]:underline underline-offset-4" - @click="setActive(tab.id)" + @click="activeTab = tab.id" > {{ tab.title }} diff --git a/packages/ui/client/components/artifacts/visual-regression/VisualRegression.vue b/packages/ui/client/components/artifacts/visual-regression/VisualRegression.vue index bda7a3869991..77cca7289eda 100644 --- a/packages/ui/client/components/artifacts/visual-regression/VisualRegression.vue +++ b/packages/ui/client/components/artifacts/visual-regression/VisualRegression.vue @@ -29,16 +29,32 @@ const groups = computed(() => ({ {{ regression.message }} - + - + - + - +