fix(typecheck): run both runtime and typecheck tests if typecheck.include overlaps with include (#6256)

This commit is contained in:
Vladimir 2024-08-13 12:42:39 +02:00 committed by GitHub
parent a68deed01a
commit 153ff01b10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 337 additions and 108 deletions

View File

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

View File

@ -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',
)

View File

@ -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<WorkspaceProject, string[]>()
for (const [project, file] of specs) {
const files = groupedFiles.get(project) || []

View File

@ -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__'

View File

@ -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(
<div>
<div p="2" h-10 flex="~ gap-2" items-center bg-header border="b base">
<StatusIcon :state="current.result?.state" :mode="current.mode" :failed-snapshot="failedSnapshot" />
<div v-if="isTypecheck" v-tooltip.bottom="'This is a typecheck test. It won\'t report results of the runtime tests'" class="i-logos:typescript-icon" flex-shrink-0 />
<div
v-if="current?.file.projectName"
font-light

View File

@ -173,8 +173,8 @@ const projectNameTextColor = computed(() => {
<div :class="opened ? 'i-carbon:chevron-down' : 'i-carbon:chevron-right op20'" op20 />
</div>
<StatusIcon :state="state" :mode="task.mode" :failed-snapshot="failedSnapshot" w-4 />
<div v-if="type === 'suite' && typecheck" class="i-logos:typescript-icon" flex-shrink-0 mr-2 />
<div flex items-end gap-2 overflow-hidden>
<div v-if="type === 'file' && typecheck" v-tooltip.bottom="'This is a typecheck test. It won\'t report results of the runtime tests'" class="i-logos:typescript-icon" flex-shrink-0 />
<span text-sm truncate font-light>
<span v-if="type === 'file' && projectName" class="rounded-full p-1 mr-1 text-xs" :style="{ backgroundColor: projectNameColor, color: projectNameTextColor }">
{{ projectName }}

View File

@ -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

View File

@ -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

View File

@ -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,
])
},
},

View File

@ -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<string, Set<WorkspaceProject>>()
public distPath!: string
private _cachedSpecs = new Map<string, WorkspaceSpec[]>()
/** @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<string | null> {
if (this.config.workspace) {
return this.config.workspace
}
@ -423,8 +437,8 @@ export class Vitest {
}
}
private async getTestDependencies(filepath: WorkspaceSpec, deps = new Set<string>()) {
const addImports = async ([project, filepath]: WorkspaceSpec) => {
private async getTestDependencies([project, filepath]: WorkspaceSpec, deps = new Set<string>()) {
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
}
return Array.from(projects).map(project => [project, file] as WorkspaceSpec)
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' }])
}
}
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

View File

@ -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)
}

View File

@ -10,7 +10,7 @@ import type { WorkspaceProject } from '../workspace'
export function createTypecheckPool(ctx: Vitest): ProcessPool {
const promisesMap = new WeakMap<WorkspaceProject, DeferPromise<void>>()
const rerunTriggered = new WeakMap<WorkspaceProject, boolean>()
const rerunTriggered = new WeakSet<WorkspaceProject>()
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<boolean>((resolve) => {
const _i = setInterval(() => {
if (!project.typechecker || rerunTriggered.get(project)) {
if (!project.typechecker || rerunTriggered.has(project)) {
resolve(true)
clearInterval(_i)
}

View File

@ -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)
}

View File

@ -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

View File

@ -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)
})
}

View File

@ -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<UserWorkspaceConfig>
export type UserProjectConfigExport =
| UserWorkspaceConfig
| Promise<UserWorkspaceConfig>
| 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
})

View File

@ -97,6 +97,7 @@ export class WorkspaceProject {
closingPromise: Promise<unknown> | 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,

View File

@ -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'

View File

@ -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<ViteUserConfig>
export type UserConfigFn = (
@ -32,14 +29,6 @@ export type UserConfigExport =
| UserConfigFnPromise
| UserConfigFn
export type UserProjectConfigFn = (
env: ConfigEnv
) => UserWorkspaceConfig | Promise<UserWorkspaceConfig>
export type UserProjectConfigExport =
| UserWorkspaceConfig
| Promise<UserWorkspaceConfig>
| UserProjectConfigFn
export function defineConfig(config: ViteUserConfig): ViteUserConfig
export function defineConfig(
config: Promise<ViteUserConfig>
@ -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
}

View File

@ -1,4 +1,5 @@
export type SerializedSpec = [
project: { name: string | undefined; root: string },
file: string,
options: { pool: string },
]

View File

@ -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
}

View File

@ -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 = {

View File

@ -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<WorkspaceSpec>,
) {
const filesWithEnv = await Promise.all(
files.map(async ([project, file]) => {

View File

@ -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,

View File

@ -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
}

View File

@ -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', () => {

View File

@ -1,3 +1,4 @@
{
"extends": "../../tsconfig.base.json"
"extends": "../../tsconfig.base.json",
"include": ["./tests/**/*.test-d.ts"]
}

View File

@ -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)
})

View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"noEmit": true,
"target": "es2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"verbatimModuleSyntax": true
},
"include": ["test"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
typecheck: {
enabled: true,
tsconfig: './tsconfig.json',
},
},
})

View File

@ -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")()')
})
})

View File

@ -1,5 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"./**/*.ts",
"./**/*.js"
],
"exclude": [
"**/dist/**",
"**/fixtures/**"