diff --git a/docs/api/expect.md b/docs/api/expect.md index c5a3e4171d81..924bdc111a9b 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -678,6 +678,22 @@ type Awaitable = T | PromiseLike }) ``` +## toMatchFileSnapshot + +- **Type:** `(filepath: string, message?: string) => Promise` + + Compare or update the snapshot with the content of a file explicitly specified (instead of the `.snap` file). + + ```ts + import { expect, it } from 'vitest' + + it('render basic', async () => { + const result = renderHTML(h('div', { class: 'foo' })) + await expect(result).toMatchFileSnapshot('./test/basic.output.html') + }) + ``` + + Note that since file system operation is async, you need to use `await` with `toMatchFileSnapshot()`. ## toThrowErrorMatchingSnapshot diff --git a/docs/guide/snapshot.md b/docs/guide/snapshot.md index 06618efcc159..67f6dec890c8 100644 --- a/docs/guide/snapshot.md +++ b/docs/guide/snapshot.md @@ -79,6 +79,23 @@ Or you can use the `--update` or `-u` flag in the CLI to make Vitest update snap vitest -u ``` +## File Snapshots + +When calling `toMatchSnapshot()`, we store all snapshots in a formatted snap file. That means we need to escaping some characters (namely the double-quote `"` and backtick `\``) in the snapshot string. Meanwhile, you might lose the syntax highlighting for the snapshot content (if they are in some language). + +To improve this case, we introduce [`toMatchFileSnapshot()`](/api/expect#tomatchfilesnapshot) to explicitly snapshot in a file. This allows you to assign any file extension to the snapshot file, and making them more readable. + +```ts +import { expect, it } from 'vitest' + +it('render basic', async () => { + const result = renderHTML(h('div', { class: 'foo' })) + await expect(result).toMatchFileSnapshot('./test/basic.output.html') +}) +``` + +It will compare with the content of `./test/basic.output.html`. And can be written back with the `--update` flag. + ## Image Snapshots It's also possible to snapshot images using [`jest-image-snapshot`](https://github.com/americanexpress/jest-image-snapshot). diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index 713589591013..fcd546cafd1e 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -1,6 +1,7 @@ import { deepMergeSnapshot } from './port/utils' import SnapshotState from './port/state' import type { SnapshotStateOptions } from './types' +import type { RawSnapshotInfo } from './port/rawSnapshot' const createMismatchError = (message: string, actual: unknown, expected: unknown) => { const error = new Error(message) @@ -35,6 +36,7 @@ interface AssertOptions { inlineSnapshot?: string error?: Error errorMessage?: string + rawSnapshot?: RawSnapshotInfo } export class SnapshotClient { @@ -79,7 +81,7 @@ export class SnapshotClient { } /** - * Should be overriden by the consumer. + * Should be overridden by the consumer. * * Vitest checks equality with @vitest/expect. */ @@ -97,6 +99,7 @@ export class SnapshotClient { inlineSnapshot, error, errorMessage, + rawSnapshot, } = options let { received } = options @@ -134,12 +137,38 @@ export class SnapshotClient { isInline, error, inlineSnapshot, + rawSnapshot, }) if (!pass) throw createMismatchError(`Snapshot \`${key || 'unknown'}\` mismatched`, actual?.trim(), expected?.trim()) } + async assertRaw(options: AssertOptions): Promise { + if (!options.rawSnapshot) + throw new Error('Raw snapshot is required') + + const { + filepath = this.filepath, + rawSnapshot, + } = options + + if (rawSnapshot.content == null) { + if (!filepath) + throw new Error('Snapshot cannot be used outside of test') + + const snapshotState = this.getSnapshotState(filepath) + + // save the filepath, so it don't lose even if the await make it out-of-context + options.filepath ||= filepath + // resolve and read the raw snapshot file + rawSnapshot.file = await snapshotState.environment.resolveRawPath(filepath, rawSnapshot.file) + rawSnapshot.content = await snapshotState.environment.readSnapshotFile(rawSnapshot.file) || undefined + } + + return this.assert(options) + } + async resetCurrent() { if (!this.snapshotState) return null diff --git a/packages/snapshot/src/env/node.ts b/packages/snapshot/src/env/node.ts index 856845cde18a..8774dd78a55e 100644 --- a/packages/snapshot/src/env/node.ts +++ b/packages/snapshot/src/env/node.ts @@ -1,5 +1,5 @@ import { existsSync, promises as fs } from 'node:fs' -import { basename, dirname, join } from 'pathe' +import { basename, dirname, isAbsolute, join, resolve } from 'pathe' import type { SnapshotEnvironment } from '../types' export class NodeSnapshotEnvironment implements SnapshotEnvironment { @@ -11,6 +11,12 @@ export class NodeSnapshotEnvironment implements SnapshotEnvironment { return `// Snapshot v${this.getVersion()}` } + async resolveRawPath(testPath: string, rawPath: string) { + return isAbsolute(rawPath) + ? rawPath + : resolve(dirname(testPath), rawPath) + } + async resolvePath(filepath: string): Promise { return join( join( diff --git a/packages/snapshot/src/port/rawSnapshot.ts b/packages/snapshot/src/port/rawSnapshot.ts new file mode 100644 index 000000000000..5863c0d9158f --- /dev/null +++ b/packages/snapshot/src/port/rawSnapshot.ts @@ -0,0 +1,22 @@ +import type { SnapshotEnvironment } from '../types' + +export interface RawSnapshotInfo { + file: string + readonly?: boolean + content?: string +} + +export interface RawSnapshot extends RawSnapshotInfo { + snapshot: string + file: string +} + +export async function saveRawSnapshots( + environment: SnapshotEnvironment, + snapshots: Array, +) { + await Promise.all(snapshots.map(async (snap) => { + if (!snap.readonly) + await environment.saveSnapshotFile(snap.file, snap.snapshot) + })) +} diff --git a/packages/snapshot/src/port/state.ts b/packages/snapshot/src/port/state.ts index 5f31eba79262..175e589135b5 100644 --- a/packages/snapshot/src/port/state.ts +++ b/packages/snapshot/src/port/state.ts @@ -11,6 +11,8 @@ import type { OptionsReceived as PrettyFormatOptions } from 'pretty-format' import type { SnapshotData, SnapshotEnvironment, SnapshotMatchOptions, SnapshotResult, SnapshotStateOptions, SnapshotUpdateState } from '../types' import type { InlineSnapshot } from './inlineSnapshot' import { saveInlineSnapshots } from './inlineSnapshot' +import type { RawSnapshot, RawSnapshotInfo } from './rawSnapshot' +import { saveRawSnapshots } from './rawSnapshot' import { addExtraLineBreaks, @@ -43,6 +45,7 @@ export default class SnapshotState { private _snapshotData: SnapshotData private _initialData: SnapshotData private _inlineSnapshots: Array + private _rawSnapshots: Array private _uncheckedKeys: Set private _snapshotFormat: PrettyFormatOptions private _environment: SnapshotEnvironment @@ -69,6 +72,7 @@ export default class SnapshotState { this._snapshotData = data this._dirty = dirty this._inlineSnapshots = [] + this._rawSnapshots = [] this._uncheckedKeys = new Set(Object.keys(this._snapshotData)) this._counters = new Map() this.expand = options.expand || false @@ -93,6 +97,10 @@ export default class SnapshotState { return new SnapshotState(testFilePath, snapshotPath, content, options) } + get environment() { + return this._environment + } + markSnapshotsAsCheckedForTest(testName: string): void { this._uncheckedKeys.forEach((uncheckedKey) => { if (keyToTestName(uncheckedKey) === testName) @@ -115,7 +123,7 @@ export default class SnapshotState { private _addSnapshot( key: string, receivedSerialized: string, - options: { isInline: boolean; error?: Error }, + options: { isInline: boolean; rawSnapshot?: RawSnapshotInfo; error?: Error }, ): void { this._dirty = true if (options.isInline) { @@ -135,6 +143,12 @@ export default class SnapshotState { ...stack, }) } + else if (options.rawSnapshot) { + this._rawSnapshots.push({ + ...options.rawSnapshot, + snapshot: receivedSerialized, + }) + } else { this._snapshotData[key] = receivedSerialized } @@ -154,7 +168,8 @@ export default class SnapshotState { async save(): Promise { const hasExternalSnapshots = Object.keys(this._snapshotData).length const hasInlineSnapshots = this._inlineSnapshots.length - const isEmpty = !hasExternalSnapshots && !hasInlineSnapshots + const hasRawSnapshots = this._rawSnapshots.length + const isEmpty = !hasExternalSnapshots && !hasInlineSnapshots && !hasRawSnapshots const status: SaveStatus = { deleted: false, @@ -168,6 +183,8 @@ export default class SnapshotState { } if (hasInlineSnapshots) await saveInlineSnapshots(this._environment, this._inlineSnapshots) + if (hasRawSnapshots) + await saveRawSnapshots(this._environment, this._rawSnapshots) status.saved = true } @@ -206,6 +223,7 @@ export default class SnapshotState { inlineSnapshot, isInline, error, + rawSnapshot, }: SnapshotMatchOptions): SnapshotReturnOptions { this._counters.set(testName, (this._counters.get(testName) || 0) + 1) const count = Number(this._counters.get(testName)) @@ -219,14 +237,24 @@ export default class SnapshotState { if (!(isInline && this._snapshotData[key] !== undefined)) this._uncheckedKeys.delete(key) - const receivedSerialized = addExtraLineBreaks(serialize(received, undefined, this._snapshotFormat)) - const expected = isInline ? inlineSnapshot : this._snapshotData[key] + let receivedSerialized = rawSnapshot && typeof received === 'string' + ? received as string + : serialize(received, undefined, this._snapshotFormat) + + if (!rawSnapshot) + receivedSerialized = addExtraLineBreaks(receivedSerialized) + + const expected = isInline + ? inlineSnapshot + : rawSnapshot + ? rawSnapshot.content + : this._snapshotData[key] const expectedTrimmed = prepareExpected(expected) const pass = expectedTrimmed === prepareExpected(receivedSerialized) const hasSnapshot = expected !== undefined - const snapshotIsPersisted = isInline || this._fileExists + const snapshotIsPersisted = isInline || this._fileExists || (rawSnapshot && rawSnapshot.content != null) - if (pass && !isInline) { + if (pass && !isInline && !rawSnapshot) { // Executing a snapshot file as JavaScript and writing the strings back // when other snapshots have changed loses the proper escaping for some // characters. Since we check every snapshot in every test, use the newly @@ -255,14 +283,14 @@ export default class SnapshotState { else this.added++ - this._addSnapshot(key, receivedSerialized, { error, isInline }) + this._addSnapshot(key, receivedSerialized, { error, isInline, rawSnapshot }) } else { this.matched++ } } else { - this._addSnapshot(key, receivedSerialized, { error, isInline }) + this._addSnapshot(key, receivedSerialized, { error, isInline, rawSnapshot }) this.added++ } diff --git a/packages/snapshot/src/port/utils.ts b/packages/snapshot/src/port/utils.ts index f3e88fdb409b..bcde3c6c7c56 100644 --- a/packages/snapshot/src/port/utils.ts +++ b/packages/snapshot/src/port/utils.ts @@ -163,6 +163,24 @@ export async function saveSnapshotFile( ) } +export async function saveSnapshotFileRaw( + environment: SnapshotEnvironment, + content: string, + snapshotPath: string, +) { + const oldContent = await environment.readSnapshotFile(snapshotPath) + const skipWriting = oldContent && oldContent === content + + if (skipWriting) + return + + await ensureDirectoryExists(environment, snapshotPath) + await environment.saveSnapshotFile( + snapshotPath, + content, + ) +} + export function prepareExpected(expected?: string) { function findStartIndent() { // Attempts to find indentation for objects. diff --git a/packages/snapshot/src/types/environment.ts b/packages/snapshot/src/types/environment.ts index f0d20c6c529f..044f633dfbb3 100644 --- a/packages/snapshot/src/types/environment.ts +++ b/packages/snapshot/src/types/environment.ts @@ -2,6 +2,7 @@ export interface SnapshotEnvironment { getVersion(): string getHeader(): string resolvePath(filepath: string): Promise + resolveRawPath(testPath: string, rawPath: string): Promise prepareDirectory(filepath: string): Promise saveSnapshotFile(filepath: string, snapshot: string): Promise readSnapshotFile(filepath: string): Promise diff --git a/packages/snapshot/src/types/index.ts b/packages/snapshot/src/types/index.ts index af2d33b814c1..53e3480410e2 100644 --- a/packages/snapshot/src/types/index.ts +++ b/packages/snapshot/src/types/index.ts @@ -1,4 +1,5 @@ import type { OptionsReceived as PrettyFormatOptions } from 'pretty-format' +import type { RawSnapshotInfo } from '../port/rawSnapshot' import type { SnapshotEnvironment } from './environment' export type { SnapshotEnvironment } @@ -21,6 +22,7 @@ export interface SnapshotMatchOptions { inlineSnapshot?: string isInline: boolean error?: Error + rawSnapshot?: RawSnapshotInfo } export interface SnapshotResult { diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index fdfb5e204d62..32020f658c87 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -72,6 +72,28 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { }, ) } + + utils.addMethod( + chai.Assertion.prototype, + 'toMatchFileSnapshot', + async function (this: Record, file: string, message?: string) { + const expected = utils.flag(this, 'object') + const test = utils.flag(this, 'vitest-test') + const errorMessage = utils.flag(this, 'message') + + await getSnapshotClient().assertRaw({ + received: expected, + message, + isInline: false, + rawSnapshot: { + file, + }, + errorMessage, + ...getTestNames(test), + }) + }, + ) + utils.addMethod( chai.Assertion.prototype, 'toMatchInlineSnapshot', diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts index 004f61ab63f9..d2d09e6261bb 100644 --- a/packages/vitest/src/types/global.ts +++ b/packages/vitest/src/types/global.ts @@ -66,12 +66,13 @@ declare global { interface JestAssertion extends jest.Matchers { // Snapshot - toMatchSnapshot(snapshot: Partial, message?: string): void - toMatchSnapshot(message?: string): void matchSnapshot(snapshot: Partial, message?: string): void matchSnapshot(message?: string): void + toMatchSnapshot(snapshot: Partial, message?: string): void + toMatchSnapshot(message?: string): void toMatchInlineSnapshot(properties: Partial, snapshot?: string, message?: string): void toMatchInlineSnapshot(snapshot?: string, message?: string): void + toMatchFileSnapshot(filepath: string, message?: string): Promise toThrowErrorMatchingSnapshot(message?: string): void toThrowErrorMatchingInlineSnapshot(snapshot?: string, message?: string): void diff --git a/test/snapshots/test/fixtures/basic/input.json b/test/snapshots/test/fixtures/basic/input.json new file mode 100644 index 000000000000..9b957607e19f --- /dev/null +++ b/test/snapshots/test/fixtures/basic/input.json @@ -0,0 +1,8 @@ +[ + [ + ".name", + { + "color": "red" + } + ] +] diff --git a/test/snapshots/test/fixtures/basic/output.css b/test/snapshots/test/fixtures/basic/output.css new file mode 100644 index 000000000000..f9b012cf6f56 --- /dev/null +++ b/test/snapshots/test/fixtures/basic/output.css @@ -0,0 +1,3 @@ +.name { + color: red; +} \ No newline at end of file diff --git a/test/snapshots/test/fixtures/multiple/input.json b/test/snapshots/test/fixtures/multiple/input.json new file mode 100644 index 000000000000..06082ebda97a --- /dev/null +++ b/test/snapshots/test/fixtures/multiple/input.json @@ -0,0 +1,15 @@ +[ + [ + ".text-red", + { + "color": "red" + } + ], + [ + ".text-lg", + { + "font-size": "1.25rem", + "line-height": "1.75rem" + } + ] +] diff --git a/test/snapshots/test/fixtures/multiple/output.css b/test/snapshots/test/fixtures/multiple/output.css new file mode 100644 index 000000000000..679bc603ecc3 --- /dev/null +++ b/test/snapshots/test/fixtures/multiple/output.css @@ -0,0 +1,7 @@ +.text-red { + color: red; +} +.text-lg { + font-size: 1.25rem; + line-height: 1.75rem; +} \ No newline at end of file diff --git a/test/snapshots/test/shapshots-file.test.ts b/test/snapshots/test/shapshots-file.test.ts new file mode 100644 index 000000000000..4752b6981fa1 --- /dev/null +++ b/test/snapshots/test/shapshots-file.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from 'vitest' + +function objectToCSS(selector: string, obj: Record) { + const body = Object.entries(obj) + .map(([key, value]) => ` ${key}: ${value};`) + .join('\n') + return `${selector} {\n${body}\n}` +} + +describe('snapshots', () => { + const files = import.meta.glob('./fixtures/**/input.json', { as: 'raw' }) + + for (const [path, file] of Object.entries(files)) { + test(path, async () => { + const entries = JSON.parse(await file()) as any[] + await expect(entries.map(i => objectToCSS(i[0], i[1])).join('\n')) + .toMatchFileSnapshot(path.replace('input.json', 'output.css')) + }) + } +}) diff --git a/test/snapshots/test/snapshot-async.test.ts b/test/snapshots/test/snapshots-async.test.ts similarity index 100% rename from test/snapshots/test/snapshot-async.test.ts rename to test/snapshots/test/snapshots-async.test.ts