fix(typecheck): avoid creating a temporary tsconfig file when typechecking (#7967)

This commit is contained in:
Vladimir 2025-05-16 17:15:10 +02:00 committed by GitHub
parent 33b930a12f
commit 34f43ae687
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 100 additions and 176 deletions

View File

@ -90,6 +90,12 @@ vitest.config === vitest.projects[0].globalConfig
This is the project's resolved test config.
## hash <Version>3.2.0</Version> {#hash}
The unique hash of this project. This value is consistent between the reruns.
It is based on the root of the project and its name. Note that the root path is not consistent between different OS, so the hash will also be different.
## vite
This is project's [`ViteDevServer`](https://vite.dev/guide/api-javascript#vitedevserver). All projects have their own Vite servers.

View File

@ -268,35 +268,6 @@ Repository: git+https://github.com/WebReflection/flatted.git
---------------------------------------
## get-tsconfig
License: MIT
By: Hiroki Osame
Repository: privatenumber/get-tsconfig
> MIT License
>
> Copyright (c) Hiroki Osame <hiroki.osame@gmail.com>
>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of this software and associated documentation files (the "Software"), to deal
> in the Software without restriction, including without limitation the rights
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
> copies of the Software, and to permit persons to whom the Software is
> furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all
> copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
> SOFTWARE.
---------------------------------------
## js-tokens
License: MIT
By: Simon Lydell
@ -550,35 +521,6 @@ Repository: git+https://github.com/quansync-dev/quansync.git
---------------------------------------
## resolve-pkg-maps
License: MIT
By: Hiroki Osame
Repository: privatenumber/resolve-pkg-maps
> MIT License
>
> Copyright (c) Hiroki Osame <hiroki.osame@gmail.com>
>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of this software and associated documentation files (the "Software"), to deal
> in the Software without restriction, including without limitation the rights
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
> copies of the Software, and to permit persons to whom the Software is
> furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all
> copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
> SOFTWARE.
---------------------------------------
## sisteransi
License: MIT
By: Terkel Gjervig

View File

@ -198,7 +198,6 @@
"chai-subset": "^1.6.0",
"find-up": "^6.3.0",
"flatted": "catalog:",
"get-tsconfig": "^4.10.0",
"happy-dom": "^17.4.4",
"jsdom": "^26.1.0",
"local-pkg": "^1.1.1",

View File

@ -9,7 +9,7 @@ import { createDefer } from '@vitest/utils'
import { Typechecker } from '../../typecheck/typechecker'
import { groupBy } from '../../utils/base'
export function createTypecheckPool(ctx: Vitest): ProcessPool {
export function createTypecheckPool(vitest: Vitest): ProcessPool {
const promisesMap = new WeakMap<TestProject, DeferPromise<void>>()
const rerunTriggered = new WeakSet<TestProject>()
@ -20,11 +20,11 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool {
const checker = project.typechecker!
const { packs, events } = checker.getTestPacksAndEvents()
await ctx._testRun.updated(packs, events)
await vitest._testRun.updated(packs, events)
if (!project.config.typecheck.ignoreSourceErrors) {
sourceErrors.forEach(error =>
ctx.state.catchError(error, 'Unhandled Source Error'),
vitest.state.catchError(error, 'Unhandled Source Error'),
)
}
@ -33,7 +33,7 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool {
if (processError) {
const error = new Error(checker.getOutput())
error.stack = ''
ctx.state.catchError(error, 'Typecheck Error')
vitest.state.catchError(error, 'Typecheck Error')
}
promisesMap.get(project)?.resolve()
@ -41,11 +41,11 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool {
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) {
await ctx.report('onFinished', files, [])
await ctx.report('onWatcherStart', files, [
if (vitest.config.watch && !vitest.runningPromise) {
await vitest.report('onFinished', files, [])
await vitest.report('onWatcherStart', files, [
...(project.config.typecheck.ignoreSourceErrors ? [] : sourceErrors),
...ctx.state.getUnhandledErrors(),
...vitest.state.getUnhandledErrors(),
])
}
}
@ -65,9 +65,9 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool {
checker.onParseStart(async () => {
const files = checker.getTestFiles()
for (const file of files) {
await ctx._testRun.enqueued(project, file)
await vitest._testRun.enqueued(project, file)
}
await ctx._testRun.collected(project, files)
await vitest._testRun.collected(project, files)
})
checker.onParseEnd(result => onParseEnd(project, result))
@ -75,9 +75,9 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool {
checker.onWatcherRerun(async () => {
rerunTriggered.add(project)
if (!ctx.runningPromise) {
ctx.state.clearErrors()
await ctx.report(
if (!vitest.runningPromise) {
vitest.state.clearErrors()
await vitest.report(
'onWatcherRerun',
files,
'File change detected. Triggering rerun.',
@ -88,15 +88,14 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool {
const testFiles = checker.getTestFiles()
for (const file of testFiles) {
await ctx._testRun.enqueued(project, file)
await vitest._testRun.enqueued(project, file)
}
await ctx._testRun.collected(project, testFiles)
await vitest._testRun.collected(project, testFiles)
const { packs, events } = checker.getTestPacksAndEvents()
await ctx._testRun.updated(packs, events)
await vitest._testRun.updated(packs, events)
})
await checker.prepare()
return checker
}
@ -118,7 +117,7 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool {
checker.setFiles(files)
await checker.collectTests()
const testFiles = checker.getTestFiles()
ctx.state.collectFiles(project, testFiles)
vitest.state.collectFiles(project, testFiles)
}
}
@ -147,9 +146,9 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool {
if (project.typechecker && !triggered) {
const testFiles = project.typechecker.getTestFiles()
for (const file of testFiles) {
await ctx._testRun.enqueued(project, file)
await vitest._testRun.enqueued(project, file)
}
await ctx._testRun.collected(project, testFiles)
await vitest._testRun.collected(project, testFiles)
await onParseEnd(project, project.typechecker.getResult())
continue
}
@ -166,7 +165,7 @@ export function createTypecheckPool(ctx: Vitest): ProcessPool {
runTests,
collectTests,
async close() {
const promises = ctx.projects.map(project =>
const promises = vitest.projects.map(project =>
project.typechecker?.stop(),
)
await Promise.all(promises)

View File

@ -70,6 +70,7 @@ export class TestProject {
/** @internal */ typechecker?: Typechecker
/** @internal */ _config?: ResolvedConfig
/** @internal */ _vite?: ViteDevServer
/** @internal */ _hash?: string
private runner!: ViteNodeRunner
@ -92,6 +93,18 @@ export class TestProject {
this.globalConfig = vitest.config
}
/**
* The unique hash of this project. This value is consistent between the reruns.
*
* It is based on the root of the project (not consistent between OS) and its name.
*/
public get hash(): string {
if (!this._hash) {
throw new Error('The server was not set. It means that `project.hash` was called before the Vite server was established.')
}
return this._hash
}
// "provide" is a property, not a method to keep the context when destructed in the global setup,
// making it a method would be a breaking change, and can be done in Vitest 3 at minimum
/**
@ -601,6 +614,12 @@ export class TestProject {
return this._configureServer(options, server)
}
private _setHash() {
this._hash = generateHash(
this._config!.root + this._config!.name,
)
}
/** @internal */
async _configureServer(options: UserConfig, server: ViteDevServer): Promise<void> {
this._config = resolveConfig(
@ -611,6 +630,7 @@ export class TestProject {
},
server.config,
)
this._setHash()
for (const _providedKey in this.config.provide) {
const providedKey = _providedKey as keyof ProvidedContext
// type is very strict here, so we cast it to any
@ -699,6 +719,7 @@ export class TestProject {
project.runner = vitest.runner
project._vite = vitest.server
project._config = vitest.config
project._setHash()
project._provideObject(vitest.config.provide)
return project
}
@ -713,6 +734,7 @@ export class TestProject {
clone.runner = parent.runner
clone._vite = parent._vite
clone._config = config
clone._setHash()
clone._parent = parent
clone._provideObject(config.provide)
return clone
@ -771,3 +793,16 @@ export async function initializeProject(
return project
}
function generateHash(str: string): string {
let hash = 0
if (str.length === 0) {
return `${hash}`
}
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32bit integer
}
return `${hash}`
}

View File

@ -1,12 +1,5 @@
import type { TypecheckConfig } from '../node/types/config'
import type { RawErrsMap, TscErrorInfo } from './types'
import { writeFile } from 'node:fs/promises'
import os from 'node:os'
import url from 'node:url'
import { getTsconfig as getTsconfigContent } from 'get-tsconfig'
import { basename, dirname, join, resolve } from 'pathe'
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
const newLineRegExp = /\r?\n/
const errCodeRegExp = /error TS(?<errCode>\d+)/
@ -63,50 +56,6 @@ export async function makeTscErrorInfo(
]
}
export async function getTsconfig(root: string, config: TypecheckConfig): Promise<{
path: string
config: Record<string, any>
}> {
const configName = config.tsconfig ? basename(config.tsconfig) : undefined
const configSearchPath = config.tsconfig
? dirname(resolve(root, config.tsconfig))
: root
const tsconfig = getTsconfigContent(configSearchPath, configName)
if (!tsconfig) {
throw new Error('no tsconfig.json found')
}
const tsconfigName = basename(tsconfig.path, '.json')
const tempTsConfigName = `${tsconfigName}.vitest-temp.json`
const tempTsbuildinfoName = `${tsconfigName}.tmp.tsbuildinfo`
const tempConfigPath = join(
dirname(tsconfig.path),
tempTsConfigName,
)
try {
const tmpTsConfig: Record<string, any> = { ...tsconfig.config }
tmpTsConfig.compilerOptions = tmpTsConfig.compilerOptions || {}
tmpTsConfig.compilerOptions.emitDeclarationOnly = false
tmpTsConfig.compilerOptions.incremental = true
tmpTsConfig.compilerOptions.tsBuildInfoFile = join(
process.versions.pnp ? join(os.tmpdir(), 'vitest') : __dirname,
tempTsbuildinfoName,
)
const tsconfigFinalContent = JSON.stringify(tmpTsConfig, null, 2)
await writeFile(tempConfigPath, tsconfigFinalContent)
return { path: tempConfigPath, config: tmpTsConfig }
}
catch (err) {
throw new Error(`failed to write ${tempTsConfigName}`, { cause: err })
}
}
export async function getRawErrsMapFromTsCompile(tscErrorStdout: string): Promise<RawErrsMap> {
const rawErrsMap: RawErrsMap = new Map()

View File

@ -8,14 +8,15 @@ import type { TestProject } from '../node/project'
import type { Awaitable } from '../types/general'
import type { FileInformation } from './collect'
import type { TscErrorInfo } from './types'
import { rm } from 'node:fs/promises'
import os from 'node:os'
import { performance } from 'node:perf_hooks'
import { eachMapping, generatedPositionFor, TraceMap } from '@vitest/utils/source-map'
import { basename, extname, resolve } from 'pathe'
import { basename, join, resolve } from 'pathe'
import { x } from 'tinyexec'
import { distDir } from '../paths'
import { convertTasksToEvents } from '../utils/tasks'
import { collectTests } from './collect'
import { getRawErrsMapFromTsCompile, getTsconfig } from './parse'
import { getRawErrsMapFromTsCompile } from './parse'
import { createIndexMap } from './utils'
export class TypeCheckError extends Error {
@ -49,13 +50,11 @@ export class Typechecker {
private _startTime = 0
private _output = ''
private _tests: Record<string, FileInformation> | null = {}
private tempConfigPath?: string
private allowJs?: boolean
private process?: ChildProcess
protected files: string[] = []
constructor(protected ctx: TestProject) {}
constructor(protected project: TestProject) {}
public setFiles(files: string[]): void {
this.files = files
@ -76,14 +75,11 @@ export class Typechecker {
protected async collectFileTests(
filepath: string,
): Promise<FileInformation | null> {
return collectTests(this.ctx, filepath)
return collectTests(this.project, filepath)
}
protected getFiles(): string[] {
return this.files.filter((filename) => {
const extension = extname(filename)
return extension !== '.js' || this.allowJs
})
return this.files
}
public async collectTests(): Promise<Record<string, FileInformation>> {
@ -225,7 +221,7 @@ export class Typechecker {
{ error: TypeCheckError; originalError: TscErrorInfo }[]
>()
errorsMap.forEach((errors, path) => {
const filepath = resolve(this.ctx.config.root, path)
const filepath = resolve(this.project.config.root, path)
const suiteErrors = errors.map((info) => {
const limit = Error.stackTraceLimit
Error.stackTraceLimit = 0
@ -260,14 +256,7 @@ export class Typechecker {
return typesErrors
}
public async clear(): Promise<void> {
if (this.tempConfigPath) {
await rm(this.tempConfigPath, { force: true })
}
}
public async stop(): Promise<void> {
await this.clear()
this.process?.kill()
this.process = undefined
}
@ -280,15 +269,6 @@ export class Typechecker {
await ctx.packageInstaller.ensureInstalled(packageName, ctx.config.root)
}
public async prepare(): Promise<void> {
const { root, typecheck } = this.ctx.config
const { config, path } = await getTsconfig(root, typecheck)
this.tempConfigPath = path
this.allowJs = typecheck.allowJs || config.allowJs || false
}
public getExitCode(): number | false {
return this.process?.exitCode != null && this.process.exitCode
}
@ -302,20 +282,29 @@ export class Typechecker {
return
}
if (!this.tempConfigPath) {
throw new Error('tsconfig was not initialized')
}
const { root, watch, typecheck } = this.project.config
const { root, watch, typecheck } = this.ctx.config
const args = ['--noEmit', '--pretty', 'false', '-p', this.tempConfigPath]
// use builtin watcher, because it's faster
const args = [
'--noEmit',
'--pretty',
'false',
'--incremental',
'--tsBuildInfoFile',
join(
process.versions.pnp ? join(os.tmpdir(), this.project.hash) : distDir,
'tsconfig.tmp.tsbuildinfo',
),
]
// use builtin watcher because it's faster
if (watch) {
args.push('--watch')
}
if (typecheck.allowJs) {
args.push('--allowJs', '--checkJs')
}
if (typecheck.tsconfig) {
args.push('-p', resolve(root, typecheck.tsconfig))
}
this._output = ''
this._startTime = performance.now()
const child = x(typecheck.checker, args, {

3
pnpm-lock.yaml generated
View File

@ -1073,9 +1073,6 @@ importers:
flatted:
specifier: 'catalog:'
version: 3.3.3
get-tsconfig:
specifier: ^4.10.0
version: 4.10.0
happy-dom:
specifier: ^17.4.4
version: 17.4.6

View File

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

View File

@ -3,7 +3,7 @@
"type": "module",
"scripts": {
"test": "vitest --test-timeout 60000",
"types": "vitest --typecheck --run",
"types": "vitest --typecheck.only --typecheck.allowJs --run",
"tsc": "tsc --watch --pretty false --noEmit"
},
"dependencies": {

View File

@ -96,7 +96,7 @@ Vitest caught 1 unhandled error during the test run.
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.
⎯⎯⎯⎯⎯⎯ Typecheck Error ⎯⎯⎯⎯⎯⎯⎯
Error: error TS18003: No inputs were found in config file '<root>/tsconfig.empty.vitest-temp.json'. Specified 'include' paths were '["src"]' and 'exclude' paths were '["**/dist/**"]'.
Error: error TS18003: No inputs were found in config file '<root>/tsconfig.empty.json'. Specified 'include' paths were '["src"]' and 'exclude' paths were '["**/dist/**"]'.
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

View File

@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.build.json",
"include": [
"**/fail.test-d.ts"
],

View File

@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.build.json",
"include": [
"src"
],

View File

@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.build.json",
"include": [
"./failing/*"
],

View File

@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.build.json",
"include": [
"./**/*.ts",
"./**/*.js"

8
tsconfig.build.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"paths": {
"vitest": ["./packages/vitest/dist/index.d.ts"]
}
}
}