Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b46594e
fix: add expect.soft support to toMatchSnapshot (#8673)
iumehara Dec 10, 2025
81bddb7
Fix imported types in chai.ts
iumehara Dec 11, 2025
135dbc1
Move expect.soft Snapshot tests to snapshot directory to test across …
iumehara Dec 16, 2025
84e80f7
Fix linting errors
iumehara Jan 27, 2026
48e2175
Merge branch 'main' into soft-snapshot
hi-ogawa Feb 3, 2026
ddb7747
test: add e2e
hi-ogawa Feb 3, 2026
654bb37
test: remove artificial tests
hi-ogawa Feb 3, 2026
10b2914
test: update
hi-ogawa Feb 3, 2026
ad74f7e
test: update
hi-ogawa Feb 3, 2026
4e43f49
test: test toThrowErrorMatchingInlineSnapshot
hi-ogawa Feb 3, 2026
ffa7cf4
wip: is this needed?
hi-ogawa Feb 3, 2026
a7305c1
chore: comment
hi-ogawa Feb 4, 2026
4da1cf4
Merge branch 'main' into soft-snapshot
hi-ogawa Feb 4, 2026
686f7da
chore: cleanup unused
hi-ogawa Feb 4, 2026
bc08a4c
chore(test/snapshots): document test infra and rename test:snaps to t…
hi-ogawa Feb 4, 2026
d49fb31
refactor: reduce types
hi-ogawa Feb 4, 2026
71449ab
fix: support soft inline snapshot
hi-ogawa Feb 4, 2026
bd60ae4
fix: allow soft
hi-ogawa Feb 4, 2026
d3ac41b
chore: comment
hi-ogawa Feb 4, 2026
b24963b
test: test soft inline snapshot
hi-ogawa Feb 4, 2026
b65a986
fix: use __INLINE_SNAPSHOT_OFFSET_3__ to tweak stack
hi-ogawa Feb 4, 2026
c2a0c90
fix: webkit ptc
hi-ogawa Feb 4, 2026
add5a14
fix: use try/finally to prevent WebKit TCO optimization
hi-ogawa Feb 4, 2026
445a8f4
chore: comment
hi-ogawa Feb 4, 2026
834aed2
chore: todo
hi-ogawa Feb 4, 2026
9aad5a4
fix: make wrapAssertion and recordAsyncExpect work together
hi-ogawa Feb 4, 2026
dbd3bd3
test: test soft file snapshot warning
hi-ogawa Feb 4, 2026
59f03fa
fix: fix non awaited soft warning
hi-ogawa Feb 4, 2026
893628e
chore: cleanup
hi-ogawa Feb 4, 2026
847a2bd
test: update
hi-ogawa Feb 4, 2026
a03645f
fix: soft async tracking
hi-ogawa Feb 4, 2026
6b6bdf4
chore: comment
hi-ogawa Feb 4, 2026
7d754ab
test: update
hi-ogawa Feb 4, 2026
658a1a1
refactor: no wrapAssertion for toMatchFileSnapshot
hi-ogawa Feb 4, 2026
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
84 changes: 47 additions & 37 deletions packages/vitest/src/integrations/snapshot/chai.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Assertion, ChaiPlugin } from '@vitest/expect'
import type { ChaiPlugin } from '@vitest/expect'
import type { Test } from '@vitest/runner'
import { equals, iterableEquality, subsetEquality } from '@vitest/expect'
import { getNames } from '@vitest/runner/utils'
Expand All @@ -7,7 +7,7 @@ import {
SnapshotClient,
stripSnapshotIndentation,
} from '@vitest/snapshot'
import { createAssertionMessage, recordAsyncExpect } from '../../../../expect/src/utils'
import { createAssertionMessage, recordAsyncExpect, wrapAssertion } from '../../../../expect/src/utils'

let _client: SnapshotClient

Expand Down Expand Up @@ -62,42 +62,38 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
}

for (const key of ['matchSnapshot', 'toMatchSnapshot']) {
utils.addMethod(
chai.Assertion.prototype,
key,
function (
this: Record<string, unknown>,
properties?: object,
message?: string,
) {
utils.flag(this, '_name', key)
const isNot = utils.flag(this, 'negate')
if (isNot) {
throw new Error(`${key} cannot be used with "not"`)
}
const expected = utils.flag(this, 'object')
const test = getTest(key, this)
if (typeof properties === 'string' && typeof message === 'undefined') {
message = properties
properties = undefined
}
const errorMessage = utils.flag(this, 'message')
getSnapshotClient().assert({
received: expected,
message,
isInline: false,
properties,
errorMessage,
...getTestNames(test),
})
},
)
utils.addMethod(chai.Assertion.prototype, key, wrapAssertion(utils, key, function (
this: Chai.AssertionStatic & Chai.Assertion,
properties?: object,
message?: string,
) {
utils.flag(this, '_name', key)
const isNot = utils.flag(this, 'negate')
if (isNot) {
throw new Error(`${key} cannot be used with "not"`)
}
const expected = utils.flag(this, 'object')
const test = getTest(key, this)
if (typeof properties === 'string' && typeof message === 'undefined') {
message = properties
properties = undefined
}
const errorMessage = utils.flag(this, 'message')
getSnapshotClient().assert({
received: expected,
message,
isInline: false,
properties,
errorMessage,
...getTestNames(test),
})
}))
}

utils.addMethod(
chai.Assertion.prototype,
'toMatchFileSnapshot',
function (this: Assertion, file: string, message?: string) {
wrapAssertion(utils, 'toMatchFileSnapshot', function (this: Chai.AssertionStatic & Chai.Assertion, file: string, message?: string) {
utils.flag(this, '_name', 'toMatchFileSnapshot')
const isNot = utils.flag(this, 'negate')
if (isNot) {
Expand All @@ -119,13 +115,19 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
...getTestNames(test),
})

// const isSoft = utils.flag(this, 'soft')
// if (isSoft) {
// // assert result with soft wrapAssertion instead of recordAsyncExpect
// return promise
// }
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.

I wasn't sure why this check is needed specifically. Let me test run by commenting out. Please let me know if there's a reason for this.

Copy link
Copy Markdown
Collaborator

@hi-ogawa hi-ogawa Feb 4, 2026

Choose a reason for hiding this comment

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

Oh wait, I realized that this should be actually good to keep since we should still make recordAsyncExpect it functional for non soft case. We just didn't have a test to catch this.

EIDIT: Ah no. wrapAssertion always exists regardless of soft or not, so recordAsyncExpect's hanging promise detection won't work (but still auto await should be working).


return recordAsyncExpect(
test,
promise,
createAssertionMessage(utils, this, true),
createAssertionMessage(utils, this as any, true),
error,
)
},
}),
)

utils.addMethod(
Expand All @@ -142,6 +144,10 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
if (isNot) {
throw new Error('toMatchInlineSnapshot cannot be used with "not"')
}
const isSoft = utils.flag(this, 'soft')
if (isSoft) {
throw new Error('toMatchInlineSnapshot cannot be used with "soft"')
}
const test = getTest('toMatchInlineSnapshot', this)
const isInsideEach = test.each || test.suite?.each
if (isInsideEach) {
Expand Down Expand Up @@ -176,7 +182,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
utils.addMethod(
chai.Assertion.prototype,
'toThrowErrorMatchingSnapshot',
function (this: Record<string, unknown>, message?: string) {
wrapAssertion(utils, 'toThrowErrorMatchingSnapshot', function (this: Chai.AssertionStatic & Chai.Assertion, properties?: object, message?: string) {
utils.flag(this, '_name', 'toThrowErrorMatchingSnapshot')
const isNot = utils.flag(this, 'negate')
if (isNot) {
Expand All @@ -194,7 +200,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
errorMessage,
...getTestNames(test),
})
},
}),
)
utils.addMethod(
chai.Assertion.prototype,
Expand All @@ -210,6 +216,10 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
'toThrowErrorMatchingInlineSnapshot cannot be used with "not"',
)
}
const isSoft = utils.flag(this, 'soft')
if (isSoft) {
throw new Error('toThrowErrorMatchingInlineSnapshot cannot be used with "soft"')
}
const test = getTest('toThrowErrorMatchingInlineSnapshot', this)
const isInsideEach = test.each || test.suite?.each
if (isInsideEach) {
Expand Down
1 change: 1 addition & 0 deletions test/snapshots/README.md
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.

Added a quick guide for snapshot test suites. We can likely simplify test:generate -> update combo to unify within a single test:integration later.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TODO: explain which test script does what.
1 change: 1 addition & 0 deletions test/snapshots/test/fixtures/soft/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__snapshots__
16 changes: 16 additions & 0 deletions test/snapshots/test/fixtures/soft/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { expect, test } from 'vitest'

test('toMatchSnapshot', () => {
expect.soft('--snap-1--').toMatchSnapshot()
expect.soft('--snap-2--').toMatchSnapshot()
})

test('toMatchFileSnapshot', async () => {
await expect.soft('--file-1--').toMatchFileSnapshot('./__snapshots__/custom1.txt')
await expect.soft('--file-2--').toMatchFileSnapshot('./__snapshots__/custom2.txt')
})

test('toThrowErrorMatchingSnapshot', () => {
expect.soft(() => { throw new Error('--error-1--') }).toThrowErrorMatchingSnapshot()
expect.soft(() => { throw new Error('--error-2--') }).toThrowErrorMatchingSnapshot()
})
11 changes: 11 additions & 0 deletions test/snapshots/test/soft-inline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { expect, test } from 'vitest'

test('not supported yet', () => {
expect(() => expect.soft('test').toMatchInlineSnapshot()).toThrowErrorMatchingInlineSnapshot(
`[Error: toMatchInlineSnapshot cannot be used with "soft"]`,
)

expect(() => expect.soft(() => {}).toThrowErrorMatchingInlineSnapshot()).toThrowErrorMatchingInlineSnapshot(
`[Error: toThrowErrorMatchingInlineSnapshot cannot be used with "soft"]`,
)
})
197 changes: 197 additions & 0 deletions test/snapshots/test/soft.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import fs, { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { expect, test } from 'vitest'
import { editFile, runVitest } from '../../test-utils'

test('soft', async () => {
const root = join(import.meta.dirname, 'fixtures/soft')
const testFile = join(root, 'basic.test.ts')
const snapshotFile = join(root, '__snapshots__/basic.test.ts.snap')
const customFile1 = join(root, '__snapshots__/custom1.txt')
const customFile2 = join(root, '__snapshots__/custom2.txt')

// reset and create snapshots from scratch
fs.rmSync(join(root, '__snapshots__'), { recursive: true, force: true })
let result = await runVitest({ root, update: 'new' })
expect(result.stderr).toMatchInlineSnapshot(`""`)
expect(readFileSync(snapshotFile, 'utf-8')).toMatchInlineSnapshot(`
"// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[\`toMatchSnapshot 1\`] = \`"--snap-1--"\`;

exports[\`toMatchSnapshot 2\`] = \`"--snap-2--"\`;

exports[\`toThrowErrorMatchingSnapshot 1\`] = \`[Error: --error-1--]\`;

exports[\`toThrowErrorMatchingSnapshot 2\`] = \`[Error: --error-2--]\`;
"
`)
expect(readFileSync(customFile1, 'utf-8')).toMatchInlineSnapshot(`"--file-1--"`)
expect(readFileSync(customFile2, 'utf-8')).toMatchInlineSnapshot(`"--file-2--"`)
expect(result.errorTree()).toMatchInlineSnapshot(`
Object {
"basic.test.ts": Object {
"toMatchFileSnapshot": "passed",
"toMatchSnapshot": "passed",
"toThrowErrorMatchingSnapshot": "passed",
},
}
`)

// edit tests to introduce snapshot errors
editFile(testFile, s => s
.replace(`--snap-1--`, `--snap-1-edit--`)
.replace(`--snap-2--`, `--snap-2-edit--`)
.replace(`--file-1--`, `--file-1-edit--`)
.replace(`--file-2--`, `--file-2-edit--`)
.replace(`--error-1--`, `--error-1-edit--`)
.replace(`--error-2--`, `--error-2-edit--`))
result = await runVitest({ root, update: false })
expect(result.stderr).toMatchInlineSnapshot(`
"
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯

FAIL basic.test.ts > toMatchSnapshot
Error: Snapshot \`toMatchSnapshot 1\` mismatched

Expected: ""--snap-1--""
Received: ""--snap-1-edit--""

❯ basic.test.ts:4:34
2|
3| test('toMatchSnapshot', () => {
4| expect.soft('--snap-1-edit--').toMatchSnapshot()
| ^
5| expect.soft('--snap-2-edit--').toMatchSnapshot()
6| })

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/6]⎯

FAIL basic.test.ts > toMatchSnapshot
Error: Snapshot \`toMatchSnapshot 2\` mismatched

Expected: ""--snap-2--""
Received: ""--snap-2-edit--""

❯ basic.test.ts:5:34
3| test('toMatchSnapshot', () => {
4| expect.soft('--snap-1-edit--').toMatchSnapshot()
5| expect.soft('--snap-2-edit--').toMatchSnapshot()
| ^
6| })
7|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/6]⎯

FAIL basic.test.ts > toMatchFileSnapshot
Error: Snapshot \`toMatchFileSnapshot 1\` mismatched

Expected: "--file-1--"
Received: "--file-1-edit--"

❯ basic.test.ts:9:3
7|
8| test('toMatchFileSnapshot', async () => {
9| await expect.soft('--file-1-edit--').toMatchFileSnapshot('./__snapsh…
| ^
10| await expect.soft('--file-2-edit--').toMatchFileSnapshot('./__snapsh…
11| })

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/6]⎯

FAIL basic.test.ts > toMatchFileSnapshot
Error: Snapshot \`toMatchFileSnapshot 2\` mismatched

Expected: "--file-2--"
Received: "--file-2-edit--"

❯ basic.test.ts:10:3
8| test('toMatchFileSnapshot', async () => {
9| await expect.soft('--file-1-edit--').toMatchFileSnapshot('./__snapsh…
10| await expect.soft('--file-2-edit--').toMatchFileSnapshot('./__snapsh…
| ^
11| })
12|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/6]⎯

FAIL basic.test.ts > toThrowErrorMatchingSnapshot
Error: Snapshot \`toThrowErrorMatchingSnapshot 1\` mismatched

Expected: "[Error: --error-1--]"
Received: "[Error: --error-1-edit--]"

❯ basic.test.ts:14:62
12|
13| test('toThrowErrorMatchingSnapshot', () => {
14| expect.soft(() => { throw new Error('--error-1-edit--') }).toThrowEr…
| ^
15| expect.soft(() => { throw new Error('--error-2-edit--') }).toThrowEr…
16| })

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/6]⎯

FAIL basic.test.ts > toThrowErrorMatchingSnapshot
Error: Snapshot \`toThrowErrorMatchingSnapshot 2\` mismatched

Expected: "[Error: --error-2--]"
Received: "[Error: --error-2-edit--]"

❯ basic.test.ts:15:62
13| test('toThrowErrorMatchingSnapshot', () => {
14| expect.soft(() => { throw new Error('--error-1-edit--') }).toThrowEr…
15| expect.soft(() => { throw new Error('--error-2-edit--') }).toThrowEr…
| ^
16| })
17|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/6]⎯

"
`)
expect(result.errorTree()).toMatchInlineSnapshot(`
Object {
"basic.test.ts": Object {
"toMatchFileSnapshot": Array [
"Snapshot \`toMatchFileSnapshot 1\` mismatched",
"Snapshot \`toMatchFileSnapshot 2\` mismatched",
],
"toMatchSnapshot": Array [
"Snapshot \`toMatchSnapshot 1\` mismatched",
"Snapshot \`toMatchSnapshot 2\` mismatched",
],
"toThrowErrorMatchingSnapshot": Array [
"Snapshot \`toThrowErrorMatchingSnapshot 1\` mismatched",
"Snapshot \`toThrowErrorMatchingSnapshot 2\` mismatched",
],
},
}
`)

// run with update
result = await runVitest({ root, update: 'all' })
expect(result.stderr).toMatchInlineSnapshot(`""`)
expect(readFileSync(snapshotFile, 'utf-8')).toMatchInlineSnapshot(`
"// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[\`toMatchSnapshot 1\`] = \`"--snap-1-edit--"\`;

exports[\`toMatchSnapshot 2\`] = \`"--snap-2-edit--"\`;

exports[\`toThrowErrorMatchingSnapshot 1\`] = \`[Error: --error-1-edit--]\`;

exports[\`toThrowErrorMatchingSnapshot 2\`] = \`[Error: --error-2-edit--]\`;
"
`)
expect(readFileSync(customFile1, 'utf-8')).toMatchInlineSnapshot(`"--file-1-edit--"`)
expect(readFileSync(customFile2, 'utf-8')).toMatchInlineSnapshot(`"--file-2-edit--"`)
expect(result.errorTree()).toMatchInlineSnapshot(`
Object {
"basic.test.ts": Object {
"toMatchFileSnapshot": "passed",
"toMatchSnapshot": "passed",
"toThrowErrorMatchingSnapshot": "passed",
},
}
`)
})
Loading