diff --git a/packages/vitest/src/node/ast-collect.ts b/packages/vitest/src/node/ast-collect.ts index 78488ccc5..80651276c 100644 --- a/packages/vitest/src/node/ast-collect.ts +++ b/packages/vitest/src/node/ast-collect.ts @@ -34,7 +34,7 @@ interface ParsedSuite extends Suite { dynamic: boolean } -interface LocalCallDefinition { +export interface LocalCallDefinition { start: number end: number name: string @@ -46,6 +46,24 @@ interface LocalCallDefinition { tags: string[] } +export interface FileInformation { + file: File + filepath: string + parsed: string + map: any + definitions: LocalCallDefinition[] +} + +export interface AstCollectOptions { + /** + * Override the pool stored on the resulting File task. Required when + * collecting typecheck files because the project's `config.pool` is the + * user's runtime pool (e.g. `forks`), not the `typescript` pool that the + * typecheck spec uses to compute its task id. + */ + pool?: string +} + const debug = createDebugger('vitest:ast-collect-info') const verbose = createDebugger('vitest:ast-collect-verbose') @@ -287,15 +305,16 @@ function astParseFile(filepath: string, code: string) { } } -export function createFailedFileTask(project: TestProject, filepath: string, error: Error): File { +export function createFailedFileTask(project: TestProject, filepath: string, error: Error, options?: AstCollectOptions): File { const config = project.serializedConfig + const pool = options?.pool ?? config.pool const baseFile = createFileTaskOriginal( filepath, config.root, config.name, - config.pool, + pool, undefined, - { typecheck: config.pool === 'typescript', __vitest_label__: config.mergeReportsLabel }, + { typecheck: pool === 'typescript', __vitest_label__: config.mergeReportsLabel }, ) const file: ParsedFile = { ...baseFile, @@ -340,16 +359,18 @@ function createFileTask( requestMap: any, filepath: string, fileTags: string[] | undefined, + options?: AstCollectOptions, ) { const { definitions, ast } = astParseFile(testFilepath, code) const config = project.serializedConfig + const pool = options?.pool ?? config.pool const baseFile = createFileTaskOriginal( filepath, config.root, config.name, - config.pool, + pool, undefined, - { typecheck: config.pool === 'typescript', __vitest_label__: config.mergeReportsLabel }, + { typecheck: pool === 'typescript', __vitest_label__: config.mergeReportsLabel }, ) const file: ParsedFile = { ...baseFile, @@ -484,31 +505,55 @@ function createFileTask( ], } } - return file + return { file, definitions } } export async function astCollectTests( project: TestProject, filepath: string, ): Promise { + const information = await astCollectFileInformation(project, filepath) + return information.file +} + +export async function astCollectFileInformation( + project: TestProject, + filepath: string, + options?: AstCollectOptions, +): Promise { const request = await transformSSR(project, filepath) const testFilepath = relative(project.config.root, filepath) if (!request) { debug?.('Cannot parse', testFilepath, '(vite didn\'t return anything)') - return createFailedFileTask( - project, + return { + file: createFailedFileTask( + project, + filepath, + new Error(`Failed to parse ${testFilepath}. Vite didn't return anything.`), + options, + ), filepath, - new Error(`Failed to parse ${testFilepath}. Vite didn't return anything.`), - ) + parsed: '', + map: null, + definitions: [], + } } - return createFileTask( + const { file, definitions } = createFileTask( project, testFilepath, request.code, request.map, filepath, request.fileTags, + options, ) + return { + file, + filepath, + parsed: request.code, + map: request.map, + definitions, + } } async function transformSSR(project: TestProject, filepath: string) { diff --git a/packages/vitest/src/typecheck/collect.ts b/packages/vitest/src/typecheck/collect.ts deleted file mode 100644 index 213512002..000000000 --- a/packages/vitest/src/typecheck/collect.ts +++ /dev/null @@ -1,276 +0,0 @@ -import type { File, RunMode, Suite, Test } from '@vitest/runner' -import type { Rollup } from 'vite' -import type { TestProject } from '../node/project' -import { - calculateSuiteHash, - createFileTask, - createTaskName, - interpretTaskModes, - someTasksAreOnly, -} from '@vitest/runner/utils' -import { ancestor as walkAst } from 'acorn-walk' -import { parseAstAsync } from 'vite' - -interface ParsedFile extends File { - start: number - end: number -} - -interface ParsedTest extends Test { - start: number - end: number -} - -interface ParsedSuite extends Suite { - start: number - end: number -} - -interface LocalCallDefinition { - start: number - end: number - name: string - type: 'suite' | 'test' - mode: RunMode - task: ParsedSuite | ParsedFile | ParsedTest -} - -export interface FileInformation { - file: File - filepath: string - parsed: string - map: Rollup.SourceMap | null - definitions: LocalCallDefinition[] -} - -export async function collectTests( - ctx: TestProject, - filepath: string, -): Promise { - const request = await ctx.vite.environments.ssr.transformRequest(filepath) - if (!request) { - return null - } - const ast = await parseAstAsync(request.code) - const projectName = ctx.name - const file: ParsedFile = { - ...createFileTask( - filepath, - ctx.config.root, - projectName, - undefined, - undefined, - { typecheck: true }, - ), - start: ast.start, - end: ast.end, - mode: 'run', - } - file.file = file - const definitions: LocalCallDefinition[] = [] - const getName = (callee: any): string | null => { - if (!callee) { - return null - } - if (callee.type === 'Identifier') { - return callee.name - } - if (callee.type === 'CallExpression') { - return getName(callee.callee) - } - if (callee.type === 'TaggedTemplateExpression') { - return getName(callee.tag) - } - if (callee.type === 'MemberExpression') { - if ( - callee.object?.type === 'Identifier' - && ['it', 'test', 'describe', 'suite'].includes(callee.object.name) - ) { - return callee.object?.name - } - // direct call as `__vite_ssr_exports_0__.test()` - if (callee.object?.name?.startsWith('__vite_ssr_')) { - return getName(callee.property) - } - // call as `__vite_ssr__.test.skip()` - return getName(callee.object?.property) - } - // unwrap (0, ...) - if (callee.type === 'SequenceExpression' && callee.expressions.length === 2) { - const [e0, e1] = callee.expressions - if (e0.type === 'Literal' && e0.value === 0) { - return getName(e1) - } - } - return null - } - - walkAst(ast as any, { - CallExpression(node) { - const { callee } = node as any - const name = getName(callee) - if (!name) { - return - } - if (!['it', 'test', 'describe', 'suite'].includes(name)) { - return - } - const property = callee?.property?.name - let mode = !property || property === name ? 'run' : property - // they will be picked up in the next iteration - if (['each', 'for', 'skipIf', 'runIf'].includes(mode)) { - return - } - - let start: number - const end = node.end - // .each - if (callee.type === 'CallExpression') { - start = callee.end - } - else if (callee.type === 'TaggedTemplateExpression') { - start = callee.end + 1 - } - else { - start = node.start - } - - const { - arguments: [messageNode], - } = node - - const isQuoted = messageNode?.type === 'Literal' || messageNode?.type === 'TemplateLiteral' - const message = isQuoted - ? request.code.slice(messageNode.start + 1, messageNode.end - 1) - : request.code.slice(messageNode.start, messageNode.end) - - // cannot statically analyze, so we always skip it - if (mode === 'skipIf' || mode === 'runIf') { - mode = 'skip' - } - definitions.push({ - start, - end, - name: message, - type: name === 'it' || name === 'test' ? 'test' : 'suite', - mode, - task: null as any, - } satisfies LocalCallDefinition) - }, - }) - let lastSuite: ParsedSuite = file - const updateLatestSuite = (index: number) => { - while (lastSuite.suite && lastSuite.end < index) { - lastSuite = lastSuite.suite as ParsedSuite - } - return lastSuite - } - definitions - .sort((a, b) => a.start - b.start) - .forEach((definition) => { - const latestSuite = updateLatestSuite(definition.start) - let mode = definition.mode - if (latestSuite.mode !== 'run') { - // inherit suite mode, if it's set - mode = latestSuite.mode - } - if (definition.type === 'suite') { - const task: ParsedSuite = { - type: definition.type, - id: '', - suite: latestSuite, - file, - tasks: [], - mode, - name: definition.name, - fullName: createTaskName([lastSuite.fullName, definition.name]), - fullTestName: createTaskName([lastSuite.fullTestName, definition.name]), - end: definition.end, - start: definition.start, - meta: { - typecheck: true, - }, - } - definition.task = task - latestSuite.tasks.push(task) - lastSuite = task - return - } - const task: ParsedTest = { - type: definition.type, - id: '', - suite: latestSuite, - file, - mode, - timeout: 0, - context: {} as any, // not used in typecheck - name: definition.name, - fullName: createTaskName([lastSuite.fullName, definition.name]), - fullTestName: createTaskName([lastSuite.fullTestName, definition.name]), - end: definition.end, - start: definition.start, - annotations: [], - artifacts: [], - meta: { - typecheck: true, - }, - } - definition.task = task - latestSuite.tasks.push(task) - }) - calculateSuiteHash(file) - const hasOnly = someTasksAreOnly(file) - interpretTaskModes( - file, - ctx.config.testNamePattern, - undefined, - undefined, - undefined, - hasOnly, - false, - ctx.config.allowOnly, - ) - return { - file, - parsed: request.code, - filepath, - map: request.map as Rollup.SourceMap | null, - definitions, - } -} - -function getNodeAsString(node: any, code: string): string { - if (node.type === 'Literal') { - return String(node.value) - } - else if (node.type === 'Identifier') { - return node.name - } - else if (node.type === 'TemplateLiteral') { - return mergeTemplateLiteral(node, code) - } - else { - return code.slice(node.start, node.end) - } -} - -function mergeTemplateLiteral(node: any, code: string): string { - let result = '' - let expressionsIndex = 0 - - for (let quasisIndex = 0; quasisIndex < node.quasis.length; quasisIndex++) { - result += node.quasis[quasisIndex].value.raw - if (expressionsIndex in node.expressions) { - const expression = node.expressions[expressionsIndex] - const string = expression.type === 'Literal' ? expression.raw : getNodeAsString(expression, code) - if (expression.type === 'TemplateLiteral') { - result += `\${\`${string}\`}` - } - else { - result += `\${${string}}` - } - expressionsIndex++ - } - } - return result -} diff --git a/packages/vitest/src/typecheck/typechecker.ts b/packages/vitest/src/typecheck/typechecker.ts index a7a180f60..65f632f31 100644 --- a/packages/vitest/src/typecheck/typechecker.ts +++ b/packages/vitest/src/typecheck/typechecker.ts @@ -3,19 +3,19 @@ import type { File, Task, TaskEventPack, TaskResultPack, TaskState } from '@vite import type { Awaitable, ParsedStack, TestError } from '@vitest/utils' import type { ChildProcess } from 'node:child_process' import type { Result } from 'tinyexec' +import type { FileInformation } from '../node/ast-collect' import type { Vitest } from '../node/core' import type { TestProject } from '../node/project' -import type { FileInformation } from './collect' import type { TscErrorInfo } from './types' import os from 'node:os' import { performance } from 'node:perf_hooks' import { eachMapping, generatedPositionFor, TraceMap } from '@jridgewell/trace-mapping' import { basename, join, resolve } from 'pathe' import { x } from 'tinyexec' +import { astCollectFileInformation } from '../node/ast-collect' import { distDir } from '../paths' import { createLocationsIndexMap } from '../utils/base' import { convertTasksToEvents } from '../utils/tasks' -import { collectTests } from './collect' import { getRawErrsMapFromTsCompile } from './parse' export class TypeCheckError extends Error { @@ -74,7 +74,7 @@ export class Typechecker { protected async collectFileTests( filepath: string, ): Promise { - return collectTests(this.project, filepath) + return astCollectFileInformation(this.project, filepath, { pool: 'typescript' }) } protected getFiles(): string[] { diff --git a/test/typescript/test/__snapshots__/runner.test.ts.snap b/test/typescript/test/__snapshots__/runner.test.ts.snap index 20a80ee26..8c144a784 100644 --- a/test/typescript/test/__snapshots__/runner.test.ts.snap +++ b/test/typescript/test/__snapshots__/runner.test.ts.snap @@ -12,7 +12,7 @@ TypeCheckError: This expression is not callable. Type 'ExpectVoid' has n `; exports[`should fail > typecheck files 2`] = ` -" FAIL fail.test-d.ts > nested suite +" FAIL fail.test-d.ts:7:0 > nested suite TypeCheckError: This expression is not callable. Type 'ExpectVoid' has no call signatures. ❯ fail.test-d.ts:15:19 13| }) @@ -23,7 +23,7 @@ TypeCheckError: This expression is not callable. Type 'ExpectVoid' has n `; exports[`should fail > typecheck files 3`] = ` -" FAIL expect-error.test-d.ts > failing test with expect-error +" FAIL expect-error.test-d.ts:4:0 > failing test with expect-error TypeCheckError: Unused '@ts-expect-error' directive. ❯ expect-error.test-d.ts:5:3 3| // @@ -34,7 +34,7 @@ TypeCheckError: Unused '@ts-expect-error' directive. `; exports[`should fail > typecheck files 4`] = ` -" FAIL fail.test-d.ts > failing test +" FAIL fail.test-d.ts:3:0 > failing test TypeCheckError: Type 'string' does not satisfy the constraint '"Expected string, Actual number"'. ❯ fail.test-d.ts:4:33 2| @@ -45,7 +45,7 @@ TypeCheckError: Type 'string' does not satisfy the constraint '"Expected string, `; exports[`should fail > typecheck files 5`] = ` -" FAIL fail.test-d.ts > nested suite > nested 2 > failing test 2 +" FAIL fail.test-d.ts:9:4 > nested suite > nested 2 > failing test 2 TypeCheckError: This expression is not callable. Type 'ExpectVoid' has no call signatures. ❯ fail.test-d.ts:10:23 8| describe('nested 2', () => { @@ -56,7 +56,7 @@ TypeCheckError: This expression is not callable. Type 'ExpectVoid' has n `; exports[`should fail > typecheck files 6`] = ` -" FAIL fail.test-d.ts > nested suite > nested 2 > failing test 2 +" FAIL fail.test-d.ts:9:4 > nested suite > nested 2 > failing test 2 TypeCheckError: This expression is not callable. Type 'ExpectUndefined' has no call signatures. ❯ fail.test-d.ts:11:23 9| test('failing test 2', () => { @@ -67,7 +67,7 @@ TypeCheckError: This expression is not callable. Type 'ExpectUndefined' `; exports[`should fail > typecheck files 7`] = ` -" FAIL js-fail.test-d.js > js test fails +" FAIL js-fail.test-d.js:5:0 > js test fails TypeCheckError: This expression is not callable. Type 'ExpectArray' has no call signatures. ❯ js-fail.test-d.js:6:19 4| @@ -78,7 +78,7 @@ TypeCheckError: This expression is not callable. Type 'ExpectArray' has `; exports[`should fail > typecheck files 8`] = ` -" FAIL node-types.test-d.ts > buffer is not available +" FAIL node-types.test-d.ts:3:0 > buffer is not available 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. ❯ node-types.test-d.ts:4:3 2| @@ -89,7 +89,7 @@ TypeCheckError: Cannot find name 'Buffer'. Do you need to install type definitio `; exports[`should fail > typecheck files 9`] = ` -" FAIL only.test-d.ts > failing test +" FAIL only.test-d.ts:3:0 > failing test TypeCheckError: Type 'string' does not satisfy the constraint '"Expected string, Actual number"'. ❯ only.test-d.ts:4:33 2| diff --git a/test/typescript/test/runner.test.ts b/test/typescript/test/runner.test.ts index d8fc2647d..1401cb3cb 100644 --- a/test/typescript/test/runner.test.ts +++ b/test/typescript/test/runner.test.ts @@ -124,7 +124,7 @@ describe('when the title is dynamic', () => { expect(vitest.stdout).toContain('✓ for: %s') expect(vitest.stdout).toContain('✓ each: %s') - expect(vitest.stdout).toContain('✓ dynamic skip') + expect(vitest.stdout).toContain('↓ dynamic skip') expect(vitest.stdout).not.toContain('✓ false') // .skipIf is not reported as a separate test expect(vitest.stdout).toContain('✓ template string') // eslint-disable-next-line no-template-curly-in-string