[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.

fix: unify typechecking and ast collection (#10449)

authored by

Vladimir and committed by
GitHub
(May 25, 2026, 10:27 PM +0200) af993b66 b1ab2b9c

+69 -300
+1 -1
test/typescript/test/runner.test.ts
··· 124 124 125 125 expect(vitest.stdout).toContain('✓ for: %s') 126 126 expect(vitest.stdout).toContain('✓ each: %s') 127 - expect(vitest.stdout).toContain('✓ dynamic skip') 127 + expect(vitest.stdout).toContain('↓ dynamic skip') 128 128 expect(vitest.stdout).not.toContain('✓ false') // .skipIf is not reported as a separate test 129 129 expect(vitest.stdout).toContain('✓ template string') 130 130 // eslint-disable-next-line no-template-curly-in-string
+57 -12
packages/vitest/src/node/ast-collect.ts
··· 34 34 dynamic: boolean 35 35 } 36 36 37 - interface LocalCallDefinition { 37 + export interface LocalCallDefinition { 38 38 start: number 39 39 end: number 40 40 name: string ··· 44 44 dynamic: boolean 45 45 concurrent: boolean | undefined 46 46 tags: string[] 47 + } 48 + 49 + export interface FileInformation { 50 + file: File 51 + filepath: string 52 + parsed: string 53 + map: any 54 + definitions: LocalCallDefinition[] 55 + } 56 + 57 + export interface AstCollectOptions { 58 + /** 59 + * Override the pool stored on the resulting File task. Required when 60 + * collecting typecheck files because the project's `config.pool` is the 61 + * user's runtime pool (e.g. `forks`), not the `typescript` pool that the 62 + * typecheck spec uses to compute its task id. 63 + */ 64 + pool?: string 47 65 } 48 66 49 67 const debug = createDebugger('vitest:ast-collect-info') ··· 287 305 } 288 306 } 289 307 290 - export function createFailedFileTask(project: TestProject, filepath: string, error: Error): File { 308 + export function createFailedFileTask(project: TestProject, filepath: string, error: Error, options?: AstCollectOptions): File { 291 309 const config = project.serializedConfig 310 + const pool = options?.pool ?? config.pool 292 311 const baseFile = createFileTaskOriginal( 293 312 filepath, 294 313 config.root, 295 314 config.name, 296 - config.pool, 315 + pool, 297 316 undefined, 298 - { typecheck: config.pool === 'typescript', __vitest_label__: config.mergeReportsLabel }, 317 + { typecheck: pool === 'typescript', __vitest_label__: config.mergeReportsLabel }, 299 318 ) 300 319 const file: ParsedFile = { 301 320 ...baseFile, ··· 340 359 requestMap: any, 341 360 filepath: string, 342 361 fileTags: string[] | undefined, 362 + options?: AstCollectOptions, 343 363 ) { 344 364 const { definitions, ast } = astParseFile(testFilepath, code) 345 365 const config = project.serializedConfig 366 + const pool = options?.pool ?? config.pool 346 367 const baseFile = createFileTaskOriginal( 347 368 filepath, 348 369 config.root, 349 370 config.name, 350 - config.pool, 371 + pool, 351 372 undefined, 352 - { typecheck: config.pool === 'typescript', __vitest_label__: config.mergeReportsLabel }, 373 + { typecheck: pool === 'typescript', __vitest_label__: config.mergeReportsLabel }, 353 374 ) 354 375 const file: ParsedFile = { 355 376 ...baseFile, ··· 484 505 ], 485 506 } 486 507 } 487 - return file 508 + return { file, definitions } 488 509 } 489 510 490 511 export async function astCollectTests( 491 512 project: TestProject, 492 513 filepath: string, 493 514 ): Promise<File> { 515 + const information = await astCollectFileInformation(project, filepath) 516 + return information.file 517 + } 518 + 519 + export async function astCollectFileInformation( 520 + project: TestProject, 521 + filepath: string, 522 + options?: AstCollectOptions, 523 + ): Promise<FileInformation> { 494 524 const request = await transformSSR(project, filepath) 495 525 const testFilepath = relative(project.config.root, filepath) 496 526 if (!request) { 497 527 debug?.('Cannot parse', testFilepath, '(vite didn\'t return anything)') 498 - return createFailedFileTask( 499 - project, 528 + return { 529 + file: createFailedFileTask( 530 + project, 531 + filepath, 532 + new Error(`Failed to parse ${testFilepath}. Vite didn't return anything.`), 533 + options, 534 + ), 500 535 filepath, 501 - new Error(`Failed to parse ${testFilepath}. Vite didn't return anything.`), 502 - ) 536 + parsed: '', 537 + map: null, 538 + definitions: [], 539 + } 503 540 } 504 - return createFileTask( 541 + const { file, definitions } = createFileTask( 505 542 project, 506 543 testFilepath, 507 544 request.code, 508 545 request.map, 509 546 filepath, 510 547 request.fileTags, 548 + options, 511 549 ) 550 + return { 551 + file, 552 + filepath, 553 + parsed: request.code, 554 + map: request.map, 555 + definitions, 556 + } 512 557 } 513 558 514 559 async function transformSSR(project: TestProject, filepath: string) {
-276
packages/vitest/src/typecheck/collect.ts
··· 1 - import type { File, RunMode, Suite, Test } from '@vitest/runner' 2 - import type { Rollup } from 'vite' 3 - import type { TestProject } from '../node/project' 4 - import { 5 - calculateSuiteHash, 6 - createFileTask, 7 - createTaskName, 8 - interpretTaskModes, 9 - someTasksAreOnly, 10 - } from '@vitest/runner/utils' 11 - import { ancestor as walkAst } from 'acorn-walk' 12 - import { parseAstAsync } from 'vite' 13 - 14 - interface ParsedFile extends File { 15 - start: number 16 - end: number 17 - } 18 - 19 - interface ParsedTest extends Test { 20 - start: number 21 - end: number 22 - } 23 - 24 - interface ParsedSuite extends Suite { 25 - start: number 26 - end: number 27 - } 28 - 29 - interface LocalCallDefinition { 30 - start: number 31 - end: number 32 - name: string 33 - type: 'suite' | 'test' 34 - mode: RunMode 35 - task: ParsedSuite | ParsedFile | ParsedTest 36 - } 37 - 38 - export interface FileInformation { 39 - file: File 40 - filepath: string 41 - parsed: string 42 - map: Rollup.SourceMap | null 43 - definitions: LocalCallDefinition[] 44 - } 45 - 46 - export async function collectTests( 47 - ctx: TestProject, 48 - filepath: string, 49 - ): Promise<null | FileInformation> { 50 - const request = await ctx.vite.environments.ssr.transformRequest(filepath) 51 - if (!request) { 52 - return null 53 - } 54 - const ast = await parseAstAsync(request.code) 55 - const projectName = ctx.name 56 - const file: ParsedFile = { 57 - ...createFileTask( 58 - filepath, 59 - ctx.config.root, 60 - projectName, 61 - undefined, 62 - undefined, 63 - { typecheck: true }, 64 - ), 65 - start: ast.start, 66 - end: ast.end, 67 - mode: 'run', 68 - } 69 - file.file = file 70 - const definitions: LocalCallDefinition[] = [] 71 - const getName = (callee: any): string | null => { 72 - if (!callee) { 73 - return null 74 - } 75 - if (callee.type === 'Identifier') { 76 - return callee.name 77 - } 78 - if (callee.type === 'CallExpression') { 79 - return getName(callee.callee) 80 - } 81 - if (callee.type === 'TaggedTemplateExpression') { 82 - return getName(callee.tag) 83 - } 84 - if (callee.type === 'MemberExpression') { 85 - if ( 86 - callee.object?.type === 'Identifier' 87 - && ['it', 'test', 'describe', 'suite'].includes(callee.object.name) 88 - ) { 89 - return callee.object?.name 90 - } 91 - // direct call as `__vite_ssr_exports_0__.test()` 92 - if (callee.object?.name?.startsWith('__vite_ssr_')) { 93 - return getName(callee.property) 94 - } 95 - // call as `__vite_ssr__.test.skip()` 96 - return getName(callee.object?.property) 97 - } 98 - // unwrap (0, ...) 99 - if (callee.type === 'SequenceExpression' && callee.expressions.length === 2) { 100 - const [e0, e1] = callee.expressions 101 - if (e0.type === 'Literal' && e0.value === 0) { 102 - return getName(e1) 103 - } 104 - } 105 - return null 106 - } 107 - 108 - walkAst(ast as any, { 109 - CallExpression(node) { 110 - const { callee } = node as any 111 - const name = getName(callee) 112 - if (!name) { 113 - return 114 - } 115 - if (!['it', 'test', 'describe', 'suite'].includes(name)) { 116 - return 117 - } 118 - const property = callee?.property?.name 119 - let mode = !property || property === name ? 'run' : property 120 - // they will be picked up in the next iteration 121 - if (['each', 'for', 'skipIf', 'runIf'].includes(mode)) { 122 - return 123 - } 124 - 125 - let start: number 126 - const end = node.end 127 - // .each 128 - if (callee.type === 'CallExpression') { 129 - start = callee.end 130 - } 131 - else if (callee.type === 'TaggedTemplateExpression') { 132 - start = callee.end + 1 133 - } 134 - else { 135 - start = node.start 136 - } 137 - 138 - const { 139 - arguments: [messageNode], 140 - } = node 141 - 142 - const isQuoted = messageNode?.type === 'Literal' || messageNode?.type === 'TemplateLiteral' 143 - const message = isQuoted 144 - ? request.code.slice(messageNode.start + 1, messageNode.end - 1) 145 - : request.code.slice(messageNode.start, messageNode.end) 146 - 147 - // cannot statically analyze, so we always skip it 148 - if (mode === 'skipIf' || mode === 'runIf') { 149 - mode = 'skip' 150 - } 151 - definitions.push({ 152 - start, 153 - end, 154 - name: message, 155 - type: name === 'it' || name === 'test' ? 'test' : 'suite', 156 - mode, 157 - task: null as any, 158 - } satisfies LocalCallDefinition) 159 - }, 160 - }) 161 - let lastSuite: ParsedSuite = file 162 - const updateLatestSuite = (index: number) => { 163 - while (lastSuite.suite && lastSuite.end < index) { 164 - lastSuite = lastSuite.suite as ParsedSuite 165 - } 166 - return lastSuite 167 - } 168 - definitions 169 - .sort((a, b) => a.start - b.start) 170 - .forEach((definition) => { 171 - const latestSuite = updateLatestSuite(definition.start) 172 - let mode = definition.mode 173 - if (latestSuite.mode !== 'run') { 174 - // inherit suite mode, if it's set 175 - mode = latestSuite.mode 176 - } 177 - if (definition.type === 'suite') { 178 - const task: ParsedSuite = { 179 - type: definition.type, 180 - id: '', 181 - suite: latestSuite, 182 - file, 183 - tasks: [], 184 - mode, 185 - name: definition.name, 186 - fullName: createTaskName([lastSuite.fullName, definition.name]), 187 - fullTestName: createTaskName([lastSuite.fullTestName, definition.name]), 188 - end: definition.end, 189 - start: definition.start, 190 - meta: { 191 - typecheck: true, 192 - }, 193 - } 194 - definition.task = task 195 - latestSuite.tasks.push(task) 196 - lastSuite = task 197 - return 198 - } 199 - const task: ParsedTest = { 200 - type: definition.type, 201 - id: '', 202 - suite: latestSuite, 203 - file, 204 - mode, 205 - timeout: 0, 206 - context: {} as any, // not used in typecheck 207 - name: definition.name, 208 - fullName: createTaskName([lastSuite.fullName, definition.name]), 209 - fullTestName: createTaskName([lastSuite.fullTestName, definition.name]), 210 - end: definition.end, 211 - start: definition.start, 212 - annotations: [], 213 - artifacts: [], 214 - meta: { 215 - typecheck: true, 216 - }, 217 - } 218 - definition.task = task 219 - latestSuite.tasks.push(task) 220 - }) 221 - calculateSuiteHash(file) 222 - const hasOnly = someTasksAreOnly(file) 223 - interpretTaskModes( 224 - file, 225 - ctx.config.testNamePattern, 226 - undefined, 227 - undefined, 228 - undefined, 229 - hasOnly, 230 - false, 231 - ctx.config.allowOnly, 232 - ) 233 - return { 234 - file, 235 - parsed: request.code, 236 - filepath, 237 - map: request.map as Rollup.SourceMap | null, 238 - definitions, 239 - } 240 - } 241 - 242 - function getNodeAsString(node: any, code: string): string { 243 - if (node.type === 'Literal') { 244 - return String(node.value) 245 - } 246 - else if (node.type === 'Identifier') { 247 - return node.name 248 - } 249 - else if (node.type === 'TemplateLiteral') { 250 - return mergeTemplateLiteral(node, code) 251 - } 252 - else { 253 - return code.slice(node.start, node.end) 254 - } 255 - } 256 - 257 - function mergeTemplateLiteral(node: any, code: string): string { 258 - let result = '' 259 - let expressionsIndex = 0 260 - 261 - for (let quasisIndex = 0; quasisIndex < node.quasis.length; quasisIndex++) { 262 - result += node.quasis[quasisIndex].value.raw 263 - if (expressionsIndex in node.expressions) { 264 - const expression = node.expressions[expressionsIndex] 265 - const string = expression.type === 'Literal' ? expression.raw : getNodeAsString(expression, code) 266 - if (expression.type === 'TemplateLiteral') { 267 - result += `\${\`${string}\`}` 268 - } 269 - else { 270 - result += `\${${string}}` 271 - } 272 - expressionsIndex++ 273 - } 274 - } 275 - return result 276 - }
+3 -3
packages/vitest/src/typecheck/typechecker.ts
··· 3 3 import type { Awaitable, ParsedStack, TestError } from '@vitest/utils' 4 4 import type { ChildProcess } from 'node:child_process' 5 5 import type { Result } from 'tinyexec' 6 + import type { FileInformation } from '../node/ast-collect' 6 7 import type { Vitest } from '../node/core' 7 8 import type { TestProject } from '../node/project' 8 - import type { FileInformation } from './collect' 9 9 import type { TscErrorInfo } from './types' 10 10 import os from 'node:os' 11 11 import { performance } from 'node:perf_hooks' 12 12 import { eachMapping, generatedPositionFor, TraceMap } from '@jridgewell/trace-mapping' 13 13 import { basename, join, resolve } from 'pathe' 14 14 import { x } from 'tinyexec' 15 + import { astCollectFileInformation } from '../node/ast-collect' 15 16 import { distDir } from '../paths' 16 17 import { createLocationsIndexMap } from '../utils/base' 17 18 import { convertTasksToEvents } from '../utils/tasks' 18 - import { collectTests } from './collect' 19 19 import { getRawErrsMapFromTsCompile } from './parse' 20 20 21 21 export class TypeCheckError extends Error { ··· 74 74 protected async collectFileTests( 75 75 filepath: string, 76 76 ): Promise<FileInformation | null> { 77 - return collectTests(this.project, filepath) 77 + return astCollectFileInformation(this.project, filepath, { pool: 'typescript' }) 78 78 } 79 79 80 80 protected getFiles(): string[] {
+8 -8
test/typescript/test/__snapshots__/runner.test.ts.snap
··· 12 12 `; 13 13 14 14 exports[`should fail > typecheck files 2`] = ` 15 - " FAIL fail.test-d.ts > nested suite 15 + " FAIL fail.test-d.ts:7:0 > nested suite 16 16 TypeCheckError: This expression is not callable. Type 'ExpectVoid<number>' has no call signatures. 17 17 ❯ fail.test-d.ts:15:19 18 18 13| }) ··· 23 23 `; 24 24 25 25 exports[`should fail > typecheck files 3`] = ` 26 - " FAIL expect-error.test-d.ts > failing test with expect-error 26 + " FAIL expect-error.test-d.ts:4:0 > failing test with expect-error 27 27 TypeCheckError: Unused '@ts-expect-error' directive. 28 28 ❯ expect-error.test-d.ts:5:3 29 29 3| // ··· 34 34 `; 35 35 36 36 exports[`should fail > typecheck files 4`] = ` 37 - " FAIL fail.test-d.ts > failing test 37 + " FAIL fail.test-d.ts:3:0 > failing test 38 38 TypeCheckError: Type 'string' does not satisfy the constraint '"Expected string, Actual number"'. 39 39 ❯ fail.test-d.ts:4:33 40 40 2| ··· 45 45 `; 46 46 47 47 exports[`should fail > typecheck files 5`] = ` 48 - " FAIL fail.test-d.ts > nested suite > nested 2 > failing test 2 48 + " FAIL fail.test-d.ts:9:4 > nested suite > nested 2 > failing test 2 49 49 TypeCheckError: This expression is not callable. Type 'ExpectVoid<number>' has no call signatures. 50 50 ❯ fail.test-d.ts:10:23 51 51 8| describe('nested 2', () => { ··· 56 56 `; 57 57 58 58 exports[`should fail > typecheck files 6`] = ` 59 - " FAIL fail.test-d.ts > nested suite > nested 2 > failing test 2 59 + " FAIL fail.test-d.ts:9:4 > nested suite > nested 2 > failing test 2 60 60 TypeCheckError: This expression is not callable. Type 'ExpectUndefined<number>' has no call signatures. 61 61 ❯ fail.test-d.ts:11:23 62 62 9| test('failing test 2', () => { ··· 67 67 `; 68 68 69 69 exports[`should fail > typecheck files 7`] = ` 70 - " FAIL js-fail.test-d.js > js test fails 70 + " FAIL js-fail.test-d.js:5:0 > js test fails 71 71 TypeCheckError: This expression is not callable. Type 'ExpectArray<number>' has no call signatures. 72 72 ❯ js-fail.test-d.js:6:19 73 73 4| ··· 78 78 `; 79 79 80 80 exports[`should fail > typecheck files 8`] = ` 81 - " FAIL node-types.test-d.ts > buffer is not available 81 + " FAIL node-types.test-d.ts:3:0 > buffer is not available 82 82 TypeCheckError: Cannot find name 'Buffer'. Do you need to install type definitions for node? Try \`npm i --save-dev @types/node\` and then add 'node' to the types field in your tsconfig. 83 83 ❯ node-types.test-d.ts:4:3 84 84 2| ··· 89 89 `; 90 90 91 91 exports[`should fail > typecheck files 9`] = ` 92 - " FAIL only.test-d.ts > failing test 92 + " FAIL only.test-d.ts:3:0 > failing test 93 93 TypeCheckError: Type 'string' does not satisfy the constraint '"Expected string, Actual number"'. 94 94 ❯ only.test-d.ts:4:33 95 95 2|