From bdc06dcbc41a9af074e83e0957ae73afd704c145 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Mon, 3 Apr 2023 16:03:46 +0200 Subject: [PATCH] feat(snapshot): introduce `toMatchFileSnapshot` and auto queuing expect promise (#3116) --- docs/api/expect.md | 16 +++++++ docs/guide/snapshot.md | 17 +++++++ packages/browser/src/client/snapshot.ts | 4 ++ packages/expect/src/jest-expect.ts | 11 ++++- packages/expect/src/utils.ts | 18 ++++++++ packages/runner/src/index.ts | 1 + packages/runner/src/run.ts | 21 +++++++-- packages/runner/src/types/tasks.ts | 4 ++ packages/snapshot/src/client.ts | 31 ++++++++++++- packages/snapshot/src/env/node.ts | 8 +++- packages/snapshot/src/manager.ts | 8 +++- packages/snapshot/src/port/rawSnapshot.ts | 22 ++++++++++ packages/snapshot/src/port/state.ts | 44 +++++++++++++++---- packages/snapshot/src/port/utils.ts | 18 ++++++++ packages/snapshot/src/types/environment.ts | 1 + packages/snapshot/src/types/index.ts | 2 + packages/vitest/src/api/setup.ts | 3 ++ packages/vitest/src/api/types.ts | 1 + .../vitest/src/integrations/chai/index.ts | 6 ++- .../vitest/src/integrations/snapshot/chai.ts | 25 +++++++++++ packages/vitest/src/types/global.ts | 5 ++- test/core/test/jest-expect.test.ts | 29 +++++++++++- test/snapshots/test/fixtures/basic/input.json | 8 ++++ test/snapshots/test/fixtures/basic/output.css | 3 ++ .../test/fixtures/multiple/input.json | 15 +++++++ .../test/fixtures/multiple/output.css | 7 +++ test/snapshots/test/shapshots-file.test.ts | 20 +++++++++ ...-async.test.ts => snapshots-async.test.ts} | 0 28 files changed, 325 insertions(+), 23 deletions(-) create mode 100644 packages/expect/src/utils.ts create mode 100644 packages/snapshot/src/port/rawSnapshot.ts create mode 100644 test/snapshots/test/fixtures/basic/input.json create mode 100644 test/snapshots/test/fixtures/basic/output.css create mode 100644 test/snapshots/test/fixtures/multiple/input.json create mode 100644 test/snapshots/test/fixtures/multiple/output.css create mode 100644 test/snapshots/test/shapshots-file.test.ts rename test/snapshots/test/{snapshot-async.test.ts => snapshots-async.test.ts} (100%) 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/browser/src/client/snapshot.ts b/packages/browser/src/client/snapshot.ts index dee67a9f3423..d1ca90262163 100644 --- a/packages/browser/src/client/snapshot.ts +++ b/packages/browser/src/client/snapshot.ts @@ -22,6 +22,10 @@ export class BrowserSnapshotEnvironment implements SnapshotEnvironment { return rpc().resolveSnapshotPath(filepath) } + resolveRawPath(testPath: string, rawPath: string): Promise { + return rpc().resolveSnapshotRawPath(testPath, rawPath) + } + removeSnapshotFile(filepath: string): Promise { return rpc().removeFile(filepath) } diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index d972ccffb6a5..c9b4e8b84a76 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -8,6 +8,7 @@ import { arrayBufferEquality, generateToBeMessage, iterableEquality, equals as j import type { AsymmetricMatcher } from './jest-asymmetric-matchers' import { diff, stringify } from './jest-matcher-utils' import { JEST_MATCHERS_OBJECT } from './constants' +import { recordAsyncExpect } from './utils' // Jest Expect Compact export const JestChaiExpect: ChaiPlugin = (chai, utils) => { @@ -633,6 +634,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { utils.addProperty(chai.Assertion.prototype, 'resolves', function __VITEST_RESOLVES__(this: any) { utils.flag(this, 'promise', 'resolves') utils.flag(this, 'error', new Error('resolves')) + const test = utils.flag(this, 'vitest-test') const obj = utils.flag(this, 'object') if (typeof obj?.then !== 'function') @@ -646,7 +648,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return result instanceof chai.Assertion ? proxy : result return async (...args: any[]) => { - return obj.then( + const promise = obj.then( (value: any) => { utils.flag(this, 'object', value) return result.call(this, ...args) @@ -655,6 +657,8 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { throw new Error(`promise rejected "${String(err)}" instead of resolving`) }, ) + + return recordAsyncExpect(test, promise) } }, }) @@ -665,6 +669,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { utils.addProperty(chai.Assertion.prototype, 'rejects', function __VITEST_REJECTS__(this: any) { utils.flag(this, 'promise', 'rejects') utils.flag(this, 'error', new Error('rejects')) + const test = utils.flag(this, 'vitest-test') const obj = utils.flag(this, 'object') const wrapper = typeof obj === 'function' ? obj() : obj // for jest compat @@ -679,7 +684,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return result instanceof chai.Assertion ? proxy : result return async (...args: any[]) => { - return wrapper.then( + const promise = wrapper.then( (value: any) => { throw new Error(`promise resolved "${String(value)}" instead of rejecting`) }, @@ -688,6 +693,8 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return result.call(this, ...args) }, ) + + return recordAsyncExpect(test, promise) } }, }) diff --git a/packages/expect/src/utils.ts b/packages/expect/src/utils.ts new file mode 100644 index 000000000000..2ce8dc563c1f --- /dev/null +++ b/packages/expect/src/utils.ts @@ -0,0 +1,18 @@ +export function recordAsyncExpect(test: any, promise: Promise) { + // record promise for test, that resolves before test ends + if (test) { + // if promise is explicitly awaited, remove it from the list + promise = promise.finally(() => { + const index = test.promises.indexOf(promise) + if (index !== -1) + test.promises.splice(index, 1) + }) + + // record promise + if (!test.promises) + test.promises = [] + test.promises.push(promise) + } + + return promise +} diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts index 8860e67d0e9c..841356efe293 100644 --- a/packages/runner/src/index.ts +++ b/packages/runner/src/index.ts @@ -2,4 +2,5 @@ export { startTests, updateTask } from './run' export { test, it, describe, suite, getCurrentSuite } from './suite' export { beforeAll, beforeEach, afterAll, afterEach, onTestFailed } from './hooks' export { setFn, getFn } from './map' +export { getCurrentTest } from './test-state' export * from './types' diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index 252514d07264..ec3f0aeb1d89 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -145,6 +145,14 @@ export async function runTest(test: Test, runner: VitestRunner) { await fn() } + // some async expect will be added to this array, in case user forget to await theme + if (test.promises) { + const result = await Promise.allSettled(test.promises) + const errors = result.map(r => r.status === 'rejected' ? r.reason : undefined).filter(Boolean) + if (errors.length) + throw errors + } + await runner.onAfterTryTest?.(test, retryCount) test.result.state = 'pass' @@ -197,10 +205,15 @@ export async function runTest(test: Test, runner: VitestRunner) { function failTask(result: TaskResult, err: unknown, runner: VitestRunner) { result.state = 'fail' - const error = processError(err, runner.config) - result.error = error - result.errors ??= [] - result.errors.push(error) + const errors = Array.isArray(err) + ? err + : [err] + for (const e of errors) { + const error = processError(e, runner.config) + result.error ??= error + result.errors ??= [] + result.errors.push(error) + } } function markTasksAsSkipped(suite: Suite, runner: VitestRunner) { diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 2a5719c29a66..0edf16df69f9 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -59,6 +59,10 @@ export interface Test extends TaskBase { fails?: boolean context: TestContext & ExtraContext onFailed?: OnTestFailedHandler[] + /** + * Store promises (from async expects) to wait for them before finishing the test + */ + promises?: Promise[] } export type Task = Test | Suite | TaskCustom | File 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/manager.ts b/packages/snapshot/src/manager.ts index dd3a98cc2d22..31482627be2d 100644 --- a/packages/snapshot/src/manager.ts +++ b/packages/snapshot/src/manager.ts @@ -1,4 +1,4 @@ -import { basename, dirname, join } from 'pathe' +import { basename, dirname, isAbsolute, join, resolve } from 'pathe' import type { SnapshotResult, SnapshotStateOptions, SnapshotSummary } from './types' export class SnapshotManager { @@ -28,6 +28,12 @@ export class SnapshotManager { return resolver(testPath, this.extension) } + + resolveRawPath(testPath: string, rawPath: string) { + return isAbsolute(rawPath) + ? rawPath + : resolve(dirname(testPath), rawPath) + } } export function emptySummary(options: Omit): SnapshotSummary { 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/api/setup.ts b/packages/vitest/src/api/setup.ts index 665c8af26e4b..04ee02dc3cd2 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -61,6 +61,9 @@ export function setup(ctx: Vitest, server?: ViteDevServer) { resolveSnapshotPath(testPath) { return ctx.snapshot.resolvePath(testPath) }, + resolveSnapshotRawPath(testPath, rawPath) { + return ctx.snapshot.resolveRawPath(testPath, rawPath) + }, removeFile(id) { return fs.unlink(id) }, diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index b50a30470eb9..feb11a392707 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -15,6 +15,7 @@ export interface WebSocketHandlers { getPaths(): string[] getConfig(): ResolvedConfig resolveSnapshotPath(testPath: string): string + resolveSnapshotRawPath(testPath: string, rawPath: string): string getModuleGraph(id: string): Promise getTransformResult(id: string): Promise readFile(id: string): Promise diff --git a/packages/vitest/src/integrations/chai/index.ts b/packages/vitest/src/integrations/chai/index.ts index 81bd78570ad8..acae961d23d1 100644 --- a/packages/vitest/src/integrations/chai/index.ts +++ b/packages/vitest/src/integrations/chai/index.ts @@ -1,6 +1,7 @@ import * as chai from 'chai' import './setup' import type { Test } from '@vitest/runner' +import { getCurrentTest } from '@vitest/runner' import { GLOBAL_EXPECT, getState, setState } from '@vitest/expect' import type { MatcherState } from '../../types/chai' import { getCurrentEnvironment, getFullName } from '../../utils' @@ -10,9 +11,10 @@ export function createExpect(test?: Test) { const { assertionCalls } = getState(expect) setState({ assertionCalls: assertionCalls + 1 }, expect) const assert = chai.expect(value, message) as unknown as Vi.Assertion - if (test) + const _test = test || getCurrentTest() + if (_test) // @ts-expect-error internal - return assert.withTest(test) as Vi.Assertion + return assert.withTest(_test) as Vi.Assertion else return assert }) as Vi.ExpectStatic diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index fdfb5e204d62..3ba55b0fe71a 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -3,6 +3,7 @@ import type { Test } from '@vitest/runner' import { getNames } from '@vitest/runner/utils' import type { SnapshotClient } from '@vitest/snapshot' import { addSerializer, stripSnapshotIndentation } from '@vitest/snapshot' +import { recordAsyncExpect } from '../../../../expect/src/utils' import { VitestSnapshotClient } from './client' let _client: SnapshotClient @@ -72,6 +73,30 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { }, ) } + + utils.addMethod( + chai.Assertion.prototype, + 'toMatchFileSnapshot', + function (this: Record, file: string, message?: string) { + const expected = utils.flag(this, 'object') + const test = utils.flag(this, 'vitest-test') as Test + const errorMessage = utils.flag(this, 'message') + + const promise = getSnapshotClient().assertRaw({ + received: expected, + message, + isInline: false, + rawSnapshot: { + file, + }, + errorMessage, + ...getTestNames(test), + }) + + return recordAsyncExpect(test, promise) + }, + ) + 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/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index cfb35980ca30..9692e7f2a9af 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -544,7 +544,7 @@ describe('async expect', () => { })()).resolves.not.toThrow(Error) }) - it('resolves trows chai', async () => { + it('resolves throws chai', async () => { const assertion = async () => { await expect((async () => new Error('msg'))()).resolves.toThrow() } @@ -552,7 +552,7 @@ describe('async expect', () => { await expect(assertion).rejects.toThrowError('expected promise to throw an error, but it didn\'t') }) - it('resolves trows jest', async () => { + it('resolves throws jest', async () => { const assertion = async () => { await expect((async () => new Error('msg'))()).resolves.toThrow(Error) } @@ -679,6 +679,31 @@ describe('async expect', () => { expect(error).toEqual(toEqualError2) } }) + + describe('promise auto queuing', () => { + it.fails('fails', () => { + expect(() => new Promise((resolve, reject) => setTimeout(reject, 500))) + .resolves + .toBe('true') + }) + + let value = 0 + + it('pass first', () => { + expect((async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + value += 1 + return value + })()) + .resolves + .toBe(1) + }) + + it('pass second', () => { + // even if 'pass first' is sync, we will still wait the expect to resolve + expect(value).toBe(1) + }) + }) }) it('compatible with jest', () => { 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..dafb3c69ccd3 --- /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[] + 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