Skip to content

Commit 7c687ad

Browse files
authored
feat(expect): support expect.soft (#3507)
1 parent dfb46e6 commit 7c687ad

38 files changed

+519
-230
lines changed

packages/expect/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"chai": "^4.3.7"
4040
},
4141
"devDependencies": {
42+
"@vitest/runner": "workspace:*",
4243
"picocolors": "^1.0.0"
4344
}
4445
}

packages/expect/rollup.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const external = [
99
...Object.keys(pkg.dependencies || {}),
1010
...Object.keys(pkg.peerDependencies || {}),
1111
'@vitest/utils/diff',
12+
'@vitest/utils/error',
1213
]
1314

1415
const plugins = [

packages/expect/src/jest-expect.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,23 @@ import { assertTypes, getColors } from '@vitest/utils'
33
import type { Constructable } from '@vitest/utils'
44
import type { EnhancedSpy } from '@vitest/spy'
55
import { isMockFunction } from '@vitest/spy'
6+
import type { Test } from '@vitest/runner'
67
import type { Assertion, ChaiPlugin } from './types'
78
import { arrayBufferEquality, generateToBeMessage, iterableEquality, equals as jestEquals, sparseArrayEquality, subsetEquality, typeEquality } from './jest-utils'
89
import type { AsymmetricMatcher } from './jest-asymmetric-matchers'
910
import { diff, stringify } from './jest-matcher-utils'
1011
import { JEST_MATCHERS_OBJECT } from './constants'
11-
import { recordAsyncExpect } from './utils'
12+
import { recordAsyncExpect, wrapSoft } from './utils'
1213

1314
// Jest Expect Compact
1415
export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
1516
const c = () => getColors()
1617

1718
function def(name: keyof Assertion | (keyof Assertion)[], fn: ((this: Chai.AssertionStatic & Assertion, ...args: any[]) => any)) {
1819
const addMethod = (n: keyof Assertion) => {
19-
utils.addMethod(chai.Assertion.prototype, n, fn)
20-
utils.addMethod((globalThis as any)[JEST_MATCHERS_OBJECT].matchers, n, fn)
20+
const softWrapper = wrapSoft(utils, fn)
21+
utils.addMethod(chai.Assertion.prototype, n, softWrapper)
22+
utils.addMethod((globalThis as any)[JEST_MATCHERS_OBJECT].matchers, n, softWrapper)
2123
}
2224

2325
if (Array.isArray(name))
@@ -636,7 +638,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
636638
utils.addProperty(chai.Assertion.prototype, 'resolves', function __VITEST_RESOLVES__(this: any) {
637639
utils.flag(this, 'promise', 'resolves')
638640
utils.flag(this, 'error', new Error('resolves'))
639-
const test = utils.flag(this, 'vitest-test')
641+
const test: Test = utils.flag(this, 'vitest-test')
640642
const obj = utils.flag(this, 'object')
641643

642644
if (typeof obj?.then !== 'function')
@@ -671,7 +673,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
671673
utils.addProperty(chai.Assertion.prototype, 'rejects', function __VITEST_REJECTS__(this: any) {
672674
utils.flag(this, 'promise', 'rejects')
673675
utils.flag(this, 'error', new Error('rejects'))
674-
const test = utils.flag(this, 'vitest-test')
676+
const test: Test = utils.flag(this, 'vitest-test')
675677
const obj = utils.flag(this, 'object')
676678
const wrapper = typeof obj === 'function' ? obj() : obj // for jest compat
677679

packages/expect/src/jest-extend.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
iterableEquality,
1818
subsetEquality,
1919
} from './jest-utils'
20+
import { wrapSoft } from './utils'
2021

2122
function getMatcherState(assertion: Chai.AssertionStatic & Chai.Assertion, expect: ExpectStatic) {
2223
const obj = assertion._obj
@@ -75,8 +76,9 @@ function JestExtendPlugin(expect: ExpectStatic, matchers: MatchersObject): ChaiP
7576
throw new JestExtendError(message(), actual, expected)
7677
}
7778

78-
utils.addMethod((globalThis as any)[JEST_MATCHERS_OBJECT].matchers, expectAssertionName, expectWrapper)
79-
utils.addMethod(c.Assertion.prototype, expectAssertionName, expectWrapper)
79+
const softWrapper = wrapSoft(utils, expectWrapper)
80+
utils.addMethod((globalThis as any)[JEST_MATCHERS_OBJECT].matchers, expectAssertionName, softWrapper)
81+
utils.addMethod(c.Assertion.prototype, expectAssertionName, softWrapper)
8082

8183
class CustomMatcher extends AsymmetricMatcher<[unknown, ...unknown[]]> {
8284
constructor(inverse = false, ...sample: [unknown, ...unknown[]]) {

packages/expect/src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export interface MatcherState {
7979
iterableEquality: Tester
8080
subsetEquality: Tester
8181
}
82+
soft?: boolean
8283
}
8384

8485
export interface SyncExpectationResult {
@@ -100,7 +101,7 @@ export type MatchersObject<T extends MatcherState = MatcherState> = Record<strin
100101

101102
export interface ExpectStatic extends Chai.ExpectStatic, AsymmetricMatchersContaining {
102103
<T>(actual: T, message?: string): Assertion<T>
103-
104+
soft<T>(actual: T, message?: string): Assertion<T>
104105
extend(expects: MatchersObject): void
105106
assertions(expected: number): void
106107
hasAssertions(): void

packages/expect/src/utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import { processError } from '@vitest/utils/error'
2+
import type { Test } from '@vitest/runner/types'
3+
import { GLOBAL_EXPECT } from './constants'
4+
import { getState } from './state'
5+
import type { Assertion, MatcherState } from './types'
6+
17
export function recordAsyncExpect(test: any, promise: Promise<any> | PromiseLike<any>) {
28
// record promise for test, that resolves before test ends
39
if (test && promise instanceof Promise) {
@@ -16,3 +22,30 @@ export function recordAsyncExpect(test: any, promise: Promise<any> | PromiseLike
1622

1723
return promise
1824
}
25+
26+
export function wrapSoft(utils: Chai.ChaiUtils, fn: (this: Chai.AssertionStatic & Assertion, ...args: any[]) => void) {
27+
return function (this: Chai.AssertionStatic & Assertion, ...args: any[]) {
28+
const test: Test = utils.flag(this, 'vitest-test')
29+
30+
// @ts-expect-error local is untyped
31+
const state: MatcherState = test?.context._local
32+
? test.context.expect.getState()
33+
: getState((globalThis as any)[GLOBAL_EXPECT])
34+
35+
if (!state.soft)
36+
return fn.apply(this, args)
37+
38+
if (!test)
39+
throw new Error('expect.soft() can only be used inside a test')
40+
41+
try {
42+
return fn.apply(this, args)
43+
}
44+
catch (err) {
45+
test.result ||= { state: 'fail' }
46+
test.result.state = 'fail'
47+
test.result.errors ||= []
48+
test.result.errors.push(processError(err))
49+
}
50+
}
51+
}

packages/runner/rollup.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const external = [
99
...builtinModules,
1010
...Object.keys(pkg.dependencies || {}),
1111
...Object.keys(pkg.peerDependencies || {}),
12-
'@vitest/utils/diff',
12+
'@vitest/utils/error',
1313
]
1414

1515
const entries = {

packages/runner/src/collect.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { relative } from 'pathe'
2+
import { processError } from '@vitest/utils/error'
23
import type { File } from './types'
34
import type { VitestRunner } from './types/runner'
45
import { calculateSuiteHash, generateHash, interpretTaskModes, someTasksAreOnly } from './utils/collect'
56
import { clearCollectorContext, getDefaultSuite } from './suite'
67
import { getHooks, setHooks } from './map'
7-
import { processError } from './utils/error'
88
import { collectorContext } from './context'
99
import { runSetupFiles } from './setup'
1010

packages/runner/src/run.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import limit from 'p-limit'
22
import { getSafeTimers, shuffle } from '@vitest/utils'
3+
import { processError } from '@vitest/utils/error'
34
import type { VitestRunner } from './types/runner'
45
import type { File, HookCleanupCallback, HookListener, SequenceHooks, Suite, SuiteHooks, Task, TaskMeta, TaskResult, TaskResultPack, TaskState, Test } from './types'
56
import { partitionSuiteChildren } from './utils/suite'
67
import { getFn, getHooks } from './map'
78
import { collectTests } from './collect'
8-
import { processError } from './utils/error'
99
import { setCurrentTest } from './test-state'
1010
import { hasFailed, hasTests } from './utils/tasks'
1111

@@ -156,7 +156,6 @@ export async function runTest(test: Test, runner: VitestRunner) {
156156
throw new Error('Test function is not found. Did you add it using `setFn`?')
157157
await fn()
158158
}
159-
160159
// some async expect will be added to this array, in case user forget to await theme
161160
if (test.promises) {
162161
const result = await Promise.allSettled(test.promises)
@@ -167,10 +166,12 @@ export async function runTest(test: Test, runner: VitestRunner) {
167166

168167
await runner.onAfterTryTest?.(test, { retry: retryCount, repeats: repeatCount })
169168

170-
if (!test.repeats)
171-
test.result.state = 'pass'
172-
else if (test.repeats && retry === retryCount)
173-
test.result.state = 'pass'
169+
if (test.result.state !== 'fail') {
170+
if (!test.repeats)
171+
test.result.state = 'pass'
172+
else if (test.repeats && retry === retryCount)
173+
test.result.state = 'pass'
174+
}
174175
}
175176
catch (e) {
176177
failTask(test.result, e)
@@ -186,6 +187,12 @@ export async function runTest(test: Test, runner: VitestRunner) {
186187

187188
if (test.result.state === 'pass')
188189
break
190+
191+
if (retryCount < retry - 1) {
192+
// reset state when retry test
193+
test.result.state = 'run'
194+
}
195+
189196
// update retry info
190197
updateTask(test, runner)
191198
}

packages/runner/src/types/tasks.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import type { Awaitable } from '@vitest/utils'
1+
import type { Awaitable, ErrorWithDiff } from '@vitest/utils'
22
import type { ChainableFunction } from '../utils/chain'
3-
import type { ErrorWithDiff } from '../utils/error'
43

54
export type RunMode = 'run' | 'skip' | 'only' | 'todo'
65
export type TaskState = RunMode | 'pass' | 'fail'

0 commit comments

Comments
 (0)