Skip to content

Commit

Permalink
feat!: throw an error, if module cannot be resolved (#3307)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va committed Jun 6, 2023
1 parent a9a8ee7 commit 1ad63b0
Show file tree
Hide file tree
Showing 12 changed files with 128 additions and 44 deletions.
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Expand Up @@ -219,6 +219,10 @@ export default withPwa(defineConfig({
text: 'Migration Guide',
link: '/guide/migration',
},
{
text: 'Common Errors',
link: '/guide/common-errors',
},
],
},
{
Expand Down
40 changes: 40 additions & 0 deletions docs/guide/common-errors.md
@@ -0,0 +1,40 @@
# Common Errors

## Cannot find module './relative-path'

If you receive an error that module cannot be found, it might mean several different things:

- 1. You misspelled the path. Make sure the path is correct.

- 2. It's possible that your rely on `baseUrl` in your `tsconfig.json`. Vite doesn't take into account `tsconfig.json` by default, so you might need to install [`vite-tsconfig-paths`](https://www.npmjs.com/package/vite-tsconfig-paths) yourself, if you rely on this behaviour.

```ts
import { defineConfig } from 'vitest/config'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
plugins: [tsconfigPaths()]
})
```

Or rewrite your path to not be relative to root:

```diff
- import helpers from 'src/helpers'
+ import helpers from '../src/helpers'
```

- 3. Make sure you don't have relative [aliases](/config/#alias). Vite treats them as relative to the file where the import is instead of the root.

```diff
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
alias: {
- '@/': './src/',
+ '@/': new URL('./src/', import.meta.url).pathname,
}
}
})
```
20 changes: 13 additions & 7 deletions packages/vite-node/src/client.ts
Expand Up @@ -228,7 +228,7 @@ export class ViteNodeRunner {
}

shouldResolveId(id: string, _importee?: string) {
return !isInternalRequest(id) && !isNodeBuiltin(id)
return !isInternalRequest(id) && !isNodeBuiltin(id) && !id.startsWith('data:')
}

private async _resolveUrl(id: string, importer?: string): Promise<[url: string, fsPath: string]> {
Expand All @@ -243,12 +243,18 @@ export class ViteNodeRunner {
if (!this.options.resolveId || exists)
return [id, path]
const resolved = await this.options.resolveId(id, importer)
const resolvedId = resolved
? normalizeRequestId(resolved.id, this.options.base)
: id
// to be compatible with dependencies that do not resolve id
const fsPath = resolved ? resolvedId : path
return [resolvedId, fsPath]
if (!resolved) {
const error = new Error(
`Cannot find module '${id}'${importer ? ` imported from '${importer}'` : ''}.`
+ '\n\n- If you rely on tsconfig.json to resolve modules, please install "vite-tsconfig-paths" plugin to handle module resolution.'
+ '\n - Make sure you don\'t have relative aliases in your Vitest config. Use absolute paths instead. Read more: https://vitest.dev/guide/common-errors',
)
Object.defineProperty(error, 'code', { value: 'ERR_MODULE_NOT_FOUND', enumerable: true })
Object.defineProperty(error, Symbol.for('vitest.error.not_found.data'), { value: { id, importer }, enumerable: false })
throw error
}
const resolvedId = normalizeRequestId(resolved.id, this.options.base)
return [resolvedId, resolvedId]
}

async resolveUrl(id: string, importee?: string) {
Expand Down
23 changes: 21 additions & 2 deletions packages/vitest/src/runtime/execute.ts
Expand Up @@ -99,18 +99,37 @@ export class VitestExecutor extends ViteNodeRunner {
}

shouldResolveId(id: string, _importee?: string | undefined): boolean {
if (isInternalRequest(id))
if (isInternalRequest(id) || id.startsWith('data:'))
return false
const environment = getCurrentEnvironment()
// do not try and resolve node builtins in Node
// import('url') returns Node internal even if 'url' package is installed
return environment === 'node' ? !isNodeBuiltin(id) : !id.startsWith('node:')
}

async originalResolveUrl(id: string, importer?: string) {
return super.resolveUrl(id, importer)
}

async resolveUrl(id: string, importer?: string) {
if (VitestMocker.pendingIds.length)
await this.mocker.resolveMocks()

if (importer && importer.startsWith('mock:'))
importer = importer.slice(5)
return super.resolveUrl(id, importer)
try {
return await super.resolveUrl(id, importer)
}
catch (error: any) {
if (error.code === 'ERR_MODULE_NOT_FOUND') {
const { id } = error[Symbol.for('vitest.error.not_found.data')]
const path = this.mocker.normalizePath(id)
const mock = this.mocker.getDependencyMock(path)
if (mock !== undefined)
return [id, id] as [string, string]
}
throw error
}
}

async dependencyRequest(id: string, fsPath: string, callstack: string[]): Promise<any> {
Expand Down
27 changes: 21 additions & 6 deletions packages/vitest/src/runtime/mocker.ts
Expand Up @@ -39,7 +39,7 @@ function isSpecialProp(prop: Key, parentType: string) {
}

export class VitestMocker {
private static pendingIds: PendingSuiteMock[] = []
public static pendingIds: PendingSuiteMock[] = []
private resolveCache = new Map<string, Record<string, string>>()

constructor(
Expand Down Expand Up @@ -88,7 +88,22 @@ export class VitestMocker {
}

private async resolvePath(rawId: string, importer: string) {
const [id, fsPath] = await this.executor.resolveUrl(rawId, importer)
let id: string
let fsPath: string
try {
[id, fsPath] = await this.executor.originalResolveUrl(rawId, importer)
}
catch (error: any) {
// it's allowed to mock unresolved modules
if (error.code === 'ERR_MODULE_NOT_FOUND') {
const { id: unresolvedId } = error[Symbol.for('vitest.error.not_found.data')]
id = unresolvedId
fsPath = unresolvedId
}
else {
throw error
}
}
// external is node_module or unresolved module
// for example, some people mock "vscode" and don't have it installed
const external = (!isAbsolute(fsPath) || this.isAModuleDirectory(fsPath)) ? rawId : null
Expand All @@ -100,7 +115,10 @@ export class VitestMocker {
}
}

private async resolveMocks() {
public async resolveMocks() {
if (!VitestMocker.pendingIds.length)
return

await Promise.all(VitestMocker.pendingIds.map(async (mock) => {
const { fsPath, external } = await this.resolvePath(mock.id, mock.importer)
if (mock.type === 'unmock')
Expand Down Expand Up @@ -353,9 +371,6 @@ export class VitestMocker {
}

public async requestWithMock(url: string, callstack: string[]) {
if (VitestMocker.pendingIds.length)
await this.resolveMocks()

const id = this.normalizePath(url)
const mock = this.getDependencyMock(id)

Expand Down
22 changes: 11 additions & 11 deletions packages/web-worker/src/shared-worker.ts
Expand Up @@ -110,7 +110,7 @@ export function createSharedWorkerConstructor(): typeof SharedWorker {

debug('initialize shared worker %s', this._vw_name)

runner.executeFile(fsPath).then(() => {
return runner.executeFile(fsPath).then(() => {
// worker should be new every time, invalidate its sub dependency
runnerOptions.moduleCache.invalidateSubDepTree([fsPath, runner.mocker.getMockPath(fsPath)])
this._vw_workerTarget.dispatchEvent(
Expand All @@ -119,17 +119,17 @@ export function createSharedWorkerConstructor(): typeof SharedWorker {
}),
)
debug('shared worker %s successfully initialized', this._vw_name)
}).catch((e) => {
debug('shared worker %s failed to initialize: %o', this._vw_name, e)
const EventConstructor = globalThis.ErrorEvent || globalThis.Event
const error = new EventConstructor('error', {
error: e,
message: e.message,
})
this.dispatchEvent(error)
this.onerror?.(error)
console.error(e)
})
}).catch((e) => {
debug('shared worker %s failed to initialize: %o', this._vw_name, e)
const EventConstructor = globalThis.ErrorEvent || globalThis.Event
const error = new EventConstructor('error', {
error: e,
message: e.message,
})
this.dispatchEvent(error)
this.onerror?.(error)
console.error(e)
})
}
}
Expand Down
22 changes: 11 additions & 11 deletions packages/web-worker/src/worker.ts
Expand Up @@ -75,25 +75,25 @@ export function createWorkerConstructor(options?: DefineWorkerOptions): typeof W

debug('initialize worker %s', this._vw_name)

runner.executeFile(fsPath).then(() => {
return runner.executeFile(fsPath).then(() => {
// worker should be new every time, invalidate its sub dependency
runnerOptions.moduleCache.invalidateSubDepTree([fsPath, runner.mocker.getMockPath(fsPath)])
const q = this._vw_messageQueue
this._vw_messageQueue = null
if (q)
q.forEach(([data, transfer]) => this.postMessage(data, transfer), this)
debug('worker %s successfully initialized', this._vw_name)
}).catch((e) => {
debug('worker %s failed to initialize: %o', this._vw_name, e)
const EventConstructor = globalThis.ErrorEvent || globalThis.Event
const error = new EventConstructor('error', {
error: e,
message: e.message,
})
this.dispatchEvent(error)
this.onerror?.(error)
console.error(e)
})
}).catch((e) => {
debug('worker %s failed to initialize: %o', this._vw_name, e)
const EventConstructor = globalThis.ErrorEvent || globalThis.Event
const error = new EventConstructor('error', {
error: e,
message: e.message,
})
this.dispatchEvent(error)
this.onerror?.(error)
console.error(e)
})
}

Expand Down
4 changes: 2 additions & 2 deletions test/core/test/imports.test.ts
Expand Up @@ -74,9 +74,9 @@ test('dynamic import has null prototype', async () => {
test('dynamic import throws an error', async () => {
const path = './some-unknown-path'
const imported = import(path)
await expect(imported).rejects.toThrowError(/Failed to load/)
await expect(imported).rejects.toThrowError(/Cannot find module '\.\/some-unknown-path'/)
// @ts-expect-error path does not exist
await expect(() => import('./some-unknown-path')).rejects.toThrowError(/Failed to load/)
await expect(() => import('./some-unknown-path')).rejects.toThrowError(/Cannot find module/)
})

test('can import @vite/client', async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/core/test/unmock-import.test.ts
Expand Up @@ -20,7 +20,7 @@ test('first import', async () => {
expect(data.state).toBe('STOPPED')
})

test('second import should had been re-mock', async () => {
test('second import should have been re-mocked', async () => {
// @ts-expect-error I know this
const { data } = await import('/data')
expect(data.state).toBe('STARTED')
Expand Down
2 changes: 1 addition & 1 deletion test/web-worker/test/init.test.ts
Expand Up @@ -66,7 +66,7 @@ it('worker with invalid url throws an error', async () => {
})
expect(event).toBeInstanceOf(ErrorEvent)
expect(event.error).toBeInstanceOf(Error)
expect(event.error.message).toContain('Failed to load')
expect(event.error.message).toContain('Cannot find module')
})

it('self injected into worker and its deps should be equal', async () => {
Expand Down
4 changes: 2 additions & 2 deletions test/web-worker/test/sharedWorker.spec.ts
@@ -1,5 +1,5 @@
import { expect, it } from 'vitest'
import MySharedWorker from './src/sharedWorker?sharedworker'
import MySharedWorker from '../src/sharedWorker?sharedworker'

function sendEventMessage(worker: SharedWorker, msg: any) {
worker.port.postMessage(msg)
Expand Down Expand Up @@ -50,7 +50,7 @@ it('throws an error on invalid path', async () => {
})
expect(event).toBeInstanceOf(ErrorEvent)
expect(event.error).toBeInstanceOf(Error)
expect(event.error.message).toContain('Failed to load')
expect(event.error.message).toContain('Cannot find module')
})

it('doesn\'t trigger events, if closed', async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/web-worker/vitest.config.ts
Expand Up @@ -12,7 +12,7 @@ export default defineConfig({
],
},
onConsoleLog(log) {
if (log.includes('Failed to load'))
if (log.includes('Cannot find module'))
return false
},
},
Expand Down

0 comments on commit 1ad63b0

Please sign in to comment.