diff --git a/examples/expect-extend/__tests__/ranges.test.ts b/examples/expect-extend/__tests__/ranges.test.ts new file mode 100644 index 000000000000..77f80e8a5ef8 --- /dev/null +++ b/examples/expect-extend/__tests__/ranges.test.ts @@ -0,0 +1,20 @@ +/** + * 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 {expect, test} from '@jest/globals'; +import '../toBeWithinRange'; + +test('is within range', () => expect(100).toBeWithinRange(90, 110)); + +test('is NOT within range', () => expect(101).not.toBeWithinRange(0, 100)); + +test('asymmetric ranges', () => { + expect({apples: 6, bananas: 3}).toEqual({ + apples: expect.toBeWithinRange(1, 10), + bananas: expect.not.toBeWithinRange(11, 20), + }); +}); diff --git a/examples/expect-extend/package.json b/examples/expect-extend/package.json new file mode 100644 index 000000000000..ae42cf64bac5 --- /dev/null +++ b/examples/expect-extend/package.json @@ -0,0 +1,29 @@ +{ + "private": true, + "version": "0.0.0", + "name": "example-expect-extend", + "devDependencies": { + "@babel/core": "*", + "@babel/preset-env": "*", + "@babel/preset-typescript": "*", + "@jest/globals": "*", + "babel-jest": "*", + "jest": "*" + }, + "scripts": { + "test": "jest" + }, + "babel": { + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "current" + } + } + ], + "@babel/preset-typescript" + ] + } +} diff --git a/examples/expect-extend/toBeWithinRange.ts b/examples/expect-extend/toBeWithinRange.ts new file mode 100644 index 000000000000..ff937aa6b64c --- /dev/null +++ b/examples/expect-extend/toBeWithinRange.ts @@ -0,0 +1,38 @@ +/** + * 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 {expect} from '@jest/globals'; + +expect.extend({ + toBeWithinRange(actual: number, floor: number, ceiling: number) { + const pass = actual >= floor && actual <= ceiling; + if (pass) { + return { + message: () => + `expected ${actual} not to be within range ${floor} - ${ceiling}`, + pass: true, + }; + } else { + return { + message: () => + `expected ${actual} to be within range ${floor} - ${ceiling}`, + pass: false, + }; + } + }, +}); + +declare module '@jest/types' { + namespace Expect { + interface AsymmetricMatchers { + toBeWithinRange(a: number, b: number): AsymmetricMatcher; + } + interface Matchers { + toBeWithinRange(a: number, b: number): R; + } + } +} diff --git a/packages/expect/__typetests__/expect.test.ts b/packages/expect/__typetests__/expect.test.ts index af5d2ad1fe21..48d8fd362437 100644 --- a/packages/expect/__typetests__/expect.test.ts +++ b/packages/expect/__typetests__/expect.test.ts @@ -5,8 +5,9 @@ * LICENSE file in the root directory of this source tree. */ -import {expectError} from 'tsd-lite'; +import {expectError, expectType} from 'tsd-lite'; import type * as expect from 'expect'; +import type * as jestMatcherUtils from 'jest-matcher-utils'; type M = expect.Matchers; type N = expect.Matchers; @@ -14,3 +15,93 @@ type N = expect.Matchers; expectError(() => { type E = expect.Matchers; }); + +// extend + +type Tester = (a: any, b: any) => boolean | undefined; + +type MatcherUtils = typeof jestMatcherUtils & { + iterableEquality: Tester; + subsetEquality: Tester; +}; + +expectType( + expect.extend({ + toBeWithinRange(actual: number, floor: number, ceiling: number) { + expectType(this.assertionCalls); + expectType(this.currentTestName); + expectType<(() => void) | undefined>(this.dontThrow); + expectType(this.error); + expectType< + ( + a: unknown, + b: unknown, + customTesters?: Array, + strictCheck?: boolean, + ) => boolean + >(this.equals); + expectType(this.expand); + expectType(this.expectedAssertionsNumber); + expectType(this.expectedAssertionsNumberError); + expectType(this.isExpectingAssertions); + expectType(this.isExpectingAssertionsError); + expectType(this.isNot); + expectType(this.promise); + expectType>(this.suppressedErrors); + expectType(this.testPath); + expectType(this.utils); + + // `snapshotState` type should not leak from `@jest/types` + + expectError(this.snapshotState); + + const pass = actual >= floor && actual <= ceiling; + if (pass) { + return { + message: () => + `expected ${actual} not to be within range ${floor} - ${ceiling}`, + pass: true, + }; + } else { + return { + message: () => + `expected ${actual} to be within range ${floor} - ${ceiling}`, + pass: false, + }; + } + }, + }), +); + +declare module '@jest/types' { + namespace Expect { + interface AsymmetricMatchers { + toBeWithinRange(floor: number, ceiling: number): AsymmetricMatcher; + } + interface Matchers { + toBeWithinRange(floor: number, ceiling: number): R; + } + } +} + +expectType(expect(100).toBeWithinRange(90, 110)); +expectType(expect(101).not.toBeWithinRange(0, 100)); + +expectType( + expect({apples: 6, bananas: 3}).toEqual({ + apples: expect.toBeWithinRange(1, 10), + bananas: expect.not.toBeWithinRange(11, 20), + }), +); + +// `addSnapshotSerializer` type should not leak from `@jest/types` + +expectError(expect.addSnapshotSerializer()); + +// snapshot matchers types should not leak from `@jest/types` + +expectError(expect({a: 1}).toMatchSnapshot()); +expectError(expect('abc').toMatchInlineSnapshot()); + +expectError(expect(jest.fn()).toThrowErrorMatchingSnapshot()); +expectError(expect(jest.fn()).toThrowErrorMatchingInlineSnapshot()); diff --git a/packages/expect/src/asymmetricMatchers.ts b/packages/expect/src/asymmetricMatchers.ts index 443552e6ff1f..1261bafe03f9 100644 --- a/packages/expect/src/asymmetricMatchers.ts +++ b/packages/expect/src/asymmetricMatchers.ts @@ -12,12 +12,9 @@ import { iterableEquality, subsetEquality, } from '@jest/expect-utils'; +import type {Expect} from '@jest/types'; import * as matcherUtils from 'jest-matcher-utils'; import {getState} from './jestMatchersObject'; -import type { - AsymmetricMatcher as AsymmetricMatcherInterface, - MatcherState, -} from './types'; const functionToString = Function.prototype.toString; @@ -64,22 +61,18 @@ export function hasProperty(obj: object | null, property: string): boolean { return hasProperty(getPrototype(obj), property); } -export abstract class AsymmetricMatcher< - T, - State extends MatcherState = MatcherState, -> implements AsymmetricMatcherInterface -{ +export abstract class AsymmetricMatcher implements Expect.AsymmetricMatcher { $$typeof = Symbol.for('jest.asymmetricMatcher'); constructor(protected sample: T, protected inverse = false) {} - protected getMatcherContext(): State { + protected getMatcherContext(): Expect.MatcherState { return { ...getState(), equals, isNot: this.inverse, utils, - } as State; + }; } abstract asymmetricMatch(other: unknown): boolean; diff --git a/packages/expect/src/extractExpectedAssertionsErrors.ts b/packages/expect/src/extractExpectedAssertionsErrors.ts index ebc24cd9d976..81a0f7808ce2 100644 --- a/packages/expect/src/extractExpectedAssertionsErrors.ts +++ b/packages/expect/src/extractExpectedAssertionsErrors.ts @@ -6,6 +6,7 @@ * */ +import type {Expect} from '@jest/types'; import { EXPECTED_COLOR, RECEIVED_COLOR, @@ -13,7 +14,6 @@ import { pluralize, } from 'jest-matcher-utils'; import {getState, setState} from './jestMatchersObject'; -import type {Expect, ExpectedAssertionsErrors} from './types'; const resetAssertionsLocalState = () => { setState({ @@ -25,9 +25,9 @@ const resetAssertionsLocalState = () => { // Create and format all errors related to the mismatched number of `expect` // calls and reset the matcher's state. -const extractExpectedAssertionsErrors: Expect['extractExpectedAssertionsErrors'] = +const extractExpectedAssertionsErrors: Expect.Expect['extractExpectedAssertionsErrors'] = () => { - const result: ExpectedAssertionsErrors = []; + const result: Expect.ExpectedAssertionsErrors = []; const { assertionCalls, expectedAssertionsNumber, diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index 7edb89b4d54d..bc936b30f883 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -9,6 +9,7 @@ /* eslint-disable local/prefer-spread-eventually */ import {equals, iterableEquality, subsetEquality} from '@jest/expect-utils'; +import type {Expect} from '@jest/types'; import * as matcherUtils from 'jest-matcher-utils'; import { any, @@ -26,7 +27,8 @@ import { } from './asymmetricMatchers'; import extractExpectedAssertionsErrors from './extractExpectedAssertionsErrors'; import { - INTERNAL_MATCHER_FLAG, + BUILD_IN_MATCHER_FLAG, + BuildInRawMatcherFn, getMatchers, getState, setMatchers, @@ -37,22 +39,12 @@ import spyMatchers from './spyMatchers'; import toThrowMatchers, { createMatcher as createThrowMatcher, } from './toThrowMatchers'; -import type { - AsyncExpectationResult, - Expect, - ExpectationResult, - MatcherState, - MatchersObject, - PromiseMatcherFn, - RawMatcherFn, - SyncExpectationResult, - ThrowingMatcherFn, -} from './types'; - -export type {Expect, MatcherState, Matchers} from './types'; +import './types-patch'; export class JestAssertionError extends Error { - matcherResult?: Omit & {message: string}; + matcherResult?: Omit & { + message: string; + }; } const isPromise = (obj: any): obj is PromiseLike => @@ -61,10 +53,10 @@ const isPromise = (obj: any): obj is PromiseLike => typeof obj.then === 'function'; const createToThrowErrorMatchingSnapshotMatcher = function ( - matcher: RawMatcherFn, + matcher: Expect.RawMatcherFn, ) { return function ( - this: MatcherState, + this: Expect.MatcherState, received: any, testNameOrInlineSnapshot?: string, ) { @@ -72,7 +64,7 @@ const createToThrowErrorMatchingSnapshotMatcher = function ( }; }; -const getPromiseMatcher = (name: string, matcher: any) => { +const getPromiseMatcher = (name: string, matcher: Expect.RawMatcherFn) => { if (name === 'toThrow' || name === 'toThrowError') { return createThrowMatcher(name, true); } else if ( @@ -85,7 +77,7 @@ const getPromiseMatcher = (name: string, matcher: any) => { return null; }; -export const expect: Expect = (actual: any, ...rest: Array) => { +export const expect: Expect.Expect = (actual: any, ...rest: Array) => { if (rest.length !== 0) { throw new Error('Expect takes at most one argument.'); } @@ -146,11 +138,11 @@ const getMessage = (message?: () => string) => const makeResolveMatcher = ( matcherName: string, - matcher: RawMatcherFn, + matcher: Expect.RawMatcherFn, isNot: boolean, actual: Promise, outerErr: JestAssertionError, - ): PromiseMatcherFn => + ): Expect.PromiseMatcherFn => (...args) => { const options = { isNot, @@ -193,11 +185,11 @@ const makeResolveMatcher = const makeRejectMatcher = ( matcherName: string, - matcher: RawMatcherFn, + matcher: Expect.RawMatcherFn, isNot: boolean, actual: Promise | (() => Promise), outerErr: JestAssertionError, - ): PromiseMatcherFn => + ): Expect.PromiseMatcherFn => (...args) => { const options = { isNot, @@ -243,17 +235,17 @@ const makeRejectMatcher = }; const makeThrowingMatcher = ( - matcher: RawMatcherFn, + matcher: BuildInRawMatcherFn, isNot: boolean, promise: string, actual: any, err?: JestAssertionError, -): ThrowingMatcherFn => +): Expect.ThrowingMatcherFn => function throwingMatcher(...args): any { let throws = true; const utils = {...matcherUtils, iterableEquality, subsetEquality}; - const matcherContext: MatcherState = { + const matcherContext: Expect.MatcherState = { // When throws is disabled, the matcher will not throw errors during test // execution but instead add them to the global matcher state. If a // matcher throws, test execution is normally stopped immediately. The @@ -269,7 +261,7 @@ const makeThrowingMatcher = ( }; const processResult = ( - result: SyncExpectationResult, + result: Expect.SyncExpectationResult, asyncError?: JestAssertionError, ) => { _validateResult(result); @@ -311,7 +303,7 @@ const makeThrowingMatcher = ( const handleError = (error: Error) => { if ( - matcher[INTERNAL_MATCHER_FLAG] === true && + matcher[BUILD_IN_MATCHER_FLAG] === true && !(error instanceof JestAssertionError) && error.name !== 'PrettyFormatPluginError' && // Guard for some environments (browsers) that do not support this feature. @@ -323,11 +315,11 @@ const makeThrowingMatcher = ( throw error; }; - let potentialResult: ExpectationResult; + let potentialResult: Expect.ExpectationResult; try { potentialResult = - matcher[INTERNAL_MATCHER_FLAG] === true + matcher[BUILD_IN_MATCHER_FLAG] === true ? matcher.call(matcherContext, actual, ...args) : // It's a trap specifically for inline snapshot to capture this name // in the stack trace, so that it can correctly get the custom matcher @@ -337,7 +329,7 @@ const makeThrowingMatcher = ( })(); if (isPromise(potentialResult)) { - const asyncResult = potentialResult as AsyncExpectationResult; + const asyncResult = potentialResult as Expect.AsyncExpectationResult; const asyncError = new JestAssertionError(); if (Error.captureStackTrace) { Error.captureStackTrace(asyncError, throwingMatcher); @@ -347,7 +339,7 @@ const makeThrowingMatcher = ( .then(aResult => processResult(aResult, asyncError)) .catch(handleError); } else { - const syncResult = potentialResult as SyncExpectationResult; + const syncResult = potentialResult as Expect.SyncExpectationResult; return processResult(syncResult); } @@ -356,9 +348,8 @@ const makeThrowingMatcher = ( } }; -expect.extend = ( - matchers: MatchersObject, -): void => setMatchers(matchers, false, expect); +expect.extend = (matchers: Expect.MatchersObject): void => + setMatchers(matchers, false, expect); expect.anything = anything; expect.any = any; @@ -424,7 +415,6 @@ setMatchers(matchers, true, expect); setMatchers(spyMatchers, true, expect); setMatchers(toThrowMatchers, true, expect); -expect.addSnapshotSerializer = () => void 0; expect.assertions = assertions; expect.hasAssertions = hasAssertions; expect.getState = getState; diff --git a/packages/expect/src/jestMatchersObject.ts b/packages/expect/src/jestMatchersObject.ts index 544530bbe398..3f3ed04dd362 100644 --- a/packages/expect/src/jestMatchersObject.ts +++ b/packages/expect/src/jestMatchersObject.ts @@ -6,24 +6,22 @@ * */ +import type {Expect} from '@jest/types'; import {AsymmetricMatcher} from './asymmetricMatchers'; -import type { - Expect, - MatcherState, - MatchersObject, - SyncExpectationResult, -} from './types'; // Global matchers object holds the list of available matchers and // the state, that can hold matcher specific values that change over time. const JEST_MATCHERS_OBJECT = Symbol.for('$$jest-matchers-object'); -// Notes a built-in/internal Jest matcher. -// Jest may override the stack trace of Errors thrown by internal matchers. -export const INTERNAL_MATCHER_FLAG = Symbol.for('$$jest-internal-matcher'); +// Expect may override the stack trace of Errors thrown by built-in matchers. +export const BUILD_IN_MATCHER_FLAG = Symbol.for('$$build-in-matcher'); + +export type BuildInRawMatcherFn = Expect.RawMatcherFn & { + [BUILD_IN_MATCHER_FLAG]?: boolean; +}; if (!global.hasOwnProperty(JEST_MATCHERS_OBJECT)) { - const defaultState: Partial = { + const defaultState: Partial = { assertionCalls: 0, expectedAssertionsNumber: null, isExpectingAssertions: false, @@ -37,36 +35,30 @@ if (!global.hasOwnProperty(JEST_MATCHERS_OBJECT)) { }); } -export const getState = (): State => +export const getState = (): Expect.MatcherState => (global as any)[JEST_MATCHERS_OBJECT].state; -export const setState = ( - state: Partial, -): void => { +export const setState = (state: Partial): void => { Object.assign((global as any)[JEST_MATCHERS_OBJECT].state, state); }; -export const getMatchers = < - State extends MatcherState = MatcherState, ->(): MatchersObject => (global as any)[JEST_MATCHERS_OBJECT].matchers; +export const getMatchers = (): Expect.MatchersObject => + (global as any)[JEST_MATCHERS_OBJECT].matchers; -export const setMatchers = ( - matchers: MatchersObject, - isInternal: boolean, - expect: Expect, +export const setMatchers = ( + matchers: Expect.MatchersObject, + isBuildIn: boolean, + expect: Expect.Expect, ): void => { Object.keys(matchers).forEach(key => { - const matcher = matchers[key]; - Object.defineProperty(matcher, INTERNAL_MATCHER_FLAG, { - value: isInternal, - }); + const matcher = matchers[key] as BuildInRawMatcherFn; + matcher[BUILD_IN_MATCHER_FLAG] = isBuildIn; - if (!isInternal) { + if (!isBuildIn) { // expect is defined class CustomMatcher extends AsymmetricMatcher< - [unknown, ...Array], - State + [unknown, ...Array] > { constructor(inverse = false, ...sample: [unknown, ...Array]) { super(sample, inverse); @@ -77,7 +69,7 @@ export const setMatchers = ( this.getMatcherContext(), other, ...this.sample, - ) as SyncExpectationResult; + ) as Expect.SyncExpectationResult; return this.inverse ? !pass : pass; } diff --git a/packages/expect/src/matchers.ts b/packages/expect/src/matchers.ts index 4293d44ff9db..11fd72f363b4 100644 --- a/packages/expect/src/matchers.ts +++ b/packages/expect/src/matchers.ts @@ -8,6 +8,7 @@ /* eslint-disable local/ban-types-eventually */ +import type {Expect} from '@jest/types'; import { arrayBufferEquality, equals, @@ -48,7 +49,6 @@ import { printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring, } from './print'; -import type {MatchersObject} from './types'; // Omit colon and one or more spaces, so can call getLabelPrinter. const EXPECTED_LABEL = 'Expected'; @@ -73,7 +73,7 @@ type ContainIterable = | DOMTokenList | HTMLCollectionOf; -const matchers: MatchersObject = { +const matchers: Expect.MatchersObject = { toBe(received: unknown, expected: unknown) { const matcherName = 'toBe'; const options: MatcherHintOptions = { diff --git a/packages/expect/src/spyMatchers.ts b/packages/expect/src/spyMatchers.ts index 2b229f6a5c87..8cd5b8e73cad 100644 --- a/packages/expect/src/spyMatchers.ts +++ b/packages/expect/src/spyMatchers.ts @@ -6,6 +6,7 @@ */ import {equals, iterableEquality} from '@jest/expect-utils'; +import type {Expect} from '@jest/types'; import {getType, isPrimitive} from 'jest-get-type'; import { DIM_COLOR, @@ -22,11 +23,6 @@ import { printWithType, stringify, } from 'jest-matcher-utils'; -import type { - MatcherState, - MatchersObject, - SyncExpectationResult, -} from './types'; // The optional property of matcher context is true if undefined. const isExpand = (expand?: boolean): boolean => expand !== false; @@ -356,10 +352,10 @@ const printReceivedResults = ( const createToBeCalledMatcher = (matcherName: string) => function ( - this: MatcherState, + this: Expect.MatcherState, received: any, expected: unknown, - ): SyncExpectationResult { + ): Expect.SyncExpectationResult { const expectedArgument = ''; const options: MatcherHintOptions = { isNot: this.isNot, @@ -403,10 +399,10 @@ const createToBeCalledMatcher = (matcherName: string) => const createToReturnMatcher = (matcherName: string) => function ( - this: MatcherState, + this: Expect.MatcherState, received: any, expected: unknown, - ): SyncExpectationResult { + ): Expect.SyncExpectationResult { const expectedArgument = ''; const options: MatcherHintOptions = { isNot: this.isNot, @@ -461,10 +457,10 @@ const createToReturnMatcher = (matcherName: string) => const createToBeCalledTimesMatcher = (matcherName: string) => function ( - this: MatcherState, + this: Expect.MatcherState, received: any, expected: number, - ): SyncExpectationResult { + ): Expect.SyncExpectationResult { const expectedArgument = 'expected'; const options: MatcherHintOptions = { isNot: this.isNot, @@ -497,10 +493,10 @@ const createToBeCalledTimesMatcher = (matcherName: string) => const createToReturnTimesMatcher = (matcherName: string) => function ( - this: MatcherState, + this: Expect.MatcherState, received: any, expected: number, - ): SyncExpectationResult { + ): Expect.SyncExpectationResult { const expectedArgument = 'expected'; const options: MatcherHintOptions = { isNot: this.isNot, @@ -545,10 +541,10 @@ const createToReturnTimesMatcher = (matcherName: string) => const createToBeCalledWithMatcher = (matcherName: string) => function ( - this: MatcherState, + this: Expect.MatcherState, received: any, ...expected: Array - ): SyncExpectationResult { + ): Expect.SyncExpectationResult { const expectedArgument = '...expected'; const options: MatcherHintOptions = { isNot: this.isNot, @@ -618,10 +614,10 @@ const createToBeCalledWithMatcher = (matcherName: string) => const createToReturnWithMatcher = (matcherName: string) => function ( - this: MatcherState, + this: Expect.MatcherState, received: any, expected: unknown, - ): SyncExpectationResult { + ): Expect.SyncExpectationResult { const expectedArgument = 'expected'; const options: MatcherHintOptions = { isNot: this.isNot, @@ -691,10 +687,10 @@ const createToReturnWithMatcher = (matcherName: string) => const createLastCalledWithMatcher = (matcherName: string) => function ( - this: MatcherState, + this: Expect.MatcherState, received: any, ...expected: Array - ): SyncExpectationResult { + ): Expect.SyncExpectationResult { const expectedArgument = '...expected'; const options: MatcherHintOptions = { isNot: this.isNot, @@ -774,10 +770,10 @@ const createLastCalledWithMatcher = (matcherName: string) => const createLastReturnedMatcher = (matcherName: string) => function ( - this: MatcherState, + this: Expect.MatcherState, received: any, expected: unknown, - ): SyncExpectationResult { + ): Expect.SyncExpectationResult { const expectedArgument = 'expected'; const options: MatcherHintOptions = { isNot: this.isNot, @@ -858,11 +854,11 @@ const createLastReturnedMatcher = (matcherName: string) => const createNthCalledWithMatcher = (matcherName: string) => function ( - this: MatcherState, + this: Expect.MatcherState, received: any, nth: number, ...expected: Array - ): SyncExpectationResult { + ): Expect.SyncExpectationResult { const expectedArgument = 'n'; const options: MatcherHintOptions = { expectedColor: (arg: string) => arg, @@ -988,11 +984,11 @@ const createNthCalledWithMatcher = (matcherName: string) => const createNthReturnedWithMatcher = (matcherName: string) => function ( - this: MatcherState, + this: Expect.MatcherState, received: any, nth: number, expected: unknown, - ): SyncExpectationResult { + ): Expect.SyncExpectationResult { const expectedArgument = 'n'; const options: MatcherHintOptions = { expectedColor: (arg: string) => arg, @@ -1116,7 +1112,7 @@ const createNthReturnedWithMatcher = (matcherName: string) => return {message, pass}; }; -const spyMatchers: MatchersObject = { +const spyMatchers: Expect.MatchersObject = { lastCalledWith: createLastCalledWithMatcher('lastCalledWith'), lastReturnedWith: createLastReturnedMatcher('lastReturnedWith'), nthCalledWith: createNthCalledWithMatcher('nthCalledWith'), diff --git a/packages/expect/src/toThrowMatchers.ts b/packages/expect/src/toThrowMatchers.ts index a30cf977917f..c4c0dd7d7588 100644 --- a/packages/expect/src/toThrowMatchers.ts +++ b/packages/expect/src/toThrowMatchers.ts @@ -9,6 +9,7 @@ /* eslint-disable local/ban-types-eventually */ import {isError} from '@jest/expect-utils'; +import type {Expect} from '@jest/types'; import { EXPECTED_COLOR, MatcherHintOptions, @@ -29,13 +30,6 @@ import { printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring, } from './print'; -import type { - ExpectationResult, - MatcherState, - MatchersObject, - RawMatcherFn, - SyncExpectationResult, -} from './types'; const DID_NOT_THROW = 'Received function did not throw'; @@ -77,12 +71,12 @@ const getThrown = (e: any): Thrown => { export const createMatcher = ( matcherName: string, fromPromise?: boolean, -): RawMatcherFn => +): Expect.RawMatcherFn => function ( - this: MatcherState, + this: Expect.MatcherState, received: Function, expected: any, - ): ExpectationResult { + ): Expect.ExpectationResult { const options = { isNot: this.isNot, promise: this.promise, @@ -141,7 +135,7 @@ export const createMatcher = ( } }; -const matchers: MatchersObject = { +const matchers: Expect.MatchersObject = { toThrow: createMatcher('toThrow'), toThrowError: createMatcher('toThrowError'), }; @@ -151,7 +145,7 @@ const toThrowExpectedRegExp = ( options: MatcherHintOptions, thrown: Thrown | null, expected: RegExp, -): SyncExpectationResult => { +): Expect.SyncExpectationResult => { const pass = thrown !== null && expected.test(thrown.message); const message = pass @@ -190,7 +184,7 @@ const toThrowExpectedAsymmetric = ( options: MatcherHintOptions, thrown: Thrown | null, expected: AsymmetricMatcher, -): SyncExpectationResult => { +): Expect.SyncExpectationResult => { const pass = thrown !== null && expected.asymmetricMatch(thrown.value); const message = pass @@ -225,7 +219,7 @@ const toThrowExpectedObject = ( options: MatcherHintOptions, thrown: Thrown | null, expected: Error, -): SyncExpectationResult => { +): Expect.SyncExpectationResult => { const pass = thrown !== null && thrown.message === expected.message; const message = pass @@ -264,7 +258,7 @@ const toThrowExpectedClass = ( options: MatcherHintOptions, thrown: Thrown | null, expected: Function, -): SyncExpectationResult => { +): Expect.SyncExpectationResult => { const pass = thrown !== null && thrown.value instanceof expected; const message = pass @@ -314,7 +308,7 @@ const toThrowExpectedString = ( options: MatcherHintOptions, thrown: Thrown | null, expected: string, -): SyncExpectationResult => { +): Expect.SyncExpectationResult => { const pass = thrown !== null && thrown.message.includes(expected); const message = pass @@ -348,7 +342,7 @@ const toThrow = ( matcherName: string, options: MatcherHintOptions, thrown: Thrown | null, -): SyncExpectationResult => { +): Expect.SyncExpectationResult => { const pass = thrown !== null; const message = pass diff --git a/packages/expect/src/types-patch.ts b/packages/expect/src/types-patch.ts new file mode 100644 index 000000000000..20d012865431 --- /dev/null +++ b/packages/expect/src/types-patch.ts @@ -0,0 +1,23 @@ +/** + * 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 {EqualsFunction, Tester} from '@jest/expect-utils'; +import type * as jestMatcherUtils from 'jest-matcher-utils'; + +declare module '@jest/types' { + namespace Expect { + interface MatcherState { + // TODO consider removing all utils from MatcherState + equals: EqualsFunction; + utils: typeof jestMatcherUtils & { + iterableEquality: Tester; + subsetEquality: Tester; + }; + } + } +} diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts index 6e4e01f7da2c..f54bba638c00 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts @@ -15,7 +15,7 @@ import { createEmptyTestResult, } from '@jest/test-result'; import type {Circus, Config, Global} from '@jest/types'; -import {Expect, expect} from 'expect'; +import {expect} from 'expect'; import {bind} from 'jest-each'; import {formatExecError, formatResultsErrors} from 'jest-message-util'; import { @@ -37,8 +37,12 @@ import createExpect from './jestExpect'; type Process = NodeJS.Process; -interface JestGlobals extends Global.TestFrameworkGlobals { - expect: Expect; +declare module '@jest/types' { + namespace Expect { + interface MatcherState { + snapshotState: SnapshotState; + } + } } export const initialize = async ({ @@ -58,7 +62,7 @@ export const initialize = async ({ testPath: Config.Path; parentProcess: Process; sendMessageToJest?: TestFileEvent; - setGlobalsForRuntime: (globals: JestGlobals) => void; + setGlobalsForRuntime: (runtimeGlobals: Global.RuntimeGlobals) => void; }): Promise<{ globals: Global.TestFrameworkGlobals; snapshotState: SnapshotState; @@ -124,7 +128,7 @@ export const initialize = async ({ addEventHandler(environment.handleTestEvent.bind(environment)); } - const runtimeGlobals: JestGlobals = { + const runtimeGlobals: Global.RuntimeGlobals = { ...globalsObject, expect: createExpect(globalConfig), }; @@ -161,7 +165,7 @@ export const initialize = async ({ snapshotFormat: config.snapshotFormat, updateSnapshot, }); - // @ts-expect-error: snapshotState is a jest extension of `expect` + expect.setState({snapshotState, testPath}); addEventHandler(handleSnapshotStateAfterRetry(snapshotState)); diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/jestExpect.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/jestExpect.ts index 6f6332f72490..0c91e1f3c6d9 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestExpect.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestExpect.ts @@ -5,8 +5,8 @@ * LICENSE file in the root directory of this source tree. */ -import type {Config} from '@jest/types'; -import {Expect, expect} from 'expect'; +import type {Config, Expect} from '@jest/types'; +import {expect} from 'expect'; import { addSerializer, toMatchInlineSnapshot, @@ -16,17 +16,19 @@ import { } from 'jest-snapshot'; export default function jestExpect( - config: Pick, -): Expect { - expect.setState({expand: config.expand}); - expect.extend({ + config: Config.GlobalConfig, +): Expect.JestExpect { + const jestExpect = expect as Expect.JestExpect; + + jestExpect.setState({expand: config.expand}); + jestExpect.extend({ toMatchInlineSnapshot, toMatchSnapshot, toThrowErrorMatchingInlineSnapshot, toThrowErrorMatchingSnapshot, }); - expect.addSnapshotSerializer = addSerializer; + jestExpect.addSnapshotSerializer = addSerializer; - return expect; + return jestExpect; } diff --git a/packages/jest-circus/src/types.ts b/packages/jest-circus/src/types.ts index 92d47eb16983..babde014441c 100644 --- a/packages/jest-circus/src/types.ts +++ b/packages/jest-circus/src/types.ts @@ -6,7 +6,6 @@ */ import type {Circus} from '@jest/types'; -import type {Expect} from 'expect'; export const STATE_SYM = Symbol( 'JEST_STATE_SYMBOL', @@ -25,7 +24,6 @@ declare global { STATE_SYM_SYMBOL: Circus.State; RETRY_TIMES_SYMBOL: string; TEST_TIMEOUT_SYMBOL: number; - expect: Expect; } } } diff --git a/packages/jest-circus/tsconfig.json b/packages/jest-circus/tsconfig.json index 4e6535abac08..9184e07ef419 100644 --- a/packages/jest-circus/tsconfig.json +++ b/packages/jest-circus/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../tsconfig", "compilerOptions": { + // we don't want `@types/jest` to be referenced + "types": [], "outDir": "build", "rootDir": "src" }, diff --git a/packages/jest-globals/package.json b/packages/jest-globals/package.json index deb0d63a4598..b1585a5c0ceb 100644 --- a/packages/jest-globals/package.json +++ b/packages/jest-globals/package.json @@ -21,8 +21,9 @@ }, "dependencies": { "@jest/environment": "^27.5.1", + "@jest/expect-utils": "^27.5.1", "@jest/types": "^27.5.1", - "expect": "^27.5.1" + "jest-matcher-utils": "^27.5.1" }, "publishConfig": { "access": "public" diff --git a/packages/jest-globals/src/index.ts b/packages/jest-globals/src/index.ts index 5d84d89125d1..8b2f9fd597e7 100644 --- a/packages/jest-globals/src/index.ts +++ b/packages/jest-globals/src/index.ts @@ -6,26 +6,43 @@ */ import type {Jest} from '@jest/environment'; +import type {EqualsFunction, Tester} from '@jest/expect-utils'; import type {Global} from '@jest/types'; -import type {Expect} from 'expect'; +import type * as jestMatcherUtils from 'jest-matcher-utils'; export declare const jest: Jest; -export declare const expect: Expect; - export declare const it: Global.GlobalAdditions['it']; -export declare const test: Global.GlobalAdditions['test']; export declare const fit: Global.GlobalAdditions['fit']; export declare const xit: Global.GlobalAdditions['xit']; + +export declare const test: Global.GlobalAdditions['test']; export declare const xtest: Global.GlobalAdditions['xtest']; + export declare const describe: Global.GlobalAdditions['describe']; -export declare const xdescribe: Global.GlobalAdditions['xdescribe']; export declare const fdescribe: Global.GlobalAdditions['fdescribe']; +export declare const xdescribe: Global.GlobalAdditions['xdescribe']; + export declare const beforeAll: Global.GlobalAdditions['beforeAll']; export declare const beforeEach: Global.GlobalAdditions['beforeEach']; export declare const afterEach: Global.GlobalAdditions['afterEach']; export declare const afterAll: Global.GlobalAdditions['afterAll']; +export declare const expect: Global.GlobalAdditions['expect']; + throw new Error( 'Do not import `@jest/globals` outside of the Jest test environment', ); + +declare module '@jest/types' { + namespace Expect { + interface MatcherState { + // TODO consider removing all utils from MatcherState + equals: EqualsFunction; + utils: typeof jestMatcherUtils & { + iterableEquality: Tester; + subsetEquality: Tester; + }; + } + } +} diff --git a/packages/jest-globals/tsconfig.json b/packages/jest-globals/tsconfig.json index ef7d4e629e02..1437352ee555 100644 --- a/packages/jest-globals/tsconfig.json +++ b/packages/jest-globals/tsconfig.json @@ -9,8 +9,9 @@ "include": ["./src/**/*"], "exclude": ["./**/__tests__/**/*"], "references": [ - {"path": "../expect"}, + {"path": "../expect-utils"}, {"path": "../jest-environment"}, + {"path": "../jest-matcher-utils"}, {"path": "../jest-types"} ] } diff --git a/packages/jest-jasmine2/src/jestExpect.ts b/packages/jest-jasmine2/src/jestExpect.ts index 1c1d2a2e85c1..bc1f674831ae 100644 --- a/packages/jest-jasmine2/src/jestExpect.ts +++ b/packages/jest-jasmine2/src/jestExpect.ts @@ -7,8 +7,8 @@ /* eslint-disable local/prefer-spread-eventually */ -import type {Global} from '@jest/types'; -import {MatcherState, expect} from 'expect'; +import type {Expect, Global} from '@jest/types'; +import {expect} from 'expect'; import { addSerializer, toMatchInlineSnapshot, @@ -16,35 +16,37 @@ import { toThrowErrorMatchingInlineSnapshot, toThrowErrorMatchingSnapshot, } from 'jest-snapshot'; -import type {Jasmine, JasmineMatchersObject, RawMatcherFn} from './types'; +import type {Jasmine, JasmineMatchersObject} from './types'; declare const global: Global.Global; export default function jestExpect(config: {expand: boolean}): void { - global.expect = expect; - expect.setState({expand: config.expand}); - expect.extend({ + const jestExpect = expect as Expect.JestExpect; + + global.expect = jestExpect; + jestExpect.setState({expand: config.expand}); + jestExpect.extend({ toMatchInlineSnapshot, toMatchSnapshot, toThrowErrorMatchingInlineSnapshot, toThrowErrorMatchingSnapshot, }); - expect.addSnapshotSerializer = addSerializer; + jestExpect.addSnapshotSerializer = addSerializer; const jasmine = global.jasmine as Jasmine; - jasmine.anything = expect.anything; - jasmine.any = expect.any; - jasmine.objectContaining = expect.objectContaining; - jasmine.arrayContaining = expect.arrayContaining; - jasmine.stringMatching = expect.stringMatching; + jasmine.anything = jestExpect.anything; + jasmine.any = jestExpect.any; + jasmine.objectContaining = jestExpect.objectContaining; + jasmine.arrayContaining = jestExpect.arrayContaining; + jasmine.stringMatching = jestExpect.stringMatching; jasmine.addMatchers = (jasmineMatchersObject: JasmineMatchersObject) => { const jestMatchersObject = Object.create(null); Object.keys(jasmineMatchersObject).forEach(name => { jestMatchersObject[name] = function ( - this: MatcherState, + this: Expect.MatcherState, ...args: Array - ): RawMatcherFn { + ): Expect.RawMatcherFn { // use "expect.extend" if you need to use equality testers (via this.equal) const result = jasmineMatchersObject[name](null, null); // if there is no 'negativeCompare', both should be handled by `compare` @@ -64,6 +66,6 @@ export default function jestExpect(config: {expand: boolean}): void { }; }); - expect.extend(jestMatchersObject); + jestExpect.extend(jestMatchersObject); }; } diff --git a/packages/jest-jasmine2/src/setup_jest_globals.ts b/packages/jest-jasmine2/src/setup_jest_globals.ts index 85473e4bf2d3..53e70c92be73 100644 --- a/packages/jest-jasmine2/src/setup_jest_globals.ts +++ b/packages/jest-jasmine2/src/setup_jest_globals.ts @@ -22,6 +22,14 @@ import type {Jasmine} from './types'; declare const global: Global.Global; +declare module '@jest/types' { + namespace Expect { + interface MatcherState { + snapshotState: SnapshotState; + } + } +} + export type SetupOptions = { config: Config.ProjectConfig; globalConfig: Config.GlobalConfig; @@ -114,8 +122,9 @@ export default async function setupJestGlobals({ snapshotFormat, updateSnapshot, }); - // @ts-expect-error: snapshotState is a jest extension of `expect` + expect.setState({snapshotState, testPath}); + // Return it back to the outer scope (test runner outside the VM). return snapshotState; } diff --git a/packages/jest-jasmine2/src/types.ts b/packages/jest-jasmine2/src/types.ts index 0266dcc0502c..39e9f2726050 100644 --- a/packages/jest-jasmine2/src/types.ts +++ b/packages/jest-jasmine2/src/types.ts @@ -6,8 +6,7 @@ */ import type {AssertionError} from 'assert'; -import type {Config} from '@jest/types'; -import type {Expect} from 'expect'; +import type {Config, Expect} from '@jest/types'; import type CallTracker from './jasmine/CallTracker'; import type Env from './jasmine/Env'; import type JsApiReporter from './jasmine/JsApiReporter'; @@ -25,25 +24,6 @@ export interface AssertionErrorWithStack extends AssertionError { stack: string; } -// TODO Add expect types to @jest/types or leave it here -// Borrowed from "expect" -// -------START------- -export type SyncExpectationResult = { - pass: boolean; - message: () => string; -}; - -export type AsyncExpectationResult = Promise; - -export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult; - -export type RawMatcherFn = ( - expected: unknown, - actual: unknown, - options?: unknown, -) => ExpectationResult; -// -------END------- - export type RunDetails = { totalSpecsDefined?: number; failedExpectations?: SuiteResult['failedExpectations']; @@ -67,8 +47,8 @@ export interface Spy extends Record { type JasmineMatcher = { (matchersUtil: unknown, context: unknown): JasmineMatcher; - compare: () => RawMatcherFn; - negativeCompare: () => RawMatcherFn; + compare: () => Expect.RawMatcherFn; + negativeCompare: () => Expect.RawMatcherFn; }; export type JasmineMatchersObject = {[id: string]: JasmineMatcher}; @@ -89,13 +69,13 @@ export type Jasmine = { version: string; testPath: Config.Path; addMatchers: (matchers: JasmineMatchersObject) => void; -} & Expect & +} & Expect.JestExpect & typeof globalThis; declare global { namespace NodeJS { interface Global { - expect: Expect; + expect: Expect.JestExpect; } } } diff --git a/packages/jest-jasmine2/tsconfig.json b/packages/jest-jasmine2/tsconfig.json index 81029dc1642f..f7813a30ad7a 100644 --- a/packages/jest-jasmine2/tsconfig.json +++ b/packages/jest-jasmine2/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../tsconfig", "compilerOptions": { + // we don't want `@types/jest` to be referenced + "types": [], "rootDir": "src", "outDir": "build" }, diff --git a/packages/jest-snapshot/src/index.ts b/packages/jest-snapshot/src/index.ts index ab9f1c1206bb..574cf8a3e6ca 100644 --- a/packages/jest-snapshot/src/index.ts +++ b/packages/jest-snapshot/src/index.ts @@ -8,7 +8,8 @@ /* eslint-disable local/ban-types-eventually */ import * as fs from 'graceful-fs'; -import type {Config} from '@jest/types'; +import {equals, iterableEquality, subsetEquality} from '@jest/expect-utils'; +import type {Config, Expect} from '@jest/types'; import type {FS as HasteFS} from 'jest-haste-map'; import { BOLD_WEIGHT, @@ -32,7 +33,7 @@ import { printReceived, printSnapshotAndReceived, } from './printSnapshot'; -import type {Context, ExpectationResult, MatchSnapshotConfig} from './types'; +import type {Context, MatchSnapshotConfig} from './types'; import {deepMerge, escapeBacktickString, serialize} from './utils'; export {addSerializer, getSerializers} from './plugins'; @@ -162,7 +163,7 @@ export const toMatchSnapshot = function ( received: unknown, propertiesOrHint?: object | Config.Path, hint?: Config.Path, -): ExpectationResult { +): Expect.ExpectationResult { const matcherName = 'toMatchSnapshot'; let properties; @@ -221,7 +222,7 @@ export const toMatchInlineSnapshot = function ( received: unknown, propertiesOrSnapshot?: object | string, inlineSnapshot?: string, -): ExpectationResult { +): Expect.ExpectationResult { const matcherName = 'toMatchInlineSnapshot'; let properties; @@ -332,9 +333,9 @@ const _toMatchSnapshot = (config: MatchSnapshotConfig) => { ); } - const propertyPass = context.equals(received, properties, [ - context.utils.iterableEquality, - context.utils.subsetEquality, + const propertyPass = equals(received, properties, [ + iterableEquality, + subsetEquality, ]); if (!propertyPass) { @@ -413,9 +414,9 @@ const _toMatchSnapshot = (config: MatchSnapshotConfig) => { export const toThrowErrorMatchingSnapshot = function ( this: Context, received: unknown, - hint: string | undefined, // because error TS1016 for hint?: string - fromPromise: boolean, -): ExpectationResult { + hint?: string, + fromPromise?: boolean, +): Expect.ExpectationResult { const matcherName = 'toThrowErrorMatchingSnapshot'; // Future breaking change: Snapshot hint must be a string @@ -438,7 +439,7 @@ export const toThrowErrorMatchingInlineSnapshot = function ( received: unknown, inlineSnapshot?: string, fromPromise?: boolean, -): ExpectationResult { +): Expect.ExpectationResult { const matcherName = 'toThrowErrorMatchingInlineSnapshot'; if (inlineSnapshot !== undefined && typeof inlineSnapshot !== 'string') { diff --git a/packages/jest-snapshot/src/types.ts b/packages/jest-snapshot/src/types.ts index 31db0429d212..751565c849c2 100644 --- a/packages/jest-snapshot/src/types.ts +++ b/packages/jest-snapshot/src/types.ts @@ -7,15 +7,11 @@ /* eslint-disable local/ban-types-eventually */ -import type {MatcherState} from 'expect'; +import type {Expect} from '@jest/types'; import type SnapshotState from './State'; -export type Context = MatcherState & { - snapshotState: SnapshotState; -}; - export type MatchSnapshotConfig = { - context: Context; + context: Expect.MatcherState; hint?: string; inlineSnapshot?: string; isInline: boolean; @@ -26,8 +22,10 @@ export type MatchSnapshotConfig = { export type SnapshotData = Record; -// copied from `expect` - should be shared -export type ExpectationResult = { - pass: boolean; - message: () => string; -}; +declare module '@jest/types' { + namespace Expect { + interface MatcherState { + snapshotState: SnapshotState; + } + } +} diff --git a/packages/jest-types/__typetests__/expect.test.ts b/packages/jest-types/__typetests__/expect.test.ts index 2c380a0c40c8..c639490b162b 100644 --- a/packages/jest-types/__typetests__/expect.test.ts +++ b/packages/jest-types/__typetests__/expect.test.ts @@ -7,6 +7,8 @@ import {expectError, expectType} from 'tsd-lite'; import {expect} from '@jest/globals'; +import type * as jestMatcherUtils from 'jest-matcher-utils'; +import type {SnapshotState} from 'jest-snapshot'; // asymmetric matchers @@ -349,27 +351,79 @@ expectError(expect(jest.fn()).toThrowErrorMatchingInlineSnapshot(true)); // extend +type Tester = (a: any, b: any) => boolean | undefined; + +type MatcherUtils = typeof jestMatcherUtils & { + iterableEquality: Tester; + subsetEquality: Tester; +}; + expectType( expect.extend({ - toBeDivisibleBy(actual: number, expected: number) { + toBeWithinRange(actual: number, floor: number, ceiling: number) { + expectType(this.assertionCalls); + expectType(this.currentTestName); + expectType<(() => void) | undefined>(this.dontThrow); + expectType(this.error); + expectType< + ( + a: unknown, + b: unknown, + customTesters?: Array, + strictCheck?: boolean, + ) => boolean + >(this.equals); + expectType(this.expand); + expectType(this.expectedAssertionsNumber); + expectType(this.expectedAssertionsNumberError); + expectType(this.isExpectingAssertions); + expectType(this.isExpectingAssertionsError); expectType(this.isNot); - - const pass = actual % expected === 0; - const message = pass - ? () => - `expected ${this.utils.printReceived( - actual, - )} not to be divisible by ${expected}` - : () => - `expected ${this.utils.printReceived( - actual, - )} to be divisible by ${expected}`; - - return {message, pass}; + expectType(this.promise); + expectType(this.snapshotState); + expectType>(this.suppressedErrors); + expectType(this.testPath); + expectType(this.utils); + + const pass = actual >= floor && actual <= ceiling; + if (pass) { + return { + message: () => + `expected ${actual} not to be within range ${floor} - ${ceiling}`, + pass: true, + }; + } else { + return { + message: () => + `expected ${actual} to be within range ${floor} - ${ceiling}`, + pass: false, + }; + } }, }), ); -// TODO -// expect(4).toBeDivisibleBy(2); -// expect.toBeDivisibleBy(2); +declare module '@jest/types' { + namespace Expect { + interface AsymmetricMatchers { + toBeWithinRange(floor: number, ceiling: number): AsymmetricMatcher; + } + interface Matchers { + toBeWithinRange(floor: number, ceiling: number): R; + } + } +} + +expectType(expect(100).toBeWithinRange(90, 110)); +expectType(expect(101).not.toBeWithinRange(0, 100)); +expectType>(expect(101).resolves.toBeWithinRange(90, 110)); +expectType>(expect(101).resolves.not.toBeWithinRange(0, 100)); +expectType>(expect(101).rejects.toBeWithinRange(90, 110)); +expectType>(expect(101).rejects.not.toBeWithinRange(0, 100)); + +expectType( + expect({apples: 6, bananas: 3}).toEqual({ + apples: expect.toBeWithinRange(1, 10), + bananas: expect.not.toBeWithinRange(11, 20), + }), +); diff --git a/packages/jest-types/src/Circus.ts b/packages/jest-types/src/Circus.ts index d93e653d7822..a8aecf64522b 100644 --- a/packages/jest-types/src/Circus.ts +++ b/packages/jest-types/src/Circus.ts @@ -39,11 +39,6 @@ export interface EventHandler { export type Event = SyncEvent | AsyncEvent; -interface JestGlobals extends Global.TestFrameworkGlobals { - // we cannot type `expect` properly as it'd create circular dependencies - expect: unknown; -} - export type SyncEvent = | { asyncError: Error; @@ -83,7 +78,7 @@ export type AsyncEvent = // first action to dispatch. Good time to initialize all settings name: 'setup'; testNamePattern?: string; - runtimeGlobals: JestGlobals; + runtimeGlobals: Global.RuntimeGlobals; parentProcess: Process; } | { diff --git a/packages/expect/src/types.ts b/packages/jest-types/src/Expect.ts similarity index 84% rename from packages/expect/src/types.ts rename to packages/jest-types/src/Expect.ts index d9b7f31307fd..0a8aad715736 100644 --- a/packages/expect/src/types.ts +++ b/packages/jest-types/src/Expect.ts @@ -6,10 +6,7 @@ * */ -import type {EqualsFunction, Tester} from '@jest/expect-utils'; -import type {Config} from '@jest/types'; -import type * as jestMatcherUtils from 'jest-matcher-utils'; -import {INTERNAL_MATCHER_FLAG} from './jestMatchersObject'; +import type * as Config from './Config'; export type SyncExpectationResult = { pass: boolean; @@ -20,20 +17,22 @@ export type AsyncExpectationResult = Promise; export type ExpectationResult = SyncExpectationResult | AsyncExpectationResult; -export type RawMatcherFn = { - (this: T, received: any, expected: any, options?: any): ExpectationResult; - [INTERNAL_MATCHER_FLAG]?: boolean; -}; +export type RawMatcherFn = ( + this: MatcherState, + actual: any, + expected: any, + options?: any, +) => ExpectationResult; export type ThrowingMatcherFn = (actual: any) => void; + export type PromiseMatcherFn = (actual: any) => Promise; -export type MatcherState = { +export interface MatcherState { assertionCalls: number; currentTestName?: string; dontThrow?: () => void; error?: Error; - equals: EqualsFunction; expand?: boolean; expectedAssertionsNumber?: number | null; expectedAssertionsNumberError?: Error; @@ -43,40 +42,39 @@ export type MatcherState = { promise: string; suppressedErrors: Array; testPath?: Config.Path; - utils: typeof jestMatcherUtils & { - iterableEquality: Tester; - subsetEquality: Tester; - }; -}; + // Not possible to have `utils` here. Importing `jest-matcher-utils` would cause circular dependencies +} -export interface AsymmetricMatcher { +export type AsymmetricMatcher = { asymmetricMatch(other: unknown): boolean; toString(): string; getExpectedType?(): string; toAsymmetricMatcher?(): string; -} -export type MatchersObject = { - [id: string]: RawMatcherFn; }; + +export type MatchersObject = { + [id: string]: RawMatcherFn; +}; + export type ExpectedAssertionsErrors = Array<{ actual: string | number; error: Error; expected: string; }>; -export type Expect = { - (actual: T): Matchers & - InverseMatchers & - PromiseMatchers; - // TODO: this is added by test runners, not `expect` itself - addSnapshotSerializer(serializer: unknown): void; +type BaseExpect = { assertions(numberOfAssertions: number): void; - // TODO: remove this `T extends` - should get from some interface merging - extend(matchers: MatchersObject): void; + extend(matchers: MatchersObject): void; extractExpectedAssertionsErrors: () => ExpectedAssertionsErrors; - getState(): State; + getState(): MatcherState; hasAssertions(): void; - setState(state: Partial): void; + setState(state: Partial): void; +}; + +export type Expect = BaseExpect & { + (actual: T): Matchers & + InverseMatchers & + PromiseMatchers; } & AsymmetricMatchers & InverseAsymmetricMatchers; @@ -97,28 +95,28 @@ export interface AsymmetricMatchers { stringMatching(sample: string | RegExp): AsymmetricMatcher; } -type PromiseMatchers = { +type PromiseMatchers = { /** * Unwraps the reason of a rejected promise so any other matcher can be chained. * If the promise is fulfilled the assertion fails. */ - rejects: Matchers, T> & InverseMatchers, T>; + rejects: Matchers> & InverseMatchers>; /** * Unwraps the value of a fulfilled promise so any other matcher can be chained. * If the promise is rejected the assertion fails. */ - resolves: Matchers, T> & InverseMatchers, T>; + resolves: Matchers> & InverseMatchers>; }; -type InverseMatchers, T = unknown> = { +type InverseMatchers> = { /** * Inverse next matcher. If you know how to test something, `.not` lets you test its opposite. */ - not: Matchers; + not: Matchers; }; // This is a copy from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/de6730f4463cba69904698035fafd906a72b9664/types/jest/index.d.ts#L570-L817 -export interface Matchers, T = unknown> { +export interface Matchers> { /** * Ensures the last call to a mock function was provided specific args. */ @@ -328,8 +326,9 @@ export interface Matchers, T = unknown> { * If you want to test that a specific error is thrown inside a function. */ toThrowError(expected?: unknown): R; +} - /* TODO: START snapshot matchers are not from `expect`, the types should not be here */ +export interface SnapshotMatchers { /** * This ensures that a value matches the most recent snapshot with property matchers. * Check out [the Snapshot Testing guide](https://jestjs.io/docs/snapshot-testing) for more information. @@ -367,5 +366,37 @@ export interface Matchers, T = unknown> { * Instead of writing the snapshot value to a .snap file, it will be written into the source code automatically. */ toThrowErrorMatchingInlineSnapshot(snapshot?: string): R; - /* TODO: END snapshot matchers are not from `expect`, the types should not be here */ } + +type JestMatchers, T = unknown> = Matchers & + SnapshotMatchers; + +type InverseJestMatchers, T = unknown> = { + /** + * Inverse next matcher. If you know how to test something, `.not` lets you test its opposite. + */ + not: JestMatchers; +}; + +type PromiseJestMatchers = { + /** + * Unwraps the reason of a rejected promise so any other matcher can be chained. + * If the promise is fulfilled the assertion fails. + */ + rejects: JestMatchers, T> & + InverseJestMatchers, T>; + /** + * Unwraps the value of a fulfilled promise so any other matcher can be chained. + * If the promise is rejected the assertion fails. + */ + resolves: JestMatchers, T> & + InverseJestMatchers, T>; +}; + +export type JestExpect = BaseExpect & { + (actual: T): JestMatchers & + InverseJestMatchers & + PromiseJestMatchers; + addSnapshotSerializer(serializer: unknown): void; +} & AsymmetricMatchers & + InverseAsymmetricMatchers; diff --git a/packages/jest-types/src/Global.ts b/packages/jest-types/src/Global.ts index 024d5777ca98..ad7ff65d45e1 100644 --- a/packages/jest-types/src/Global.ts +++ b/packages/jest-types/src/Global.ts @@ -6,6 +6,7 @@ */ import type {CoverageMapData} from 'istanbul-lib-coverage'; +import type * as Expect from './Expect'; export type ValidTestReturnValues = void | undefined; type TestReturnValuePromise = Promise; @@ -122,7 +123,11 @@ export interface TestFrameworkGlobals { afterAll: HookBase; } -export interface GlobalAdditions extends TestFrameworkGlobals { +export interface RuntimeGlobals extends TestFrameworkGlobals { + expect: Expect.JestExpect; +} + +export interface GlobalAdditions extends RuntimeGlobals { __coverage__: CoverageMapData; jasmine: Jasmine; fail: () => void; diff --git a/packages/jest-types/src/index.ts b/packages/jest-types/src/index.ts index 8255474b61ba..f1ef6821b645 100644 --- a/packages/jest-types/src/index.ts +++ b/packages/jest-types/src/index.ts @@ -7,8 +7,9 @@ import type * as Circus from './Circus'; import type * as Config from './Config'; +import type * as Expect from './Expect'; import type * as Global from './Global'; import type * as TestResult from './TestResult'; import type * as TransformTypes from './Transform'; -export type {Circus, Config, Global, TestResult, TransformTypes}; +export type {Circus, Config, Expect, Global, TestResult, TransformTypes}; diff --git a/yarn.lock b/yarn.lock index d11b8f194ba9..3fe75f5bc12d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2586,13 +2586,14 @@ __metadata: languageName: unknown linkType: soft -"@jest/globals@^27.5.1, @jest/globals@workspace:*, @jest/globals@workspace:packages/jest-globals": +"@jest/globals@*, @jest/globals@^27.5.1, @jest/globals@workspace:*, @jest/globals@workspace:packages/jest-globals": version: 0.0.0-use.local resolution: "@jest/globals@workspace:packages/jest-globals" dependencies: "@jest/environment": ^27.5.1 + "@jest/expect-utils": ^27.5.1 "@jest/types": ^27.5.1 - expect: ^27.5.1 + jest-matcher-utils: ^27.5.1 languageName: unknown linkType: soft @@ -9836,6 +9837,19 @@ __metadata: languageName: unknown linkType: soft +"example-expect-extend@workspace:examples/expect-extend": + version: 0.0.0-use.local + resolution: "example-expect-extend@workspace:examples/expect-extend" + dependencies: + "@babel/core": "*" + "@babel/preset-env": "*" + "@babel/preset-typescript": "*" + "@jest/globals": "*" + babel-jest: "*" + jest: "*" + languageName: unknown + linkType: soft + "example-getting-started@workspace:examples/getting-started": version: 0.0.0-use.local resolution: "example-getting-started@workspace:examples/getting-started"