diff --git a/packages/expect/README.md b/packages/expect/README.md new file mode 100644 index 00000000000..da5d16d8cd4 --- /dev/null +++ b/packages/expect/README.md @@ -0,0 +1,17 @@ +# @vitest/expect + +Jest's expect matchers as a Chai plugin. + +## Usage + +```js +import * as chai from 'chai' +import { JestAsymmetricMatchers, JestChaiExpect, JestExtend } from '@vitest/expect' + +// allows using expect.extend instead of chai.use to extend plugins +chai.use(JestExtend) +// adds all jest matchers to expect +chai.use(JestChaiExpect) +// adds asymmetric matchers like stringContaining, objectContaining +chai.use(JestAsymmetricMatchers) +``` diff --git a/packages/expect/package.json b/packages/expect/package.json new file mode 100644 index 00000000000..9e28a90d4ce --- /dev/null +++ b/packages/expect/package.json @@ -0,0 +1,37 @@ +{ + "name": "@vitest/expect", + "type": "module", + "version": "0.26.2", + "description": "Jest's expect matchers as a Chai plugin", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/vitest-dev/vitest.git", + "directory": "packages/expect" + }, + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./*": "./*" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rimraf dist && rollup -c", + "dev": "rollup -c --watch", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "@vitest/spy": "workspace:*", + "@vitest/utils": "workspace:*", + "chai": "^4.3.7", + "picocolors": "^1.0.0" + } +} diff --git a/packages/expect/rollup.config.js b/packages/expect/rollup.config.js new file mode 100644 index 00000000000..fddfda5974c --- /dev/null +++ b/packages/expect/rollup.config.js @@ -0,0 +1,51 @@ +import { builtinModules } from 'module' +import esbuild from 'rollup-plugin-esbuild' +import dts from 'rollup-plugin-dts' +import { defineConfig } from 'rollup' +import pkg from './package.json' + +const external = [ + ...builtinModules, + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), +] + +const plugins = [ + esbuild({ + target: 'node14', + }), +] + +export default defineConfig([ + { + input: 'src/index.ts', + output: { + dir: 'dist', + format: 'esm', + entryFileNames: '[name].js', + chunkFileNames: 'chunk-[name].js', + }, + external, + plugins, + onwarn, + }, + { + input: 'src/index.ts', + output: { + dir: 'dist', + entryFileNames: '[name].d.ts', + format: 'esm', + }, + external, + plugins: [ + dts({ respectExternal: true }), + ], + onwarn, + }, +]) + +function onwarn(message) { + if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code)) + return + console.error(message) +} diff --git a/packages/vitest/src/integrations/chai/constants.ts b/packages/expect/src/constants.ts similarity index 100% rename from packages/vitest/src/integrations/chai/constants.ts rename to packages/expect/src/constants.ts index 24fa9ad16e0..97a47089fd1 100644 --- a/packages/vitest/src/integrations/chai/constants.ts +++ b/packages/expect/src/constants.ts @@ -1,3 +1,3 @@ -export const GLOBAL_EXPECT = Symbol.for('expect-global') export const MATCHERS_OBJECT = Symbol.for('matchers-object') export const JEST_MATCHERS_OBJECT = Symbol.for('$$jest-matchers-object') +export const GLOBAL_EXPECT = Symbol.for('expect-global') diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts new file mode 100644 index 00000000000..e472669a1e1 --- /dev/null +++ b/packages/expect/src/index.ts @@ -0,0 +1,7 @@ +export * from './jest-asymmetric-matchers' +export * from './jest-utils' +export * from './constants' +export * from './types' +export { getState, setState } from './state' +export { JestChaiExpect } from './jest-expect' +export { JestExtend } from './jest-extend' diff --git a/packages/vitest/src/integrations/chai/jest-asymmetric-matchers.ts b/packages/expect/src/jest-asymmetric-matchers.ts similarity index 98% rename from packages/vitest/src/integrations/chai/jest-asymmetric-matchers.ts rename to packages/expect/src/jest-asymmetric-matchers.ts index a4a895e0c45..8f8f2cf3210 100644 --- a/packages/vitest/src/integrations/chai/jest-asymmetric-matchers.ts +++ b/packages/expect/src/jest-asymmetric-matchers.ts @@ -1,6 +1,6 @@ -import type { ChaiPlugin, MatcherState } from '../../types/chai' +import type { ChaiPlugin, MatcherState } from './types' import { GLOBAL_EXPECT } from './constants' -import { getState } from './jest-expect' +import { getState } from './state' import * as matcherUtils from './jest-matcher-utils' import { equals, isA } from './jest-utils' diff --git a/packages/vitest/src/integrations/chai/jest-expect.ts b/packages/expect/src/jest-expect.ts similarity index 92% rename from packages/vitest/src/integrations/chai/jest-expect.ts rename to packages/expect/src/jest-expect.ts index 3c703654755..ed6f0af3d2f 100644 --- a/packages/vitest/src/integrations/chai/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -1,44 +1,14 @@ import c from 'picocolors' import { AssertionError } from 'chai' -import type { EnhancedSpy } from '../spy' -import { isMockFunction } from '../spy' -import { addSerializer } from '../snapshot/port/plugins' -import type { Constructable, Test } from '../../types' -import { assertTypes } from '../../utils' -import { unifiedDiff } from '../../utils/diff' -import type { ChaiPlugin, MatcherState } from '../../types/chai' +import { assertTypes, unifiedDiff } from '@vitest/utils' +import type { Constructable } from '@vitest/utils' +import type { EnhancedSpy } from '@vitest/spy' +import { isMockFunction } from '@vitest/spy' +import type { ChaiPlugin } from './types' import { arrayBufferEquality, generateToBeMessage, iterableEquality, equals as jestEquals, sparseArrayEquality, subsetEquality, typeEquality } from './jest-utils' import type { AsymmetricMatcher } from './jest-asymmetric-matchers' import { stringify } from './jest-matcher-utils' -import { GLOBAL_EXPECT, JEST_MATCHERS_OBJECT, MATCHERS_OBJECT } from './constants' - -if (!Object.prototype.hasOwnProperty.call(globalThis, MATCHERS_OBJECT)) { - const globalState = new WeakMap() - const matchers = Object.create(null) - Object.defineProperty(globalThis, MATCHERS_OBJECT, { - get: () => globalState, - }) - Object.defineProperty(globalThis, JEST_MATCHERS_OBJECT, { - configurable: true, - get: () => ({ - state: globalState.get((globalThis as any)[GLOBAL_EXPECT]), - matchers, - }), - }) -} - -export const getState = (expect: Vi.ExpectStatic): State => - (globalThis as any)[MATCHERS_OBJECT].get(expect) - -export const setState = ( - state: Partial, - expect: Vi.ExpectStatic, -): void => { - const map = (globalThis as any)[MATCHERS_OBJECT] - const current = map.get(expect) || {} - Object.assign(current, state) - map.set(expect, current) -} +import { JEST_MATCHERS_OBJECT } from './constants' // Jest Expect Compact export const JestChaiExpect: ChaiPlugin = (chai, utils) => { @@ -681,7 +651,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return result.call(this, ...args) }, (err: any) => { - throw new Error(`promise rejected "${toString(err)}" instead of resolving`) + throw new Error(`promise rejected "${String(err)}" instead of resolving`) }, ) } @@ -710,7 +680,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return async (...args: any[]) => { return wrapper.then( (value: any) => { - throw new Error(`promise resolved "${toString(value)}" instead of rejecting`) + throw new Error(`promise resolved "${String(value)}" instead of rejecting`) }, (err: any) => { utils.flag(this, 'object', err) @@ -723,19 +693,4 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return proxy }) - - utils.addMethod( - chai.expect, - 'addSnapshotSerializer', - addSerializer, - ) -} - -function toString(value: any) { - try { - return `${value}` - } - catch (_error) { - return 'unknown' - } } diff --git a/packages/vitest/src/integrations/chai/jest-extend.ts b/packages/expect/src/jest-extend.ts similarity index 95% rename from packages/vitest/src/integrations/chai/jest-extend.ts rename to packages/expect/src/jest-extend.ts index bbe0b0eafd8..0c2e2608c73 100644 --- a/packages/vitest/src/integrations/chai/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -4,11 +4,10 @@ import type { MatcherState, MatchersObject, SyncExpectationResult, -} from '../../types/chai' -import { getSnapshotClient } from '../snapshot/chai' +} from './types' import { JEST_MATCHERS_OBJECT } from './constants' import { AsymmetricMatcher } from './jest-asymmetric-matchers' -import { getState } from './jest-expect' +import { getState } from './state' import * as matcherUtils from './jest-matcher-utils' @@ -39,7 +38,6 @@ const getMatcherState = (assertion: Chai.AssertionStatic & Chai.Assertion, expec equals, // needed for built-in jest-snapshots, but we don't use it suppressedErrors: [], - snapshotState: getSnapshotClient().snapshotState!, } return { diff --git a/packages/vitest/src/integrations/chai/jest-matcher-utils.ts b/packages/expect/src/jest-matcher-utils.ts similarity index 65% rename from packages/vitest/src/integrations/chai/jest-matcher-utils.ts rename to packages/expect/src/jest-matcher-utils.ts index 48b358aa173..21902c5c2d0 100644 --- a/packages/vitest/src/integrations/chai/jest-matcher-utils.ts +++ b/packages/expect/src/jest-matcher-utils.ts @@ -1,11 +1,8 @@ -// we are using only the ones needed by @testing-library/jest-dom -// if you need more, just ask - import c from 'picocolors' -import type { PrettyFormatOptions } from 'pretty-format' -import { format as prettyFormat, plugins as prettyFormatPlugins } from 'pretty-format' -import { unifiedDiff } from '../../utils/diff' -import type { DiffOptions, MatcherHintOptions } from '../../types/matcher-utils' +import { stringify, unifiedDiff } from '@vitest/utils' +import type { DiffOptions, MatcherHintOptions } from './types' + +export { stringify } export const EXPECTED_COLOR = c.green export const RECEIVED_COLOR = c.red @@ -13,24 +10,6 @@ export const INVERTED_COLOR = c.inverse export const BOLD_WEIGHT = c.bold export const DIM_COLOR = c.dim -const { - AsymmetricMatcher, - DOMCollection, - DOMElement, - Immutable, - ReactElement, - ReactTestComponent, -} = prettyFormatPlugins - -const PLUGINS = [ - ReactTestComponent, - ReactElement, - DOMElement, - DOMCollection, - Immutable, - AsymmetricMatcher, -] - export function matcherHint( matcherName: string, received = 'received', @@ -39,12 +18,12 @@ export function matcherHint( ) { const { comment = '', - expectedColor = EXPECTED_COLOR, isDirectExpectCall = false, // seems redundant with received === '' isNot = false, promise = '', - receivedColor = RECEIVED_COLOR, secondArgument = '', + expectedColor = EXPECTED_COLOR, + receivedColor = RECEIVED_COLOR, secondArgumentColor = EXPECTED_COLOR, } = options let hint = '' @@ -102,35 +81,6 @@ const SPACE_SYMBOL = '\u{00B7}' // middle dot const replaceTrailingSpaces = (text: string): string => text.replace(/\s+$/gm, spaces => SPACE_SYMBOL.repeat(spaces.length)) -export function stringify(object: unknown, maxDepth = 10, { maxLength, ...options }: PrettyFormatOptions & { maxLength?: number } = {}): string { - const MAX_LENGTH = maxLength ?? 10000 - let result - - try { - result = prettyFormat(object, { - maxDepth, - escapeString: false, - // min: true, - plugins: PLUGINS, - ...options, - }) - } - catch { - result = prettyFormat(object, { - callToJSON: false, - maxDepth, - escapeString: false, - // min: true, - plugins: PLUGINS, - ...options, - }) - } - - return result.length >= MAX_LENGTH && maxDepth > 1 - ? stringify(object, Math.floor(maxDepth / 2)) - : result -} - export const printReceived = (object: unknown): string => RECEIVED_COLOR(replaceTrailingSpaces(stringify(object))) export const printExpected = (value: unknown): string => diff --git a/packages/vitest/src/integrations/chai/jest-utils.ts b/packages/expect/src/jest-utils.ts similarity index 99% rename from packages/vitest/src/integrations/chai/jest-utils.ts rename to packages/expect/src/jest-utils.ts index 23f0046278c..6dc0d593a2f 100644 --- a/packages/vitest/src/integrations/chai/jest-utils.ts +++ b/packages/expect/src/jest-utils.ts @@ -21,8 +21,9 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { isObject } from '../../utils' -import type { Tester } from '../../types/chai' + +import { isObject } from '@vitest/utils' +import type { Tester } from './types' // Extracted out of jasmine 2.5.2 export function equals( diff --git a/packages/expect/src/state.ts b/packages/expect/src/state.ts new file mode 100644 index 00000000000..aaa0f4301b5 --- /dev/null +++ b/packages/expect/src/state.ts @@ -0,0 +1,30 @@ +import type { MatcherState } from './types' +import { GLOBAL_EXPECT, JEST_MATCHERS_OBJECT, MATCHERS_OBJECT } from './constants' + +if (!Object.prototype.hasOwnProperty.call(globalThis, MATCHERS_OBJECT)) { + const globalState = new WeakMap() + const matchers = Object.create(null) + Object.defineProperty(globalThis, MATCHERS_OBJECT, { + get: () => globalState, + }) + Object.defineProperty(globalThis, JEST_MATCHERS_OBJECT, { + configurable: true, + get: () => ({ + state: globalState.get((globalThis as any)[GLOBAL_EXPECT]), + matchers, + }), + }) +} + +export const getState = (expect: Vi.ExpectStatic): State => + (globalThis as any)[MATCHERS_OBJECT].get(expect) + +export const setState = ( + state: Partial, + expect: Vi.ExpectStatic, +): void => { + const map = (globalThis as any)[MATCHERS_OBJECT] + const current = map.get(expect) || {} + Object.assign(current, state) + map.set(expect, current) +} diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts new file mode 100644 index 00000000000..3ff834b1775 --- /dev/null +++ b/packages/expect/src/types.ts @@ -0,0 +1,95 @@ +import type { use as chaiUse } from 'chai' + +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Formatter } from 'picocolors/types' +import type * as jestMatcherUtils from './jest-matcher-utils' + +export type FirstFunctionArgument = T extends (arg: infer A) => unknown ? A : never +export type ChaiPlugin = FirstFunctionArgument + +export type Tester = (a: any, b: any) => boolean | undefined + +export interface MatcherHintOptions { + comment?: string + expectedColor?: Formatter + isDirectExpectCall?: boolean + isNot?: boolean + promise?: string + receivedColor?: Formatter + secondArgument?: string + secondArgumentColor?: Formatter +} + +export interface DiffOptions { + aAnnotation?: string + aColor?: Formatter + aIndicator?: string + bAnnotation?: string + bColor?: Formatter + bIndicator?: string + changeColor?: Formatter + changeLineTrailingSpaceColor?: Formatter + commonColor?: Formatter + commonIndicator?: string + commonLineTrailingSpaceColor?: Formatter + contextLines?: number + emptyFirstOrLastLinePlaceholder?: string + expand?: boolean + includeChangeCounts?: boolean + omitAnnotationLines?: boolean + patchColor?: Formatter + // pretty-format type + compareKeys?: any +} + +export interface MatcherState { + assertionCalls: number + currentTestName?: string + dontThrow?: () => void + error?: Error + equals: ( + a: unknown, + b: unknown, + customTesters?: Array, + strictCheck?: boolean, + ) => boolean + expand?: boolean + expectedAssertionsNumber?: number | null + expectedAssertionsNumberErrorGen?: (() => Error) | null + isExpectingAssertions?: boolean + isExpectingAssertionsError?: Error | null + isNot: boolean + // environment: VitestEnvironment + promise: string + // snapshotState: SnapshotState + suppressedErrors: Array + testPath?: string + utils: typeof jestMatcherUtils & { + iterableEquality: Tester + subsetEquality: Tester + } +} + +export interface SyncExpectationResult { + pass: boolean + message: () => string + actual?: any + expected?: any +} + +export type AsyncExpectationResult = Promise + +export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult + +export interface RawMatcherFn { + (this: T, received: any, expected: any, options?: any): ExpectationResult +} + +export type MatchersObject = Record> diff --git a/packages/spy/README.md b/packages/spy/README.md new file mode 100644 index 00000000000..5d23c876f4d --- /dev/null +++ b/packages/spy/README.md @@ -0,0 +1,3 @@ +# @vitest/spy + +Lightweight Jest compatible spy implementation. diff --git a/packages/spy/package.json b/packages/spy/package.json new file mode 100644 index 00000000000..0ae85c3443f --- /dev/null +++ b/packages/spy/package.json @@ -0,0 +1,34 @@ +{ + "name": "@vitest/spy", + "type": "module", + "version": "0.26.2", + "description": "Lightweight Jest compatible spy implementation", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/vitest-dev/vitest.git", + "directory": "packages/spy" + }, + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./*": "./*" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rimraf dist && rollup -c", + "dev": "rollup -c --watch", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "tinyspy": "^1.0.2" + } +} diff --git a/packages/spy/rollup.config.js b/packages/spy/rollup.config.js new file mode 100644 index 00000000000..fddfda5974c --- /dev/null +++ b/packages/spy/rollup.config.js @@ -0,0 +1,51 @@ +import { builtinModules } from 'module' +import esbuild from 'rollup-plugin-esbuild' +import dts from 'rollup-plugin-dts' +import { defineConfig } from 'rollup' +import pkg from './package.json' + +const external = [ + ...builtinModules, + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), +] + +const plugins = [ + esbuild({ + target: 'node14', + }), +] + +export default defineConfig([ + { + input: 'src/index.ts', + output: { + dir: 'dist', + format: 'esm', + entryFileNames: '[name].js', + chunkFileNames: 'chunk-[name].js', + }, + external, + plugins, + onwarn, + }, + { + input: 'src/index.ts', + output: { + dir: 'dist', + entryFileNames: '[name].d.ts', + format: 'esm', + }, + external, + plugins: [ + dts({ respectExternal: true }), + ], + onwarn, + }, +]) + +function onwarn(message) { + if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code)) + return + console.error(message) +} diff --git a/packages/spy/src/index.ts b/packages/spy/src/index.ts new file mode 100644 index 00000000000..b83d2c9a0f6 --- /dev/null +++ b/packages/spy/src/index.ts @@ -0,0 +1,295 @@ +import type { SpyImpl } from 'tinyspy' +import * as tinyspy from 'tinyspy' + +interface MockResultReturn { + type: 'return' + value: T +} +interface MockResultIncomplete { + type: 'incomplete' + value: undefined +} +interface MockResultThrow { + type: 'throw' + value: any +} + +type MockResult = MockResultReturn | MockResultThrow | MockResultIncomplete + +export interface MockContext { + calls: TArgs[] + instances: TReturns[] + invocationCallOrder: number[] + results: MockResult[] + lastCall: TArgs | undefined +} + +type Procedure = (...args: any[]) => any + +type Methods = { + [K in keyof T]: T[K] extends Procedure ? K : never +}[keyof T] & (string | symbol) +type Properties = { + [K in keyof T]: T[K] extends Procedure ? never : K +}[keyof T] & (string | symbol) +type Classes = { + [K in keyof T]: T[K] extends new (...args: any[]) => any ? K : never +}[keyof T] & (string | symbol) + +export interface SpyInstance { + getMockName(): string + mockName(n: string): this + mock: MockContext + mockClear(): this + mockReset(): this + mockRestore(): void + getMockImplementation(): ((...args: TArgs) => TReturns) | undefined + mockImplementation(fn: ((...args: TArgs) => TReturns) | (() => Promise)): this + mockImplementationOnce(fn: ((...args: TArgs) => TReturns) | (() => Promise)): this + mockReturnThis(): this + mockReturnValue(obj: TReturns): this + mockReturnValueOnce(obj: TReturns): this + mockResolvedValue(obj: Awaited): this + mockResolvedValueOnce(obj: Awaited): this + mockRejectedValue(obj: any): this + mockRejectedValueOnce(obj: any): this +} + +export interface MockInstance extends SpyInstance {} + +export interface Mock extends SpyInstance { + new (...args: TArgs): TReturns + (...args: TArgs): TReturns +} +export interface PartialMock extends SpyInstance> { + new (...args: TArgs): TReturns + (...args: TArgs): TReturns +} + +export type MaybeMockedConstructor = T extends new ( + ...args: Array +) => infer R + ? Mock, R> + : T +export type MockedFunction = Mock, ReturnType> & { + [K in keyof T]: T[K]; +} +export type PartiallyMockedFunction = PartialMock, ReturnType> & { + [K in keyof T]: T[K]; +} +export type MockedFunctionDeep = Mock, ReturnType> & MockedObjectDeep +export type PartiallyMockedFunctionDeep = PartialMock, ReturnType> & MockedObjectDeep +export type MockedObject = MaybeMockedConstructor & { + [K in Methods]: T[K] extends Procedure + ? MockedFunction + : T[K]; +} & { [K in Properties]: T[K] } +export type MockedObjectDeep = MaybeMockedConstructor & { + [K in Methods]: T[K] extends Procedure + ? MockedFunctionDeep + : T[K]; +} & { [K in Properties]: MaybeMockedDeep } + +export type MaybeMockedDeep = T extends Procedure + ? MockedFunctionDeep + : T extends object + ? MockedObjectDeep + : T + +export type MaybePartiallyMockedDeep = T extends Procedure + ? PartiallyMockedFunctionDeep + : T extends object + ? MockedObjectDeep + : T + +export type MaybeMocked = T extends Procedure + ? MockedFunction + : T extends object + ? MockedObject + : T + +export type MaybePartiallyMocked = T extends Procedure + ? PartiallyMockedFunction + : T extends object + ? MockedObject + : T + +interface Constructable { + new (...args: any[]): any +} + +export type MockedClass = MockInstance< + T extends new (...args: infer P) => any ? P : never, + InstanceType +> & { + prototype: T extends { prototype: any } ? Mocked : never +} & T + +export type Mocked = { + [P in keyof T]: T[P] extends (...args: infer Args) => infer Returns + ? MockInstance + : T[P] extends Constructable + ? MockedClass + : T[P] +} & +T + +export type EnhancedSpy = SpyInstance & SpyImpl + +export const spies = new Set() + +export function isMockFunction(fn: any): fn is EnhancedSpy { + return typeof fn === 'function' + && '_isMockFunction' in fn + && fn._isMockFunction +} + +export function spyOn>>( + obj: T, + methodName: S, + accessType: 'get', +): SpyInstance<[], T[S]> +export function spyOn>>( + obj: T, + methodName: G, + accessType: 'set', +): SpyInstance<[T[G]], void> +export function spyOn> | Classes>)>( + obj: T, + methodName: M, +): Required[M] extends (...args: infer A) => infer R | (new (...args: infer A) => infer R) ? SpyInstance : never +export function spyOn( + obj: T, + method: K, + accessType?: 'get' | 'set', +): SpyInstance { + const dictionary = { + get: 'getter', + set: 'setter', + } as const + const objMethod = accessType ? { [dictionary[accessType]]: method } : method + + const stub = tinyspy.spyOn(obj, objMethod as any) + + return enhanceSpy(stub) as SpyInstance +} + +let callOrder = 0 + +function enhanceSpy( + spy: SpyImpl, +): SpyInstance { + const stub = spy as unknown as EnhancedSpy + + let implementation: ((...args: TArgs) => TReturns) | undefined + + let instances: any[] = [] + let invocations: number[] = [] + + const mockContext = { + get calls() { + return stub.calls + }, + get instances() { + return instances + }, + get invocationCallOrder() { + return invocations + }, + get results() { + return stub.results.map(([callType, value]) => { + const type = callType === 'error' ? 'throw' : 'return' + return { type, value } + }) + }, + get lastCall() { + return stub.calls[stub.calls.length - 1] + }, + } + + let onceImplementations: ((...args: TArgs) => TReturns)[] = [] + + let name: string = (stub as any).name + + stub.getMockName = () => name || 'vi.fn()' + stub.mockName = (n) => { + name = n + return stub + } + + stub.mockClear = () => { + stub.reset() + instances = [] + invocations = [] + return stub + } + + stub.mockReset = () => { + stub.mockClear() + implementation = () => undefined as unknown as TReturns + onceImplementations = [] + return stub + } + + stub.mockRestore = () => { + stub.mockReset() + implementation = undefined + return stub + } + + stub.getMockImplementation = () => implementation + stub.mockImplementation = (fn: (...args: TArgs) => TReturns) => { + implementation = fn + return stub + } + + stub.mockImplementationOnce = (fn: (...args: TArgs) => TReturns) => { + onceImplementations.push(fn) + return stub + } + + stub.mockReturnThis = () => + stub.mockImplementation(function (this: TReturns) { + return this + }) + + stub.mockReturnValue = (val: TReturns) => stub.mockImplementation(() => val) + stub.mockReturnValueOnce = (val: TReturns) => stub.mockImplementationOnce(() => val) + + stub.mockResolvedValue = (val: Awaited) => + stub.mockImplementation(() => Promise.resolve(val as TReturns)) + + stub.mockResolvedValueOnce = (val: Awaited) => + stub.mockImplementationOnce(() => Promise.resolve(val as TReturns)) + + stub.mockRejectedValue = (val: unknown) => + stub.mockImplementation(() => Promise.reject(val)) + + stub.mockRejectedValueOnce = (val: unknown) => + stub.mockImplementationOnce(() => Promise.reject(val)) + + Object.defineProperty(stub, 'mock', { + get: () => mockContext, + }) + + stub.willCall(function (this: unknown, ...args) { + instances.push(this) + invocations.push(++callOrder) + const impl = onceImplementations.shift() || implementation || stub.getOriginal() || (() => {}) + return impl.apply(this, args) + }) + + spies.add(stub) + + return stub as any +} + +export function fn(): Mock +export function fn( + implementation: (...args: TArgs) => R +): Mock +export function fn( + implementation?: (...args: TArgs) => R, +): Mock { + return enhanceSpy(tinyspy.spyOn({ fn: implementation || (() => {}) }, 'fn')) as unknown as Mock +} diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 00000000000..38bb54e9886 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,48 @@ +{ + "name": "@vitest/utils", + "type": "module", + "version": "0.26.2", + "description": "Shared Vitest utility functions", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/vitest-dev/vitest.git", + "directory": "packages/utils" + }, + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./diff": { + "types": "./dist/diff.d.ts", + "import": "./dist/diff.js" + }, + "./helpers": { + "types": "./dist/helpers.d.ts", + "import": "./dist/helpers.js" + }, + "./*": "./*" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rimraf dist && rollup -c", + "dev": "rollup -c --watch", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "cli-truncate": "^3.1.0", + "diff": "^5.1.0", + "picocolors": "^1.0.0", + "pretty-format": "^27.5.1" + }, + "devDependencies": { + "@types/diff": "^5.0.2" + } +} diff --git a/packages/utils/rollup.config.js b/packages/utils/rollup.config.js new file mode 100644 index 00000000000..d9cef53a261 --- /dev/null +++ b/packages/utils/rollup.config.js @@ -0,0 +1,64 @@ +import { builtinModules } from 'module' +import { defineConfig } from 'rollup' +import esbuild from 'rollup-plugin-esbuild' +import dts from 'rollup-plugin-dts' +import resolve from '@rollup/plugin-node-resolve' +import json from '@rollup/plugin-json' +import pkg from './package.json' + +const entries = { + index: 'src/index.ts', + helpers: 'src/helpers.ts', + diff: 'src/diff.ts', + types: 'src/types.ts', +} + +const external = [ + ...builtinModules, + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), +] + +const plugins = [ + resolve({ + preferBuiltins: true, + }), + json(), + esbuild({ + target: 'node14', + }), +] + +export default defineConfig([ + { + input: entries, + output: { + dir: 'dist', + format: 'esm', + entryFileNames: '[name].js', + chunkFileNames: 'chunk-[name].js', + }, + external, + plugins, + onwarn, + }, + { + input: entries, + output: { + dir: 'dist', + entryFileNames: '[name].d.ts', + format: 'esm', + }, + external, + plugins: [ + dts({ respectExternal: true }), + ], + onwarn, + }, +]) + +function onwarn(message) { + if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code)) + return + console.error(message) +} diff --git a/packages/utils/src/diff.ts b/packages/utils/src/diff.ts new file mode 100644 index 00000000000..1b720554c97 --- /dev/null +++ b/packages/utils/src/diff.ts @@ -0,0 +1,128 @@ +import c from 'picocolors' +import * as diff from 'diff' +import cliTruncate from 'cli-truncate' + +export function formatLine(line: string, outputTruncateLength?: number) { + return cliTruncate(line, (outputTruncateLength ?? (process.stdout?.columns || 80)) - 4) +} + +export interface DiffOptions { + noColor?: boolean + outputDiffMaxLines?: number + outputTruncateLength?: number + outputDiffLines?: number + showLegend?: boolean +} + +/** +* Returns unified diff between two strings with coloured ANSI output. +* +* @private +* @param {String} actual +* @param {String} expected +* @return {string} The diff. +*/ + +export function unifiedDiff(actual: string, expected: string, options: DiffOptions = {}) { + if (actual === expected) + return '' + + const { outputTruncateLength, outputDiffLines, outputDiffMaxLines, noColor, showLegend = true } = options + + const indent = ' ' + const diffLimit = outputDiffLines || 15 + const diffMaxLines = outputDiffMaxLines || 50 + + const counts = { + '+': 0, + '-': 0, + } + let previousState: '-' | '+' | null = null + let previousCount = 0 + + const str = (str: string) => str + const dim = noColor ? str : c.dim + const green = noColor ? str : c.green + const red = noColor ? str : c.red + function preprocess(line: string) { + if (!line || line.match(/\\ No newline/)) + return + + const char = line[0] as '+' | '-' + if ('-+'.includes(char)) { + if (previousState !== char) { + previousState = char + previousCount = 0 + } + previousCount++ + counts[char]++ + if (previousCount === diffLimit) + return dim(`${char} ...`) + else if (previousCount > diffLimit) + return + } + return line + } + + const msg = diff.createPatch('string', expected, actual) + let lines = msg.split('\n').slice(5).map(preprocess).filter(Boolean) as string[] + let moreLines = 0 + const isCompact = counts['+'] === 1 && counts['-'] === 1 && lines.length === 2 + + if (lines.length > diffMaxLines) { + const firstDiff = lines.findIndex(line => line[0] === '-' || line[0] === '+') + const displayLines = lines.slice(firstDiff - 2, diffMaxLines) + const lastDisplayedIndex = firstDiff - 2 + diffMaxLines + if (lastDisplayedIndex < lines.length) + moreLines = lines.length - lastDisplayedIndex + lines = displayLines + } + + let formatted = lines.map((line: string) => { + line = line.replace(/\\"/g, '"') + if (line[0] === '-') { + line = formatLine(line.slice(1), outputTruncateLength) + if (isCompact) + return green(line) + return green(`- ${formatLine(line, outputTruncateLength)}`) + } + if (line[0] === '+') { + line = formatLine(line.slice(1), outputTruncateLength) + if (isCompact) + return red(line) + return red(`+ ${formatLine(line, outputTruncateLength)}`) + } + if (line.match(/@@/)) + return '--' + return ` ${line}` + }) + + if (moreLines) + formatted.push(dim(`... ${moreLines} more lines`)) + + if (showLegend) { + // Compact mode + if (isCompact) { + formatted = [ + `${green('- Expected')} ${formatted[0]}`, + `${red('+ Received')} ${formatted[1]}`, + ] + } + else { + if (formatted[0].includes('"')) + formatted[0] = formatted[0].replace('"', '') + + const last = formatted.length - 1 + if (formatted[last].endsWith('"')) + formatted[last] = formatted[last].slice(0, formatted[last].length - 1) + + formatted.unshift( + green(`- Expected - ${counts['-']}`), + red(`+ Received + ${counts['+']}`), + '', + ) + } + } + + return formatted.map(i => i ? (indent + i) : i).join('\n') +} diff --git a/packages/utils/src/helpers.ts b/packages/utils/src/helpers.ts new file mode 100644 index 00000000000..f2f963ebaae --- /dev/null +++ b/packages/utils/src/helpers.ts @@ -0,0 +1,10 @@ +export function assertTypes(value: unknown, name: string, types: string[]): void { + const receivedType = typeof value + const pass = types.includes(receivedType) + if (!pass) + throw new TypeError(`${name} value must be ${types.join(' or ')}, received "${receivedType}"`) +} + +export function isObject(item: unknown): boolean { + return item != null && typeof item === 'object' && !Array.isArray(item) +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 00000000000..db9ed73002e --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,4 @@ +export * from './diff' +export * from './helpers' +export * from './types' +export * from './stringify' diff --git a/packages/utils/src/stringify.ts b/packages/utils/src/stringify.ts new file mode 100644 index 00000000000..ce778af8e7d --- /dev/null +++ b/packages/utils/src/stringify.ts @@ -0,0 +1,49 @@ +import type { PrettyFormatOptions } from 'pretty-format' +import { format as prettyFormat, plugins as prettyFormatPlugins } from 'pretty-format' + +const { + AsymmetricMatcher, + DOMCollection, + DOMElement, + Immutable, + ReactElement, + ReactTestComponent, +} = prettyFormatPlugins + +const PLUGINS = [ + ReactTestComponent, + ReactElement, + DOMElement, + DOMCollection, + Immutable, + AsymmetricMatcher, +] + +export function stringify(object: unknown, maxDepth = 10, { maxLength, ...options }: PrettyFormatOptions & { maxLength?: number } = {}): string { + const MAX_LENGTH = maxLength ?? 10000 + let result + + try { + result = prettyFormat(object, { + maxDepth, + escapeString: false, + // min: true, + plugins: PLUGINS, + ...options, + }) + } + catch { + result = prettyFormat(object, { + callToJSON: false, + maxDepth, + escapeString: false, + // min: true, + plugins: PLUGINS, + ...options, + }) + } + + return result.length >= MAX_LENGTH && maxDepth > 1 + ? stringify(object, Math.floor(maxDepth / 2)) + : result +} diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts new file mode 100644 index 00000000000..730ef610aa2 --- /dev/null +++ b/packages/utils/src/types.ts @@ -0,0 +1,25 @@ +export type Awaitable = T | PromiseLike +export type Nullable = T | null | undefined +export type Arrayable = T | Array +export type ArgumentsType = T extends (...args: infer U) => any ? U : never + +export type MergeInsertions = + T extends object + ? { [K in keyof T]: MergeInsertions } + : T + +export type DeepMerge = MergeInsertions<{ + [K in keyof F | keyof S]: K extends keyof S & keyof F + ? DeepMerge + : K extends keyof S + ? S[K] + : K extends keyof F + ? F[K] + : never; +}> + +export type MutableArray = { -readonly [k in keyof T]: T[k] } + +export interface Constructable { + new (...args: any[]): any +} diff --git a/packages/vitest/LICENSE.md b/packages/vitest/LICENSE.md index ba7d48a919a..d8862289288 100644 --- a/packages/vitest/LICENSE.md +++ b/packages/vitest/LICENSE.md @@ -250,7 +250,7 @@ Repository: chalk/ansi-regex > MIT License > -> Copyright (c) Sindre Sorhus (sindresorhus.com) +> Copyright (c) Sindre Sorhus (https://sindresorhus.com) > > Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: > @@ -267,7 +267,7 @@ Repository: chalk/ansi-styles > MIT License > -> Copyright (c) Sindre Sorhus (sindresorhus.com) +> Copyright (c) Sindre Sorhus (https://sindresorhus.com) > > Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: > diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 3dc2d141b87..1b9d7fb01d7 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -131,7 +131,10 @@ "@types/natural-compare": "^1.4.1", "@types/prompts": "^2.4.2", "@types/sinonjs__fake-timers": "^8.1.2", + "@vitest/expect": "workspace:*", + "@vitest/spy": "workspace:*", "@vitest/ui": "workspace:*", + "@vitest/utils": "workspace:*", "birpc": "^0.2.3", "cac": "^6.7.14", "chai-subset": "^1.6.0", diff --git a/packages/vitest/rollup.config.js b/packages/vitest/rollup.config.js index 9c1328766d6..9194833a907 100644 --- a/packages/vitest/rollup.config.js +++ b/packages/vitest/rollup.config.js @@ -140,6 +140,7 @@ function licensePlugin() { } const licenses = new Set() const dependencyLicenseTexts = dependencies + .filter(({ name }) => !name.startsWith('@vitest/')) .sort(({ name: nameA }, { name: nameB }) => nameA > nameB ? 1 : nameB > nameA ? -1 : 0, ) diff --git a/packages/vitest/src/integrations/chai/index.ts b/packages/vitest/src/integrations/chai/index.ts index 76cddfac59e..b245b926ad1 100644 --- a/packages/vitest/src/integrations/chai/index.ts +++ b/packages/vitest/src/integrations/chai/index.ts @@ -1,10 +1,9 @@ import * as chai from 'chai' import './setup' +import { GLOBAL_EXPECT, getState, setState } from '@vitest/expect' +import type { MatcherState } from '../../types/chai' import type { Test } from '../../types' import { getCurrentEnvironment, getFullName } from '../../utils' -import type { MatcherState } from '../../types/chai' -import { getState, setState } from './jest-expect' -import { GLOBAL_EXPECT } from './constants' export function createExpect(test?: Test) { const expect = ((value: any, message?: string): Vi.Assertion => { @@ -19,18 +18,23 @@ export function createExpect(test?: Test) { }) as Vi.ExpectStatic Object.assign(expect, chai.expect) - expect.getState = () => getState(expect) + expect.getState = () => getState(expect) expect.setState = state => setState(state as Partial, expect) - setState({ + // @ts-expect-error global is not typed + const globalState = getState(globalThis[GLOBAL_EXPECT]) || {} + + setState({ + // this should also add "snapshotState" that is added conditionally + ...globalState, assertionCalls: 0, isExpectingAssertions: false, isExpectingAssertionsError: null, expectedAssertionsNumber: null, expectedAssertionsNumberErrorGen: null, environment: getCurrentEnvironment(), - testPath: test?.suite.file?.filepath, - currentTestName: test ? getFullName(test) : undefined, + testPath: test ? test.suite.file?.filepath : globalState.testPath, + currentTestName: test ? getFullName(test) : globalState.currentTestName, }, expect) // @ts-expect-error untyped diff --git a/packages/vitest/src/integrations/chai/setup.ts b/packages/vitest/src/integrations/chai/setup.ts index 8414ebb60b1..1687e013b8f 100644 --- a/packages/vitest/src/integrations/chai/setup.ts +++ b/packages/vitest/src/integrations/chai/setup.ts @@ -1,9 +1,7 @@ import * as chai from 'chai' import Subset from 'chai-subset' +import { JestAsymmetricMatchers, JestChaiExpect, JestExtend } from '@vitest/expect' import { SnapshotPlugin } from '../snapshot/chai' -import { JestExtend } from './jest-extend' -import { JestChaiExpect } from './jest-expect' -import { JestAsymmetricMatchers } from './jest-asymmetric-matchers' chai.use(JestExtend) chai.use(JestChaiExpect) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 9bec0c4ad47..fefbcc65a76 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -1,6 +1,7 @@ -import type { ChaiPlugin } from '../../types/chai' +import type { ChaiPlugin } from '@vitest/expect' import { SnapshotClient } from './client' import { stripSnapshotIndentation } from './port/inlineSnapshot' +import { addSerializer } from './port/plugins' let _client: SnapshotClient @@ -123,4 +124,9 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { }) }, ) + utils.addMethod( + chai.expect, + 'addSnapshotSerializer', + addSerializer, + ) } diff --git a/packages/vitest/src/integrations/snapshot/client.ts b/packages/vitest/src/integrations/snapshot/client.ts index 72576a90ab5..a28dabefcfb 100644 --- a/packages/vitest/src/integrations/snapshot/client.ts +++ b/packages/vitest/src/integrations/snapshot/client.ts @@ -1,8 +1,8 @@ import { expect } from 'chai' +import { equals, iterableEquality, subsetEquality } from '@vitest/expect' import type { Test } from '../../types' import { rpc } from '../../runtime/rpc' import { getNames, getWorkerState } from '../../utils' -import { equals, iterableEquality, subsetEquality } from '../chai/jest-utils' import { deepMergeSnapshot } from './port/utils' import SnapshotState from './port/state' diff --git a/packages/vitest/src/integrations/spy.ts b/packages/vitest/src/integrations/spy.ts index b83d2c9a0f6..5908224361b 100644 --- a/packages/vitest/src/integrations/spy.ts +++ b/packages/vitest/src/integrations/spy.ts @@ -1,295 +1 @@ -import type { SpyImpl } from 'tinyspy' -import * as tinyspy from 'tinyspy' - -interface MockResultReturn { - type: 'return' - value: T -} -interface MockResultIncomplete { - type: 'incomplete' - value: undefined -} -interface MockResultThrow { - type: 'throw' - value: any -} - -type MockResult = MockResultReturn | MockResultThrow | MockResultIncomplete - -export interface MockContext { - calls: TArgs[] - instances: TReturns[] - invocationCallOrder: number[] - results: MockResult[] - lastCall: TArgs | undefined -} - -type Procedure = (...args: any[]) => any - -type Methods = { - [K in keyof T]: T[K] extends Procedure ? K : never -}[keyof T] & (string | symbol) -type Properties = { - [K in keyof T]: T[K] extends Procedure ? never : K -}[keyof T] & (string | symbol) -type Classes = { - [K in keyof T]: T[K] extends new (...args: any[]) => any ? K : never -}[keyof T] & (string | symbol) - -export interface SpyInstance { - getMockName(): string - mockName(n: string): this - mock: MockContext - mockClear(): this - mockReset(): this - mockRestore(): void - getMockImplementation(): ((...args: TArgs) => TReturns) | undefined - mockImplementation(fn: ((...args: TArgs) => TReturns) | (() => Promise)): this - mockImplementationOnce(fn: ((...args: TArgs) => TReturns) | (() => Promise)): this - mockReturnThis(): this - mockReturnValue(obj: TReturns): this - mockReturnValueOnce(obj: TReturns): this - mockResolvedValue(obj: Awaited): this - mockResolvedValueOnce(obj: Awaited): this - mockRejectedValue(obj: any): this - mockRejectedValueOnce(obj: any): this -} - -export interface MockInstance extends SpyInstance {} - -export interface Mock extends SpyInstance { - new (...args: TArgs): TReturns - (...args: TArgs): TReturns -} -export interface PartialMock extends SpyInstance> { - new (...args: TArgs): TReturns - (...args: TArgs): TReturns -} - -export type MaybeMockedConstructor = T extends new ( - ...args: Array -) => infer R - ? Mock, R> - : T -export type MockedFunction = Mock, ReturnType> & { - [K in keyof T]: T[K]; -} -export type PartiallyMockedFunction = PartialMock, ReturnType> & { - [K in keyof T]: T[K]; -} -export type MockedFunctionDeep = Mock, ReturnType> & MockedObjectDeep -export type PartiallyMockedFunctionDeep = PartialMock, ReturnType> & MockedObjectDeep -export type MockedObject = MaybeMockedConstructor & { - [K in Methods]: T[K] extends Procedure - ? MockedFunction - : T[K]; -} & { [K in Properties]: T[K] } -export type MockedObjectDeep = MaybeMockedConstructor & { - [K in Methods]: T[K] extends Procedure - ? MockedFunctionDeep - : T[K]; -} & { [K in Properties]: MaybeMockedDeep } - -export type MaybeMockedDeep = T extends Procedure - ? MockedFunctionDeep - : T extends object - ? MockedObjectDeep - : T - -export type MaybePartiallyMockedDeep = T extends Procedure - ? PartiallyMockedFunctionDeep - : T extends object - ? MockedObjectDeep - : T - -export type MaybeMocked = T extends Procedure - ? MockedFunction - : T extends object - ? MockedObject - : T - -export type MaybePartiallyMocked = T extends Procedure - ? PartiallyMockedFunction - : T extends object - ? MockedObject - : T - -interface Constructable { - new (...args: any[]): any -} - -export type MockedClass = MockInstance< - T extends new (...args: infer P) => any ? P : never, - InstanceType -> & { - prototype: T extends { prototype: any } ? Mocked : never -} & T - -export type Mocked = { - [P in keyof T]: T[P] extends (...args: infer Args) => infer Returns - ? MockInstance - : T[P] extends Constructable - ? MockedClass - : T[P] -} & -T - -export type EnhancedSpy = SpyInstance & SpyImpl - -export const spies = new Set() - -export function isMockFunction(fn: any): fn is EnhancedSpy { - return typeof fn === 'function' - && '_isMockFunction' in fn - && fn._isMockFunction -} - -export function spyOn>>( - obj: T, - methodName: S, - accessType: 'get', -): SpyInstance<[], T[S]> -export function spyOn>>( - obj: T, - methodName: G, - accessType: 'set', -): SpyInstance<[T[G]], void> -export function spyOn> | Classes>)>( - obj: T, - methodName: M, -): Required[M] extends (...args: infer A) => infer R | (new (...args: infer A) => infer R) ? SpyInstance : never -export function spyOn( - obj: T, - method: K, - accessType?: 'get' | 'set', -): SpyInstance { - const dictionary = { - get: 'getter', - set: 'setter', - } as const - const objMethod = accessType ? { [dictionary[accessType]]: method } : method - - const stub = tinyspy.spyOn(obj, objMethod as any) - - return enhanceSpy(stub) as SpyInstance -} - -let callOrder = 0 - -function enhanceSpy( - spy: SpyImpl, -): SpyInstance { - const stub = spy as unknown as EnhancedSpy - - let implementation: ((...args: TArgs) => TReturns) | undefined - - let instances: any[] = [] - let invocations: number[] = [] - - const mockContext = { - get calls() { - return stub.calls - }, - get instances() { - return instances - }, - get invocationCallOrder() { - return invocations - }, - get results() { - return stub.results.map(([callType, value]) => { - const type = callType === 'error' ? 'throw' : 'return' - return { type, value } - }) - }, - get lastCall() { - return stub.calls[stub.calls.length - 1] - }, - } - - let onceImplementations: ((...args: TArgs) => TReturns)[] = [] - - let name: string = (stub as any).name - - stub.getMockName = () => name || 'vi.fn()' - stub.mockName = (n) => { - name = n - return stub - } - - stub.mockClear = () => { - stub.reset() - instances = [] - invocations = [] - return stub - } - - stub.mockReset = () => { - stub.mockClear() - implementation = () => undefined as unknown as TReturns - onceImplementations = [] - return stub - } - - stub.mockRestore = () => { - stub.mockReset() - implementation = undefined - return stub - } - - stub.getMockImplementation = () => implementation - stub.mockImplementation = (fn: (...args: TArgs) => TReturns) => { - implementation = fn - return stub - } - - stub.mockImplementationOnce = (fn: (...args: TArgs) => TReturns) => { - onceImplementations.push(fn) - return stub - } - - stub.mockReturnThis = () => - stub.mockImplementation(function (this: TReturns) { - return this - }) - - stub.mockReturnValue = (val: TReturns) => stub.mockImplementation(() => val) - stub.mockReturnValueOnce = (val: TReturns) => stub.mockImplementationOnce(() => val) - - stub.mockResolvedValue = (val: Awaited) => - stub.mockImplementation(() => Promise.resolve(val as TReturns)) - - stub.mockResolvedValueOnce = (val: Awaited) => - stub.mockImplementationOnce(() => Promise.resolve(val as TReturns)) - - stub.mockRejectedValue = (val: unknown) => - stub.mockImplementation(() => Promise.reject(val)) - - stub.mockRejectedValueOnce = (val: unknown) => - stub.mockImplementationOnce(() => Promise.reject(val)) - - Object.defineProperty(stub, 'mock', { - get: () => mockContext, - }) - - stub.willCall(function (this: unknown, ...args) { - instances.push(this) - invocations.push(++callOrder) - const impl = onceImplementations.shift() || implementation || stub.getOriginal() || (() => {}) - return impl.apply(this, args) - }) - - spies.add(stub) - - return stub as any -} - -export function fn(): Mock -export function fn( - implementation: (...args: TArgs) => R -): Mock -export function fn( - implementation?: (...args: TArgs) => R, -): Mock { - return enhanceSpy(tinyspy.spyOn({ fn: implementation || (() => {}) }, 'fn')) as unknown as Mock -} +export * from '@vitest/spy' diff --git a/packages/vitest/src/node/error.ts b/packages/vitest/src/node/error.ts index 9bdd1532f72..31c30fa0378 100644 --- a/packages/vitest/src/node/error.ts +++ b/packages/vitest/src/node/error.ts @@ -3,10 +3,10 @@ import { existsSync, readFileSync } from 'fs' import { normalize, relative } from 'pathe' import c from 'picocolors' import cliTruncate from 'cli-truncate' +import { stringify } from '@vitest/utils' import type { ErrorWithDiff, ParsedStack } from '../types' import { lineSplitRE, parseStacktrace, positionToOffset } from '../utils/source-map' import { F_POINTER } from '../utils/figures' -import { stringify } from '../integrations/chai/jest-matcher-utils' import { TypeCheckError } from '../typecheck/typechecker' import { type DiffOptions, unifiedDiff } from '../utils/diff' import type { Vitest } from './core' diff --git a/packages/vitest/src/runtime/error.ts b/packages/vitest/src/runtime/error.ts index d66560d499c..15a56c331a4 100644 --- a/packages/vitest/src/runtime/error.ts +++ b/packages/vitest/src/runtime/error.ts @@ -1,6 +1,6 @@ import util from 'util' import { util as ChaiUtil } from 'chai' -import { stringify } from '../integrations/chai/jest-matcher-utils' +import { stringify } from '@vitest/utils' import { deepClone, getType, getWorkerState } from '../utils' const IS_RECORD_SYMBOL = '@@__IMMUTABLE_RECORD__@@' diff --git a/packages/vitest/src/runtime/run.ts b/packages/vitest/src/runtime/run.ts index 18490bb19a9..45164b75041 100644 --- a/packages/vitest/src/runtime/run.ts +++ b/packages/vitest/src/runtime/run.ts @@ -1,11 +1,12 @@ import { performance } from 'perf_hooks' import limit from 'p-limit' +import { GLOBAL_EXPECT, getState, setState } from '@vitest/expect' import type { BenchTask, Benchmark, BenchmarkResult, File, HookCleanupCallback, HookListener, ResolvedConfig, SequenceHooks, Suite, SuiteHooks, Task, TaskResult, TaskState, Test } from '../types' import { vi } from '../integrations/vi' import { clearTimeout, createDefer, getFullName, getWorkerState, hasFailed, hasTests, isBrowser, isNode, isRunningInBenchmark, partitionSuiteChildren, setTimeout, shuffle } from '../utils' -import { getState, setState } from '../integrations/chai/jest-expect' -import { GLOBAL_EXPECT } from '../integrations/chai/constants' import { takeCoverageInsideWorker } from '../integrations/coverage' +import type { MatcherState } from '../types/chai' +import { getSnapshotClient } from '../integrations/snapshot/chai' import { getBenchOptions, getFn, getHooks } from './map' import { rpc } from './rpc' import { collectTests } from './collect' @@ -148,7 +149,7 @@ export async function runTest(test: Test) { for (let retryCount = 0; retryCount < retry; retryCount++) { let beforeEachCleanups: HookCleanupCallback[] = [] try { - setState({ + setState({ assertionCalls: 0, isExpectingAssertions: false, isExpectingAssertionsError: null, @@ -156,6 +157,7 @@ export async function runTest(test: Test) { expectedAssertionsNumberErrorGen: null, testPath: test.suite.file?.filepath, currentTestName: getFullName(test), + snapshotState: getSnapshotClient().snapshotState, }, (globalThis as any)[GLOBAL_EXPECT]) beforeEachCleanups = await callSuiteHook(test.suite, test, 'beforeEach', [test.context, test.suite]) diff --git a/packages/vitest/src/types/chai.ts b/packages/vitest/src/types/chai.ts index 0d96ab3af5c..94069cfe916 100644 --- a/packages/vitest/src/types/chai.ts +++ b/packages/vitest/src/types/chai.ts @@ -1,63 +1,8 @@ -import type { use as chaiUse } from 'chai' - -/** - * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import type * as jestMatcherUtils from '../integrations/chai/jest-matcher-utils' +import type { MatcherState as JestMatcherState } from '@vitest/expect' import type SnapshotState from '../integrations/snapshot/port/state' import type { VitestEnvironment } from './config' -export type FirstFunctionArgument = T extends (arg: infer A) => unknown ? A : never -export type ChaiPlugin = FirstFunctionArgument - -export type Tester = (a: any, b: any) => boolean | undefined - -export interface MatcherState { - assertionCalls: number - currentTestName?: string - dontThrow?: () => void - error?: Error - equals: ( - a: unknown, - b: unknown, - customTesters?: Array, - strictCheck?: boolean, - ) => boolean - expand?: boolean - expectedAssertionsNumber?: number | null - expectedAssertionsNumberErrorGen?: (() => Error) | null - isExpectingAssertions?: boolean - isExpectingAssertionsError?: Error | null - isNot: boolean +export interface MatcherState extends JestMatcherState { environment: VitestEnvironment - promise: string snapshotState: SnapshotState - suppressedErrors: Array - testPath?: string - utils: typeof jestMatcherUtils & { - iterableEquality: Tester - subsetEquality: Tester - } -} - -export interface SyncExpectationResult { - pass: boolean - message: () => string - actual?: any - expected?: any } - -export type AsyncExpectationResult = Promise - -export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult - -export interface RawMatcherFn { - (this: T, received: any, expected: any, options?: any): ExpectationResult -} - -export type MatchersObject = Record> diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts index 4ea4a275b3c..a1b87d37dae 100644 --- a/packages/vitest/src/types/global.ts +++ b/packages/vitest/src/types/global.ts @@ -1,5 +1,6 @@ import type { Plugin as PrettyFormatPlugin } from 'pretty-format' -import type { MatcherState, MatchersObject } from './chai' +import type { MatchersObject } from '@vitest/expect' +import type { MatcherState } from './chai' import type { Constructable } from './general' type Promisify = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51ee866e89b..a346a2d17a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -684,6 +684,24 @@ importers: '@types/istanbul-reports': 3.0.1 pathe: 0.2.0 + packages/expect: + specifiers: + '@vitest/spy': workspace:* + '@vitest/utils': workspace:* + chai: ^4.3.7 + picocolors: ^1.0.0 + dependencies: + '@vitest/spy': link:../spy + '@vitest/utils': link:../utils + chai: 4.3.7 + picocolors: 1.0.0 + + packages/spy: + specifiers: + tinyspy: ^1.0.2 + dependencies: + tinyspy: 1.0.2 + packages/ui: specifiers: '@faker-js/faker': ^7.6.0 @@ -753,6 +771,21 @@ importers: vue: 3.2.45 vue-router: 4.1.6_vue@3.2.45 + packages/utils: + specifiers: + '@types/diff': ^5.0.2 + cli-truncate: ^3.1.0 + diff: ^5.1.0 + picocolors: ^1.0.0 + pretty-format: ^27.5.1 + dependencies: + cli-truncate: 3.1.0 + diff: 5.1.0 + picocolors: 1.0.0 + pretty-format: 27.5.1 + devDependencies: + '@types/diff': 5.0.2 + packages/vite-node: specifiers: '@types/debug': ^4.1.7 @@ -796,7 +829,10 @@ importers: '@types/node': '*' '@types/prompts': ^2.4.2 '@types/sinonjs__fake-timers': ^8.1.2 + '@vitest/expect': workspace:* + '@vitest/spy': workspace:* '@vitest/ui': workspace:* + '@vitest/utils': workspace:* acorn: ^8.8.1 acorn-walk: ^8.2.0 birpc: ^0.2.3 @@ -864,7 +900,10 @@ importers: '@types/natural-compare': 1.4.1 '@types/prompts': 2.4.2 '@types/sinonjs__fake-timers': 8.1.2 + '@vitest/expect': link:../expect + '@vitest/spy': link:../spy '@vitest/ui': link:../ui + '@vitest/utils': link:../utils birpc: 0.2.3 cac: 6.7.14 chai-subset: 1.6.0 @@ -8965,7 +9004,6 @@ packages: /ansi-regex/6.0.1: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} - dev: true /ansi-styles/2.2.1: resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==} @@ -8987,12 +9025,10 @@ packages: /ansi-styles/5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - dev: true /ansi-styles/6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} - dev: true /ansi-to-html/0.6.15: resolution: {integrity: sha512-28ijx2aHJGdzbs+O5SNQF65r6rrKYnkuwTYm8lZlChuoJ9P1vVzIpWO20sQTqTPDXYp6NFwk326vApTtLVFXpQ==} @@ -10296,7 +10332,6 @@ packages: dependencies: slice-ansi: 5.0.0 string-width: 5.1.2 - dev: true /cli-width/3.0.0: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} @@ -11295,7 +11330,6 @@ packages: /diff/5.1.0: resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==} engines: {node: '>=0.3.1'} - dev: true /diffie-hellman/5.0.3: resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} @@ -11468,7 +11502,6 @@ packages: /eastasianwidth/0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - dev: true /ecc-jsbn/0.1.2: resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} @@ -11519,7 +11552,6 @@ packages: /emoji-regex/9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - dev: true /emojis-list/3.0.0: resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} @@ -14111,7 +14143,6 @@ packages: /is-fullwidth-code-point/4.0.0: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} engines: {node: '>=12'} - dev: true /is-function/1.0.2: resolution: {integrity: sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==} @@ -17052,7 +17083,6 @@ packages: ansi-regex: 5.0.1 ansi-styles: 5.2.0 react-is: 17.0.2 - dev: true /pretty-format/29.0.1: resolution: {integrity: sha512-iTHy3QZMzuL484mSTYbQIM1AHhEQsH8mXWS2/vd2yFBYnG3EBqGiMONo28PlPgrW7P/8s/1ISv+y7WH306l8cw==} @@ -17469,7 +17499,6 @@ packages: /react-is/17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - dev: true /react-is/18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} @@ -18571,7 +18600,6 @@ packages: dependencies: ansi-styles: 6.2.1 is-fullwidth-code-point: 4.0.0 - dev: true /snapdragon-node/2.1.1: resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==} @@ -18919,7 +18947,6 @@ packages: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 strip-ansi: 7.0.1 - dev: true /string.prototype.matchall/4.0.7: resolution: {integrity: sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==} @@ -19016,7 +19043,6 @@ packages: engines: {node: '>=12'} dependencies: ansi-regex: 6.0.1 - dev: true /strip-bom/2.0.0: resolution: {integrity: sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==} diff --git a/test/core/test/diff.test.ts b/test/core/test/diff.test.ts index 98e5eea4641..d57fcd938e6 100644 --- a/test/core/test/diff.test.ts +++ b/test/core/test/diff.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vitest' +import { stringify } from '@vitest/utils' import { displayDiff } from 'vitest/src/node/error' -import { stringify } from 'vitest/src/integrations/chai/jest-matcher-utils' test('displays an error for large objects', () => { const objectA = new Array(1000).fill(0).map((_, i) => ({ i, long: 'a'.repeat(i) })) diff --git a/test/core/test/file-path.test.ts b/test/core/test/file-path.test.ts index a848dcafd52..c0b1a4f3731 100644 --- a/test/core/test/file-path.test.ts +++ b/test/core/test/file-path.test.ts @@ -4,7 +4,7 @@ import { isWindows, slash, toFilePath } from '../../../packages/vite-node/src/ut // @ts-expect-error aliased to ../src/aliased-mod.ts import { getPaths as getAbsoluteAliasedPaths } from '$/aliased-mod' // @ts-expect-error aliased to ../src/aliased-mod.ts -import { getPaths as getRelativeAliasedPath } from '@/aliased-mod' +import { getPaths as getRelativeAliasedPath } from '#/aliased-mod' vi.mock('fs') diff --git a/test/core/test/imports.test.ts b/test/core/test/imports.test.ts index 30992837767..9ec6e9a34f8 100644 --- a/test/core/test/imports.test.ts +++ b/test/core/test/imports.test.ts @@ -20,7 +20,7 @@ test('Relative imports in imported modules work', async () => { test('dynamic aliased import works', async () => { const stringTimeoutMod = await import('./../src/timeout') - const timeoutPath = '@/timeout' + const timeoutPath = '#/timeout' const variableTimeoutMod = await import(timeoutPath) expect(stringTimeoutMod).toBe(variableTimeoutMod) diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 442aebf944d..450403fa244 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -1,7 +1,7 @@ /* eslint-disable no-sparse-arrays */ import { AssertionError } from 'assert' import { describe, expect, it, vi } from 'vitest' -import { generateToBeMessage } from 'vitest/src/integrations/chai/jest-utils' +import { generateToBeMessage } from '@vitest/expect' class TestError extends Error {} diff --git a/test/core/test/local-context.test.ts b/test/core/test/local-context.test.ts index a475840dfc5..8ace7b7e348 100644 --- a/test/core/test/local-context.test.ts +++ b/test/core/test/local-context.test.ts @@ -25,7 +25,14 @@ describe('local test context works with implicit type', () => { it('works with implicit type', (context: LocalTestContext) => { expect(context.bar).toBe('bar') }) - it.only('is chainable with implicit type', (context: LocalTestContext) => { + it('is chainable with implicit type', (context: LocalTestContext) => { expect(context.bar).toBe('bar') }) }) + +describe('context expect', () => { + it('has snapshotState', ({ expect: localExpect }) => { + expect(expect.getState().snapshotState).toBeDefined() + expect(localExpect.getState().snapshotState).toBeDefined() + }) +}) diff --git a/test/core/vitest.config.ts b/test/core/vitest.config.ts index 4a37556787f..fa07e6ba847 100644 --- a/test/core/vitest.config.ts +++ b/test/core/vitest.config.ts @@ -33,8 +33,9 @@ export default defineConfig({ }, resolve: { alias: [ - { find: '@', replacement: resolve(__dirname, 'src') }, + { find: '#', replacement: resolve(__dirname, 'src') }, { find: '$', replacement: 'src' }, + { find: '@vitest', replacement: resolve(__dirname, '..', '..', 'packages') }, ], }, test: { diff --git a/tsconfig.json b/tsconfig.json index 03bec70b5b0..ebe03fc547a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,9 @@ "paths": { "@vitest/ws-client": ["./packages/ws-client/src/index.ts"], "@vitest/ui": ["./packages/ui/node/index.ts"], + "@vitest/utils": ["./packages/utils/src/index.ts"], + "@vitest/spy": ["./packages/spy/src/index.ts"], + "@vitest/expect": ["./packages/expect/src/index.ts"], "@vitest/browser": ["./packages/browser/src/node/index.ts"], "#types": ["./packages/vitest/src/index.ts"], "~/*": ["./packages/ui/client/*"], @@ -38,6 +41,7 @@ "**/dist/**", "./packages/vitest/dist/**", "./packages/vitest/*.d.ts", + "./packages/vitest/*.d.cts", "./packages/ui/client/**", "./examples/**/*.*", "./bench/**",