Skip to content

Commit 7c687ad

Browse files
authoredJun 6, 2023
feat(expect): support expect.soft (#3507)
1 parent dfb46e6 commit 7c687ad

38 files changed

+519
-230
lines changed
 

‎packages/expect/package.json

+1
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

+1
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

+7-5
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

+4-2
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

+2-1
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

+33
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

+1-1
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

+1-1
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

+13-6
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

+1-2
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'

‎packages/runner/src/utils/collect.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { processError } from '@vitest/utils/error'
12
import type { Suite, TaskBase } from '../types'
2-
import { processError } from './error'
33

44
/**
55
* If any tasks been marked as `only`, mark all other tasks as `skip`.

‎packages/runner/src/utils/error.ts

-175
This file was deleted.

‎packages/runner/src/utils/index.ts

-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,3 @@ export * from './collect'
22
export * from './suite'
33
export * from './tasks'
44
export * from './chain'
5-
export * from './error'

‎packages/utils/error.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './dist/error.js'

‎packages/utils/package.json

+4
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"types": "./dist/diff.d.ts",
2525
"import": "./dist/diff.js"
2626
},
27+
"./error": {
28+
"types": "./dist/error.d.ts",
29+
"import": "./dist/error.js"
30+
},
2731
"./helpers": {
2832
"types": "./dist/helpers.d.ts",
2933
"import": "./dist/helpers.js"

‎packages/utils/rollup.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const entries = {
1010
index: 'src/index.ts',
1111
helpers: 'src/helpers.ts',
1212
diff: 'src/diff.ts',
13+
error: 'src/error.ts',
1314
types: 'src/types.ts',
1415
}
1516

‎packages/utils/src/base.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
interface ErrorOptions {
2+
message?: string
3+
stackTraceLimit?: number
4+
}
5+
/**
6+
* Get original stacktrace without source map support the most performant way.
7+
* - Create only 1 stack frame.
8+
* - Rewrite prepareStackTrace to bypass "support-stack-trace" (usually takes ~250ms).
9+
*/
10+
export function createSimpleStackTrace(options?: ErrorOptions) {
11+
const { message = 'error', stackTraceLimit = 1 } = options || {}
12+
const limit = Error.stackTraceLimit
13+
const prepareStackTrace = Error.prepareStackTrace
14+
Error.stackTraceLimit = stackTraceLimit
15+
Error.prepareStackTrace = e => e.stack
16+
const err = new Error(message)
17+
const stackTrace = err.stack || ''
18+
Error.prepareStackTrace = prepareStackTrace
19+
Error.stackTraceLimit = limit
20+
return stackTrace
21+
}

‎packages/utils/src/error.ts

+174-21
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,175 @@
1-
interface ErrorOptions {
2-
message?: string
3-
stackTraceLimit?: number
4-
}
5-
6-
/**
7-
* Get original stacktrace without source map support the most performant way.
8-
* - Create only 1 stack frame.
9-
* - Rewrite prepareStackTrace to bypass "support-stack-trace" (usually takes ~250ms).
10-
*/
11-
export function createSimpleStackTrace(options?: ErrorOptions) {
12-
const { message = 'error', stackTraceLimit = 1 } = options || {}
13-
const limit = Error.stackTraceLimit
14-
const prepareStackTrace = Error.prepareStackTrace
15-
Error.stackTraceLimit = stackTraceLimit
16-
Error.prepareStackTrace = e => e.stack
17-
const err = new Error(message)
18-
const stackTrace = err.stack || ''
19-
Error.prepareStackTrace = prepareStackTrace
20-
Error.stackTraceLimit = limit
21-
return stackTrace
1+
import type { DiffOptions } from './diff'
2+
import { unifiedDiff } from './diff'
3+
import { format } from './display'
4+
import { deepClone, getOwnProperties, getType } from './helpers'
5+
import { stringify } from './stringify'
6+
7+
const IS_RECORD_SYMBOL = '@@__IMMUTABLE_RECORD__@@'
8+
const IS_COLLECTION_SYMBOL = '@@__IMMUTABLE_ITERABLE__@@'
9+
10+
function isImmutable(v: any) {
11+
return v && (v[IS_COLLECTION_SYMBOL] || v[IS_RECORD_SYMBOL])
12+
}
13+
14+
const OBJECT_PROTO = Object.getPrototypeOf({})
15+
16+
function getUnserializableMessage(err: unknown) {
17+
if (err instanceof Error)
18+
return `<unserializable>: ${err.message}`
19+
if (typeof err === 'string')
20+
return `<unserializable>: ${err}`
21+
return '<unserializable>'
22+
}
23+
24+
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
25+
export function serializeError(val: any, seen = new WeakMap()): any {
26+
if (!val || typeof val === 'string')
27+
return val
28+
if (typeof val === 'function')
29+
return `Function<${val.name || 'anonymous'}>`
30+
if (typeof val === 'symbol')
31+
return val.toString()
32+
if (typeof val !== 'object')
33+
return val
34+
// cannot serialize immutables as immutables
35+
if (isImmutable(val))
36+
return serializeError(val.toJSON(), seen)
37+
if (val instanceof Promise || (val.constructor && val.constructor.prototype === 'AsyncFunction'))
38+
return 'Promise'
39+
if (typeof Element !== 'undefined' && val instanceof Element)
40+
return val.tagName
41+
if (typeof val.asymmetricMatch === 'function')
42+
return `${val.toString()} ${format(val.sample)}`
43+
44+
if (seen.has(val))
45+
return seen.get(val)
46+
47+
if (Array.isArray(val)) {
48+
const clone: any[] = new Array(val.length)
49+
seen.set(val, clone)
50+
val.forEach((e, i) => {
51+
try {
52+
clone[i] = serializeError(e, seen)
53+
}
54+
catch (err) {
55+
clone[i] = getUnserializableMessage(err)
56+
}
57+
})
58+
return clone
59+
}
60+
else {
61+
// Objects with `Error` constructors appear to cause problems during worker communication
62+
// using `MessagePort`, so the serialized error object is being recreated as plain object.
63+
const clone = Object.create(null)
64+
seen.set(val, clone)
65+
66+
let obj = val
67+
while (obj && obj !== OBJECT_PROTO) {
68+
Object.getOwnPropertyNames(obj).forEach((key) => {
69+
if (key in clone)
70+
return
71+
try {
72+
clone[key] = serializeError(val[key], seen)
73+
}
74+
catch (err) {
75+
// delete in case it has a setter from prototype that might throw
76+
delete clone[key]
77+
clone[key] = getUnserializableMessage(err)
78+
}
79+
})
80+
obj = Object.getPrototypeOf(obj)
81+
}
82+
return clone
83+
}
84+
}
85+
86+
function normalizeErrorMessage(message: string) {
87+
return message.replace(/__vite_ssr_import_\d+__\./g, '')
88+
}
89+
90+
export function processError(err: any, options: DiffOptions = {}) {
91+
if (!err || typeof err !== 'object')
92+
return { message: err }
93+
// stack is not serialized in worker communication
94+
// we stringify it first
95+
if (err.stack)
96+
err.stackStr = String(err.stack)
97+
if (err.name)
98+
err.nameStr = String(err.name)
99+
100+
const clonedActual = deepClone(err.actual, { forceWritable: true })
101+
const clonedExpected = deepClone(err.expected, { forceWritable: true })
102+
103+
const { replacedActual, replacedExpected } = replaceAsymmetricMatcher(clonedActual, clonedExpected)
104+
105+
if (err.showDiff || (err.showDiff === undefined && err.expected !== undefined && err.actual !== undefined))
106+
err.diff = unifiedDiff(replacedActual, replacedExpected, options)
107+
108+
if (typeof err.expected !== 'string')
109+
err.expected = stringify(err.expected, 10)
110+
if (typeof err.actual !== 'string')
111+
err.actual = stringify(err.actual, 10)
112+
113+
// some Error implementations don't allow rewriting message
114+
try {
115+
if (typeof err.message === 'string')
116+
err.message = normalizeErrorMessage(err.message)
117+
118+
if (typeof err.cause === 'object' && typeof err.cause.message === 'string')
119+
err.cause.message = normalizeErrorMessage(err.cause.message)
120+
}
121+
catch {}
122+
123+
try {
124+
return serializeError(err)
125+
}
126+
catch (e: any) {
127+
return serializeError(new Error(`Failed to fully serialize error: ${e?.message}\nInner error message: ${err?.message}`))
128+
}
129+
}
130+
131+
function isAsymmetricMatcher(data: any) {
132+
const type = getType(data)
133+
return type === 'Object' && typeof data.asymmetricMatch === 'function'
134+
}
135+
136+
function isReplaceable(obj1: any, obj2: any) {
137+
const obj1Type = getType(obj1)
138+
const obj2Type = getType(obj2)
139+
return obj1Type === obj2Type && obj1Type === 'Object'
140+
}
141+
142+
export function replaceAsymmetricMatcher(actual: any, expected: any, actualReplaced = new WeakSet(), expectedReplaced = new WeakSet()) {
143+
if (!isReplaceable(actual, expected))
144+
return { replacedActual: actual, replacedExpected: expected }
145+
if (actualReplaced.has(actual) || expectedReplaced.has(expected))
146+
return { replacedActual: actual, replacedExpected: expected }
147+
actualReplaced.add(actual)
148+
expectedReplaced.add(expected)
149+
getOwnProperties(expected).forEach((key) => {
150+
const expectedValue = expected[key]
151+
const actualValue = actual[key]
152+
if (isAsymmetricMatcher(expectedValue)) {
153+
if (expectedValue.asymmetricMatch(actualValue))
154+
actual[key] = expectedValue
155+
}
156+
else if (isAsymmetricMatcher(actualValue)) {
157+
if (actualValue.asymmetricMatch(expectedValue))
158+
expected[key] = actualValue
159+
}
160+
else if (isReplaceable(actualValue, expectedValue)) {
161+
const replaced = replaceAsymmetricMatcher(
162+
actualValue,
163+
expectedValue,
164+
actualReplaced,
165+
expectedReplaced,
166+
)
167+
actual[key] = replaced.replacedActual
168+
expected[key] = replaced.replacedExpected
169+
}
170+
})
171+
return {
172+
replacedActual: actual,
173+
replacedExpected: expected,
174+
}
22175
}

‎packages/utils/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ export * from './random'
66
export * from './display'
77
export * from './constants'
88
export * from './colors'
9-
export * from './error'
9+
export * from './base'
1010
export * from './source-map'

‎packages/vitest/rollup.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const external = [
6161
'vite-node/server',
6262
'vite-node/utils',
6363
'@vitest/utils/diff',
64+
'@vitest/utils/error',
6465
'@vitest/runner/utils',
6566
'@vitest/runner/types',
6667
'@vitest/snapshot/environment',

‎packages/vitest/src/integrations/chai/index.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { getCurrentEnvironment, getFullName } from '../../utils'
1212
export function createExpect(test?: Test) {
1313
const expect = ((value: any, message?: string): Assertion => {
1414
const { assertionCalls } = getState(expect)
15-
setState({ assertionCalls: assertionCalls + 1 }, expect)
15+
setState({ assertionCalls: assertionCalls + 1, soft: false }, expect)
1616
const assert = chai.expect(value, message) as unknown as Assertion
1717
const _test = test || getCurrentTest()
1818
if (_test)
@@ -45,6 +45,14 @@ export function createExpect(test?: Test) {
4545
// @ts-expect-error untyped
4646
expect.extend = matchers => chai.expect.extend(expect, matchers)
4747

48+
expect.soft = (...args) => {
49+
const assert = expect(...args)
50+
expect.setState({
51+
soft: true,
52+
})
53+
return assert
54+
}
55+
4856
function assertions(expected: number) {
4957
const errorGen = () => new Error(`expected number of assertions to be ${expected}, but got ${expect.getState().assertionCalls}`)
5058
if (Error.captureStackTrace)

‎packages/vitest/src/node/reporters/junit.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { hostname } from 'node:os'
33
import { dirname, relative, resolve } from 'pathe'
44

55
import type { Task } from '@vitest/runner'
6-
import type { ErrorWithDiff } from '@vitest/runner/utils'
6+
import type { ErrorWithDiff } from '@vitest/utils'
77
import type { Vitest } from '../../node'
88
import type { Reporter } from '../../types/reporter'
99
import { parseErrorStacktrace } from '../../utils/source-map'

‎packages/vitest/src/node/reporters/tap.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Task } from '@vitest/runner'
2-
import type { ParsedStack } from '@vitest/runner/utils'
2+
import type { ParsedStack } from '@vitest/utils'
33
import type { Vitest } from '../../node'
44
import type { Reporter } from '../../types/reporter'
55
import { parseErrorStacktrace } from '../../utils/source-map'

‎packages/vitest/src/runtime/execute.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ModuleCacheMap, ViteNodeRunner } from 'vite-node/client'
33
import { isInternalRequest, isNodeBuiltin, isPrimitive } from 'vite-node/utils'
44
import type { ViteNodeRunnerOptions } from 'vite-node'
55
import { normalize, relative, resolve } from 'pathe'
6-
import { processError } from '@vitest/runner/utils'
6+
import { processError } from '@vitest/utils/error'
77
import type { MockMap } from '../types/mocker'
88
import { getCurrentEnvironment, getWorkerState } from '../utils/global'
99
import type { ContextRPC, ContextTestEnvironment, ResolvedConfig } from '../types'

‎packages/vitest/src/types/general.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type { ErrorWithDiff, ParsedStack } from '@vitest/runner/utils'
1+
export type { ErrorWithDiff, ParsedStack } from '@vitest/utils'
22

33
export type Awaitable<T> = T | PromiseLike<T>
44
export type Nullable<T> = T | null | undefined

‎pnpm-lock.yaml

+16-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/core/test/error.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { processError } from '@vitest/runner/utils'
1+
import { processError } from '@vitest/utils/error'
22
import { expect, test } from 'vitest'
33

44
test('Can correctly process error where actual and expected contains non writable properties', () => {

‎test/core/test/expect.test.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getCurrentTest } from '@vitest/runner'
2+
import { describe, expect, expectTypeOf, test } from 'vitest'
3+
4+
describe('expect.soft', () => {
5+
test('types', () => {
6+
expectTypeOf(expect.soft).toEqualTypeOf(expect)
7+
expectTypeOf(expect.soft(7)).toEqualTypeOf(expect(7))
8+
expectTypeOf(expect.soft(5)).toHaveProperty('toBe')
9+
expectTypeOf(expect.soft(7)).not.toHaveProperty('toCustom')
10+
})
11+
12+
test('return value', () => {
13+
expect(expect.soft('test')).toHaveProperty('toBe')
14+
expect(expect.soft('test')).toHaveProperty('toEqual')
15+
})
16+
17+
test('with extend', () => {
18+
expect.extend({
19+
toBeFoo(received) {
20+
const { isNot } = this
21+
return {
22+
// do not alter your "pass" based on isNot. Vitest does it for you
23+
pass: received === 'foo',
24+
message: () => `${received} is${isNot ? ' not' : ''} foo`,
25+
}
26+
},
27+
})
28+
expect(expect.soft('test')).toHaveProperty('toBeFoo')
29+
})
30+
31+
test('should have multiple error', () => {
32+
expect.soft(1).toBe(2)
33+
expect.soft(2).toBe(3)
34+
getCurrentTest()!.result!.state = 'run'
35+
expect(getCurrentTest()?.result?.errors).toHaveLength(2)
36+
})
37+
38+
test.fails('should be a failure', () => {
39+
expect.soft('test1').toBe('test res')
40+
expect.soft('test2').toBe('test res')
41+
expect.soft('test3').toBe('test res')
42+
})
43+
})

‎test/core/test/replace-matcher.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { replaceAsymmetricMatcher } from '@vitest/utils/error'
12
import { describe, expect, it } from 'vitest'
2-
import { replaceAsymmetricMatcher } from '@vitest/runner/utils'
33

44
describe('replace asymmetric matcher', () => {
55
const expectReplaceAsymmetricMatcher = (actual: any, expected: any) => {

‎test/core/test/serialize.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @vitest-environment jsdom
22

3+
import { serializeError } from '@vitest/utils/error'
34
import { describe, expect, it } from 'vitest'
4-
import { serializeError } from '@vitest/runner/utils'
55

66
describe('error serialize', () => {
77
it('works', () => {

‎test/core/vitest.config.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { basename, dirname, join, resolve } from 'pathe'
2-
import { defineConfig } from 'vitest/config'
2+
import { defaultExclude, defineConfig } from 'vitest/config'
33

44
export default defineConfig({
55
plugins: [
@@ -41,6 +41,7 @@ export default defineConfig({
4141
},
4242
test: {
4343
name: 'core',
44+
exclude: ['**/fixtures/**', ...defaultExclude],
4445
slowTestThreshold: 1000,
4546
testTimeout: 2000,
4647
setupFiles: [
+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { expect, test } from 'vitest'
2+
3+
interface CustomMatchers<R = unknown> {
4+
toBeDividedBy(divisor: number): R
5+
}
6+
7+
declare module 'vitest' {
8+
interface Assertion<T = any> extends CustomMatchers<T> {}
9+
}
10+
11+
expect.extend({
12+
toBeDividedBy(received, divisor) {
13+
const pass = received % divisor === 0
14+
if (pass) {
15+
return {
16+
message: () =>
17+
`expected ${received} not to be divisible by ${divisor}`,
18+
pass: true,
19+
}
20+
}
21+
else {
22+
return {
23+
message: () =>
24+
`expected ${received} to be divisible by ${divisor}`,
25+
pass: false,
26+
}
27+
}
28+
},
29+
})
30+
31+
test('basic', () => {
32+
expect.soft(1).toBe(2)
33+
expect.soft(2).toBe(3)
34+
})
35+
36+
test('promise', async () => {
37+
await expect.soft(
38+
new Promise((resolve) => {
39+
setTimeout(() => {
40+
resolve(1)
41+
})
42+
}),
43+
).resolves.toBe(2)
44+
await expect.soft(
45+
new Promise((resolve) => {
46+
setTimeout(() => {
47+
resolve(2)
48+
})
49+
}),
50+
).resolves.toBe(3)
51+
})
52+
53+
test('with expect', () => {
54+
expect.soft(1).toEqual(2)
55+
expect(10).toEqual(20)
56+
expect.soft(2).toEqual(3)
57+
})
58+
59+
test('with expect.extend', () => {
60+
expect.soft(1).toEqual(2)
61+
expect.soft(3).toBeDividedBy(4)
62+
expect(5).toEqual(6)
63+
})
64+
65+
test('passed', () => {
66+
expect.soft(1).toEqual(1)
67+
expect(10).toEqual(10)
68+
expect.soft(2).toEqual(2)
69+
})
70+
71+
let num = 0
72+
test('retry will passed', () => {
73+
expect.soft(num += 1).toBe(3)
74+
expect.soft(num += 1).toBe(4)
75+
}, {
76+
retry: 2,
77+
})
78+
79+
num = 0
80+
test('retry will failed', () => {
81+
expect.soft(num += 1).toBe(4)
82+
expect.soft(num += 1).toBe(5)
83+
}, {
84+
retry: 2,
85+
})

‎test/failing/fixtures/vite.config.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from 'vite'
2+
3+
export default defineConfig({
4+
test: {
5+
threads: false,
6+
isolate: false,
7+
},
8+
})

‎test/failing/package.json

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "@vitest/test-failing",
3+
"private": true,
4+
"scripts": {
5+
"test": "vitest",
6+
"coverage": "vitest run --coverage"
7+
},
8+
"devDependencies": {
9+
"@vitest/runner": "workspace:*",
10+
"vitest": "workspace:*"
11+
}
12+
}

‎test/failing/test/expect.test.ts

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { resolve } from 'node:path'
2+
import type { UserConfig } from 'vitest'
3+
import { describe, expect, test } from 'vitest'
4+
import { getCurrentTest } from '@vitest/runner'
5+
import { runVitest } from '../../test-utils'
6+
7+
describe('expect.soft', () => {
8+
const run = (config?: UserConfig) => runVitest({ root: resolve('./fixtures'), include: ['expects/soft.test.ts'], setupFiles: [], testNamePattern: getCurrentTest()?.name, testTimeout: 4000, ...config }, ['soft'])
9+
10+
test('basic', async () => {
11+
const { stderr } = await run()
12+
expect.soft(stderr).toContain('AssertionError: expected 1 to be 2')
13+
expect.soft(stderr).toContain('AssertionError: expected 2 to be 3')
14+
})
15+
16+
test('promise', async () => {
17+
const { stderr } = await run()
18+
expect.soft(stderr).toContain('AssertionError: expected 2 to be 3')
19+
expect.soft(stderr).toContain('AssertionError: expected 1 to be 2')
20+
})
21+
22+
test('with expect', async () => {
23+
const { stderr } = await run()
24+
expect.soft(stderr).toContain('AssertionError: expected 1 to deeply equal 2')
25+
expect.soft(stderr).toContain('AssertionError: expected 10 to deeply equal 20')
26+
expect.soft(stderr).not.toContain('AssertionError: expected 2 to deeply equal 3')
27+
})
28+
29+
test('with expect.extend', async () => {
30+
const { stderr } = await run()
31+
expect.soft(stderr).toContain('AssertionError: expected 1 to deeply equal 2')
32+
expect.soft(stderr).toContain('Error: expected 3 to be divisible by 4')
33+
expect.soft(stderr).toContain('AssertionError: expected 5 to deeply equal 6')
34+
})
35+
36+
test('passed', async () => {
37+
const { stdout } = await run()
38+
expect.soft(stdout).toContain('soft.test.ts > passed')
39+
})
40+
41+
test('retry will passed', async () => {
42+
const { stdout } = await run()
43+
expect.soft(stdout).toContain('soft.test.ts > retry will passed')
44+
})
45+
46+
test('retry will failed', async () => {
47+
const { stderr } = await run()
48+
expect.soft(stderr).toContain('AssertionError: expected 1 to be 4')
49+
expect.soft(stderr).toContain('AssertionError: expected 2 to be 5')
50+
expect.soft(stderr).toContain('AssertionError: expected 3 to be 4')
51+
expect.soft(stderr).toContain('AssertionError: expected 4 to be 5')
52+
expect.soft(stderr).toContain('4/4')
53+
})
54+
}, 4000)

‎test/failing/vite.config.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineConfig } from 'vite'
2+
3+
export default defineConfig({
4+
test: {
5+
include: ['test/*.test.ts'],
6+
chaiConfig: {
7+
truncateThreshold: 9999,
8+
},
9+
},
10+
})
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { expect } from 'vitest'
2+
3+
expect.soft(1 + 1).toEqual(3)

‎test/fails/test/__snapshots__/runner.test.ts.snap

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ exports[`should fail empty.test.ts > empty.test.ts 1`] = `"Error: No test suite
88
99
exports[`should fail expect.test.ts > expect.test.ts 1`] = `"AssertionError: expected 2 to deeply equal 3"`;
1010
11+
exports[`should fail expect-soft.test.ts > expect-soft.test.ts 1`] = `"Error: expect.soft() can only be used inside a test"`;
12+
1113
exports[`should fail hook-timeout.test.ts > hook-timeout.test.ts 1`] = `"Error: Hook timed out in 10ms."`;
1214
1315
exports[`should fail hooks-called.test.ts > hooks-called.test.ts 1`] = `

0 commit comments

Comments
 (0)
Please sign in to comment.