Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions packages/vitest/src/runtime/moduleRunner/cachedResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,48 @@ const normalizedDistDir = normalize(distDir)
const relativeIds: Record<string, string> = {}
const externalizeMap = new Map<string, string>()

// Windows paths are case-insensitive, so the same file can be spelled several
// ways. `distDir` comes from `import.meta.url` and keeps the case the CLI was
// invoked with, while ids come from Vite and carry the case of `process.cwd()`
// with an uppercase drive. Node keys its module registry on the URL, so
// externalizing a test file's `vitest` import to a different spelling of the
// same file evaluates a second copy of the runtime. That copy never goes
// through `clearCollectorContext`, so its `runner` is undefined and the first
// `describe()` in the file throws. Match Vitest's own dist directory
// regardless of case, and always hand back the spelling Vitest was loaded
// with. Only on Windows: elsewhere two spellings really are two files.
const isWindows = process.platform === 'win32'
const distDirUrl = pathToFileURL(distDir).href
const lowerDistDir = distDir.toLowerCase()
const lowerNormalizedDistDir = normalizedDistDir.toLowerCase()
const lowerDistDirUrl = distDirUrl.toLowerCase()

function isVitestDistId(id: string): boolean {
if (id.includes(distDir) || id.includes(normalizedDistDir)) {
return true
}
if (!isWindows) {
return false
}
const lowerId = id.toLowerCase()
return lowerId.includes(lowerDistDir)
|| lowerId.includes(lowerNormalizedDistDir)
|| lowerId.includes(lowerDistDirUrl)
}

function withLoadedVitestCasing(externalize: string): string {
if (!isWindows) {
return externalize
}
const index = externalize.toLowerCase().indexOf(lowerDistDirUrl)
if (index === -1) {
return externalize
}
return externalize.slice(0, index)
+ distDirUrl
+ externalize.slice(index + distDirUrl.length)
}

// all Vitest imports always need to be externalized
export function getCachedVitestImport(
id: string,
Expand All @@ -25,11 +67,11 @@ export function getCachedVitestImport(
// so we already have it cached by Node.js
const root = state().config.root
const relativeRoot = relativeIds[root] ?? (relativeIds[root] = normalizedDistDir.slice(root.length))
if (id.includes(distDir) || id.includes(normalizedDistDir)) {
if (isVitestDistId(id)) {
const { file, postfix } = splitFileAndPostfix(id)
const externalize = id.startsWith('file://')
? id
: `${pathToFileURL(file)}${postfix}`
? withLoadedVitestCasing(id)
: `${withLoadedVitestCasing(pathToFileURL(file).href)}${postfix}`
externalizeMap.set(id, externalize)
return { externalize, type: 'module' }
}
Expand All @@ -40,7 +82,7 @@ export function getCachedVitestImport(
) {
const { file, postfix } = splitFileAndPostfix(id)
const path = join(root, file)
const externalize = `${pathToFileURL(path)}${postfix}`
const externalize = `${withLoadedVitestCasing(pathToFileURL(path).href)}${postfix}`
externalizeMap.set(id, externalize)
return { externalize, type: 'module' }
}
Expand Down
34 changes: 34 additions & 0 deletions test/e2e/test/windows-drive-case.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { fileURLToPath } from 'node:url'
import { join } from 'pathe'
import { x } from 'tinyexec'
import { expect, test } from 'vitest'
import { runVitestCli } from '../../test-utils'

const _DRIVE_LETTER_START_RE = /^[A-Z]:\//i
const root = join(import.meta.dirname, '../fixtures/windows-drive-case')
const cwd = root.replace(_DRIVE_LETTER_START_RE, r => r.toLowerCase())
const cli = fileURLToPath(new URL('../../../packages/vitest/vitest.mjs', import.meta.url))

test.runIf(process.platform === 'win32')(`works on windows with a lowercase drive: ${cwd}`, async () => {
const { stderr, stdout } = await runVitestCli({
Expand All @@ -17,3 +20,34 @@ test.runIf(process.platform === 'win32')(`works on windows with a lowercase driv
expect(stderr).toBe('')
expect(stdout).toContain('1 passed')
})

// The case that breaks is Vitest being *loaded* through a differently spelled
// path, which is what a local install does: `npx vitest` resolves the binary
// through the working directory, so Vitest comes from `c:\…` while Vite
// normalizes module ids to `C:/…`. Node treats the two URLs as different
// modules, and the test file used to end up importing a second copy of the
// runtime with no collector state. Depending on where the file's first call
// lands, that surfaces as "failed to find the current suite" or as
// "Cannot read properties of undefined (reading 'config')".
const spellings = {
'a lowercase drive letter': (path: string) => path.replace(/^[A-Z]:\\/i, r => r.toLowerCase()),
'an entirely lowercase path': (path: string) => path.toLowerCase(),
}

for (const [name, spell] of Object.entries(spellings)) {
test.runIf(process.platform === 'win32')(`loads a single Vitest instance when the CLI is resolved through ${name}`, async () => {
const spelledCli = spell(cli)
// Guard the premise: on a UNC path there is no drive letter to respell and
// the test would pass without exercising anything.
expect(spelledCli).toMatch(/^[a-z]:\\/)

const { stdout, stderr, exitCode } = await x('node', [spelledCli, 'run', '--no-watch', '--maxWorkers=1'], {
nodeOptions: { cwd, env: { ...process.env, AI_AGENT: '' } },
})

expect(stderr).not.toContain('failed to find the current suite')
expect(stderr).not.toContain('reading \'config\'')
expect(stdout).toContain('1 passed')
expect(exitCode).toBe(0)
})
}
Loading