From 153ff01b10a7364d23f0c59a7a73d105a22ae520 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 13 Aug 2024 12:42:39 +0200 Subject: [PATCH] fix(typecheck): run both runtime and typecheck tests if `typecheck.include` overlaps with `include` (#6256) --- docs/guide/testing-types.md | 8 +- packages/browser/src/node/plugin.ts | 2 +- packages/browser/src/node/pool.ts | 4 +- packages/browser/src/node/serverTester.ts | 2 +- packages/ui/client/components/FileDetails.vue | 5 + .../components/explorer/ExplorerItem.vue | 2 +- .../ui/client/composables/explorer/types.ts | 2 +- .../ui/client/composables/explorer/utils.ts | 6 +- packages/vitest/src/api/setup.ts | 3 +- packages/vitest/src/node/core.ts | 94 +++++++++++++----- packages/vitest/src/node/pool.ts | 15 +-- packages/vitest/src/node/pools/typecheck.ts | 8 +- packages/vitest/src/node/reporters/base.ts | 3 + .../node/reporters/renderers/listRenderer.ts | 5 + packages/vitest/src/node/state.ts | 19 +++- packages/vitest/src/node/types/config.ts | 22 ++++- packages/vitest/src/node/workspace.ts | 26 +++-- .../src/node/workspace/resolveWorkspace.ts | 3 +- packages/vitest/src/public/config.ts | 23 +---- packages/vitest/src/runtime/types/utils.ts | 1 + packages/vitest/src/typecheck/collect.ts | 96 +++++++++++++++---- packages/vitest/src/typecheck/typechecker.ts | 2 +- packages/vitest/src/utils/test-helpers.ts | 4 +- packages/ws-client/src/state.ts | 2 +- test/browser/test/dom.test.ts | 2 + test/core/test/sequencers.test.ts | 2 +- test/reporters/tsconfig.json | 3 +- .../test/dynamic-title.test-d.ts | 34 +++++++ .../fixtures/dynamic-title/tsconfig.json | 12 +++ .../fixtures/dynamic-title/vitest.config.ts | 10 ++ test/typescript/test/runner.test.ts | 21 ++++ test/typescript/tsconfig.json | 4 + 32 files changed, 337 insertions(+), 108 deletions(-) create mode 100644 test/typescript/fixtures/dynamic-title/test/dynamic-title.test-d.ts create mode 100644 test/typescript/fixtures/dynamic-title/tsconfig.json create mode 100644 test/typescript/fixtures/dynamic-title/vitest.config.ts diff --git a/docs/guide/testing-types.md b/docs/guide/testing-types.md index ef8d6d760..e26abc1e8 100644 --- a/docs/guide/testing-types.md +++ b/docs/guide/testing-types.md @@ -14,7 +14,13 @@ Vitest allows you to write tests for your types, using `expectTypeOf` or `assert Under the hood Vitest calls `tsc` or `vue-tsc`, depending on your config, and parses results. Vitest will also print out type errors in your source code, if it finds any. You can disable it with [`typecheck.ignoreSourceErrors`](/config/#typecheck-ignoresourceerrors) config option. -Keep in mind that Vitest doesn't run or compile these files, they are only statically analyzed by the compiler, and because of that you cannot use any dynamic statements. Meaning, you cannot use dynamic test names, and `test.each`, `test.runIf`, `test.skipIf`, `test.concurrent` APIs. But you can use other APIs, like `test`, `describe`, `.only`, `.skip` and `.todo`. +Keep in mind that Vitest doesn't run these files, they are only statically analyzed by the compiler. Meaning, that if you use a dynamic name or `test.each` or `test.for`, the test name will not be evaluated - it will be displayed as is. + +::: warning +Before Vitest 2.1, your `typecheck.include` overrode the `include` pattern, so your runtime tests did not actually run; they were only type-checked. + +Since Vitest 2.1, if your `include` and `typecheck.include` overlap, Vitest will report type tests and runtime tests as separate entries. +::: Using CLI flags, like `--allowOnly` and `-t` are also supported for type checking. diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index 749442364..dc0b8f096 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -157,7 +157,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { name: 'vitest:browser:tests', enforce: 'pre', async config() { - const allTestFiles = await project.globTestFiles() + const { testFiles: allTestFiles } = await project.globTestFiles() const browserTestFiles = allTestFiles.filter( file => getFilePoolName(project, file) === 'browser', ) diff --git a/packages/browser/src/node/pool.ts b/packages/browser/src/node/pool.ts index e1a0b5ea6..dec4d1310 100644 --- a/packages/browser/src/node/pool.ts +++ b/packages/browser/src/node/pool.ts @@ -1,7 +1,7 @@ import * as nodeos from 'node:os' import crypto from 'node:crypto' import { relative } from 'pathe' -import type { BrowserProvider, ProcessPool, Vitest, WorkspaceProject } from 'vitest/node' +import type { BrowserProvider, ProcessPool, Vitest, WorkspaceProject, WorkspaceSpec } from 'vitest/node' import { createDebugger } from 'vitest/node' const debug = createDebugger('vitest:browser:pool') @@ -92,7 +92,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { await Promise.all(promises) } - const runWorkspaceTests = async (method: 'run' | 'collect', specs: [WorkspaceProject, string][]) => { + const runWorkspaceTests = async (method: 'run' | 'collect', specs: WorkspaceSpec[]) => { const groupedFiles = new Map() for (const [project, file] of specs) { const files = groupedFiles.get(project) || [] diff --git a/packages/browser/src/node/serverTester.ts b/packages/browser/src/node/serverTester.ts index 6dccc013d..4e658da8e 100644 --- a/packages/browser/src/node/serverTester.ts +++ b/packages/browser/src/node/serverTester.ts @@ -22,7 +22,7 @@ export async function resolveTester( const { contextId, testFile } = server.resolveTesterUrl(url.pathname) const project = server.project const state = server.state - const testFiles = await project.globTestFiles() + const { testFiles } = await project.globTestFiles() // if decoded test file is "__vitest_all__" or not in the list of known files, run all tests const tests = testFile === '__vitest_all__' diff --git a/packages/ui/client/components/FileDetails.vue b/packages/ui/client/components/FileDetails.vue index 5bfe490c5..7cd54f94e 100644 --- a/packages/ui/client/components/FileDetails.vue +++ b/packages/ui/client/components/FileDetails.vue @@ -35,6 +35,10 @@ const failedSnapshot = computed(() => { return current.value && hasFailedSnapshot(current.value) }) +const isTypecheck = computed(() => { + return !!current.value?.meta?.typecheck +}) + function open() { const filePath = current.value?.filepath if (filePath) { @@ -122,6 +126,7 @@ debouncedWatch(
+
{
-
+
{{ projectName }} diff --git a/packages/ui/client/composables/explorer/types.ts b/packages/ui/client/composables/explorer/types.ts index 744dbce87..2e55175a6 100644 --- a/packages/ui/client/composables/explorer/types.ts +++ b/packages/ui/client/composables/explorer/types.ts @@ -55,12 +55,12 @@ export interface ParentTreeNode extends UITaskTreeNode { export interface SuiteTreeNode extends ParentTreeNode { fileId: string type: 'suite' - typecheck?: boolean } export interface FileTreeNode extends ParentTreeNode { type: 'file' filepath: string + typecheck: boolean | undefined projectName?: string projectNameColor: string collectDuration?: number diff --git a/packages/ui/client/composables/explorer/utils.ts b/packages/ui/client/composables/explorer/utils.ts index 9efd1193b..73f948d67 100644 --- a/packages/ui/client/composables/explorer/utils.ts +++ b/packages/ui/client/composables/explorer/utils.ts @@ -46,6 +46,7 @@ export function createOrUpdateFileNode( let fileNode = explorerTree.nodes.get(file.id) as FileTreeNode | undefined if (fileNode) { + fileNode.typecheck = !!file.meta && 'typecheck' in file.meta fileNode.state = file.result?.state fileNode.mode = file.mode fileNode.duration = file.result?.duration @@ -66,6 +67,7 @@ export function createOrUpdateFileNode( type: 'file', children: new Set(), tasks: [], + typecheck: !!file.meta && 'typecheck' in file.meta, indent: 0, duration: file.result?.duration, filepath: file.filepath, @@ -141,9 +143,6 @@ export function createOrUpdateNode( taskNode.mode = task.mode taskNode.duration = task.result?.duration taskNode.state = task.result?.state - if (isSuiteNode(taskNode)) { - taskNode.typecheck = !!task.meta && 'typecheck' in task.meta - } } else { if (isAtomTest(task)) { @@ -168,7 +167,6 @@ export function createOrUpdateNode( parentId, name: task.name, mode: task.mode, - typecheck: !!task.meta && 'typecheck' in task.meta, type: 'suite', expandable: true, // When the current run finish, we will expand all nodes when required, here we expand only the opened nodes diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 2f380a56d..b283c0b3a 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -103,12 +103,13 @@ export function setup(ctx: Vitest, _server?: ViteDevServer) { }, async getTestFiles() { const spec = await ctx.globTestFiles() - return spec.map(([project, file]) => [ + return spec.map(([project, file, options]) => [ { name: project.config.name, root: project.config.root, }, file, + options, ]) }, }, diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index c940e63a7..d091647a7 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -17,7 +17,7 @@ import { WebSocketReporter } from '../api/setup' import type { SerializedCoverageConfig } from '../runtime/config' import type { SerializedSpec } from '../runtime/types/utils' import type { ArgumentsType, OnServerRestartHandler, ProvidedContext, UserConsoleLog } from '../types/general' -import { createPool } from './pool' +import { createPool, getFilePoolName } from './pool' import type { ProcessPool, WorkspaceSpec } from './pool' import { createBenchmarkReporters, createReporters } from './reporters/utils' import { StateManager } from './state' @@ -77,10 +77,14 @@ export class Vitest { private resolvedProjects: WorkspaceProject[] = [] public projects: WorkspaceProject[] = [] - private projectsTestFiles = new Map>() public distPath!: string + private _cachedSpecs = new Map() + + /** @deprecated use `_cachedSpecs` */ + projectTestFiles = this._cachedSpecs + constructor( public readonly mode: VitestRunMode, options: VitestOptions = {}, @@ -103,7 +107,7 @@ export class Vitest { this.coverageProvider = undefined this.runningPromise = undefined this.distPath = undefined! - this.projectsTestFiles.clear() + this._cachedSpecs.clear() const resolved = resolveConfig(this.mode, options, server.config, this.logger) @@ -190,6 +194,13 @@ export class Vitest { this.getCoreWorkspaceProject().provide(key, value) } + /** + * @deprecated internal, use `_createCoreProject` instead + */ + createCoreProject() { + return this._createCoreProject() + } + /** * @internal */ @@ -202,6 +213,9 @@ export class Vitest { return this.coreWorkspaceProject } + /** + * @deprecated use Reported Task API instead + */ public getProjectByTaskId(taskId: string): WorkspaceProject { const task = this.state.idMap.get(taskId) const projectName = (task as File).projectName || task?.file?.projectName || '' @@ -216,7 +230,7 @@ export class Vitest { || this.projects[0] } - private async getWorkspaceConfigPath() { + private async getWorkspaceConfigPath(): Promise { if (this.config.workspace) { return this.config.workspace } @@ -423,8 +437,8 @@ export class Vitest { } } - private async getTestDependencies(filepath: WorkspaceSpec, deps = new Set()) { - const addImports = async ([project, filepath]: WorkspaceSpec) => { + private async getTestDependencies([project, filepath]: WorkspaceSpec, deps = new Set()) { + const addImports = async (project: WorkspaceProject, filepath: string) => { if (deps.has(filepath)) { return } @@ -440,13 +454,13 @@ export class Vitest { const path = await project.server.pluginContainer.resolveId(dep, filepath, { ssr: true }) const fsPath = path && !path.external && path.id.split('?')[0] if (fsPath && !fsPath.includes('node_modules') && !deps.has(fsPath) && existsSync(fsPath)) { - await addImports([project, fsPath]) + await addImports(project, fsPath) } })) } - await addImports(filepath) - deps.delete(filepath[1]) + await addImports(project, filepath) + deps.delete(filepath) return deps } @@ -500,12 +514,31 @@ export class Vitest { return runningTests } + /** + * @deprecated remove when vscode extension supports "getFileWorkspaceSpecs" + */ getProjectsByTestFile(file: string) { - const projects = this.projectsTestFiles.get(file) - if (!projects) { - return [] + return this.getFileWorkspaceSpecs(file) + } + + getFileWorkspaceSpecs(file: string) { + const _cached = this._cachedSpecs.get(file) + if (_cached) { + return _cached + } + + const specs: WorkspaceSpec[] = [] + for (const project of this.projects) { + if (project.isTestFile(file)) { + const pool = getFilePoolName(project, file) + specs.push([project, file, { pool }]) + } + if (project.isTypecheckFile(file)) { + specs.push([project, file, { pool: 'typescript' }]) + } } - return Array.from(projects).map(project => [project, file] as WorkspaceSpec) + specs.forEach(spec => this.ensureSpecCached(spec)) + return specs } async initializeGlobalSetup(paths: WorkspaceSpec[]) { @@ -538,8 +571,11 @@ export class Vitest { await this.report('onPathsCollected', filepaths) await this.report('onSpecsCollected', specs.map( - ([project, file]) => - [{ name: project.config.name, root: project.config.root }, file] as SerializedSpec, + ([project, file, options]) => + [{ + name: project.config.name, + root: project.config.root, + }, file, options] satisfies SerializedSpec, )) // previous run @@ -856,7 +892,6 @@ export class Vitest { })) if (matchingProjects.length > 0) { - this.projectsTestFiles.set(id, new Set(matchingProjects)) this.changedTests.add(id) this.scheduleRerun([id]) } @@ -1054,17 +1089,32 @@ export class Vitest { public async globTestFiles(filters: string[] = []) { const files: WorkspaceSpec[] = [] await Promise.all(this.projects.map(async (project) => { - const specs = await project.globTestFiles(filters) - specs.forEach((file) => { - files.push([project, file]) - const projects = this.projectsTestFiles.get(file) || new Set() - projects.add(project) - this.projectsTestFiles.set(file, projects) + const { testFiles, typecheckTestFiles } = await project.globTestFiles(filters) + testFiles.forEach((file) => { + const pool = getFilePoolName(project, file) + const spec: WorkspaceSpec = [project, file, { pool }] + this.ensureSpecCached(spec) + files.push(spec) + }) + typecheckTestFiles.forEach((file) => { + const spec: WorkspaceSpec = [project, file, { pool: 'typescript' }] + this.ensureSpecCached(spec) + files.push(spec) }) })) return files } + private ensureSpecCached(spec: WorkspaceSpec) { + const file = spec[1] + const specs = this._cachedSpecs.get(file) || [] + const included = specs.some(_s => _s[0] === spec[0] && _s[2].pool === spec[2].pool) + if (!included) { + specs.push(spec) + this._cachedSpecs.set(file, specs) + } + } + // The server needs to be running for communication shouldKeepServer() { return !!this.config?.watch diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index 4b891b838..2e225e0e5 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -10,7 +10,7 @@ import type { WorkspaceProject } from './workspace' import { createTypecheckPool } from './pools/typecheck' import { createVmForksPool } from './pools/vmForks' -export type WorkspaceSpec = [project: WorkspaceProject, testFile: string] +export type WorkspaceSpec = [project: WorkspaceProject, testFile: string, options: { pool: Pool }] export type RunWithFiles = ( files: WorkspaceSpec[], invalidates?: string[] @@ -39,14 +39,7 @@ export const builtinPools: BuiltinPool[] = [ 'typescript', ] -function getDefaultPoolName(project: WorkspaceProject, file: string): Pool { - if (project.config.typecheck.enabled) { - for (const glob of project.config.typecheck.include) { - if (mm.isMatch(file, glob, { cwd: project.config.root })) { - return 'typescript' - } - } - } +function getDefaultPoolName(project: WorkspaceProject): Pool { if (project.config.browser.enabled) { return 'browser' } @@ -64,7 +57,7 @@ export function getFilePoolName(project: WorkspaceProject, file: string) { return pool as Pool } } - return getDefaultPoolName(project, file) + return getDefaultPoolName(project) } export function createPool(ctx: Vitest): ProcessPool { @@ -172,7 +165,7 @@ export function createPool(ctx: Vitest): ProcessPool { } for (const spec of files) { - const pool = getFilePoolName(spec[0], spec[1]) + const { pool } = spec[2] filesByPool[pool] ??= [] filesByPool[pool].push(spec) } diff --git a/packages/vitest/src/node/pools/typecheck.ts b/packages/vitest/src/node/pools/typecheck.ts index b0ce984e0..ca476443b 100644 --- a/packages/vitest/src/node/pools/typecheck.ts +++ b/packages/vitest/src/node/pools/typecheck.ts @@ -10,7 +10,7 @@ import type { WorkspaceProject } from '../workspace' export function createTypecheckPool(ctx: Vitest): ProcessPool { const promisesMap = new WeakMap>() - const rerunTriggered = new WeakMap() + const rerunTriggered = new WeakSet() async function onParseEnd( project: WorkspaceProject, @@ -36,7 +36,7 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool { promisesMap.get(project)?.resolve() - rerunTriggered.set(project, false) + rerunTriggered.delete(project) // triggered by TSC watcher, not Vitest watcher, so we need to emulate what Vitest does in this case if (ctx.config.watch && !ctx.runningPromise) { @@ -68,7 +68,7 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool { checker.onParseEnd(result => onParseEnd(project, result)) checker.onWatcherRerun(async () => { - rerunTriggered.set(project, true) + rerunTriggered.add(project) if (!ctx.runningPromise) { ctx.state.clearErrors() @@ -123,7 +123,7 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool { // check that watcher actually triggered rerun const _p = new Promise((resolve) => { const _i = setInterval(() => { - if (!project.typechecker || rerunTriggered.get(project)) { + if (!project.typechecker || rerunTriggered.has(project)) { resolve(true) clearInterval(_i) } diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index 702e0bc17..1f530052c 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -154,6 +154,9 @@ export abstract class BaseReporter implements Reporter { } let title = ` ${getStateSymbol(task)} ` + if (task.meta.typecheck) { + title += `${c.bgBlue(c.bold(' TS '))} ` + } if (task.projectName) { title += formatProjectName(task.projectName) } diff --git a/packages/vitest/src/node/reporters/renderers/listRenderer.ts b/packages/vitest/src/node/reporters/renderers/listRenderer.ts index 6eedf6c55..6be8b1a71 100644 --- a/packages/vitest/src/node/reporters/renderers/listRenderer.ts +++ b/packages/vitest/src/node/reporters/renderers/listRenderer.ts @@ -124,6 +124,11 @@ function renderTree( prefix += formatProjectName(task.projectName) } + if (level === 0 && task.type === 'suite' && task.meta.typecheck) { + prefix += c.bgBlue(c.bold(' TS ')) + prefix += ' ' + } + if ( task.type === 'test' && task.result?.retryCount diff --git a/packages/vitest/src/node/state.ts b/packages/vitest/src/node/state.ts index dc7b79faf..63fd78f61 100644 --- a/packages/vitest/src/node/state.ts +++ b/packages/vitest/src/node/state.ts @@ -78,7 +78,16 @@ export class StateManager { .flat() .filter(file => file && !file.local) } - return Array.from(this.filesMap.values()).flat().filter(file => !file.local) + return Array.from(this.filesMap.values()).flat().filter(file => !file.local).sort((f1, f2) => { + // print typecheck files first + if (f1.meta?.typecheck && f2.meta?.typecheck) { + return 0 + } + if (f1.meta?.typecheck) { + return -1 + } + return 1 + }) } getFilepaths(): string[] { @@ -100,8 +109,8 @@ export class StateManager { collectFiles(project: WorkspaceProject, files: File[] = []) { files.forEach((file) => { const existing = this.filesMap.get(file.filepath) || [] - const otherProject = existing.filter( - i => i.projectName !== file.projectName, + const otherFiles = existing.filter( + i => i.projectName !== file.projectName || i.meta.typecheck !== file.meta.typecheck, ) const currentFile = existing.find( i => i.projectName === file.projectName, @@ -111,8 +120,8 @@ export class StateManager { if (currentFile) { file.logs = currentFile.logs } - otherProject.push(file) - this.filesMap.set(file.filepath, otherProject) + otherFiles.push(file) + this.filesMap.set(file.filepath, otherFiles) this.updateId(file, project) }) } diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 019b0f671..4b0939dbc 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1,4 +1,4 @@ -import type { AliasOptions, DepOptimizationConfig, ServerOptions } from 'vite' +import type { AliasOptions, ConfigEnv, DepOptimizationConfig, ServerOptions, UserConfig as ViteUserConfig } from 'vite' import type { PrettyFormatOptions } from '@vitest/pretty-format' import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers' import type { SequenceHooks, SequenceSetupFiles } from '@vitest/runner' @@ -1094,4 +1094,22 @@ export type ResolvedProjectConfig = Omit< NonProjectOptions > -export type { UserWorkspaceConfig } from '../../public/config' +export interface UserWorkspaceConfig extends ViteUserConfig { + test?: ProjectConfig +} + +export type UserProjectConfigFn = ( + env: ConfigEnv +) => UserWorkspaceConfig | Promise +export type UserProjectConfigExport = + | UserWorkspaceConfig + | Promise + | UserProjectConfigFn + +export type WorkspaceProjectConfiguration = string | (UserProjectConfigExport & { + /** + * Relative path to the extendable config. All other options will be merged with this config. + * @example '../vite.config.ts' + */ + extends?: string +}) diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 408f1da3f..494c3147e 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -97,6 +97,7 @@ export class WorkspaceProject { closingPromise: Promise | undefined testFilesList: string[] | null = null + typecheckFilesList: string[] | null = null public testProject!: TestProject @@ -225,15 +226,24 @@ export class WorkspaceProject { ? [] : this.globAllTestFiles(include, exclude, includeSource, dir), typecheck.enabled - ? this.globFiles(typecheck.include, typecheck.exclude, dir) + ? (this.typecheckFilesList || this.globFiles(typecheck.include, typecheck.exclude, dir)) : [], ]) - return this.filterFiles( - [...testFiles, ...typecheckTestFiles], - filters, - dir, - ) + this.typecheckFilesList = typecheckTestFiles + + return { + testFiles: this.filterFiles( + testFiles, + filters, + dir, + ), + typecheckTestFiles: this.filterFiles( + typecheckTestFiles, + filters, + dir, + ), + } } async globAllTestFiles( @@ -275,6 +285,10 @@ export class WorkspaceProject { return this.testFilesList && this.testFilesList.includes(id) } + isTypecheckFile(id: string) { + return this.typecheckFilesList && this.typecheckFilesList.includes(id) + } + async globFiles(include: string[], exclude: string[], cwd: string) { const globOptions: fg.Options = { dot: true, diff --git a/packages/vitest/src/node/workspace/resolveWorkspace.ts b/packages/vitest/src/node/workspace/resolveWorkspace.ts index a84857be7..314466649 100644 --- a/packages/vitest/src/node/workspace/resolveWorkspace.ts +++ b/packages/vitest/src/node/workspace/resolveWorkspace.ts @@ -3,9 +3,8 @@ import { isMainThread } from 'node:worker_threads' import { dirname, relative, resolve } from 'pathe' import { mergeConfig } from 'vite' import fg from 'fast-glob' -import type { UserWorkspaceConfig, WorkspaceProjectConfiguration } from '../../public/config' import type { Vitest } from '../core' -import type { UserConfig } from '../types/config' +import type { UserConfig, UserWorkspaceConfig, WorkspaceProjectConfiguration } from '../types/config' import type { WorkspaceProject } from '../workspace' import { initializeProject } from '../workspace' import { configFiles as defaultConfigFiles } from '../../constants' diff --git a/packages/vitest/src/public/config.ts b/packages/vitest/src/public/config.ts index b8a7341e6..8b77d9a17 100644 --- a/packages/vitest/src/public/config.ts +++ b/packages/vitest/src/public/config.ts @@ -1,11 +1,7 @@ import '../node/types/vite' import type { ConfigEnv, UserConfig as ViteUserConfig } from 'vite' -import type { ProjectConfig } from '../node/types/config' - -export interface UserWorkspaceConfig extends ViteUserConfig { - test?: ProjectConfig -} +import type { UserProjectConfigExport, UserProjectConfigFn, UserWorkspaceConfig, WorkspaceProjectConfiguration } from '../node/types/config' // will import vitest declare test in module 'vite' export { @@ -20,6 +16,7 @@ export { extraInlineDeps } from '../constants' export type { Plugin } from 'vite' export type { ConfigEnv, ViteUserConfig as UserConfig } +export type { UserProjectConfigExport, UserProjectConfigFn, UserWorkspaceConfig, WorkspaceProjectConfiguration } export type UserConfigFnObject = (env: ConfigEnv) => ViteUserConfig export type UserConfigFnPromise = (env: ConfigEnv) => Promise export type UserConfigFn = ( @@ -32,14 +29,6 @@ export type UserConfigExport = | UserConfigFnPromise | UserConfigFn -export type UserProjectConfigFn = ( - env: ConfigEnv -) => UserWorkspaceConfig | Promise -export type UserProjectConfigExport = - | UserWorkspaceConfig - | Promise - | UserProjectConfigFn - export function defineConfig(config: ViteUserConfig): ViteUserConfig export function defineConfig( config: Promise @@ -58,14 +47,6 @@ export function defineProject(config: UserProjectConfigExport): UserProjectConfi return config } -export type WorkspaceProjectConfiguration = string | (UserProjectConfigExport & { - /** - * Relative path to the extendable config. All other options will be merged with this config. - * @example '../vite.config.ts' - */ - extends?: string -}) - export function defineWorkspace(config: WorkspaceProjectConfiguration[]): WorkspaceProjectConfiguration[] { return config } diff --git a/packages/vitest/src/runtime/types/utils.ts b/packages/vitest/src/runtime/types/utils.ts index e65c04ce3..7e76bcb08 100644 --- a/packages/vitest/src/runtime/types/utils.ts +++ b/packages/vitest/src/runtime/types/utils.ts @@ -1,4 +1,5 @@ export type SerializedSpec = [ project: { name: string | undefined; root: string }, file: string, + options: { pool: string }, ] diff --git a/packages/vitest/src/typecheck/collect.ts b/packages/vitest/src/typecheck/collect.ts index 7c7aca0a4..cc4fac80a 100644 --- a/packages/vitest/src/typecheck/collect.ts +++ b/packages/vitest/src/typecheck/collect.ts @@ -2,7 +2,6 @@ import { relative } from 'pathe' import { parseAstAsync } from 'vite' import { ancestor as walkAst } from 'acorn-walk' import type { RawSourceMap } from 'vite-node' - import { calculateSuiteHash, generateHash, @@ -54,16 +53,18 @@ export async function collectTests( } const ast = await parseAstAsync(request.code) const testFilepath = relative(ctx.config.root, filepath) + const projectName = ctx.getName() + const typecheckSubprojectName = projectName ? `${projectName}:__typecheck__` : '__typecheck__' const file: ParsedFile = { filepath, type: 'suite', - id: generateHash(`${testFilepath}${ctx.config.name || ''}`), + id: generateHash(`${testFilepath}${typecheckSubprojectName}`), name: testFilepath, mode: 'run', tasks: [], start: ast.start, end: ast.end, - projectName: ctx.getName(), + projectName, meta: { typecheck: true }, file: null!, } @@ -76,6 +77,12 @@ export async function collectTests( 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') { // direct call as `__vite_ssr_exports_0__.test()` if (callee.object?.name?.startsWith('__vite_ssr_')) { @@ -86,6 +93,7 @@ export async function collectTests( } return null } + walkAst(ast as any, { CallExpression(node) { const { callee } = node as any @@ -96,27 +104,45 @@ export async function collectTests( if (!['it', 'test', 'describe', 'suite'].includes(name)) { return } - const { - arguments: [{ value: message }], - } = node as any const property = callee?.property?.name - let mode = !property || property === name ? 'run' : property - if (!['run', 'skip', 'todo', 'only', 'skipIf', 'runIf'].includes(mode)) { - throw new Error( - `${name}.${mode} syntax is not supported when testing types`, - ) + const mode = !property || property === name ? 'run' : property + // the test node for skipIf and runIf will be the next CallExpression + if (mode === 'each' || mode === 'skipIf' || mode === 'runIf' || mode === 'for') { + return } - // cannot statically analyze, so we always skip it - if (mode === 'skipIf' || mode === 'runIf') { - mode = 'skip' + + let start: number + const end = node.end + + if (callee.type === 'CallExpression') { + start = callee.end + } + else if (callee.type === 'TaggedTemplateExpression') { + start = callee.end + 1 + } + else { + start = node.start + } + + const { + arguments: [messageNode], + } = node + + if (!messageNode) { + // called as "test()" + return } + + const message = getNodeAsString(messageNode, request.code) + definitions.push({ - start: node.start, - end: node.end, + start, + end, name: message, type: name === 'it' || name === 'test' ? 'test' : 'suite', mode, - } as LocalCallDefinition) + task: null as any, + } satisfies LocalCallDefinition) }, }) let lastSuite: ParsedSuite = file @@ -189,3 +215,39 @@ export async function collectTests( 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 b50507aae..760194f4a 100644 --- a/packages/vitest/src/typecheck/typechecker.ts +++ b/packages/vitest/src/typecheck/typechecker.ts @@ -145,7 +145,7 @@ export class Typechecker { ...definitions.sort((a, b) => b.start - a.start), ] // has no map for ".js" files that use // @ts-check - const traceMap = map && new TraceMap(map as unknown as RawSourceMap) + const traceMap = (map && new TraceMap(map as unknown as RawSourceMap)) const indexMap = createIndexMap(parsed) const markState = (task: Task, state: TaskState) => { task.result = { diff --git a/packages/vitest/src/utils/test-helpers.ts b/packages/vitest/src/utils/test-helpers.ts index dcfaef619..fd24f3135 100644 --- a/packages/vitest/src/utils/test-helpers.ts +++ b/packages/vitest/src/utils/test-helpers.ts @@ -1,8 +1,8 @@ import { promises as fs } from 'node:fs' import mm from 'micromatch' -import type { WorkspaceProject } from '../node/workspace' import type { EnvironmentOptions, TransformModePatterns, VitestEnvironment } from '../node/types/config' import type { ContextTestEnvironment } from '../types/worker' +import type { WorkspaceSpec } from '../node/pool' import { groupBy } from './base' export const envsOrder = ['node', 'jsdom', 'happy-dom', 'edge-runtime'] @@ -27,7 +27,7 @@ function getTransformMode( } export async function groupFilesByEnv( - files: (readonly [WorkspaceProject, string])[], + files: Array, ) { const filesWithEnv = await Promise.all( files.map(async ([project, file]) => { diff --git a/packages/ws-client/src/state.ts b/packages/ws-client/src/state.ts index e00b92333..82741ae18 100644 --- a/packages/ws-client/src/state.ts +++ b/packages/ws-client/src/state.ts @@ -48,7 +48,7 @@ export class StateManager { files.forEach((file) => { const existing = this.filesMap.get(file.filepath) || [] const otherProject = existing.filter( - i => i.projectName !== file.projectName, + i => i.projectName !== file.projectName || i.meta.typecheck !== file.meta.typecheck, ) const currentFile = existing.find( i => i.projectName === file.projectName, diff --git a/test/browser/test/dom.test.ts b/test/browser/test/dom.test.ts index 4722a443e..eb7182c4a 100644 --- a/test/browser/test/dom.test.ts +++ b/test/browser/test/dom.test.ts @@ -87,5 +87,7 @@ function createWrapper() { const wrapper = document.createElement('div') wrapper.className = 'wrapper' document.body.appendChild(wrapper) + wrapper.style.height = '200px' + wrapper.style.width = '200px' return wrapper } diff --git a/test/core/test/sequencers.test.ts b/test/core/test/sequencers.test.ts index 448b1515b..74f131815 100644 --- a/test/core/test/sequencers.test.ts +++ b/test/core/test/sequencers.test.ts @@ -26,7 +26,7 @@ function buildWorkspace() { const workspace = buildWorkspace() function workspaced(files: string[]) { - return files.map(file => [workspace, file] as WorkspaceSpec) + return files.map(file => [workspace, file, { pool: 'forks' }] satisfies WorkspaceSpec) } describe('base sequencer', () => { diff --git a/test/reporters/tsconfig.json b/test/reporters/tsconfig.json index 9536a0f41..57e707262 100644 --- a/test/reporters/tsconfig.json +++ b/test/reporters/tsconfig.json @@ -1,3 +1,4 @@ { - "extends": "../../tsconfig.base.json" + "extends": "../../tsconfig.base.json", + "include": ["./tests/**/*.test-d.ts"] } diff --git a/test/typescript/fixtures/dynamic-title/test/dynamic-title.test-d.ts b/test/typescript/fixtures/dynamic-title/test/dynamic-title.test-d.ts new file mode 100644 index 000000000..19d62fbd2 --- /dev/null +++ b/test/typescript/fixtures/dynamic-title/test/dynamic-title.test-d.ts @@ -0,0 +1,34 @@ +import { expectTypeOf, test } from 'vitest' + +test.each(['some-value'])('each: %s', () => { + expectTypeOf(1).toEqualTypeOf(2) +}) + +test.for(['some-value'])('for: %s', () => { + expectTypeOf(1).toEqualTypeOf(2) +}) + +test.skipIf(false)('dynamic skip', () => { + expectTypeOf(1).toEqualTypeOf(2) +}) + +test(`template string`, () => { + expectTypeOf(1).toEqualTypeOf(2) +}) + +test(`template ${'some value'} string`, () => { + expectTypeOf(1).toEqualTypeOf(2) +}) + +test(`template ${`literal`} string`, () => { + expectTypeOf(1).toEqualTypeOf(2) +}) + +const name = 'some value' +test(name, () => { + expectTypeOf(1).toEqualTypeOf(2) +}) + +test((() => 'some name')(), () => { + expectTypeOf(1).toEqualTypeOf(2) +}) diff --git a/test/typescript/fixtures/dynamic-title/tsconfig.json b/test/typescript/fixtures/dynamic-title/tsconfig.json new file mode 100644 index 000000000..16d8f31d6 --- /dev/null +++ b/test/typescript/fixtures/dynamic-title/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "noEmit": true, + "target": "es2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "verbatimModuleSyntax": true + }, + "include": ["test"], + "exclude": ["node_modules"] +} diff --git a/test/typescript/fixtures/dynamic-title/vitest.config.ts b/test/typescript/fixtures/dynamic-title/vitest.config.ts new file mode 100644 index 000000000..4abab689f --- /dev/null +++ b/test/typescript/fixtures/dynamic-title/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + typecheck: { + enabled: true, + tsconfig: './tsconfig.json', + }, + }, +}) diff --git a/test/typescript/test/runner.test.ts b/test/typescript/test/runner.test.ts index 3e6b9ffcd..b64df34e9 100644 --- a/test/typescript/test/runner.test.ts +++ b/test/typescript/test/runner.test.ts @@ -112,3 +112,24 @@ describe('ignoreSourceErrors', () => { expect(vitest.stderr).not.toContain('TypeCheckError: Cannot find name \'thisIsSourceError\'') }) }) + +describe('when the title is dynamic', () => { + it('works correctly', async () => { + const vitest = await runVitest({ + root: resolve(__dirname, '../fixtures/dynamic-title'), + reporters: [['default', { isTTY: true }]], + }) + + expect(vitest.stdout).toContain('✓ for: %s') + expect(vitest.stdout).toContain('✓ each: %s') + 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 + expect(vitest.stdout).toContain('✓ template ${"some value"} string') + // eslint-disable-next-line no-template-curly-in-string + expect(vitest.stdout).toContain('✓ template ${`literal`} string') + expect(vitest.stdout).toContain('✓ name') + expect(vitest.stdout).toContain('✓ (() => "some name")()') + }) +}) diff --git a/test/typescript/tsconfig.json b/test/typescript/tsconfig.json index 403b1ee01..f7781f12f 100644 --- a/test/typescript/tsconfig.json +++ b/test/typescript/tsconfig.json @@ -1,5 +1,9 @@ { "extends": "../../tsconfig.base.json", + "include": [ + "./**/*.ts", + "./**/*.js" + ], "exclude": [ "**/dist/**", "**/fixtures/**" -- 2.51.2