Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -120,5 +120,8 @@ export default async function toMatchScreenshot(
]
.filter(element => element !== null)
.join('\n'),
meta: {
outcome: result.outcome,
},
Comment on lines +123 to +125
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is the best way to accomplish this, but I didn't find other ways to check source/origin of an error and to attach additional data

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, but one question. Is assertion here and expectAssertionName available in JestExtendPlugin always same?

([expectAssertionName, expectAssertion]) => {

We could always add assertion as error metadata in the plugin level, then move properties like JestExtendError.contenxt: { assertionName, meta }.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could definitely do that — I wasn't sure if it made sense to add it to all errors, but I guess it might be useful in the future 👍🏼

}
}
8 changes: 5 additions & 3 deletions packages/browser/src/client/tester/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,16 @@ export function createBrowserRunner(
}

onTaskFinished = async (task: Task) => {
const lastErrorContext = task.result?.errors?.at(-1)?.context
if (
this.config.browser.screenshotFailures
&& document.body.clientHeight > 0
&& task.result?.state === 'fail'
&& task.type === 'test'
&& task.artifacts.every(
artifact => artifact.type !== 'internal:toMatchScreenshot',
)
&& !(
lastErrorContext
&& Reflect.get(lastErrorContext, 'assertionName') === 'toMatchScreenshot'
&& Reflect.get(lastErrorContext, 'meta')?.outcome !== 'unstable-screenshot')
) {
const screenshot = await page.screenshot({
timeout: this.config.browser.providerOptions?.actionTimeout ?? 5_000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ function buildOutput(
case 'unstable-screenshot':
return {
pass: false,
outcome: outcome.type,
reference: outcome.reference && {
path: outcome.reference.path,
width: outcome.reference.image.metadata.width,
Expand All @@ -286,6 +287,7 @@ function buildOutput(
case 'missing-reference': {
return {
pass: false,
outcome: outcome.type,
reference: {
path: outcome.reference.path,
width: outcome.reference.image.metadata.width,
Expand All @@ -302,11 +304,12 @@ function buildOutput(
case 'update-reference':
case 'matched-immediately':
case 'matched-after-comparison':
return { pass: true }
return { pass: true, outcome: outcome.type }

case 'mismatch':
return {
pass: false,
outcome: outcome.type,
reference: {
path: outcome.reference.path,
width: outcome.reference.image.metadata.width,
Expand All @@ -333,6 +336,7 @@ function buildOutput(

return {
pass: false,
outcome: null as never,
actual: null,
reference: null,
diff: null,
Expand Down
8 changes: 8 additions & 0 deletions packages/browser/src/shared/screenshotMatcher/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,20 @@ interface ScreenshotData { path: string; width: number; height: number }
export type ScreenshotMatcherOutput = Promise<
{
pass: false
outcome:
| 'unstable-screenshot'
| 'missing-reference'
| 'mismatch'
reference: ScreenshotData | null
actual: ScreenshotData | null
diff: ScreenshotData | null
message: string
}
| {
pass: true
outcome:
| 'update-reference'
| 'matched-immediately'
| 'matched-after-comparison'
}
>
25 changes: 20 additions & 5 deletions packages/expect/src/jest-extend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ function getMatcherState(
}

class JestExtendError extends Error {
constructor(message: string, public actual?: any, public expected?: any) {
constructor(
message: string,
public actual?: any,
public expected?: any,
public context?: { assertionName: string; meta?: object },
) {
super(message)
}
}
Expand All @@ -92,23 +97,33 @@ function JestExtendPlugin(
&& typeof (result as any).then === 'function'
) {
const thenable = result as PromiseLike<SyncExpectationResult>
return thenable.then(({ pass, message, actual, expected }) => {
return thenable.then(({ pass, message, actual, expected, meta }) => {
if ((pass && isNot) || (!pass && !isNot)) {
const errorMessage = customMessage != null
? customMessage
: message()
throw new JestExtendError(errorMessage, actual, expected)
throw new JestExtendError(
errorMessage,
actual,
expected,
{ assertionName: expectAssertionName, meta },
)
}
})
}

const { pass, message, actual, expected } = result as SyncExpectationResult
const { pass, message, actual, expected, meta } = result as SyncExpectationResult

if ((pass && isNot) || (!pass && !isNot)) {
const errorMessage = customMessage != null
? customMessage
: message()
throw new JestExtendError(errorMessage, actual, expected)
throw new JestExtendError(
errorMessage,
actual,
expected,
{ assertionName: expectAssertionName, meta },
)
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export interface SyncExpectationResult {
message: () => string
actual?: any
expected?: any
meta?: object
}

export type AsyncExpectationResult = Promise<SyncExpectationResult>
Expand Down
100 changes: 100 additions & 0 deletions test/browser/specs/failure-screenshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { TestFsStructure } from '../../test-utils'
import { describe, expect, test } from 'vitest'
import { runInlineTests } from '../../test-utils'
import utilsContent from '../fixtures/expect-dom/utils?raw'
import { instances, provider } from '../settings'

const testFilename = 'basic.test.ts'

async function runBrowserTests(
structure: TestFsStructure,
) {
return runInlineTests({
...structure,
'vitest.config.js': `
import { ${provider.name} } from '@vitest/browser-${provider.name}'
export default {
test: {
browser: {
enabled: true,
screenshotFailures: true,
provider: ${provider.name}(),
ui: false,
headless: true,
instances: ${JSON.stringify(instances.slice(0, 1) /* logic not bound to browser instance */)},
},
reporters: ['verbose'],
update: 'new',
},
}`,
})
}

describe('failure screenshots', () => {
describe('`toMatchScreenshot`', () => {
test('usually does NOT produce a failure screenshot', async () => {
Copy link
Copy Markdown
Member Author

@macarie macarie Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should technically control all exit paths of the assertion, but I don't think it's worth the added time in each CI run. It might be easier to just test the cases that expect the opposite (like the one below) as the condition is quite straightforward.

Edit: since the line this comment is talking about is not clear in the UI, the subject is the test with the usually does NOT produce a failure screenshot description.

const { stderr } = await runBrowserTests(
{
[testFilename]: /* ts */`
import { page } from 'vitest/browser'
import { test } from 'vitest'
import { render } from './utils'

test('screenshot-initial', async ({ expect }) => {
render('<div data-testid="el">Test</div>')
await expect(page.getByTestId('el')).toMatchScreenshot()
})
`,
'utils.ts': utilsContent,
},
)

expect(stderr).toContain('No existing reference screenshot found; a new one was created.')
expect(stderr).not.toContain('Failure screenshot:')
})

test('unstable screenshot fails produces a failure screenshot', async () => {
const { stderr } = await runBrowserTests(
{
[testFilename]: /* ts */`
import { page } from 'vitest/browser'
import { test } from 'vitest'
import { render } from './utils'

test('screenshot-unstable', async ({ expect }) => {
render('<div data-testid="el">Test</div>')
await expect(page.getByTestId('el')).toMatchScreenshot({ timeout: 1 })
})
`,
'utils.ts': utilsContent,
},
)

expect(stderr).toContain('Could not capture a stable screenshot within 1ms.')
expect(stderr).toContain('Failure screenshot:')
})

test('`expect.soft` produces a failure screenshot', async () => {
const { stderr } = await runBrowserTests(
{
[testFilename]: /* ts */`
import { page } from 'vitest/browser'
import { test } from 'vitest'
import { render } from './utils'

test('screenshot-soft-then-fail', async ({ expect }) => {
render('<div data-testid="el">Test</div>')
await expect.soft(page.getByTestId('el')).toMatchScreenshot()
expect(1).toBe(2)
})
`,
'utils.ts': utilsContent,
},
)

expect(stderr).toContain('No existing reference screenshot found; a new one was created.')
expect(stderr).toContain('expected 1 to be 2')
expect(stderr).toContain('Failure screenshot:')
})
})
})
Loading