Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: rewrite how vite-node resolves id #2463

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions examples/mocks/src/A.ts
@@ -0,0 +1,5 @@
import { funcB } from './B'

export function funcA() {
return funcB
}
5 changes: 5 additions & 0 deletions examples/mocks/src/B.ts
@@ -0,0 +1,5 @@
import { funcA } from './A'

export function funcB() {
return funcA
}
5 changes: 5 additions & 0 deletions examples/mocks/src/main.js
@@ -0,0 +1,5 @@
import { funcA } from './A'

export function main() {
return funcA()
}
2 changes: 1 addition & 1 deletion examples/mocks/test/axios-not-mocked.test.ts
@@ -1,7 +1,7 @@
import axios from 'axios'

test('mocked axios', async () => {
const { default: ax } = await vi.importMock('axios')
const { default: ax } = await vi.importMock<any>('axios')

await ax.get('string')

Expand Down
11 changes: 11 additions & 0 deletions examples/mocks/test/circular.spec.ts
@@ -0,0 +1,11 @@
import { expect, test, vi } from 'vitest'
import { main } from '../src/main.js'

vi.mock('../src/A', async () => ({
...(await vi.importActual<any>('../src/A')),
funcA: () => 'mockedA',
}))

test('main', () => {
expect(main()).toBe('mockedA')
})
2 changes: 1 addition & 1 deletion examples/mocks/test/error-mock.spec.ts
Expand Up @@ -4,5 +4,5 @@ vi.mock('../src/default', () => {

test('when using top level variable, gives helpful message', async () => {
await expect(() => import('../src/default').then(m => m.default)).rejects
.toThrowErrorMatchingInlineSnapshot('"[vitest] There was an error, when mocking a module. If you are using vi.mock, make sure you are not using top level variables inside, since this call is hoisted. Read more: https://vitest.dev/api/#vi-mock"')
.toThrowErrorMatchingInlineSnapshot('"[vitest] There was an error, when mocking a module. If you are using \\"vi.mock\\" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. Read more: https://vitest.dev/api/#vi-mock"')
})
2 changes: 1 addition & 1 deletion examples/mocks/test/factory.test.ts
Expand Up @@ -57,7 +57,7 @@ describe('mocking with factory', () => {

it('non-object return on factory gives error', async () => {
await expect(() => import('../src/default').then(m => m.default)).rejects
.toThrowError('[vitest] vi.mock(path: string, factory?: () => unknown) is not returning an object. Did you mean to return an object with a "default" key?')
.toThrowError('[vitest] vi.mock("../src/default.ts", factory?: () => unknown) is not returning an object. Did you mean to return an object with a "default" key?')
})

test('defined exports on mock', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/coverage-c8/src/provider.ts
Expand Up @@ -85,7 +85,7 @@ export class C8CoverageProvider implements CoverageProvider {
// This is a magic number. It corresponds to the amount of code
// that we add in packages/vite-node/src/client.ts:114 (vm.runInThisContext)
// TODO: Include our transformations in sourcemaps
const offset = 224
const offset = 203

report._getSourceMap = (coverage: Profiler.ScriptCoverage) => {
const path = _url.pathToFileURL(coverage.url.split('?')[0]).href
Expand Down
2 changes: 1 addition & 1 deletion packages/vite-node/package.json
Expand Up @@ -70,7 +70,7 @@
},
"scripts": {
"build": "rimraf dist && rollup -c",
"dev": "rollup -c --watch --watch.include=src -m inline",
"dev": "rollup -c --watch --watch.include 'src/**' -m inline",
"prepublishOnly": "pnpm build",
"typecheck": "tsc --noEmit"
},
Expand Down
148 changes: 65 additions & 83 deletions packages/vite-node/src/client.ts
@@ -1,10 +1,12 @@
import { createRequire } from 'module'
// we need native dirname, because windows __dirname has \\
// eslint-disable-next-line no-restricted-imports
import { dirname } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import vm from 'vm'
import { dirname, extname, isAbsolute, resolve } from 'pathe'
import { isNodeBuiltin } from 'mlly'
import { resolve } from 'pathe'
import createDebug from 'debug'
import { cleanUrl, isPrimitive, normalizeModuleId, normalizeRequestId, slash, toFilePath } from './utils'
import { VALID_ID_PREFIX, cleanUrl, isInternalRequest, isPrimitive, normalizeModuleId, normalizeRequestId, slash, toFilePath } from './utils'
import type { HotContext, ModuleCache, ViteNodeRunnerOptions } from './types'
import { extractSourceMap } from './source-map'

Expand Down Expand Up @@ -145,24 +147,24 @@ export class ViteNodeRunner {
}

async executeFile(file: string) {
return await this.cachedRequest(`/@fs/${slash(resolve(file))}`, [])
const url = `/@fs/${slash(resolve(file))}`
return await this.cachedRequest(url, url, [])
}

async executeId(id: string) {
return await this.cachedRequest(id, [])
async executeId(rawId: string) {
const [id, url] = await this.resolveUrl(rawId)
return await this.cachedRequest(id, url, [])
}

getSourceMap(id: string) {
return this.moduleCache.getSourceMap(id)
}

/** @internal */
async cachedRequest(rawId: string, callstack: string[]) {
const id = normalizeRequestId(rawId, this.options.base)
const fsPath = toFilePath(id, this.root)
async cachedRequest(id: string, fsPath: string, callstack: string[]) {
const importee = callstack[callstack.length - 1]

const mod = this.moduleCache.get(fsPath)
const importee = callstack[callstack.length - 1]

if (!mod.importers)
mod.importers = new Set()
Expand All @@ -188,79 +190,68 @@ export class ViteNodeRunner {
}
}

async resolveUrl(id: string, importee?: string): Promise<[url: string, fsPath: string]> {
if (isInternalRequest(id))
return [id, id]
// we don't pass down importee here, because otherwise Vite doesn't resolve it correctly
if (importee && id.startsWith(VALID_ID_PREFIX))
importee = undefined
id = normalizeRequestId(id, this.options.base)
if (!this.options.resolveId)
return [id, toFilePath(id, this.root)]
const resolved = await this.options.resolveId(id, importee)
const resolvedId = resolved
? normalizeRequestId(resolved.id, this.options.base)
: id
// to be compatible with dependencies that do not resolve id
const fsPath = resolved ? resolvedId : toFilePath(id, this.root)
return [resolvedId, fsPath]
}

/** @internal */
async directRequest(id: string, fsPath: string, _callstack: string[]) {
const callstack = [..._callstack, fsPath]
async dependencyRequest(id: string, fsPath: string, callstack: string[]) {
const getStack = () => {
return `stack:\n${[...callstack, fsPath].reverse().map(p => `- ${p}`).join('\n')}`
}

let mod = this.moduleCache.get(fsPath)
let debugTimer: any
if (this.debug)
debugTimer = setTimeout(() => console.warn(() => `module ${fsPath} takes over 2s to load.\n${getStack()}`), 2000)

const request = async (dep: string) => {
const depFsPath = toFilePath(normalizeRequestId(dep, this.options.base), this.root)
const getStack = () => {
return `stack:\n${[...callstack, depFsPath].reverse().map(p => `- ${p}`).join('\n')}`
try {
if (callstack.includes(fsPath)) {
const depExports = this.moduleCache.get(fsPath)?.exports
if (depExports)
return depExports
throw new Error(`[vite-node] Failed to resolve circular dependency, ${getStack()}`)
}

let debugTimer: any
if (this.debug)
debugTimer = setTimeout(() => console.warn(() => `module ${depFsPath} takes over 2s to load.\n${getStack()}`), 2000)

try {
if (callstack.includes(depFsPath)) {
const depExports = this.moduleCache.get(depFsPath)?.exports
if (depExports)
return depExports
throw new Error(`[vite-node] Failed to resolve circular dependency, ${getStack()}`)
}

return await this.cachedRequest(dep, callstack)
}
finally {
if (debugTimer)
clearTimeout(debugTimer)
}
return await this.cachedRequest(id, fsPath, callstack)
}
finally {
if (debugTimer)
clearTimeout(debugTimer)
}
}

Object.defineProperty(request, 'callstack', { get: () => callstack })

const resolveId = async (dep: string, callstackPosition = 1): Promise<[dep: string, id: string | undefined]> => {
if (this.options.resolveId && this.shouldResolveId(dep)) {
let importer: string | undefined = callstack[callstack.length - callstackPosition]
if (importer && !dep.startsWith('.'))
importer = undefined
if (importer && importer.startsWith('mock:'))
importer = importer.slice(5)
const resolved = await this.options.resolveId(normalizeRequestId(dep), importer)
return [dep, resolved?.id]
}
/** @internal */
async directRequest(id: string, fsPath: string, _callstack: string[]) {
const moduleId = normalizeModuleId(fsPath)
const callstack = [..._callstack, moduleId]

return [dep, undefined]
}
const mod = this.moduleCache.get(fsPath)

const [dep, resolvedId] = await resolveId(id, 2)
const request = async (dep: string) => {
const [id, depFsPath] = await this.resolveUrl(dep, fsPath)
return this.dependencyRequest(id, depFsPath, callstack)
}

const requestStubs = this.options.requestStubs || DEFAULT_REQUEST_STUBS
if (id in requestStubs)
return requestStubs[id]

// eslint-disable-next-line prefer-const
let { code: transformed, externalize } = await this.options.fetchModule(resolvedId || dep)

// in case we resolved fsPath incorrectly, Vite will return the correct file path
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved
// in that case we need to update cache, so we don't have the same module as different exports
// but we ignore fsPath that has custom query, because it might need to be different
if (resolvedId && !fsPath.includes('?') && fsPath !== resolvedId) {
if (this.moduleCache.has(resolvedId)) {
mod = this.moduleCache.get(resolvedId)
this.moduleCache.set(fsPath, mod)
if (mod.promise)
return mod.promise
if (mod.exports)
return mod.exports
}
else {
this.moduleCache.set(resolvedId, mod)
}
}
let { code: transformed, externalize } = await this.options.fetchModule(id)

if (externalize) {
debugNative(externalize)
Expand All @@ -270,13 +261,12 @@ export class ViteNodeRunner {
}

if (transformed == null)
throw new Error(`[vite-node] Failed to load ${id}`)

const file = cleanUrl(resolvedId || fsPath)
throw new Error(`[vite-node] Failed to load "${id}" imported from ${callstack[callstack.length - 2]}`)

const modulePath = cleanUrl(moduleId)
// disambiguate the `<UNIT>:/` on windows: see nodejs/node#31710
const url = pathToFileURL(file).href
const meta = { url }
const href = pathToFileURL(modulePath).href
const meta = { url: href }
const exports = Object.create(null)
Object.defineProperty(exports, Symbol.toStringTag, {
value: 'Module',
Expand Down Expand Up @@ -305,7 +295,7 @@ export class ViteNodeRunner {
})

Object.assign(mod, { code: transformed, exports })
const __filename = fileURLToPath(url)
const __filename = fileURLToPath(href)
const moduleProxy = {
set exports(value) {
exportAll(cjsExports, value)
Expand Down Expand Up @@ -339,10 +329,9 @@ export class ViteNodeRunner {
__vite_ssr_exports__: exports,
__vite_ssr_exportAll__: (obj: any) => exportAll(exports, obj),
__vite_ssr_import_meta__: meta,
__vitest_resolve_id__: resolveId,

// cjs compact
require: createRequire(url),
require: createRequire(href),
exports: cjsExports,
module: moduleProxy,
__filename,
Expand Down Expand Up @@ -373,13 +362,6 @@ export class ViteNodeRunner {
return context
}

shouldResolveId(dep: string) {
if (isNodeBuiltin(dep) || dep in (this.options.requestStubs || DEFAULT_REQUEST_STUBS) || dep.startsWith('/@vite'))
return false

return !isAbsolute(dep) || !extname(dep)
}

/**
* Define if a module should be interop-ed
* This function mostly for the ability to override by subclass
Expand Down
29 changes: 8 additions & 21 deletions packages/vite-node/src/utils.ts
@@ -1,7 +1,6 @@
import { fileURLToPath, pathToFileURL } from 'url'
import { existsSync } from 'fs'
import { relative, resolve } from 'pathe'
import { isNodeBuiltin } from 'mlly'
import { resolve } from 'pathe'
import type { Arrayable, Nullable } from './types'

export const isWindows = process.platform === 'win32'
Expand All @@ -14,6 +13,8 @@ export function mergeSlashes(str: string) {
return str.replace(/\/\//g, '/')
}

export const VALID_ID_PREFIX = '/@id/'

export function normalizeRequestId(id: string, base?: string): string {
if (base && id.startsWith(base))
id = `/${id.slice(base.length)}`
Expand All @@ -40,10 +41,14 @@ export const hashRE = /#.*$/s
export const cleanUrl = (url: string): string =>
url.replace(hashRE, '').replace(queryRE, '')

export const isInternalRequest = (id: string): boolean => {
return id.startsWith('/@vite/')
}

export function normalizeModuleId(id: string) {
return id
.replace(/\\/g, '/')
.replace(/^\/@fs\//, '/')
.replace(/^\/@fs\//, isWindows ? '' : '/')
.replace(/^file:\//, '/')
.replace(/^\/+/, '/')
}
Expand All @@ -52,24 +57,6 @@ export function isPrimitive(v: any) {
return v !== Object(v)
}

export function pathFromRoot(root: string, filename: string) {
if (isNodeBuiltin(filename))
return filename

// don't replace with "/" on windows, "/C:/foo" is not a valid path
filename = filename.replace(/^\/@fs\//, isWindows ? '' : '/')

if (!filename.startsWith(root))
return filename

const relativePath = relative(root, filename)

const segments = relativePath.split('/')
const startIndex = segments.findIndex(segment => segment !== '..' && segment !== '.')

return `/${segments.slice(startIndex).join('/')}`
}

export function toFilePath(id: string, root: string): string {
let absolute = (() => {
if (id.startsWith('/@fs/'))
Expand Down
24 changes: 5 additions & 19 deletions packages/vitest/src/integrations/vi.ts
Expand Up @@ -2,7 +2,7 @@ import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers'
import { parseStacktrace } from '../utils/source-map'
import type { VitestMocker } from '../runtime/mocker'
import type { ResolvedConfig, RuntimeConfig } from '../types'
import { getWorkerState, resetModules, setTimeout } from '../utils'
import { getWorkerState, resetModules, waitForImportsToResolve } from '../utils'
import { FakeTimers } from './mock/timers'
import type { EnhancedSpy, MaybeMocked, MaybeMockedDeep, MaybePartiallyMocked, MaybePartiallyMockedDeep } from './spy'
import { fn, isMockFunction, spies, spyOn } from './spy'
Expand Down Expand Up @@ -245,26 +245,12 @@ class VitestUtils {
}

/**
* Wait for all imports to load.
* Useful, if you have a synchronous call that starts
* importing a module that you cannot wait otherwise.
* Wait for all imports to load. Useful, if you have a synchronous call that starts
* importing a module that you cannot await otherwise.
* Will also wait for new imports, started during the wait.
*/
public async dynamicImportSettled() {
const state = getWorkerState()
const promises: Promise<unknown>[] = []
for (const mod of state.moduleCache.values()) {
if (mod.promise && !mod.evaluated)
promises.push(mod.promise)
}
if (!promises.length)
return
await Promise.allSettled(promises)
// wait until the end of the loop, so `.then` on modules is called,
// like in import('./example').then(...)
// also call dynamicImportSettled again in case new imports were added
await new Promise(resolve => setTimeout(resolve, 1))
.then(() => Promise.resolve())
.then(() => this.dynamicImportSettled())
return waitForImportsToResolve()
}

private _config: null | ResolvedConfig = null
Expand Down