[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat: introduce experimental reported tasks (#6149)

authored by

Vladimir and committed by
GitHub
(Jul 30, 2024, 1:21 PM +0200) 13d85bd1 e7acd0cf

+1258 -112
+1 -1
packages/vitest/rollup.config.js
··· 95 95 json(), 96 96 commonjs(), 97 97 esbuild({ 98 - target: 'node14', 98 + target: 'node18', 99 99 }), 100 100 ] 101 101
+11 -3
packages/runner/src/collect.ts
··· 1 1 import { processError } from '@vitest/utils/error' 2 + import { toArray } from '@vitest/utils' 2 3 import type { File, SuiteHooks } from './types/tasks' 3 4 import type { VitestRunner } from './types/runner' 4 5 import { ··· 34 35 clearCollectorContext(filepath, runner) 35 36 36 37 try { 37 - const setupStart = now() 38 - await runSetupFiles(config, runner) 38 + const setupFiles = toArray(config.setupFiles) 39 + if (setupFiles.length) { 40 + const setupStart = now() 41 + await runSetupFiles(config, setupFiles, runner) 42 + const setupEnd = now() 43 + file.setupDuration = setupEnd - setupStart 44 + } 45 + else { 46 + file.setupDuration = 0 47 + } 39 48 40 49 const collectStart = now() 41 - file.setupDuration = collectStart - setupStart 42 50 43 51 await runner.importFile(filepath, 'collect') 44 52
+1 -2
packages/runner/src/setup.ts
··· 1 - import { toArray } from '@vitest/utils' 2 1 import type { VitestRunner, VitestRunnerConfig } from './types/runner' 3 2 4 3 export async function runSetupFiles( 5 4 config: VitestRunnerConfig, 5 + files: string[], 6 6 runner: VitestRunner, 7 7 ): Promise<void> { 8 - const files = toArray(config.setupFiles) 9 8 if (config.sequence.setupFiles === 'parallel') { 10 9 await Promise.all( 11 10 files.map(async (fsPath) => {
+16 -11
packages/utils/src/error.ts
··· 1 1 import { type DiffOptions, printDiffOrStringify } from './diff' 2 2 import { format, stringify } from './display' 3 + import type { TestError } from './types' 3 4 4 5 // utils is bundled for any environment and might not support `Element` 5 6 declare class Element { ··· 26 27 } 27 28 28 29 // https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm 29 - export function serializeError(val: any, seen: WeakMap<WeakKey, any> = new WeakMap()): any { 30 + export function serializeValue(val: any, seen: WeakMap<WeakKey, any> = new WeakMap()): any { 30 31 if (!val || typeof val === 'string') { 31 32 return val 32 33 } ··· 41 42 } 42 43 // cannot serialize immutables as immutables 43 44 if (isImmutable(val)) { 44 - return serializeError(val.toJSON(), seen) 45 + return serializeValue(val.toJSON(), seen) 45 46 } 46 47 if ( 47 48 val instanceof Promise ··· 56 57 return `${val.toString()} ${format(val.sample)}` 57 58 } 58 59 if (typeof val.toJSON === 'function') { 59 - return serializeError(val.toJSON(), seen) 60 + return serializeValue(val.toJSON(), seen) 60 61 } 61 62 62 63 if (seen.has(val)) { ··· 69 70 seen.set(val, clone) 70 71 val.forEach((e, i) => { 71 72 try { 72 - clone[i] = serializeError(e, seen) 73 + clone[i] = serializeValue(e, seen) 73 74 } 74 75 catch (err) { 75 76 clone[i] = getUnserializableMessage(err) ··· 90 91 return 91 92 } 92 93 try { 93 - clone[key] = serializeError(val[key], seen) 94 + clone[key] = serializeValue(val[key], seen) 94 95 } 95 96 catch (err) { 96 97 // delete in case it has a setter from prototype that might throw ··· 104 105 } 105 106 } 106 107 108 + export { serializeValue as serializeError } 109 + 107 110 function normalizeErrorMessage(message: string) { 108 111 return message.replace(/__(vite_ssr_import|vi_import)_\d+__\./g, '') 109 112 } 110 113 111 114 export function processError( 112 - err: any, 115 + _err: any, 113 116 diffOptions?: DiffOptions, 114 117 seen: WeakSet<WeakKey> = new WeakSet(), 115 118 ): any { 116 - if (!err || typeof err !== 'object') { 117 - return { message: err } 119 + if (!_err || typeof _err !== 'object') { 120 + return { message: String(_err) } 118 121 } 122 + const err = _err as TestError 123 + 119 124 // stack is not serialized in worker communication 120 125 // we stringify it first 121 126 if (err.stack) { ··· 133 138 ) { 134 139 err.diff = printDiffOrStringify(err.actual, err.expected, { 135 140 ...diffOptions, 136 - ...err.diffOptions, 141 + ...err.diffOptions as DiffOptions, 137 142 }) 138 143 } 139 144 ··· 163 168 catch {} 164 169 165 170 try { 166 - return serializeError(err) 171 + return serializeValue(err) 167 172 } 168 173 catch (e: any) { 169 - return serializeError( 174 + return serializeValue( 170 175 new Error( 171 176 `Failed to fully serialize error: ${e?.message}\nInner error message: ${err?.message}`, 172 177 ),
+2
packages/utils/src/index.ts
··· 47 47 Constructable, 48 48 ParsedStack, 49 49 ErrorWithDiff, 50 + SerializedError, 51 + TestError, 50 52 } from './types'
+1 -1
packages/utils/src/source-map.ts
··· 15 15 ignoreStackEntries?: (RegExp | string)[] 16 16 getSourceMap?: (file: string) => unknown 17 17 getFileName?: (id: string) => string 18 - frameFilter?: (error: Error, frame: ParsedStack) => boolean | void 18 + frameFilter?: (error: ErrorWithDiff, frame: ParsedStack) => boolean | void 19 19 } 20 20 21 21 const CHROME_IE_STACK_REGEXP = /^\s*at .*(?:\S:\d+|\(native\))/m
+23 -2
packages/utils/src/types.ts
··· 32 32 column: number 33 33 } 34 34 35 - export interface ErrorWithDiff extends Error { 36 - name: string 35 + export interface SerializedError { 36 + message: string 37 + stack?: string 38 + name?: string 39 + stacks?: ParsedStack[] 40 + cause?: SerializedError 41 + [key: string]: unknown 42 + } 43 + 44 + export interface TestError extends SerializedError { 45 + cause?: TestError 46 + diff?: string 47 + actual?: string 48 + expected?: string 49 + } 50 + 51 + /** 52 + * @deprecated Use `TestError` instead 53 + */ 54 + export interface ErrorWithDiff { 55 + message: string 56 + name?: string 57 + cause?: unknown 37 58 nameStr?: string 38 59 stack?: string 39 60 stackStr?: string
+1 -1
packages/ws-client/src/index.ts
··· 4 4 5 5 // eslint-disable-next-line no-restricted-imports 6 6 import type { WebSocketEvents, WebSocketHandlers } from 'vitest' 7 - import { StateManager } from '../../vitest/src/node/state' 7 + import { StateManager } from './state' 8 8 9 9 export * from '../../vitest/src/utils/tasks' 10 10
+134
packages/ws-client/src/state.ts
··· 1 + import type { File, Task, TaskResultPack } from '@vitest/runner' 2 + // eslint-disable-next-line no-restricted-imports 3 + import type { UserConsoleLog } from 'vitest' 4 + 5 + // can't import actual functions from utils, because it's incompatible with @vitest/browsers 6 + import { createFileTask } from '@vitest/runner/utils' 7 + 8 + // Note this file is shared for both node and browser, be aware to avoid node specific logic 9 + export class StateManager { 10 + filesMap = new Map<string, File[]>() 11 + pathsSet: Set<string> = new Set() 12 + idMap = new Map<string, Task>() 13 + 14 + getPaths() { 15 + return Array.from(this.pathsSet) 16 + } 17 + 18 + /** 19 + * Return files that were running or collected. 20 + */ 21 + getFiles(keys?: string[]): File[] { 22 + if (keys) { 23 + return keys 24 + .map(key => this.filesMap.get(key)!) 25 + .flat() 26 + .filter(file => file && !file.local) 27 + } 28 + return Array.from(this.filesMap.values()).flat().filter(file => !file.local) 29 + } 30 + 31 + getFilepaths(): string[] { 32 + return Array.from(this.filesMap.keys()) 33 + } 34 + 35 + getFailedFilepaths() { 36 + return this.getFiles() 37 + .filter(i => i.result?.state === 'fail') 38 + .map(i => i.filepath) 39 + } 40 + 41 + collectPaths(paths: string[] = []) { 42 + paths.forEach((path) => { 43 + this.pathsSet.add(path) 44 + }) 45 + } 46 + 47 + collectFiles(files: File[] = []) { 48 + files.forEach((file) => { 49 + const existing = this.filesMap.get(file.filepath) || [] 50 + const otherProject = existing.filter( 51 + i => i.projectName !== file.projectName, 52 + ) 53 + const currentFile = existing.find( 54 + i => i.projectName === file.projectName, 55 + ) 56 + // keep logs for the previous file because it should always be initiated before the collections phase 57 + // which means that all logs are collected during the collection and not inside tests 58 + if (currentFile) { 59 + file.logs = currentFile.logs 60 + } 61 + otherProject.push(file) 62 + this.filesMap.set(file.filepath, otherProject) 63 + this.updateId(file) 64 + }) 65 + } 66 + 67 + // this file is reused by ws-client, and should not rely on heavy dependencies like workspace 68 + clearFiles( 69 + _project: { config: { name: string | undefined; root: string } }, 70 + paths: string[] = [], 71 + ) { 72 + const project = _project 73 + paths.forEach((path) => { 74 + const files = this.filesMap.get(path) 75 + const fileTask = createFileTask( 76 + path, 77 + project.config.root, 78 + project.config.name || '', 79 + ) 80 + fileTask.local = true 81 + this.idMap.set(fileTask.id, fileTask) 82 + if (!files) { 83 + this.filesMap.set(path, [fileTask]) 84 + return 85 + } 86 + const filtered = files.filter( 87 + file => file.projectName !== project.config.name, 88 + ) 89 + // always keep a File task, so we can associate logs with it 90 + if (!filtered.length) { 91 + this.filesMap.set(path, [fileTask]) 92 + } 93 + else { 94 + this.filesMap.set(path, [...filtered, fileTask]) 95 + } 96 + }) 97 + } 98 + 99 + updateId(task: Task) { 100 + if (this.idMap.get(task.id) === task) { 101 + return 102 + } 103 + this.idMap.set(task.id, task) 104 + if (task.type === 'suite') { 105 + task.tasks.forEach((task) => { 106 + this.updateId(task) 107 + }) 108 + } 109 + } 110 + 111 + updateTasks(packs: TaskResultPack[]) { 112 + for (const [id, result, meta] of packs) { 113 + const task = this.idMap.get(id) 114 + if (task) { 115 + task.result = result 116 + task.meta = meta 117 + // skipped with new PendingError 118 + if (result?.state === 'skip') { 119 + task.mode = 'skip' 120 + } 121 + } 122 + } 123 + } 124 + 125 + updateUserLog(log: UserConsoleLog) { 126 + const task = log.taskId && this.idMap.get(log.taskId) 127 + if (task) { 128 + if (!task.logs) { 129 + task.logs = [] 130 + } 131 + task.logs.push(log) 132 + } 133 + } 134 + }
+246
test/cli/test/reported-tasks.test.ts
··· 1 + import { beforeAll, expect, it } from 'vitest' 2 + import { resolve } from 'pathe' 3 + import type { File } from 'vitest' 4 + import type { StateManager } from 'vitest/src/node/state.js' 5 + import type { WorkspaceProject } from 'vitest/node' 6 + import { runVitest } from '../../test-utils' 7 + import type { TestCase, TestCollection, TestFile } from '../../../packages/vitest/src/node/reporters/reported-tasks' 8 + 9 + const now = new Date() 10 + // const finishedFiles: File[] = [] 11 + const collectedFiles: File[] = [] 12 + let state: StateManager 13 + let project: WorkspaceProject 14 + let files: File[] 15 + let testFile: TestFile 16 + 17 + beforeAll(async () => { 18 + const { ctx } = await runVitest({ 19 + root: resolve(__dirname, '..', 'fixtures', 'reported-tasks'), 20 + include: ['**/*.test.ts'], 21 + reporters: [ 22 + 'verbose', 23 + { 24 + // onFinished(files) { 25 + // finishedFiles.push(...files || []) 26 + // }, 27 + onCollected(files) { 28 + collectedFiles.push(...files || []) 29 + }, 30 + }, 31 + ], 32 + includeTaskLocation: true, 33 + logHeapUsage: true, 34 + }) 35 + state = ctx!.state 36 + project = ctx!.getCoreWorkspaceProject() 37 + files = state.getFiles() 38 + expect(files).toHaveLength(1) 39 + testFile = state.getReportedEntity(files[0])! as TestFile 40 + expect(testFile).toBeDefined() 41 + }) 42 + 43 + it('correctly reports a file', () => { 44 + // suite properties not available on file 45 + expect(testFile).not.toHaveProperty('parent') 46 + expect(testFile).not.toHaveProperty('options') 47 + expect(testFile).not.toHaveProperty('file') 48 + expect(testFile).not.toHaveProperty('fullName') 49 + expect(testFile).not.toHaveProperty('name') 50 + 51 + expect(testFile.type).toBe('file') 52 + expect(testFile.task).toBe(files[0]) 53 + expect(testFile.id).toBe(files[0].id) 54 + expect(testFile.location).toBeUndefined() 55 + expect(testFile.moduleId).toBe(resolve('./fixtures/reported-tasks/1_first.test.ts')) 56 + expect(testFile.project.workspaceProject).toBe(project) 57 + expect(testFile.children.size).toBe(14) 58 + 59 + const tests = [...testFile.children.tests()] 60 + expect(tests).toHaveLength(11) 61 + const deepTests = [...testFile.children.allTests()] 62 + expect(deepTests).toHaveLength(19) 63 + 64 + const suites = [...testFile.children.suites()] 65 + expect(suites).toHaveLength(3) 66 + const deepSuites = [...testFile.children.allSuites()] 67 + expect(deepSuites).toHaveLength(4) 68 + 69 + const diagnostic = testFile.diagnostic() 70 + expect(diagnostic).toBeDefined() 71 + expect(diagnostic.environmentSetupDuration).toBeGreaterThan(0) 72 + expect(diagnostic.prepareDuration).toBeGreaterThan(0) 73 + expect(diagnostic.collectDuration).toBeGreaterThan(0) 74 + expect(diagnostic.duration).toBeGreaterThan(0) 75 + // doesn't have a setup file 76 + expect(diagnostic.setupDuration).toBe(0) 77 + }) 78 + 79 + it('correctly reports a passed test', () => { 80 + const passedTest = findTest(testFile.children, 'runs a test') 81 + expect(passedTest.type).toBe('test') 82 + expect(passedTest.task).toBe(files[0].tasks[0]) 83 + expect(passedTest.name).toBe('runs a test') 84 + expect(passedTest.fullName).toBe('runs a test') 85 + expect(passedTest.file).toBe(testFile) 86 + expect(passedTest.parent).toBe(testFile) 87 + expect(passedTest.options).toEqual({ 88 + each: undefined, 89 + concurrent: undefined, 90 + shuffle: undefined, 91 + retry: undefined, 92 + repeats: undefined, 93 + mode: 'run', 94 + }) 95 + expect(passedTest.meta()).toEqual({}) 96 + 97 + const result = passedTest.result()! 98 + expect(result).toBeDefined() 99 + expect(result.state).toBe('passed') 100 + expect(result.errors).toBeUndefined() 101 + 102 + const diagnostic = passedTest.diagnostic()! 103 + expect(diagnostic).toBeDefined() 104 + expect(diagnostic.heap).toBeGreaterThan(0) 105 + expect(diagnostic.duration).toBeGreaterThan(0) 106 + expect(date(new Date(diagnostic.startTime))).toBe(date(now)) 107 + expect(diagnostic.flaky).toBe(false) 108 + expect(diagnostic.repeatCount).toBe(0) 109 + expect(diagnostic.repeatCount).toBe(0) 110 + }) 111 + 112 + it('correctly reports failed test', () => { 113 + const passedTest = findTest(testFile.children, 'fails a test') 114 + expect(passedTest.type).toBe('test') 115 + expect(passedTest.task).toBe(files[0].tasks[1]) 116 + expect(passedTest.name).toBe('fails a test') 117 + expect(passedTest.fullName).toBe('fails a test') 118 + expect(passedTest.file).toBe(testFile) 119 + expect(passedTest.parent).toBe(testFile) 120 + expect(passedTest.options).toEqual({ 121 + each: undefined, 122 + concurrent: undefined, 123 + shuffle: undefined, 124 + retry: undefined, 125 + repeats: undefined, 126 + mode: 'run', 127 + }) 128 + expect(passedTest.meta()).toEqual({}) 129 + 130 + const result = passedTest.result()! 131 + expect(result).toBeDefined() 132 + expect(result.state).toBe('failed') 133 + expect(result.errors).toHaveLength(1) 134 + expect(result.errors![0]).toMatchObject({ 135 + diff: expect.any(String), 136 + message: 'expected 1 to be 2 // Object.is equality', 137 + ok: false, 138 + stack: expect.stringContaining('expected 1 to be 2 // Object.is equality'), 139 + stacks: [ 140 + { 141 + column: 13, 142 + file: resolve('./fixtures/reported-tasks/1_first.test.ts'), 143 + line: 10, 144 + method: '', 145 + }, 146 + ], 147 + }) 148 + 149 + const diagnostic = passedTest.diagnostic()! 150 + expect(diagnostic).toBeDefined() 151 + expect(diagnostic.heap).toBeGreaterThan(0) 152 + expect(diagnostic.duration).toBeGreaterThan(0) 153 + expect(date(new Date(diagnostic.startTime))).toBe(date(now)) 154 + expect(diagnostic.flaky).toBe(false) 155 + expect(diagnostic.repeatCount).toBe(0) 156 + expect(diagnostic.repeatCount).toBe(0) 157 + }) 158 + 159 + it('correctly reports multiple failures', () => { 160 + const testCase = findTest(testFile.children, 'fails multiple times') 161 + const result = testCase.result()! 162 + expect(result).toBeDefined() 163 + expect(result.state).toBe('failed') 164 + expect(result.errors).toHaveLength(2) 165 + expect(result.errors![0]).toMatchObject({ 166 + message: 'expected 1 to be 2 // Object.is equality', 167 + }) 168 + expect(result.errors![1]).toMatchObject({ 169 + message: 'expected 2 to be 3 // Object.is equality', 170 + }) 171 + }) 172 + 173 + it('correctly reports test assigned options', () => { 174 + const testOptionSkip = findTest(testFile.children, 'skips an option test') 175 + expect(testOptionSkip.options.mode).toBe('skip') 176 + const testModifierSkip = findTest(testFile.children, 'skips a .modifier test') 177 + expect(testModifierSkip.options.mode).toBe('skip') 178 + 179 + const testOptionTodo = findTest(testFile.children, 'todos an option test') 180 + expect(testOptionTodo.options.mode).toBe('todo') 181 + const testModifierTodo = findTest(testFile.children, 'todos a .modifier test') 182 + expect(testModifierTodo.options.mode).toBe('todo') 183 + }) 184 + 185 + it('correctly reports retried tests', () => { 186 + const testRetry = findTest(testFile.children, 'retries a test') 187 + expect(testRetry.options.retry).toBe(5) 188 + expect(testRetry.options.repeats).toBeUndefined() 189 + expect(testRetry.result()!.state).toBe('failed') 190 + }) 191 + 192 + it('correctly reports flaky tests', () => { 193 + const testFlaky = findTest(testFile.children, 'retries a test with success') 194 + const diagnostic = testFlaky.diagnostic()! 195 + expect(diagnostic.flaky).toBe(true) 196 + expect(diagnostic.retryCount).toBe(2) 197 + expect(diagnostic.repeatCount).toBe(0) 198 + const result = testFlaky.result()! 199 + expect(result.state).toBe('passed') 200 + expect(result.errors).toHaveLength(2) 201 + }) 202 + 203 + it('correctly reports repeated tests', () => { 204 + const testRepeated = findTest(testFile.children, 'repeats a test') 205 + const diagnostic = testRepeated.diagnostic()! 206 + expect(diagnostic.flaky).toBe(false) 207 + expect(diagnostic.retryCount).toBe(0) 208 + expect(diagnostic.repeatCount).toBe(5) 209 + const result = testRepeated.result()! 210 + expect(result.state).toBe('failed') 211 + expect(result.errors).toHaveLength(6) 212 + }) 213 + 214 + it('correctly passed down metadata', () => { 215 + const testMetadata = findTest(testFile.children, 'registers a metadata') 216 + const meta = testMetadata.meta() 217 + expect(meta).toHaveProperty('key', 'value') 218 + }) 219 + 220 + function date(time: Date) { 221 + return `${time.getDate()}/${time.getMonth() + 1}/${time.getFullYear()}` 222 + } 223 + 224 + function deepFind(children: TestCollection, name: string): TestCase | undefined { 225 + for (const task of children) { 226 + if (task.type === 'test') { 227 + if (task.name === name) { 228 + return task 229 + } 230 + } 231 + if (task.type === 'suite') { 232 + const result = deepFind(task.children, name) 233 + if (result) { 234 + return result 235 + } 236 + } 237 + } 238 + } 239 + 240 + function findTest(children: TestCollection, name: string): TestCase { 241 + const testCase = deepFind(children, name) 242 + if (!testCase) { 243 + throw new Error(`Test "${name}" not found`) 244 + } 245 + return testCase 246 + }
+13 -13
test/core/test/serialize.test.ts
··· 1 1 // @vitest-environment jsdom 2 2 3 - import { serializeError } from '@vitest/utils/error' 3 + import { serializeValue } from '@vitest/utils/error' 4 4 import { describe, expect, it } from 'vitest' 5 5 6 6 describe('error serialize', () => { 7 7 it('works', () => { 8 - expect(serializeError(undefined)).toEqual(undefined) 9 - expect(serializeError(null)).toEqual(null) 10 - expect(serializeError('hi')).toEqual('hi') 8 + expect(serializeValue(undefined)).toEqual(undefined) 9 + expect(serializeValue(null)).toEqual(null) 10 + expect(serializeValue('hi')).toEqual('hi') 11 11 12 - expect(serializeError({ 12 + expect(serializeValue({ 13 13 foo: 'hi', 14 14 promise: new Promise(() => {}), 15 15 fn: () => {}, ··· 35 35 error.whateverArray = [error, error] 36 36 error.whateverArrayClone = error.whateverArray 37 37 38 - expect(serializeError(error)).toMatchSnapshot() 38 + expect(serializeValue(error)).toMatchSnapshot() 39 39 }) 40 40 41 41 it('Should handle object with getter/setter correctly', () => { ··· 51 51 }, 52 52 } 53 53 54 - expect(serializeError(user)).toEqual({ 54 + expect(serializeValue(user)).toEqual({ 55 55 name: 'John', 56 56 surname: 'Smith', 57 57 fullName: 'John Smith', ··· 70 70 71 71 Object.defineProperty(user, 'fullName', { enumerable: false, value: 'John Smith' }) 72 72 73 - const serialized = serializeError(user) 73 + const serialized = serializeValue(user) 74 74 expect(serialized).not.toBe(user) 75 75 expect(serialized).toEqual({ 76 76 name: 'John', ··· 86 86 // `MessagePort`, so the serialized error object should have been recreated as plain object. 87 87 const error = new Error('test') 88 88 89 - const serialized = serializeError(error) 89 + const serialized = serializeValue(error) 90 90 expect(Object.getPrototypeOf(serialized)).toBe(null) 91 91 expect(serialized).toEqual({ 92 92 constructor: 'Function<Error>', ··· 114 114 }, 115 115 }], 116 116 }) 117 - expect(serializeError(error)).toEqual({ 117 + expect(serializeValue(error)).toEqual({ 118 118 array: [ 119 119 { 120 120 name: '<unserializable>: name cannot be accessed', ··· 131 131 132 132 it('can serialize DOMException', () => { 133 133 const err = new DOMException('You failed', 'InvalidStateError') 134 - expect(serializeError(err)).toMatchObject({ 134 + expect(serializeValue(err)).toMatchObject({ 135 135 NETWORK_ERR: 19, 136 136 name: 'InvalidStateError', 137 137 message: 'You failed', ··· 160 160 immutableRecord, 161 161 }) 162 162 163 - expect(serializeError(error)).toMatchObject({ 163 + expect(serializeValue(error)).toMatchObject({ 164 164 stack: expect.stringContaining('Error: test'), 165 165 immutableList: ['foo'], 166 166 immutableRecord: { foo: 'bar' }, ··· 186 186 }, 187 187 } 188 188 189 - const serialized = serializeError(error) 189 + const serialized = serializeValue(error) 190 190 191 191 expect(serialized).toEqual({ 192 192 key: 'value',
+1 -1
packages/browser/src/node/rpc.ts
··· 70 70 ctx.state.catchError(error, type) 71 71 }, 72 72 async onCollected(files) { 73 - ctx.state.collectFiles(files) 73 + ctx.state.collectFiles(project, files) 74 74 await ctx.report('onCollected', files) 75 75 }, 76 76 async onTaskUpdate(packs) {
+5 -5
packages/utils/src/diff/index.ts
··· 69 69 * @param options Diff options 70 70 * @returns {string | null} a string diff 71 71 */ 72 - export function diff(a: any, b: any, options?: DiffOptions): string | null { 72 + export function diff(a: any, b: any, options?: DiffOptions): string | undefined { 73 73 if (Object.is(a, b)) { 74 74 return '' 75 75 } ··· 80 80 if (aType === 'object' && typeof a.asymmetricMatch === 'function') { 81 81 if (a.$$typeof !== Symbol.for('jest.asymmetricMatcher')) { 82 82 // Do not know expected type of user-defined asymmetric matcher. 83 - return null 83 + return undefined 84 84 } 85 85 if (typeof a.getExpectedType !== 'function') { 86 86 // For example, expect.anything() matches either null or undefined 87 - return null 87 + return undefined 88 88 } 89 89 expectedType = a.getExpectedType() 90 90 // Primitive types boolean and number omit difference below. ··· 104 104 } 105 105 106 106 if (omitDifference) { 107 - return null 107 + return undefined 108 108 } 109 109 110 110 switch (aType) { ··· 234 234 expected: unknown, 235 235 received: unknown, 236 236 options?: DiffOptions, 237 - ): string | null { 237 + ): string | undefined { 238 238 const { aAnnotation, bAnnotation } = normalizeDiffOptions(options) 239 239 240 240 if (
-4
packages/vitest/src/api/setup.ts
··· 46 46 function setupClient(ws: WebSocket) { 47 47 const rpc = createBirpc<WebSocketEvents, WebSocketHandlers>( 48 48 { 49 - async onCollected(files) { 50 - ctx.state.collectFiles(files) 51 - await ctx.report('onCollected', files) 52 - }, 53 49 async onTaskUpdate(packs) { 54 50 ctx.state.updateTasks(packs) 55 51 await ctx.report('onTaskUpdate', packs)
-1
packages/vitest/src/api/types.ts
··· 27 27 } 28 28 29 29 export interface WebSocketHandlers { 30 - onCollected: (files?: File[]) => Promise<void> 31 30 onTaskUpdate: (packs: TaskResultPack[]) => void 32 31 getFiles: () => File[] 33 32 getTestFiles: () => Promise<SerializedSpec[]>
+1 -1
packages/vitest/src/node/core.ts
··· 440 440 files.forEach((file) => { 441 441 file.logs?.forEach(log => this.state.updateUserLog(log)) 442 442 }) 443 - this.state.collectFiles(files) 443 + this.state.collectFiles(project, files) 444 444 } 445 445 446 446 await this.report('onCollected', files).catch(noop)
+1 -1
packages/vitest/src/node/error.ts
··· 306 306 ) 307 307 } 308 308 309 - export function displayDiff(diff: string | null, console: Console) { 309 + export function displayDiff(diff: string | undefined, console: Console) { 310 310 if (diff) { 311 311 console.error(`\n${diff}\n`) 312 312 }
+77
packages/vitest/src/node/reported-workspace-project.ts
··· 1 + import type { ProvidedContext } from '../types/general' 2 + import type { ResolvedConfig, ResolvedProjectConfig, SerializedConfig } from './types/config' 3 + import type { WorkspaceProject } from './workspace' 4 + import type { Vitest } from './core' 5 + 6 + export class TestProject { 7 + /** 8 + * The global vitest instance. 9 + * @experimental The public Vitest API is experimental and does not follow semver. 10 + */ 11 + public readonly vitest: Vitest 12 + /** 13 + * The workspace project this test project is associated with. 14 + * @experimental The public Vitest API is experimental and does not follow semver. 15 + */ 16 + public readonly workspaceProject: WorkspaceProject 17 + 18 + /** 19 + * Resolved project configuration. 20 + */ 21 + public readonly config: ResolvedProjectConfig 22 + /** 23 + * Resolved global configuration. If there are no workspace projects, this will be the same as `config`. 24 + */ 25 + public readonly globalConfig: ResolvedConfig 26 + 27 + /** 28 + * The name of the project or an empty string if not set. 29 + */ 30 + public readonly name: string 31 + 32 + constructor(workspaceProject: WorkspaceProject) { 33 + this.workspaceProject = workspaceProject 34 + this.vitest = workspaceProject.ctx 35 + this.globalConfig = workspaceProject.ctx.config 36 + this.config = workspaceProject.config 37 + this.name = workspaceProject.getName() 38 + } 39 + 40 + /** 41 + * Serialized project configuration. This is the config that tests receive. 42 + */ 43 + public get serializedConfig() { 44 + return this.workspaceProject.getSerializableConfig() 45 + } 46 + 47 + /** 48 + * Custom context provided to the project. 49 + */ 50 + public context(): ProvidedContext { 51 + return this.workspaceProject.getProvidedContext() 52 + } 53 + 54 + /** 55 + * Provide a custom context to the project. This context will be available for tests once they run. 56 + */ 57 + public provide<T extends keyof ProvidedContext & string>( 58 + key: T, 59 + value: ProvidedContext[T], 60 + ): void { 61 + this.workspaceProject.provide(key, value) 62 + } 63 + 64 + public toJSON(): SerializedTestProject { 65 + return { 66 + name: this.name, 67 + serializedConfig: this.serializedConfig, 68 + context: this.context(), 69 + } 70 + } 71 + } 72 + 73 + interface SerializedTestProject { 74 + name: string 75 + serializedConfig: SerializedConfig 76 + context: ProvidedContext 77 + }
+28 -12
packages/vitest/src/node/state.ts
··· 1 1 import type { File, Task, TaskResultPack } from '@vitest/runner' 2 - 3 - // can't import actual functions from utils, because it's incompatible with @vitest/browsers 4 2 import { createFileTask } from '@vitest/runner/utils' 5 3 import type { AggregateError as AggregateErrorPonyfill } from '../utils/base' 6 4 import type { UserConsoleLog } from '../types/general' 7 5 import type { WorkspaceProject } from './workspace' 6 + import { TestCase, TestFile, TestSuite } from './reporters/reported-tasks' 8 7 9 8 export function isAggregateError(err: unknown): err is AggregateErrorPonyfill { 10 9 if (typeof AggregateError !== 'undefined' && err instanceof AggregateError) { ··· 14 13 return err instanceof Error && 'errors' in err 15 14 } 16 15 17 - // Note this file is shared for both node and browser, be aware to avoid node specific logic 18 16 export class StateManager { 19 17 filesMap = new Map<string, File[]>() 20 18 pathsSet: Set<string> = new Set() ··· 22 20 taskFileMap = new WeakMap<Task, File>() 23 21 errorsSet = new Set<unknown>() 24 22 processTimeoutCauses = new Set<string>() 23 + reportedTasksMap = new WeakMap<Task, TestCase | TestFile | TestSuite>() 25 24 26 25 catchError(err: unknown, type: string): void { 27 26 if (isAggregateError(err)) { ··· 98 97 }) 99 98 } 100 99 101 - collectFiles(files: File[] = []) { 100 + collectFiles(project: WorkspaceProject, files: File[] = []) { 102 101 files.forEach((file) => { 103 102 const existing = this.filesMap.get(file.filepath) || [] 104 103 const otherProject = existing.filter( ··· 114 113 } 115 114 otherProject.push(file) 116 115 this.filesMap.set(file.filepath, otherProject) 117 - this.updateId(file) 116 + this.updateId(file, project) 118 117 }) 119 118 } 120 119 121 - // this file is reused by ws-client, and should not rely on heavy dependencies like workspace 122 120 clearFiles( 123 - _project: { config: { name: string | undefined; root: string } }, 121 + project: WorkspaceProject, 124 122 paths: string[] = [], 125 123 ) { 126 - const project = _project as WorkspaceProject 127 124 paths.forEach((path) => { 128 125 const files = this.filesMap.get(path) 129 126 const fileTask = createFileTask( ··· 132 129 project.config.name, 133 130 ) 134 131 fileTask.local = true 132 + TestFile.register(fileTask, project) 135 133 this.idMap.set(fileTask.id, fileTask) 136 134 if (!files) { 137 135 this.filesMap.set(path, [fileTask]) ··· 150 148 }) 151 149 } 152 150 153 - updateId(task: Task) { 151 + updateId(task: Task, project: WorkspaceProject) { 154 152 if (this.idMap.get(task.id) === task) { 155 153 return 156 154 } 155 + 156 + if (task.type === 'suite' && 'filepath' in task) { 157 + TestFile.register(task, project) 158 + } 159 + else if (task.type === 'suite') { 160 + TestSuite.register(task, project) 161 + } 162 + else { 163 + TestCase.register(task, project) 164 + } 165 + 157 166 this.idMap.set(task.id, task) 158 167 if (task.type === 'suite') { 159 168 task.tasks.forEach((task) => { 160 - this.updateId(task) 169 + this.updateId(task, project) 161 170 }) 162 171 } 172 + } 173 + 174 + getReportedEntity(task: Task) { 175 + return this.reportedTasksMap.get(task) 163 176 } 164 177 165 178 updateTasks(packs: TaskResultPack[]) { ··· 192 205 ).length 193 206 } 194 207 195 - cancelFiles(files: string[], root: string, projectName: string) { 208 + cancelFiles(files: string[], project: WorkspaceProject) { 196 209 this.collectFiles( 197 - files.map(filepath => createFileTask(filepath, root, projectName)), 210 + project, 211 + files.map(filepath => 212 + createFileTask(filepath, project.config.root, project.config.name), 213 + ), 198 214 ) 199 215 } 200 216 }
+20 -24
packages/vitest/src/node/workspace.ts
··· 1 1 import { promises as fs } from 'node:fs' 2 - import { rm } from 'node:fs/promises' 3 2 import { tmpdir } from 'node:os' 3 + import { rm } from 'node:fs/promises' 4 4 import fg from 'fast-glob' 5 5 import mm from 'micromatch' 6 6 import { ··· 24 24 import type { ProvidedContext } from '../types/general' 25 25 import type { 26 26 ResolvedConfig, 27 + SerializedConfig, 27 28 UserConfig, 28 29 UserWorkspaceConfig, 29 30 } from './types/config' ··· 37 38 import { CoverageTransform } from './plugins/coverageTransform' 38 39 import { serializeConfig } from './config/serializeConfig' 39 40 import type { Vitest } from './core' 41 + import { TestProject } from './reported-workspace-project' 40 42 41 43 interface InitializeProjectOptions extends UserWorkspaceConfig { 42 44 workspaceConfigPath: string ··· 95 97 closingPromise: Promise<unknown> | undefined 96 98 97 99 testFilesList: string[] | null = null 100 + 101 + public testProject!: TestProject 98 102 99 103 public readonly id = nanoid() 100 104 public readonly tmpDir = join(tmpdir(), this.id) ··· 204 208 getSourceMapModuleById(id: string): TransformResult['map'] | undefined { 205 209 const mod = this.server.moduleGraph.getModuleById(id) 206 210 return mod?.ssrTransformResult?.map || mod?.transformResult?.map 207 - } 208 - 209 - getBrowserSourceMapModuleById( 210 - id: string, 211 - ): TransformResult['map'] | undefined { 212 - return this.browser?.vite.moduleGraph.getModuleById(id)?.transformResult?.map 213 211 } 214 212 215 213 get reporters() { ··· 366 364 project.server = ctx.server 367 365 project.runner = ctx.runner 368 366 project.config = ctx.config 367 + project.testProject = new TestProject(project) 369 368 return project 370 369 } 371 370 ··· 385 384 server.config, 386 385 this.ctx.logger, 387 386 ) 387 + this.testProject = new TestProject(this) 388 388 389 389 this.server = server 390 390 ··· 404 404 await this.initBrowserServer(this.server.config.configFile) 405 405 } 406 406 407 - isBrowserEnabled() { 407 + isBrowserEnabled(): boolean { 408 408 return isBrowserEnabled(this.config) 409 409 } 410 410 411 - getSerializableConfig(method: 'run' | 'collect' = 'run') { 412 - // TODO: call `serializeConfig` only once 413 - const config = deepMerge(serializeConfig( 411 + getSerializableConfig(): SerializedConfig { 412 + // TODO: serialize the config _once_ or when needed 413 + const config = serializeConfig( 414 414 this.config, 415 415 this.ctx.config, 416 - this.server?.config, 417 - ), (this.ctx.configOverride || {})) 418 - 419 - // disable heavy features when collecting because they are not needed 420 - if (method === 'collect') { 421 - if (this.config.browser.provider && this.config.browser.provider !== 'preview') { 422 - config.browser.headless = true 423 - } 424 - config.snapshotSerializers = [] 425 - config.diff = undefined 416 + this.server.config, 417 + ) 418 + if (!this.ctx.configOverride) { 419 + return config 426 420 } 427 - 428 - return config 421 + return deepMerge( 422 + config, 423 + this.ctx.configOverride, 424 + ) 429 425 } 430 426 431 427 close() { ··· 444 440 445 441 private async clearTmpDir() { 446 442 try { 447 - await rm(this.tmpDir, { force: true, recursive: true }) 443 + await rm(this.tmpDir, { recursive: true }) 448 444 } 449 445 catch {} 450 446 }
+25 -5
packages/vitest/src/public/index.ts
··· 3 3 import '../types/global' 4 4 5 5 import type { 6 + Custom as Custom_, 7 + File as File_, 8 + Suite as Suite_, 9 + Task as Task_, 10 + Test as Test_, 11 + } from '@vitest/runner' 12 + import type { 6 13 CollectLineNumbers as CollectLineNumbers_, 7 14 CollectLines as CollectLines_, 8 15 Context as Context_, ··· 120 127 /** @deprecated import `TypeCheckContext` from `vitest/node` instead */ 121 128 export type Context = Context_ 122 129 130 + /** @deprecated use `RunnerTestSuite` instead */ 131 + export type Suite = Suite_ 132 + /** @deprecated use `RunnerTestFile` instead */ 133 + export type File = File_ 134 + /** @deprecated use `RunnerTestCase` instead */ 135 + export type Test = Test_ 136 + /** @deprecated use `RunnerCustomCase` instead */ 137 + export type Custom = Custom_ 138 + /** @deprecated use `RunnerTask` instead */ 139 + export type Task = Task_ 140 + 123 141 export type { 124 142 RunMode, 125 143 TaskState, 126 144 TaskBase, 127 145 TaskResult, 128 146 TaskResultPack, 129 - Suite, 130 - File, 131 - Test, 132 - Task, 147 + Suite as RunnerTestSuite, 148 + File as RunnerTestFile, 149 + Test as RunnerTestCase, 150 + Task as RunnerTask, 151 + Custom as RunnerCustomCase, 133 152 DoneCallback, 134 153 TestFunction, 135 154 TestOptions, ··· 144 163 TestContext, 145 164 TaskContext, 146 165 ExtendedContext, 147 - Custom, 148 166 TaskCustomOptions, 149 167 OnTestFailedHandler, 150 168 TaskMeta, ··· 200 218 ProvidedContext, 201 219 AfterSuiteRunMeta, 202 220 } from '../types/general' 221 + 222 + export type { TestError, SerializedError } from '@vitest/utils' 203 223 204 224 /** @deprecated import from `vitest/environments` instead */ 205 225 export type EnvironmentReturn = EnvironmentReturn_
+15
packages/vitest/src/public/node.ts
··· 48 48 export { isFileServingAllowed, createServer, parseAst, parseAstAsync } from 'vite' 49 49 export type * as Vite from 'vite' 50 50 51 + export { TestCase, TestFile, TestSuite } from '../node/reporters/reported-tasks' 52 + export { TestProject } from '../node/reported-workspace-project' 53 + export type { 54 + TestCollection, 55 + 56 + TaskOptions, 57 + TestDiagnostic, 58 + FileDiagnostic, 59 + TestResult, 60 + TestResultPassed, 61 + TestResultFailed, 62 + TestResultSkipped, 63 + } from '../node/reporters/reported-tasks' 64 + 51 65 export type { 52 66 SequenceHooks, 53 67 SequenceSetupFiles, ··· 68 82 UserConfig, 69 83 ResolvedConfig, 70 84 ProjectConfig, 85 + ResolvedProjectConfig, 71 86 UserWorkspaceConfig, 72 87 RuntimeConfig, 73 88 } from '../node/types/config'
+84
test/cli/fixtures/reported-tasks/1_first.test.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + 3 + it('runs a test', async () => { 4 + await new Promise(r => setTimeout(r, 10)) 5 + expect(1).toBe(1) 6 + }) 7 + 8 + it('fails a test', async () => { 9 + await new Promise(r => setTimeout(r, 10)) 10 + expect(1).toBe(2) 11 + }) 12 + 13 + it('fails multiple times', () => { 14 + expect.soft(1).toBe(2) 15 + expect.soft(3).toBe(3) 16 + expect.soft(2).toBe(3) 17 + }) 18 + 19 + it('skips an option test', { skip: true }) 20 + it.skip('skips a .modifier test') 21 + 22 + it('todos an option test', { todo: true }) 23 + it.todo('todos a .modifier test') 24 + 25 + it('retries a test', { retry: 5 }, () => { 26 + expect(1).toBe(2) 27 + }) 28 + 29 + let counter = 0 30 + it('retries a test with success', { retry: 5 }, () => { 31 + expect(counter++).toBe(2) 32 + }) 33 + 34 + it('repeats a test', { repeats: 5 }, () => { 35 + expect(1).toBe(2) 36 + }) 37 + 38 + describe('a group', () => { 39 + it('runs a test in a group', () => { 40 + expect(1).toBe(1) 41 + }) 42 + 43 + it('todos an option test in a group', { todo: true }) 44 + 45 + describe('a nested group', () => { 46 + it('runs a test in a nested group', () => { 47 + expect(1).toBe(1) 48 + }) 49 + 50 + it('fails a test in a nested group', () => { 51 + expect(1).toBe(2) 52 + }) 53 + 54 + it.concurrent('runs first concurrent test in a nested group', () => { 55 + expect(1).toBe(1) 56 + }) 57 + 58 + it.concurrent('runs second concurrent test in a nested group', () => { 59 + expect(1).toBe(1) 60 + }) 61 + }) 62 + }) 63 + 64 + describe.shuffle('shuffled group', () => { 65 + it('runs a test in a shuffled group', () => { 66 + expect(1).toBe(1) 67 + }) 68 + }) 69 + 70 + describe.each([1])('each group %s', (groupValue) => { 71 + it.each([2])('each test %s', (itValue) => { 72 + expect(groupValue + itValue).toBe(3) 73 + }) 74 + }) 75 + 76 + it('registers a metadata', (ctx) => { 77 + ctx.task.meta.key = 'value' 78 + }) 79 + 80 + declare module 'vitest' { 81 + interface TaskMeta { 82 + key?: string 83 + } 84 + }
+6 -6
test/core/test/__snapshots__/jest-expect.test.ts.snap
··· 3 3 exports[`asymmetric matcher error 1`] = ` 4 4 { 5 5 "actual": "hello", 6 - "diff": null, 6 + "diff": undefined, 7 7 "expected": "StringContaining "xx"", 8 8 "message": "expected 'hello' to deeply equal StringContaining "xx"", 9 9 } ··· 12 12 exports[`asymmetric matcher error 2`] = ` 13 13 { 14 14 "actual": "hello", 15 - "diff": null, 15 + "diff": undefined, 16 16 "expected": "StringNotContaining "ll"", 17 17 "message": "expected 'hello' to deeply equal StringNotContaining "ll"", 18 18 } ··· 200 200 exports[`asymmetric matcher error 14`] = ` 201 201 { 202 202 "actual": "hello", 203 - "diff": null, 203 + "diff": undefined, 204 204 "expected": "StringMatching /xx/", 205 205 "message": "expected 'hello' to deeply equal StringMatching /xx/", 206 206 } ··· 222 222 exports[`asymmetric matcher error 16`] = ` 223 223 { 224 224 "actual": "hello", 225 - "diff": null, 225 + "diff": undefined, 226 226 "expected": "StringContaining "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"", 227 227 "message": "expected 'hello' to deeply equal StringContaining{…}", 228 228 } ··· 231 231 exports[`asymmetric matcher error 17`] = ` 232 232 { 233 233 "actual": "hello", 234 - "diff": null, 234 + "diff": undefined, 235 235 "expected": "StringContaining "xx"", 236 236 "message": "expected error to match asymmetric matcher", 237 237 } ··· 253 253 exports[`asymmetric matcher error 19`] = ` 254 254 { 255 255 "actual": "hello", 256 - "diff": null, 256 + "diff": undefined, 257 257 "expected": "StringContaining "ll"", 258 258 "message": "expected error not to match asymmetric matcher", 259 259 }
+1 -2
packages/ui/client/composables/client/static.ts
··· 9 9 } from 'vitest' 10 10 import { parse } from 'flatted' 11 11 import { decompressSync, strFromU8 } from 'fflate' 12 - import { StateManager } from '../../../../vitest/src/node/state' 12 + import { StateManager } from '../../../../ws-client/src/state' 13 13 14 14 interface HTMLReportMetadata { 15 15 paths: string[] ··· 55 55 }, 56 56 getTransformResult: asyncNoop, 57 57 onDone: noop, 58 - onCollected: asyncNoop, 59 58 onTaskUpdate: noop, 60 59 writeFile: asyncNoop, 61 60 rerun: asyncNoop,
+1 -1
packages/vitest/src/node/pools/forks.ts
··· 137 137 && error instanceof Error 138 138 && /The task has been cancelled/.test(error.message) 139 139 ) { 140 - ctx.state.cancelFiles(files, ctx.config.root, project.config.name) 140 + ctx.state.cancelFiles(files, project) 141 141 } 142 142 else { 143 143 throw error
+1 -1
packages/vitest/src/node/pools/rpc.ts
··· 76 76 return ctx.report('onPathsCollected', paths) 77 77 }, 78 78 onCollected(files) { 79 - ctx.state.collectFiles(files) 79 + ctx.state.collectFiles(project, files) 80 80 return ctx.report('onCollected', files) 81 81 }, 82 82 onAfterSuiteRun(meta) {
+1 -1
packages/vitest/src/node/pools/threads.ts
··· 135 135 && error instanceof Error 136 136 && /The task has been cancelled/.test(error.message) 137 137 ) { 138 - ctx.state.cancelFiles(files, ctx.config.root, project.config.name) 138 + ctx.state.cancelFiles(files, project) 139 139 } 140 140 else { 141 141 throw error
+4 -4
packages/vitest/src/node/pools/typecheck.ts
··· 61 61 checker.setFiles(files) 62 62 63 63 checker.onParseStart(async () => { 64 - ctx.state.collectFiles(checker.getTestFiles()) 64 + ctx.state.collectFiles(project, checker.getTestFiles()) 65 65 await ctx.report('onCollected') 66 66 }) 67 67 ··· 80 80 } 81 81 82 82 await checker.collectTests() 83 - ctx.state.collectFiles(checker.getTestFiles()) 83 + ctx.state.collectFiles(project, checker.getTestFiles()) 84 84 85 85 await ctx.report('onTaskUpdate', checker.getTestPacks()) 86 86 await ctx.report('onCollected') ··· 107 107 const checker = await createWorkspaceTypechecker(project, files) 108 108 checker.setFiles(files) 109 109 await checker.collectTests() 110 - ctx.state.collectFiles(checker.getTestFiles()) 110 + ctx.state.collectFiles(project, checker.getTestFiles()) 111 111 await ctx.report('onCollected') 112 112 } 113 113 } ··· 135 135 }) 136 136 const triggered = await _p 137 137 if (project.typechecker && !triggered) { 138 - ctx.state.collectFiles(project.typechecker.getTestFiles()) 138 + ctx.state.collectFiles(project, project.typechecker.getTestFiles()) 139 139 await ctx.report('onCollected') 140 140 await onParseEnd(project, project.typechecker.getResult()) 141 141 continue
+1 -1
packages/vitest/src/node/pools/vmForks.ts
··· 147 147 && error instanceof Error 148 148 && /The task has been cancelled/.test(error.message) 149 149 ) { 150 - ctx.state.cancelFiles(files, ctx.config.root, project.config.name) 150 + ctx.state.cancelFiles(files, project) 151 151 } 152 152 else { 153 153 throw error
+1 -1
packages/vitest/src/node/pools/vmThreads.ts
··· 141 141 && error instanceof Error 142 142 && /The task has been cancelled/.test(error.message) 143 143 ) { 144 - ctx.state.cancelFiles(files, ctx.config.root, project.config.name) 144 + ctx.state.cancelFiles(files, project) 145 145 } 146 146 else { 147 147 throw error
+14
packages/vitest/src/node/reporters/index.ts
··· 28 28 } 29 29 export type { BaseReporter, Reporter } 30 30 31 + export { TestCase, TestFile, TestSuite } from './reported-tasks' 32 + export type { 33 + TestCollection, 34 + 35 + TaskOptions, 36 + TestDiagnostic, 37 + FileDiagnostic, 38 + 39 + TestResult, 40 + TestResultFailed, 41 + TestResultPassed, 42 + TestResultSkipped, 43 + } from './reported-tasks' 44 + 31 45 export type { 32 46 JsonAssertionResult, 33 47 JsonTestResult,
+507
packages/vitest/src/node/reporters/reported-tasks.ts
··· 1 + import type { 2 + Custom as RunnerCustomCase, 3 + Task as RunnerTask, 4 + Test as RunnerTestCase, 5 + File as RunnerTestFile, 6 + Suite as RunnerTestSuite, 7 + TaskMeta, 8 + } from '@vitest/runner' 9 + import type { TestError } from '@vitest/utils' 10 + import { getTestName } from '../../utils/tasks' 11 + import type { WorkspaceProject } from '../workspace' 12 + import { TestProject } from '../reported-workspace-project' 13 + 14 + class ReportedTaskImplementation { 15 + /** 16 + * Task instance. 17 + * @experimental Public runner task API is experimental and does not follow semver. 18 + */ 19 + public readonly task: RunnerTask 20 + 21 + /** 22 + * The project assosiacted with the test or suite. 23 + */ 24 + public readonly project: TestProject 25 + 26 + /** 27 + * Unique identifier. 28 + * This ID is deterministic and will be the same for the same test across multiple runs. 29 + * The ID is based on the file path and test position. 30 + */ 31 + public readonly id: string 32 + 33 + /** 34 + * Location in the file where the test or suite is defined. 35 + */ 36 + public readonly location: { line: number; column: number } | undefined 37 + 38 + protected constructor( 39 + task: RunnerTask, 40 + project: WorkspaceProject, 41 + ) { 42 + this.task = task 43 + this.project = project.testProject || (project.testProject = new TestProject(project)) 44 + this.id = task.id 45 + this.location = task.location 46 + } 47 + 48 + /** 49 + * Creates a new reported task instance and stores it in the project's state for future use. 50 + */ 51 + static register(task: RunnerTask, project: WorkspaceProject) { 52 + const state = new this(task, project) as TestCase | TestSuite | TestFile 53 + storeTask(project, task, state) 54 + return state 55 + } 56 + } 57 + 58 + export class TestCase extends ReportedTaskImplementation { 59 + #fullName: string | undefined 60 + 61 + declare public readonly task: RunnerTestCase | RunnerCustomCase 62 + public readonly type: 'test' | 'custom' = 'test' 63 + 64 + /** 65 + * Direct reference to the test file where the test or suite is defined. 66 + */ 67 + public readonly file: TestFile 68 + 69 + /** 70 + * Name of the test. 71 + */ 72 + public readonly name: string 73 + 74 + /** 75 + * Options that the test was initiated with. 76 + */ 77 + public readonly options: TaskOptions 78 + 79 + /** 80 + * Parent suite. If suite was called directly inside the file, the parent will be the file. 81 + */ 82 + public readonly parent: TestSuite | TestFile 83 + 84 + protected constructor(task: RunnerTestSuite | RunnerTestFile, project: WorkspaceProject) { 85 + super(task, project) 86 + 87 + this.name = task.name 88 + this.file = getReportedTask(project, task.file) as TestFile 89 + const suite = this.task.suite 90 + if (suite) { 91 + this.parent = getReportedTask(project, suite) as TestSuite 92 + } 93 + else { 94 + this.parent = this.file 95 + } 96 + this.options = buildOptions(task) 97 + } 98 + 99 + /** 100 + * Full name of the test including all parent suites separated with `>`. 101 + */ 102 + public get fullName(): string { 103 + if (this.#fullName === undefined) { 104 + this.#fullName = getTestName(this.task, ' > ') 105 + } 106 + return this.#fullName 107 + } 108 + 109 + /** 110 + * Result of the test. Will be `undefined` if test is not finished yet or was just collected. 111 + */ 112 + public result(): TestResult | undefined { 113 + const result = this.task.result 114 + if (!result || result.state === 'run') { 115 + return undefined 116 + } 117 + const state = result.state === 'fail' 118 + ? 'failed' 119 + : result.state === 'pass' 120 + ? 'passed' 121 + : 'skipped' 122 + return { 123 + state, 124 + errors: result.errors as TestError[] | undefined, 125 + } as TestResult 126 + } 127 + 128 + /** 129 + * Checks if the test passed successfully. 130 + * If the test is not finished yet or was skipped, it will return `true`. 131 + */ 132 + public ok(): boolean { 133 + const result = this.result() 134 + return !result || result.state !== 'failed' 135 + } 136 + 137 + /** 138 + * Custom metadata that was attached to the test during its execution. 139 + */ 140 + public meta(): TaskMeta { 141 + return this.task.meta 142 + } 143 + 144 + /** 145 + * Useful information about the test like duration, memory usage, etc. 146 + * Diagnostic is only available after the test has finished. 147 + */ 148 + public diagnostic(): TestDiagnostic | undefined { 149 + const result = this.task.result 150 + // startTime should always be available if the test has properly finished 151 + if (!result || result.state === 'run' || !result.startTime) { 152 + return undefined 153 + } 154 + return { 155 + heap: result.heap, 156 + duration: result.duration!, 157 + startTime: result.startTime, 158 + retryCount: result.retryCount ?? 0, 159 + repeatCount: result.repeatCount ?? 0, 160 + flaky: !!result.retryCount && result.state === 'pass' && result.retryCount > 0, 161 + } 162 + } 163 + } 164 + 165 + class TestCollection { 166 + #task: RunnerTestSuite | RunnerTestFile 167 + #project: WorkspaceProject 168 + 169 + constructor(task: RunnerTestSuite | RunnerTestFile, project: WorkspaceProject) { 170 + this.#task = task 171 + this.#project = project 172 + } 173 + 174 + /** 175 + * Test or a suite at a specific index in the array. 176 + */ 177 + at(index: number): TestCase | TestSuite | undefined { 178 + if (index < 0) { 179 + index = this.size + index 180 + } 181 + return getReportedTask(this.#project, this.#task.tasks[index]) as TestCase | TestSuite | undefined 182 + } 183 + 184 + /** 185 + * The number of tests and suites in the collection. 186 + */ 187 + get size(): number { 188 + return this.#task.tasks.length 189 + } 190 + 191 + /** 192 + * The same collection, but in an array form for easier manipulation. 193 + */ 194 + array(): (TestCase | TestSuite)[] { 195 + return Array.from(this) 196 + } 197 + 198 + /** 199 + * Iterates over all tests and suites in the collection. 200 + */ 201 + *values(): IterableIterator<TestCase | TestSuite> { 202 + return this[Symbol.iterator]() 203 + } 204 + 205 + /** 206 + * Filters all tests that are part of this collection's suite and its children. 207 + */ 208 + *allTests(state?: TestResult['state'] | 'running'): IterableIterator<TestCase> { 209 + for (const child of this) { 210 + if (child.type === 'suite') { 211 + yield * child.children.allTests(state) 212 + } 213 + else if (state) { 214 + const testState = getTestState(child) 215 + if (state === testState) { 216 + yield child 217 + } 218 + } 219 + else { 220 + yield child 221 + } 222 + } 223 + } 224 + 225 + /** 226 + * Filters only tests that are part of this collection. 227 + */ 228 + *tests(state?: TestResult['state'] | 'running'): IterableIterator<TestCase> { 229 + for (const child of this) { 230 + if (child.type !== 'test') { 231 + continue 232 + } 233 + 234 + if (state) { 235 + const testState = getTestState(child) 236 + if (state === testState) { 237 + yield child 238 + } 239 + } 240 + else { 241 + yield child 242 + } 243 + } 244 + } 245 + 246 + /** 247 + * Filters only suites that are part of this collection. 248 + */ 249 + *suites(): IterableIterator<TestSuite> { 250 + for (const child of this) { 251 + if (child.type === 'suite') { 252 + yield child 253 + } 254 + } 255 + } 256 + 257 + /** 258 + * Filters all suites that are part of this collection's suite and its children. 259 + */ 260 + *allSuites(): IterableIterator<TestSuite> { 261 + for (const child of this) { 262 + if (child.type === 'suite') { 263 + yield child 264 + yield * child.children.allSuites() 265 + } 266 + } 267 + } 268 + 269 + *[Symbol.iterator](): IterableIterator<TestSuite | TestCase> { 270 + for (const task of this.#task.tasks) { 271 + yield getReportedTask(this.#project, task) as TestSuite | TestCase 272 + } 273 + } 274 + } 275 + 276 + export type { TestCollection } 277 + 278 + abstract class SuiteImplementation extends ReportedTaskImplementation { 279 + declare public readonly task: RunnerTestSuite | RunnerTestFile 280 + 281 + /** 282 + * Collection of suites and tests that are part of this suite. 283 + */ 284 + public readonly children: TestCollection 285 + 286 + protected constructor(task: RunnerTestSuite | RunnerTestFile, project: WorkspaceProject) { 287 + super(task, project) 288 + this.children = new TestCollection(task, project) 289 + } 290 + } 291 + 292 + export class TestSuite extends SuiteImplementation { 293 + #fullName: string | undefined 294 + 295 + declare public readonly task: RunnerTestSuite 296 + public readonly type = 'suite' 297 + 298 + /** 299 + * Name of the test or the suite. 300 + */ 301 + public readonly name: string 302 + 303 + /** 304 + * Direct reference to the test file where the test or suite is defined. 305 + */ 306 + public readonly file: TestFile 307 + 308 + /** 309 + * Parent suite. If suite was called directly inside the file, the parent will be the file. 310 + */ 311 + public readonly parent: TestSuite | TestFile 312 + 313 + /** 314 + * Options that suite was initiated with. 315 + */ 316 + public readonly options: TaskOptions 317 + 318 + protected constructor(task: RunnerTestSuite, project: WorkspaceProject) { 319 + super(task, project) 320 + 321 + this.name = task.name 322 + this.file = getReportedTask(project, task.file) as TestFile 323 + const suite = this.task.suite 324 + if (suite) { 325 + this.parent = getReportedTask(project, suite) as TestSuite 326 + } 327 + else { 328 + this.parent = this.file 329 + } 330 + this.options = buildOptions(task) 331 + } 332 + 333 + /** 334 + * Full name of the suite including all parent suites separated with `>`. 335 + */ 336 + public get fullName(): string { 337 + if (this.#fullName === undefined) { 338 + this.#fullName = getTestName(this.task, ' > ') 339 + } 340 + return this.#fullName 341 + } 342 + } 343 + 344 + export class TestFile extends SuiteImplementation { 345 + declare public readonly task: RunnerTestFile 346 + declare public readonly location: undefined 347 + public readonly type = 'file' 348 + 349 + /** 350 + * This is usually an absolute UNIX file path. 351 + * It can be a virtual id if the file is not on the disk. 352 + * This value corresponds to Vite's `ModuleGraph` id. 353 + */ 354 + public readonly moduleId: string 355 + 356 + protected constructor(task: RunnerTestFile, project: WorkspaceProject) { 357 + super(task, project) 358 + this.moduleId = task.filepath 359 + } 360 + 361 + /** 362 + * Useful information about the file like duration, memory usage, etc. 363 + * If the file was not executed yet, all diagnostic values will return `0`. 364 + */ 365 + public diagnostic(): FileDiagnostic { 366 + const setupDuration = this.task.setupDuration || 0 367 + const collectDuration = this.task.collectDuration || 0 368 + const prepareDuration = this.task.prepareDuration || 0 369 + const environmentSetupDuration = this.task.environmentLoad || 0 370 + const duration = this.task.result?.duration || 0 371 + return { 372 + environmentSetupDuration, 373 + prepareDuration, 374 + collectDuration, 375 + setupDuration, 376 + duration, 377 + } 378 + } 379 + } 380 + 381 + export interface TaskOptions { 382 + each: boolean | undefined 383 + concurrent: boolean | undefined 384 + shuffle: boolean | undefined 385 + retry: number | undefined 386 + repeats: number | undefined 387 + mode: 'run' | 'only' | 'skip' | 'todo' 388 + } 389 + 390 + function buildOptions(task: RunnerTestCase | RunnerCustomCase | RunnerTestFile | RunnerTestSuite): TaskOptions { 391 + return { 392 + each: task.each, 393 + concurrent: task.concurrent, 394 + shuffle: task.shuffle, 395 + retry: task.retry, 396 + repeats: task.repeats, 397 + mode: task.mode, 398 + } 399 + } 400 + 401 + export type TestResult = TestResultPassed | TestResultFailed | TestResultSkipped 402 + 403 + export interface TestResultPassed { 404 + /** 405 + * The test passed successfully. 406 + */ 407 + state: 'passed' 408 + /** 409 + * Errors that were thrown during the test execution. 410 + * 411 + * **Note**: If test was retried successfully, errors will still be reported. 412 + */ 413 + errors: TestError[] | undefined 414 + } 415 + 416 + export interface TestResultFailed { 417 + /** 418 + * The test failed to execute. 419 + */ 420 + state: 'failed' 421 + /** 422 + * Errors that were thrown during the test execution. 423 + */ 424 + errors: TestError[] 425 + } 426 + 427 + export interface TestResultSkipped { 428 + /** 429 + * The test was skipped with `only`, `skip` or `todo` flag. 430 + * You can see which one was used in the `mode` option. 431 + */ 432 + state: 'skipped' 433 + /** 434 + * Skipped tests have no errors. 435 + */ 436 + errors: undefined 437 + } 438 + 439 + export interface TestDiagnostic { 440 + /** 441 + * The amount of memory used by the test in bytes. 442 + * This value is only available if the test was executed with `logHeapUsage` flag. 443 + */ 444 + heap: number | undefined 445 + /** 446 + * The time it takes to execute the test in ms. 447 + */ 448 + duration: number 449 + /** 450 + * The time in ms when the test started. 451 + */ 452 + startTime: number 453 + /** 454 + * The amount of times the test was retried. 455 + */ 456 + retryCount: number 457 + /** 458 + * The amount of times the test was repeated as configured by `repeats` option. 459 + * This value can be lower if the test failed during the repeat and no `retry` is configured. 460 + */ 461 + repeatCount: number 462 + /** 463 + * If test passed on a second retry. 464 + */ 465 + flaky: boolean 466 + } 467 + 468 + export interface FileDiagnostic { 469 + /** 470 + * The time it takes to import and initiate an environment. 471 + */ 472 + environmentSetupDuration: number 473 + /** 474 + * The time it takes Vitest to setup test harness (runner, mocks, etc.). 475 + */ 476 + prepareDuration: number 477 + /** 478 + * The time it takes to import the test file. 479 + * This includes importing everything in the file and executing suite callbacks. 480 + */ 481 + collectDuration: number 482 + /** 483 + * The time it takes to import the setup file. 484 + */ 485 + setupDuration: number 486 + /** 487 + * Accumulated duration of all tests and hooks in the file. 488 + */ 489 + duration: number 490 + } 491 + 492 + function getTestState(test: TestCase): TestResult['state'] | 'running' { 493 + const result = test.result() 494 + return result ? result.state : 'running' 495 + } 496 + 497 + function storeTask(project: WorkspaceProject, runnerTask: RunnerTask, reportedTask: TestCase | TestSuite | TestFile): void { 498 + project.ctx.state.reportedTasksMap.set(runnerTask, reportedTask) 499 + } 500 + 501 + function getReportedTask(project: WorkspaceProject, runnerTask: RunnerTask): TestCase | TestSuite | TestFile { 502 + const reportedTask = project.ctx.state.getReportedEntity(runnerTask) 503 + if (!reportedTask) { 504 + throw new Error(`Task instance was not found for ${runnerTask.type} "${runnerTask.name}"`) 505 + } 506 + return reportedTask 507 + }
+15 -7
packages/vitest/src/node/types/config.ts
··· 10 10 } from '../reporters' 11 11 import type { TestSequencerConstructor } from '../sequencers/types' 12 12 import type { ChaiConfig } from '../../integrations/chai/config' 13 - import type { Arrayable, ParsedStack } from '../../types/general' 13 + import type { Arrayable, ErrorWithDiff, ParsedStack } from '../../types/general' 14 14 import type { JSDOMOptions } from '../../types/jsdom-options' 15 15 import type { HappyDOMOptions } from '../../types/happy-dom-options' 16 16 import type { EnvironmentOptions } from '../../types/environment' ··· 620 620 * 621 621 * Return `false` to omit the frame. 622 622 */ 623 - onStackTrace?: (error: Error, frame: ParsedStack) => boolean | void 623 + onStackTrace?: (error: ErrorWithDiff, frame: ParsedStack) => boolean | void 624 624 625 625 /** 626 626 * Indicates if CSS files should be processed. ··· 1019 1019 minWorkers: number 1020 1020 } 1021 1021 1022 - export type ProjectConfig = Omit< 1023 - UserConfig, 1024 - | 'sequencer' 1022 + type NonProjectOptions = 1025 1023 | 'shard' 1026 1024 | 'watch' 1027 1025 | 'run' ··· 1029 1027 | 'update' 1030 1028 | 'reporters' 1031 1029 | 'outputFile' 1032 - | 'poolOptions' 1033 1030 | 'teardownTimeout' 1034 1031 | 'silent' 1035 1032 | 'forceRerunTriggers' ··· 1047 1044 | 'slowTestThreshold' 1048 1045 | 'inspect' 1049 1046 | 'inspectBrk' 1050 - | 'deps' 1051 1047 | 'coverage' 1052 1048 | 'maxWorkers' 1053 1049 | 'minWorkers' 1054 1050 | 'fileParallelism' 1051 + 1052 + export type ProjectConfig = Omit< 1053 + UserConfig, 1054 + NonProjectOptions 1055 + | 'sequencer' 1056 + | 'deps' 1057 + | 'poolOptions' 1055 1058 > & { 1056 1059 sequencer?: Omit<SequenceOptions, 'sequencer' | 'seed'> 1057 1060 deps?: Omit<DepsOptions, 'moduleDirectories'> ··· 1064 1067 forks?: Pick<NonNullable<PoolOptions['forks']>, 'singleFork' | 'isolate'> 1065 1068 } 1066 1069 } 1070 + 1071 + export type ResolvedProjectConfig = Omit< 1072 + ResolvedConfig, 1073 + NonProjectOptions 1074 + > 1067 1075 1068 1076 export type { UserWorkspaceConfig } from '../../public/config'