[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(experimental/snapshot): support custom snapshot matcher (#9973)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>

authored by

Hiroshi Ogawa
Claude Opus 4.6 (1M context)
Codex
and committed by
GitHub
(Apr 2, 2026, 9:08 AM +0900) 59b0e641 398657e8

+867 -151
+4
docs/guide/extending-matchers.md
··· 107 107 expect.extend({ customMatcher }) 108 108 ``` 109 109 110 + ::: tip 111 + To build custom **snapshot matchers** (wrappers around `toMatchSnapshot` / `toMatchInlineSnapshot` / `toMatchFileSnapshot`), use the composable functions from `vitest/runtime`. See [Custom Snapshot Matchers](/guide/snapshot#custom-snapshot-matchers). 112 + ::: 113 + 110 114 Matcher function has access to `this` context with the following properties: 111 115 112 116 ## `isNot`
+30
docs/guide/migration.md
··· 650 650 651 651 Otherwise your snapshots will have a lot of escaped `"` characters. 652 652 653 + ### Custom Snapshot Matchers <Badge type="warning">experimental</Badge> <Version>4.1.3</Version> 654 + 655 + Jest imports snapshot composables from `jest-snapshot`. In Vitest, import from `vitest/runtime` instead: 656 + 657 + ```ts 658 + const { toMatchSnapshot } = require('jest-snapshot') // [!code --] 659 + import { toMatchSnapshot } from 'vitest/runtime' // [!code ++] 660 + 661 + expect.extend({ 662 + toMatchTrimmedSnapshot(received: string, length: number) { 663 + return toMatchSnapshot.call(this, received.slice(0, length)) 664 + }, 665 + }) 666 + ``` 667 + 668 + For inline snapshots, the same applies: 669 + 670 + ```ts 671 + const { toMatchInlineSnapshot } = require('jest-snapshot') // [!code --] 672 + import { toMatchInlineSnapshot } from 'vitest/runtime' // [!code ++] 673 + 674 + expect.extend({ 675 + toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) { 676 + return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot) 677 + }, 678 + }) 679 + ``` 680 + 681 + See [Custom Snapshot Matchers](/guide/snapshot#custom-snapshot-matchers) for the full guide. 682 + 653 683 ## Migrating from Mocha + Chai + Sinon {#mocha-chai-sinon} 654 684 655 685 Vitest provides excellent support for migrating from Mocha+Chai+Sinon test suites. While Vitest uses a Jest-compatible API by default, it also provides Chai-style assertions for spy/mock testing, making migration easier.
+70
docs/guide/snapshot.md
··· 200 200 201 201 We are using Jest's `pretty-format` for serializing snapshots. You can read more about it here: [pretty-format](https://github.com/facebook/jest/blob/main/packages/pretty-format/README.md#serialize). 202 202 203 + ## Custom Snapshot Matchers <Badge type="warning">experimental</Badge> <Version>4.1.3</Version> {#custom-snapshot-matchers} 204 + 205 + You can build custom snapshot matchers using the composable functions exported from `vitest/runtime`. These let you transform values before snapshotting while preserving full snapshot lifecycle support (creation, update, inline rewriting). 206 + 207 + ```ts 208 + import { expect, test } from 'vitest' 209 + import { toMatchFileSnapshot, toMatchInlineSnapshot, toMatchSnapshot } from 'vitest/runtime' 210 + 211 + expect.extend({ 212 + toMatchTrimmedSnapshot(received: string, length: number) { 213 + return toMatchSnapshot.call(this, received.slice(0, length)) 214 + }, 215 + toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) { 216 + return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot) 217 + }, 218 + async toMatchTrimmedFileSnapshot(received: string, file: string) { 219 + return toMatchFileSnapshot.call(this, received.slice(0, 10), file) 220 + }, 221 + }) 222 + 223 + test('file snapshot', () => { 224 + expect('extra long string oh my gerd').toMatchTrimmedSnapshot(10) 225 + }) 226 + 227 + test('inline snapshot', () => { 228 + expect('extra long string oh my gerd').toMatchTrimmedInlineSnapshot() 229 + }) 230 + 231 + test('raw file snapshot', async () => { 232 + await expect('extra long string oh my gerd').toMatchTrimmedFileSnapshot('./raw-file.txt') 233 + }) 234 + ``` 235 + 236 + The composables return `{ pass, message }` so you can further customize the error: 237 + 238 + ```ts 239 + expect.extend({ 240 + toMatchTrimmedSnapshot(received: string, length: number) { 241 + const result = toMatchSnapshot.call(this, received.slice(0, length)) 242 + return { ...result, message: () => `Trimmed snapshot failed: ${result.message()}` } 243 + }, 244 + }) 245 + ``` 246 + 247 + ::: warning 248 + For inline snapshot matchers, the snapshot argument must be the last parameter (or second-to-last when using property matchers). Vitest rewrites the last string argument in the source code, so custom arguments before the snapshot work, but custom arguments after it are not supported. 249 + ::: 250 + 251 + ::: tip 252 + File snapshot matchers must be `async` — `toMatchFileSnapshot` returns a `Promise`. Remember to `await` the result in the matcher and in your test. 253 + ::: 254 + 255 + For TypeScript, extend the `Assertion` interface: 256 + 257 + ```ts 258 + import 'vitest' 259 + 260 + declare module 'vitest' { 261 + interface Assertion<T = any> { 262 + toMatchTrimmedSnapshot: (length: number) => T 263 + toMatchTrimmedInlineSnapshot: (inlineSnapshot?: string) => T 264 + toMatchTrimmedFileSnapshot: (file: string) => Promise<T> 265 + } 266 + } 267 + ``` 268 + 269 + ::: tip 270 + See [Extending Matchers](/guide/extending-matchers) for more on `expect.extend` and custom matcher conventions. 271 + ::: 272 + 203 273 ## Difference from Jest 204 274 205 275 Vitest provides an almost compatible Snapshot feature with [Jest's](https://jestjs.io/docs/snapshot-testing) with a few exceptions:
+3 -2
packages/expect/src/jest-extend.ts
··· 53 53 suppressedErrors: [], 54 54 soft: util.flag(assertion, 'soft') as boolean | undefined, 55 55 poll: util.flag(assertion, 'poll') as boolean | undefined, 56 + __vitest_assertion__: assertion as any, 56 57 } 57 58 Object.assign(matcherState, { task }) 58 59 ··· 89 90 return (_, utils) => { 90 91 Object.entries(matchers).forEach( 91 92 ([expectAssertionName, expectAssertion]) => { 92 - function expectWrapper( 93 + function __VITEST_EXTEND_ASSERTION__( 93 94 this: Chai.AssertionStatic & Chai.Assertion, 94 95 ...args: any[] 95 96 ) { ··· 133 134 } 134 135 } 135 136 136 - const softWrapper = wrapAssertion(utils, expectAssertionName, expectWrapper) 137 + const softWrapper = wrapAssertion(utils, expectAssertionName, __VITEST_EXTEND_ASSERTION__) 137 138 utils.addMethod( 138 139 (globalThis as any)[JEST_MATCHERS_OBJECT].matchers, 139 140 expectAssertionName,
+7
packages/expect/src/types.ts
··· 82 82 } 83 83 soft?: boolean 84 84 poll?: boolean 85 + /** 86 + * this allows `expect.extend`-based custom matcher 87 + * to implement builtin vitest/chai assertion equivalent feature. 88 + * this used for custom snapshot matcher API. 89 + */ 90 + /** @internal */ 91 + __vitest_assertion__: Assertion 85 92 } 86 93 87 94 export interface SyncExpectationResult {
+2 -1
packages/expect/src/utils.ts
··· 5 5 6 6 export function createAssertionMessage( 7 7 util: Chai.ChaiUtils, 8 - assertion: Assertion, 8 + assertion: Chai.Assertion, 9 9 hasArgs: boolean, 10 10 ) { 11 11 const soft = util.flag(assertion, 'soft') ? '.soft' : '' ··· 92 92 test.result.errors.push(processError(err)) 93 93 } 94 94 95 + /** wrap assertion function to support `expect.soft` and provide assertion name as `_name` */ 95 96 export function wrapAssertion( 96 97 utils: Chai.ChaiUtils, 97 98 name: string,
+3
packages/snapshot/src/client.ts
··· 48 48 error?: Error 49 49 errorMessage?: string 50 50 rawSnapshot?: RawSnapshotInfo 51 + assertionName?: string 51 52 } 52 53 53 54 /** Same shape as expect.extend custom matcher result (SyncExpectationResult from @vitest/expect) */ ··· 119 120 error, 120 121 errorMessage, 121 122 rawSnapshot, 123 + assertionName, 122 124 } = options 123 125 let { received } = options 124 126 ··· 173 175 error, 174 176 inlineSnapshot, 175 177 rawSnapshot, 178 + assertionName, 176 179 }) 177 180 178 181 return {
+3
test/core/test/exports.test.ts
··· 165 165 "__INTERNAL": "object", 166 166 "builtinEnvironments": "object", 167 167 "populateGlobal": "function", 168 + "toMatchFileSnapshot": "function", 169 + "toMatchInlineSnapshot": "function", 170 + "toMatchSnapshot": "function", 168 171 }, 169 172 "./snapshot": { 170 173 "VitestSnapshotEnvironment": "function",
+338
test/snapshots/test/custom-matcher.test.ts
··· 1 + import fs, { readFileSync } from 'node:fs' 2 + import { join } from 'node:path' 3 + import { expect, test } from 'vitest' 4 + import { editFile, runVitest } from '../../test-utils' 5 + 6 + const INLINE_BLOCK_RE = /\/\/ -- TEST INLINE START --\n([\s\S]*?)\/\/ -- TEST INLINE END --/g 7 + 8 + function extractInlineBlocks(content: string): string { 9 + return [...content.matchAll(INLINE_BLOCK_RE)] 10 + .map(m => m[1].trim()) 11 + .join('\n\n') 12 + } 13 + 14 + test('custom snapshot matcher', async () => { 15 + const root = join(import.meta.dirname, 'fixtures/custom-matcher') 16 + const testFile = join(root, 'basic.test.ts') 17 + const snapshotFile = join(root, '__snapshots__/basic.test.ts.snap') 18 + const rawSnapshotFile = join(root, '__snapshots__/raw.txt') 19 + 20 + // remove snapshots 21 + fs.rmSync(join(root, '__snapshots__'), { recursive: true, force: true }) 22 + editFile(testFile, s => s.replace(/toMatchCustomInlineSnapshot\(`[^`]*`\)/g, 'toMatchCustomInlineSnapshot()')) 23 + 24 + // create snapshots from scratch 25 + let result = await runVitest({ root, update: 'new' }) 26 + expect(result.stderr).toMatchInlineSnapshot(`""`) 27 + expect(readFileSync(snapshotFile, 'utf-8')).toMatchInlineSnapshot(` 28 + "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 29 + 30 + exports[\`file 1\`] = \` 31 + Object { 32 + "length": 6, 33 + "reversed": "ahahah", 34 + } 35 + \`; 36 + 37 + exports[\`properties 1 1\`] = \` 38 + Object { 39 + "length": 6, 40 + "reversed": "opopop", 41 + } 42 + \`; 43 + 44 + exports[\`properties 2 1\`] = \` 45 + Object { 46 + "length": toSatisfy<[Function lessThan10]>, 47 + "reversed": "epepep", 48 + } 49 + \`; 50 + " 51 + `) 52 + expect(readFileSync(rawSnapshotFile, 'utf-8')).toMatchInlineSnapshot(` 53 + "Object { 54 + "length": 6, 55 + "reversed": "ihihih", 56 + }" 57 + `) 58 + expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` 59 + "test('inline', () => { 60 + expect(\`hehehe\`).toMatchCustomInlineSnapshot(\` 61 + Object { 62 + "length": 6, 63 + "reversed": "eheheh", 64 + } 65 + \`) 66 + })" 67 + `) 68 + expect(result.errorTree()).toMatchInlineSnapshot(` 69 + Object { 70 + "basic.test.ts": Object { 71 + "file": "passed", 72 + "inline": "passed", 73 + "properties 1": "passed", 74 + "properties 2": "passed", 75 + "raw": "passed", 76 + }, 77 + } 78 + `) 79 + 80 + // edit tests to introduce snapshot errors 81 + editFile(testFile, s => s 82 + .replace('`hahaha`', '`hahaha-edit`') 83 + .replace('`popopo`', '`popopo-edit`') 84 + .replace('`pepepe`', '`pepepe-edit`') 85 + .replace('`hihihi`', '`hihihi-edit`') 86 + .replace('`hehehe`', '`hehehe-edit`')) 87 + 88 + result = await runVitest({ root, update: 'none' }) 89 + expect(result.stderr).toMatchInlineSnapshot(` 90 + " 91 + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯ 92 + 93 + FAIL basic.test.ts > file 94 + Error: [custom error] Snapshot \`file 1\` mismatched 95 + 96 + - Expected 97 + + Received 98 + 99 + Object { 100 + - "length": 6, 101 + + "length": 11, 102 + - "reversed": "ahahah", 103 + + "reversed": "tide-ahahah", 104 + } 105 + 106 + ❯ basic.test.ts:46:25 107 + 44| 108 + 45| test('file', () => { 109 + 46| expect(\`hahaha-edit\`).toMatchCustomSnapshot() 110 + | ^ 111 + 47| }) 112 + 48| 113 + 114 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/5]⎯ 115 + 116 + FAIL basic.test.ts > properties 1 117 + Error: [custom error] Snapshot properties mismatched 118 + 119 + - Expected 120 + + Received 121 + 122 + { 123 + - "length": 6, 124 + + "length": 11, 125 + + "reversed": "tide-opopop", 126 + } 127 + 128 + ❯ basic.test.ts:50:25 129 + 48| 130 + 49| test('properties 1', () => { 131 + 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) 132 + | ^ 133 + 51| }) 134 + 52| 135 + 136 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯ 137 + 138 + FAIL basic.test.ts > properties 2 139 + Error: [custom error] Snapshot properties mismatched 140 + 141 + - Expected 142 + + Received 143 + 144 + { 145 + - "length": toSatisfy<[Function lessThan10]>, 146 + + "length": 11, 147 + + "reversed": "tide-epepep", 148 + } 149 + 150 + ❯ basic.test.ts:54:25 151 + 52| 152 + 53| test('properties 2', () => { 153 + 54| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… 154 + | ^ 155 + 55| }) 156 + 56| 157 + 158 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯ 159 + 160 + FAIL basic.test.ts > raw 161 + Error: [custom error] Snapshot \`raw 1\` mismatched 162 + 163 + - Expected 164 + + Received 165 + 166 + Object { 167 + - "length": 6, 168 + + "length": 11, 169 + - "reversed": "ihihih", 170 + + "reversed": "tide-ihihih", 171 + } 172 + 173 + ❯ basic.test.ts:58:3 174 + 56| 175 + 57| test('raw', async () => { 176 + 58| await expect(\`hihihi-edit\`).toMatchCustomFileSnapshot('./__snapshots… 177 + | ^ 178 + 59| }) 179 + 60| 180 + 181 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯ 182 + 183 + FAIL basic.test.ts > inline 184 + Error: [custom error] Snapshot \`inline 1\` mismatched 185 + 186 + - Expected 187 + + Received 188 + 189 + Object { 190 + - "length": 6, 191 + + "length": 11, 192 + - "reversed": "eheheh", 193 + + "reversed": "tide-eheheh", 194 + } 195 + 196 + ❯ basic.test.ts:63:25 197 + 61| // -- TEST INLINE START -- 198 + 62| test('inline', () => { 199 + 63| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` 200 + | ^ 201 + 64| Object { 202 + 65| "length": 6, 203 + 204 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/5]⎯ 205 + 206 + " 207 + `) 208 + expect(result.errorTree()).toMatchInlineSnapshot(` 209 + Object { 210 + "basic.test.ts": Object { 211 + "file": Array [ 212 + "[custom error] Snapshot \`file 1\` mismatched", 213 + ], 214 + "inline": Array [ 215 + "[custom error] Snapshot \`inline 1\` mismatched", 216 + ], 217 + "properties 1": Array [ 218 + "[custom error] Snapshot properties mismatched", 219 + ], 220 + "properties 2": Array [ 221 + "[custom error] Snapshot properties mismatched", 222 + ], 223 + "raw": Array [ 224 + "[custom error] Snapshot \`raw 1\` mismatched", 225 + ], 226 + }, 227 + } 228 + `) 229 + 230 + // run with update 231 + result = await runVitest({ root, update: 'all' }) 232 + expect(result.stderr).toMatchInlineSnapshot(` 233 + " 234 + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ 235 + 236 + FAIL basic.test.ts > properties 1 237 + Error: [custom error] Snapshot properties mismatched 238 + 239 + - Expected 240 + + Received 241 + 242 + { 243 + - "length": 6, 244 + + "length": 11, 245 + + "reversed": "tide-opopop", 246 + } 247 + 248 + ❯ basic.test.ts:50:25 249 + 48| 250 + 49| test('properties 1', () => { 251 + 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) 252 + | ^ 253 + 51| }) 254 + 52| 255 + 256 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ 257 + 258 + FAIL basic.test.ts > properties 2 259 + Error: [custom error] Snapshot properties mismatched 260 + 261 + - Expected 262 + + Received 263 + 264 + { 265 + - "length": toSatisfy<[Function lessThan10]>, 266 + + "length": 11, 267 + + "reversed": "tide-epepep", 268 + } 269 + 270 + ❯ basic.test.ts:54:25 271 + 52| 272 + 53| test('properties 2', () => { 273 + 54| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… 274 + | ^ 275 + 55| }) 276 + 56| 277 + 278 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ 279 + 280 + " 281 + `) 282 + expect(readFileSync(snapshotFile, 'utf-8')).toMatchInlineSnapshot(` 283 + "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 284 + 285 + exports[\`file 1\`] = \` 286 + Object { 287 + "length": 11, 288 + "reversed": "tide-ahahah", 289 + } 290 + \`; 291 + 292 + exports[\`properties 1 1\`] = \` 293 + Object { 294 + "length": 6, 295 + "reversed": "opopop", 296 + } 297 + \`; 298 + 299 + exports[\`properties 2 1\`] = \` 300 + Object { 301 + "length": toSatisfy<[Function lessThan10]>, 302 + "reversed": "epepep", 303 + } 304 + \`; 305 + " 306 + `) 307 + expect(readFileSync(rawSnapshotFile, 'utf-8')).toMatchInlineSnapshot(` 308 + "Object { 309 + "length": 11, 310 + "reversed": "tide-ihihih", 311 + }" 312 + `) 313 + expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` 314 + "test('inline', () => { 315 + expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` 316 + Object { 317 + "length": 11, 318 + "reversed": "tide-eheheh", 319 + } 320 + \`) 321 + })" 322 + `) 323 + expect(result.errorTree()).toMatchInlineSnapshot(` 324 + Object { 325 + "basic.test.ts": Object { 326 + "file": "passed", 327 + "inline": "passed", 328 + "properties 1": Array [ 329 + "[custom error] Snapshot properties mismatched", 330 + ], 331 + "properties 2": Array [ 332 + "[custom error] Snapshot properties mismatched", 333 + ], 334 + "raw": "passed", 335 + }, 336 + } 337 + `) 338 + })
+48 -24
packages/snapshot/src/port/inlineSnapshot.ts
··· 6 6 offsetToLineNumber, 7 7 positionToOffset, 8 8 } from '@vitest/utils/offset' 9 + import { memo } from './utils' 9 10 10 11 export interface InlineSnapshot { 11 12 snapshot: string ··· 13 14 file: string 14 15 line: number 15 16 column: number 17 + // it maybe possible to accurately extract this from `ParsedStack.method`, 18 + // but for now, we ask higher level assertion to pass it explicitly 19 + // since this is useful for certain error messages before we extract stack. 20 + assertionName?: string 16 21 } 17 22 18 23 export async function saveInlineSnapshots( ··· 33 38 34 39 for (const snap of snaps) { 35 40 const index = positionToOffset(code, snap.line, snap.column) 36 - replaceInlineSnap(code, s, index, snap.snapshot) 41 + replaceInlineSnap(code, s, index, snap.snapshot, snap.assertionName) 37 42 } 38 43 39 44 const transformed = s.toString() ··· 44 49 ) 45 50 } 46 51 47 - const startObjectRegex 52 + const defaultStartObjectRegex 48 53 = /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*\{/ 54 + 55 + function escapeRegExp(s: string): string { 56 + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 57 + } 58 + 59 + const buildStartObjectRegex = memo((assertionName: string) => { 60 + const replaced = defaultStartObjectRegex.source.replace( 61 + 'toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot', 62 + escapeRegExp(assertionName), 63 + ) 64 + return new RegExp(replaced) 65 + }) 49 66 50 67 function replaceObjectSnap( 51 68 code: string, 52 69 s: MagicString, 53 70 index: number, 54 71 newSnap: string, 72 + assertionName?: string, 55 73 ) { 56 74 let _code = code.slice(index) 57 - const startMatch = startObjectRegex.exec(_code) 75 + const regex = assertionName ? buildStartObjectRegex(assertionName) : defaultStartObjectRegex 76 + const startMatch = regex.exec(_code) 58 77 if (!startMatch) { 59 78 return false 60 79 } ··· 121 140 .replace(/\$\{/g, '\\${')}\n${indent}${quote}` 122 141 } 123 142 124 - const toMatchInlineName = 'toMatchInlineSnapshot' 125 - const toThrowErrorMatchingInlineName = 'toThrowErrorMatchingInlineSnapshot' 143 + const defaultMethodNames = ['toMatchInlineSnapshot', 'toThrowErrorMatchingInlineSnapshot'] 126 144 127 145 // on webkit, the line number is at the end of the method, not at the start 128 - function getCodeStartingAtIndex(code: string, index: number) { 129 - const indexInline = index - toMatchInlineName.length 130 - if (code.slice(indexInline, index) === toMatchInlineName) { 131 - return { 132 - code: code.slice(indexInline), 133 - index: indexInline, 134 - } 135 - } 136 - const indexThrowInline = index - toThrowErrorMatchingInlineName.length 137 - if (code.slice(index - indexThrowInline, index) === toThrowErrorMatchingInlineName) { 138 - return { 139 - code: code.slice(index - indexThrowInline), 140 - index: index - indexThrowInline, 146 + function getCodeStartingAtIndex(code: string, index: number, methodNames: string[]) { 147 + for (const name of methodNames) { 148 + const adjusted = index - name.length 149 + if (adjusted >= 0 && code.slice(adjusted, index) === name) { 150 + return { 151 + code: code.slice(adjusted), 152 + index: adjusted, 153 + } 141 154 } 142 155 } 143 156 return { ··· 146 159 } 147 160 } 148 161 149 - const startRegex 162 + const defaultStartRegex 150 163 = /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*[\w$]*(['"`)])/ 164 + 165 + const buildStartRegex = memo((assertionName: string) => { 166 + const replaced = defaultStartRegex.source.replace( 167 + 'toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot', 168 + escapeRegExp(assertionName), 169 + ) 170 + return new RegExp(replaced) 171 + }) 172 + 151 173 export function replaceInlineSnap( 152 174 code: string, 153 175 s: MagicString, 154 176 currentIndex: number, 155 177 newSnap: string, 178 + assertionName?: string, 156 179 ): boolean { 157 - const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex) 180 + const methodNames = assertionName ? [assertionName] : defaultMethodNames 181 + const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex, methodNames) 158 182 183 + const startRegex = assertionName ? buildStartRegex(assertionName) : defaultStartRegex 159 184 const startMatch = startRegex.exec(codeStartingAtIndex) 160 185 161 - const firstKeywordMatch = /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/.exec( 162 - codeStartingAtIndex, 163 - ) 186 + const keywordRegex = assertionName ? new RegExp(escapeRegExp(assertionName)) : /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/ 187 + const firstKeywordMatch = keywordRegex.exec(codeStartingAtIndex) 164 188 165 189 if (!startMatch || startMatch.index !== firstKeywordMatch?.index) { 166 - return replaceObjectSnap(code, s, index, newSnap) 190 + return replaceObjectSnap(code, s, index, newSnap, assertionName) 167 191 } 168 192 169 193 const quote = startMatch[1]
+15 -2
packages/snapshot/src/port/state.ts
··· 177 177 } 178 178 } 179 179 180 + // custom matcher registered via expect.extend() — the wrapper function 181 + // in jest-extend.ts is named __VITEST_EXTEND_ASSERTION__ 182 + const customMatcherIndex = stacks.findIndex(i => 183 + i.method.includes('__VITEST_EXTEND_ASSERTION__'), 184 + ) 185 + if (customMatcherIndex !== -1) { 186 + return stacks[customMatcherIndex + 3] ?? null 187 + } 188 + 180 189 // inline snapshot function is called __INLINE_SNAPSHOT__ 181 190 // in integrations/snapshot/chai.ts 182 191 const stackIndex = stacks.findIndex(i => ··· 188 197 private _addSnapshot( 189 198 key: string, 190 199 receivedSerialized: string, 191 - options: { rawSnapshot?: RawSnapshotInfo; stack?: ParsedStack; testId: string }, 200 + options: { rawSnapshot?: RawSnapshotInfo; stack?: ParsedStack; testId: string; assertionName?: string }, 192 201 ): void { 193 202 this._dirty = true 194 203 if (options.stack) { 195 204 this._inlineSnapshots.push({ 205 + ...options.stack, 196 206 snapshot: receivedSerialized, 197 207 testId: options.testId, 198 - ...options.stack, 208 + assertionName: options.assertionName, 199 209 }) 200 210 } 201 211 else if (options.rawSnapshot) { ··· 294 304 isInline, 295 305 error, 296 306 rawSnapshot, 307 + assertionName, 297 308 }: SnapshotMatchOptions): SnapshotReturnOptions { 298 309 // this also increments counter for inline snapshots. maybe we shouldn't? 299 310 this._counters.increment(testName) ··· 422 433 stack, 423 434 testId, 424 435 rawSnapshot, 436 + assertionName, 425 437 }) 426 438 } 427 439 else { ··· 433 445 stack, 434 446 testId, 435 447 rawSnapshot, 448 + assertionName, 436 449 }) 437 450 this.added.increment(testId) 438 451 }
+11
packages/snapshot/src/port/utils.ts
··· 286 286 return total 287 287 } 288 288 } 289 + 290 + /* @__NO_SIDE_EFFECTS__ */ 291 + export function memo<T, U>(fn: (arg: T) => U): (arg: T) => U { 292 + const cache = new Map<T, U>() 293 + return (arg: T) => { 294 + if (!cache.has(arg)) { 295 + cache.set(arg, fn(arg)) 296 + } 297 + return cache.get(arg)! 298 + } 299 + }
+1
packages/snapshot/src/types/index.ts
··· 32 32 isInline: boolean 33 33 error?: Error 34 34 rawSnapshot?: RawSnapshotInfo 35 + assertionName?: string 35 36 } 36 37 37 38 export interface SnapshotResult {
+1
packages/vitest/src/public/runtime.ts
··· 11 11 12 12 export { environments as builtinEnvironments } from '../integrations/env/index' 13 13 export { populateGlobal } from '../integrations/env/utils' 14 + export { toMatchFileSnapshot, toMatchInlineSnapshot, toMatchSnapshot } from '../integrations/snapshot/chai' 14 15 export { VitestNodeSnapshotEnvironment as VitestSnapshotEnvironment } from '../integrations/snapshot/environments/node' 15 16 export type { 16 17 Environment,
+260 -122
packages/vitest/src/integrations/snapshot/chai.ts
··· 1 - import type { Assertion, ChaiPlugin } from '@vitest/expect' 1 + import type { ChaiPlugin, MatcherState, SyncExpectationResult } from '@vitest/expect' 2 2 import type { Test } from '@vitest/runner' 3 - import { createAssertionMessage, equals, iterableEquality, recordAsyncExpect, subsetEquality, wrapAssertion } from '@vitest/expect' 3 + import { chai, createAssertionMessage, equals, iterableEquality, recordAsyncExpect, subsetEquality, wrapAssertion } from '@vitest/expect' 4 4 import { getNames } from '@vitest/runner/utils' 5 5 import { 6 6 addSerializer, 7 7 SnapshotClient, 8 8 stripSnapshotIndentation, 9 9 } from '@vitest/snapshot' 10 + import { getWorkerState } from '../../runtime/utils' 10 11 11 12 let _client: SnapshotClient 12 13 ··· 51 52 } 52 53 } 53 54 54 - export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { 55 - function getTest(assertionName: string, obj: object) { 56 - const test = utils.flag(obj, 'vitest-test') 57 - if (!test) { 58 - throw new Error(`'${assertionName}' cannot be used without test context`) 59 - } 60 - return test as Test 55 + function getAssertionName(assertion: Chai.Assertion): string { 56 + const name = chai.util.flag(assertion, '_name') as string | undefined 57 + if (!name) { 58 + throw new Error('Assertion name is not set. This is a bug in Vitest. Please, open a new issue with reproduction.') 61 59 } 60 + return name 61 + } 62 62 63 + function getTest(obj: Chai.Assertion) { 64 + const test = chai.util.flag(obj, 'vitest-test') 65 + if (!test) { 66 + throw new Error(`'${getAssertionName(obj)}' cannot be used without test context`) 67 + } 68 + return test as Test 69 + } 70 + 71 + function validateAssertion(assertion: Chai.Assertion): void { 72 + if (chai.util.flag(assertion, 'negate')) { 73 + throw new Error(`${getAssertionName(assertion)} cannot be used with "not"`) 74 + } 75 + } 76 + 77 + export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { 63 78 for (const key of ['matchSnapshot', 'toMatchSnapshot']) { 64 79 utils.addMethod( 65 80 chai.Assertion.prototype, 66 81 key, 67 82 wrapAssertion(utils, key, function ( 68 83 this, 69 - properties?: object, 70 - message?: string, 84 + propertiesOrHint?: object | string, 85 + hint?: string, 71 86 ) { 72 - utils.flag(this, '_name', key) 73 - const isNot = utils.flag(this, 'negate') 74 - if (isNot) { 75 - throw new Error(`${key} cannot be used with "not"`) 76 - } 77 - const expected = utils.flag(this, 'object') 78 - const test = getTest(key, this) 79 - if (typeof properties === 'string' && typeof message === 'undefined') { 80 - message = properties 81 - properties = undefined 82 - } 83 - const errorMessage = utils.flag(this, 'message') 84 - getSnapshotClient().assert({ 85 - received: expected, 86 - message, 87 - isInline: false, 88 - properties, 89 - errorMessage, 90 - ...getTestNames(test), 87 + const result = toMatchSnapshotImpl({ 88 + assertion: this, 89 + received: utils.flag(this, 'object'), 90 + ...normalizeArguments(propertiesOrHint, hint), 91 91 }) 92 + return assertMatchResult(result) 92 93 }), 93 94 ) 94 95 } ··· 96 97 utils.addMethod( 97 98 chai.Assertion.prototype, 98 99 'toMatchFileSnapshot', 99 - function (this: Assertion, file: string, message?: string) { 100 + function (this: Chai.Assertion, filepath: string, hint?: string) { 101 + // set name manually since it's not wrapped by wrapAssertion 100 102 utils.flag(this, '_name', 'toMatchFileSnapshot') 101 - const isNot = utils.flag(this, 'negate') 102 - if (isNot) { 103 - throw new Error('toMatchFileSnapshot cannot be used with "not"') 104 - } 105 - const error = new Error('resolves') 106 - const expected = utils.flag(this, 'object') 107 - const test = getTest('toMatchFileSnapshot', this) 108 - const errorMessage = utils.flag(this, 'message') 109 - 110 - const promise = getSnapshotClient().assertRaw({ 111 - received: expected, 112 - message, 113 - isInline: false, 114 - rawSnapshot: { 115 - file, 116 - }, 117 - errorMessage, 118 - ...getTestNames(test), 103 + // validate early synchronously just not to break some existing tests 104 + validateAssertion(this) 105 + const resultPromise = toMatchFileSnapshotImpl({ 106 + assertion: this, 107 + received: utils.flag(this, 'object'), 108 + filepath, 109 + hint, 119 110 }) 120 - 111 + const assertPromise = resultPromise.then(result => assertMatchResult(result)) 121 112 return recordAsyncExpect( 122 - test, 123 - promise, 113 + getTest(this), 114 + assertPromise, 124 115 createAssertionMessage(utils, this, true), 125 - error, 116 + new Error('resolves'), 126 117 utils.flag(this, 'soft'), 127 118 ) 128 119 }, ··· 133 124 'toMatchInlineSnapshot', 134 125 wrapAssertion(utils, 'toMatchInlineSnapshot', function __INLINE_SNAPSHOT_OFFSET_3__( 135 126 this, 136 - properties?: object, 137 - inlineSnapshot?: string, 138 - message?: string, 127 + propertiesOrInlineSnapshot?: object | string, 128 + inlineSnapshotOrHint?: string, 129 + hint?: string, 139 130 ) { 140 - utils.flag(this, '_name', 'toMatchInlineSnapshot') 141 - const isNot = utils.flag(this, 'negate') 142 - if (isNot) { 143 - throw new Error('toMatchInlineSnapshot cannot be used with "not"') 144 - } 145 - const test = getTest('toMatchInlineSnapshot', this) 146 - const expected = utils.flag(this, 'object') 147 - const error = utils.flag(this, 'error') 148 - if (typeof properties === 'string') { 149 - message = inlineSnapshot 150 - inlineSnapshot = properties 151 - properties = undefined 152 - } 153 - if (inlineSnapshot) { 154 - inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) 155 - } 156 - const errorMessage = utils.flag(this, 'message') 157 - 158 - getSnapshotClient().assert({ 159 - received: expected, 160 - message, 131 + const result = toMatchSnapshotImpl({ 132 + assertion: this, 133 + received: utils.flag(this, 'object'), 161 134 isInline: true, 162 - properties, 163 - inlineSnapshot, 164 - error, 165 - errorMessage, 166 - ...getTestNames(test), 135 + ...normalizeInlineArguments(propertiesOrInlineSnapshot, inlineSnapshotOrHint, hint), 167 136 }) 137 + return assertMatchResult(result) 168 138 }), 169 139 ) 170 140 utils.addMethod( 171 141 chai.Assertion.prototype, 172 142 'toThrowErrorMatchingSnapshot', 173 - wrapAssertion(utils, 'toThrowErrorMatchingSnapshot', function (this, properties?: object, message?: string) { 174 - utils.flag(this, '_name', 'toThrowErrorMatchingSnapshot') 175 - const isNot = utils.flag(this, 'negate') 176 - if (isNot) { 177 - throw new Error( 178 - 'toThrowErrorMatchingSnapshot cannot be used with "not"', 179 - ) 180 - } 181 - const expected = utils.flag(this, 'object') 182 - const test = getTest('toThrowErrorMatchingSnapshot', this) 143 + wrapAssertion(utils, 'toThrowErrorMatchingSnapshot', function (this, propertiesOrHint?: object | string, hint?: string) { 144 + validateAssertion(this) 145 + const received = utils.flag(this, 'object') 183 146 const promise = utils.flag(this, 'promise') as string | undefined 184 - const errorMessage = utils.flag(this, 'message') 185 - getSnapshotClient().assert({ 186 - received: getError(expected, promise), 187 - message, 188 - errorMessage, 189 - ...getTestNames(test), 147 + const result = toMatchSnapshotImpl({ 148 + assertion: this, 149 + received: getError(received, promise), 150 + ...normalizeArguments(propertiesOrHint, hint), 190 151 }) 152 + return assertMatchResult(result) 191 153 }), 192 154 ) 193 155 utils.addMethod( ··· 195 157 'toThrowErrorMatchingInlineSnapshot', 196 158 wrapAssertion(utils, 'toThrowErrorMatchingInlineSnapshot', function __INLINE_SNAPSHOT_OFFSET_3__( 197 159 this, 198 - inlineSnapshot: string, 199 - message: string, 160 + inlineSnapshotOrHint?: string, 161 + hint?: string, 200 162 ) { 201 - const isNot = utils.flag(this, 'negate') 202 - if (isNot) { 203 - throw new Error( 204 - 'toThrowErrorMatchingInlineSnapshot cannot be used with "not"', 205 - ) 206 - } 207 - const test = getTest('toThrowErrorMatchingInlineSnapshot', this) 208 - const expected = utils.flag(this, 'object') 209 - const error = utils.flag(this, 'error') 163 + validateAssertion(this) 164 + const received = utils.flag(this, 'object') 210 165 const promise = utils.flag(this, 'promise') as string | undefined 211 - const errorMessage = utils.flag(this, 'message') 212 - 213 - if (inlineSnapshot) { 214 - inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) 215 - } 216 - 217 - getSnapshotClient().assert({ 218 - received: getError(expected, promise), 219 - message, 220 - inlineSnapshot, 166 + const result = toMatchSnapshotImpl({ 167 + assertion: this, 168 + received: getError(received, promise), 221 169 isInline: true, 222 - error, 223 - errorMessage, 224 - ...getTestNames(test), 170 + ...normalizeInlineArguments(undefined, inlineSnapshotOrHint, hint), 225 171 }) 172 + return assertMatchResult(result) 226 173 }), 227 174 ) 228 175 utils.addMethod(chai.expect, 'addSnapshotSerializer', addSerializer) 176 + } 177 + 178 + // toMatchSnapshot(propertiesOrHint?, hint?) 179 + function normalizeArguments( 180 + propertiesOrHint?: object | string, 181 + hint?: string, 182 + ): { properties?: object; hint?: string } { 183 + if (typeof propertiesOrHint === 'string') { 184 + return { hint: propertiesOrHint } 185 + } 186 + return { properties: propertiesOrHint, hint } 187 + } 188 + 189 + // toMatchInlineSnapshot(propertiesOrInlineSnapshot?, inlineSnapshotOrHint?, hint?) 190 + function normalizeInlineArguments( 191 + propertiesOrInlineSnapshot?: object | string, 192 + inlineSnapshotOrHint?: string, 193 + hint?: string, 194 + ): { properties?: object; inlineSnapshot?: string; hint?: string } { 195 + let inlineSnapshot: string | undefined 196 + if (typeof propertiesOrInlineSnapshot === 'string') { 197 + inlineSnapshot = stripSnapshotIndentation(propertiesOrInlineSnapshot) 198 + return { inlineSnapshot, hint: inlineSnapshotOrHint } 199 + } 200 + if (inlineSnapshotOrHint) { 201 + inlineSnapshot = stripSnapshotIndentation(inlineSnapshotOrHint) 202 + } 203 + return { properties: propertiesOrInlineSnapshot, inlineSnapshot, hint } 204 + } 205 + 206 + function toMatchSnapshotImpl(options: { 207 + assertion: Chai.Assertion 208 + received: unknown 209 + properties?: object 210 + hint?: string 211 + isInline?: boolean 212 + inlineSnapshot?: string 213 + }): SyncExpectationResult { 214 + const { assertion } = options 215 + validateAssertion(assertion) 216 + const assertionName = getAssertionName(assertion) 217 + const test = getTest(assertion) 218 + return getSnapshotClient().match({ 219 + received: options.received, 220 + properties: options.properties, 221 + message: options.hint, 222 + isInline: options.isInline, 223 + inlineSnapshot: options.inlineSnapshot, 224 + errorMessage: chai.util.flag(assertion, 'message'), 225 + // pass `assertionName` for inline snapshot stack probing 226 + assertionName, 227 + // set by async assertion (e.g. resolves/rejects) for inline snapshot stack probing 228 + error: chai.util.flag(assertion, 'error'), 229 + ...getTestNames(test), 230 + }) 231 + } 232 + 233 + async function toMatchFileSnapshotImpl(options: { 234 + assertion: Chai.Assertion 235 + received: unknown 236 + filepath: string 237 + hint?: string 238 + }): Promise<SyncExpectationResult> { 239 + const { assertion } = options 240 + validateAssertion(assertion) 241 + const test = getTest(assertion) 242 + const testNames = getTestNames(test) 243 + const snapshotState = getSnapshotClient().getSnapshotState(testNames.filepath) 244 + const rawSnapshotFile = await snapshotState.environment.resolveRawPath(testNames.filepath, options.filepath) 245 + const rawSnapshotContent = await snapshotState.environment.readSnapshotFile(rawSnapshotFile) 246 + return getSnapshotClient().match({ 247 + received: options.received, 248 + message: options.hint, 249 + errorMessage: chai.util.flag(assertion, 'message'), 250 + rawSnapshot: { 251 + file: rawSnapshotFile, 252 + content: rawSnapshotContent ?? undefined, 253 + }, 254 + ...testNames, 255 + }) 256 + } 257 + 258 + function assertMatchResult(result: SyncExpectationResult): void { 259 + if (!result.pass) { 260 + throw Object.assign(new Error(result.message()), { 261 + actual: result.actual, 262 + expected: result.expected, 263 + diffOptions: { 264 + expand: getWorkerState().config.snapshotOptions.expand, 265 + }, 266 + }) 267 + } 268 + } 269 + 270 + /** 271 + * Composable for building custom snapshot matchers via `expect.extend`. 272 + * Call with `this` bound to the matcher state. Returns `{ pass, message }` 273 + * compatible with the custom matcher return contract. 274 + * 275 + * @example 276 + * ```ts 277 + * import { toMatchSnapshot } from 'vitest/runtime' 278 + * 279 + * expect.extend({ 280 + * toMatchTrimmedSnapshot(received: string) { 281 + * return toMatchSnapshot.call(this, received.slice(0, 10)) 282 + * }, 283 + * }) 284 + * ``` 285 + * 286 + * @experimental 287 + * @see https://vitest.dev/guide/snapshot.html#custom-snapshot-matchers 288 + */ 289 + export function toMatchSnapshot( 290 + this: MatcherState, 291 + received: unknown, 292 + propertiesOrHint?: object | string, 293 + hint?: string, 294 + ): SyncExpectationResult { 295 + return toMatchSnapshotImpl({ 296 + assertion: this.__vitest_assertion__, 297 + received, 298 + ...normalizeArguments(propertiesOrHint, hint), 299 + }) 300 + } 301 + 302 + /** 303 + * Composable for building custom inline snapshot matchers via `expect.extend`. 304 + * Call with `this` bound to the matcher state. Returns `{ pass, message }` 305 + * compatible with the custom matcher return contract. 306 + * 307 + * @example 308 + * ```ts 309 + * import { toMatchInlineSnapshot } from 'vitest/runtime' 310 + * 311 + * expect.extend({ 312 + * toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) { 313 + * return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot) 314 + * }, 315 + * }) 316 + * ``` 317 + * 318 + * @experimental 319 + * @see https://vitest.dev/guide/snapshot.html#custom-snapshot-matchers 320 + */ 321 + export function toMatchInlineSnapshot( 322 + this: MatcherState, 323 + received: unknown, 324 + propertiesOrInlineSnapshot?: object | string, 325 + inlineSnapshotOrHint?: string, 326 + hint?: string, 327 + ): SyncExpectationResult { 328 + return toMatchSnapshotImpl({ 329 + assertion: this.__vitest_assertion__, 330 + received, 331 + isInline: true, 332 + ...normalizeInlineArguments(propertiesOrInlineSnapshot, inlineSnapshotOrHint, hint), 333 + }) 334 + } 335 + 336 + /** 337 + * Composable for building custom file snapshot matchers via `expect.extend`. 338 + * Call with `this` bound to the matcher state. Returns a `Promise<{ pass, message }>` 339 + * compatible with the custom matcher return contract. 340 + * 341 + * @example 342 + * ```ts 343 + * import { toMatchFileSnapshot } from 'vitest/runtime' 344 + * 345 + * expect.extend({ 346 + * async toMatchTrimmedFileSnapshot(received: string, file: string) { 347 + * return toMatchFileSnapshot.call(this, received.slice(0, 10), file) 348 + * }, 349 + * }) 350 + * ``` 351 + * 352 + * @experimental 353 + * @see https://vitest.dev/guide/snapshot.html#custom-snapshot-matchers 354 + */ 355 + export function toMatchFileSnapshot( 356 + this: MatcherState, 357 + received: unknown, 358 + filepath: string, 359 + hint?: string, 360 + ): Promise<SyncExpectationResult> { 361 + return toMatchFileSnapshotImpl({ 362 + assertion: this.__vitest_assertion__, 363 + received, 364 + filepath, 365 + hint, 366 + }) 229 367 }
+1
test/snapshots/test/fixtures/custom-matcher/.gitignore
··· 1 + __snapshots__
+70
test/snapshots/test/fixtures/custom-matcher/basic.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + import { toMatchFileSnapshot, toMatchInlineSnapshot, toMatchSnapshot } from "vitest/runtime" 3 + 4 + // custom snapshot matcher to wraper input code string 5 + interface CustomMatchers<R = unknown> { 6 + toMatchCustomSnapshot: (properties?: object) => R 7 + toMatchCustomInlineSnapshot: (snapshot?: string) => R 8 + toMatchCustomFileSnapshot: (filepath: string) => Promise<R> 9 + } 10 + 11 + declare module 'vitest' { 12 + interface Assertion<T = any> extends CustomMatchers<T> {} 13 + interface AsymmetricMatchersContaining extends CustomMatchers {} 14 + } 15 + 16 + function formatCustom(input: string) { 17 + return { 18 + reversed: input.split('').reverse().join(''), 19 + length: input.length, 20 + } 21 + } 22 + 23 + expect.extend({ 24 + toMatchCustomSnapshot(actual: string, properties?: object) { 25 + const actualCustom = formatCustom(actual) 26 + const result = toMatchSnapshot.call(this, actualCustom, properties) 27 + // result can be further enhanced 28 + return { ...result, message: () => `[custom error] ${result.message()}` } 29 + }, 30 + toMatchCustomInlineSnapshot( 31 + actual: string, 32 + inlineSnapshot?: string, 33 + ) { 34 + const actualCustom = formatCustom(actual) 35 + const result = toMatchInlineSnapshot.call(this, actualCustom, inlineSnapshot) 36 + return { ...result, message: () => `[custom error] ${result.message()}` } 37 + }, 38 + async toMatchCustomFileSnapshot(actual: string, filepath: string) { 39 + const actualCustom = formatCustom(actual) 40 + const result = await toMatchFileSnapshot.call(this, actualCustom, filepath) 41 + return { ...result, message: () => `[custom error] ${result.message()}` } 42 + }, 43 + }) 44 + 45 + test('file', () => { 46 + expect(`hahaha`).toMatchCustomSnapshot() 47 + }) 48 + 49 + test('properties 1', () => { 50 + expect(`popopo`).toMatchCustomSnapshot({ length: 6 }) 51 + }) 52 + 53 + test('properties 2', () => { 54 + expect(`pepepe`).toMatchCustomSnapshot({ length: expect.toSatisfy(function lessThan10(n) { return n < 10 }) }) 55 + }) 56 + 57 + test('raw', async () => { 58 + await expect(`hihihi`).toMatchCustomFileSnapshot('./__snapshots__/raw.txt') 59 + }) 60 + 61 + // -- TEST INLINE START -- 62 + test('inline', () => { 63 + expect(`hehehe`).toMatchCustomInlineSnapshot(` 64 + Object { 65 + "length": 6, 66 + "reversed": "eheheh", 67 + } 68 + `) 69 + }) 70 + // -- TEST INLINE END --