Skip to content

Commit 56b319a

Browse files
authored
fix: resolve node builtins in client environments (#11417)
1 parent 6052c05 commit 56b319a

5 files changed

Lines changed: 139 additions & 3 deletions

File tree

‎packages/vitest/src/node/plugins/config.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import type { ResolvedApiConfig } from '../types/config'
44
import { relative } from 'pathe'
55
import * as vite from 'vite'
66
import { generateScopedClassName } from '../../integrations/css/css-modules'
7-
import { createViteLogger, silenceImportViteIgnoreWarning } from '../viteLogger'
7+
import { createViteLogger, silenceViteWarnings } from '../viteLogger'
88
import { ModuleRunnerTransform } from './runnerTransform'
99
import { getDefaultResolveOptions } from './utils'
10+
import { VitestBuiltinResolver } from './vitestResolver'
1011

1112
export function ViteConfigPlugin(harness: PluginHarness): Plugin[] {
1213
let root: string
@@ -103,7 +104,7 @@ export function ViteConfigPlugin(harness: PluginHarness): Plugin[] {
103104
config.customLogger = createViteLogger(harness.logger, viteConfig.logLevel || 'warn', {
104105
allowClearScreen: false,
105106
})
106-
config.customLogger = silenceImportViteIgnoreWarning(config.customLogger)
107+
config.customLogger = silenceViteWarnings(config.customLogger, browserEnabled)
107108

108109
return config
109110
},
@@ -141,5 +142,6 @@ export function ViteConfigPlugin(harness: PluginHarness): Plugin[] {
141142
},
142143
},
143144
ModuleRunnerTransform(),
145+
VitestBuiltinResolver(),
144146
]
145147
}

‎packages/vitest/src/node/plugins/vitestResolver.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Plugin } from 'vite'
22
import type { PluginHarness } from '../config/pluginHarness'
33
import { join, resolve } from 'pathe'
44
import { distDir } from '../../paths'
5+
import { isBrowserExternal, isBuiltin } from '../../utils/modules'
56

67
export function VitestProjectResolver(harness: PluginHarness): Plugin {
78
let browserEnabled = false
@@ -73,3 +74,26 @@ export function VitestCoreResolver(): Plugin {
7374
},
7475
}
7576
}
77+
78+
export function VitestBuiltinResolver(): Plugin {
79+
return {
80+
name: 'vitest:resolve-builtin',
81+
enforce: 'pre',
82+
applyToEnvironment(environment) {
83+
return (
84+
environment.config.consumer === 'client' && environment.config.dev.moduleRunnerTransform
85+
)
86+
},
87+
async resolveId(id, importer, options) {
88+
if (!isBuiltin(id)) {
89+
return
90+
}
91+
const resolved = await this.resolve(id, importer, { ...options, skipSelf: true })
92+
// Vite replaces builtins with a browser shim, but the module runner can import them
93+
if (resolved && isBrowserExternal(resolved.id)) {
94+
return { id, external: true }
95+
}
96+
return resolved
97+
},
98+
}
99+
}

‎packages/vitest/src/node/viteLogger.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,16 @@ export function createViteLogger(
127127
}
128128

129129
// silence warning by Vite for statically not analyzable dynamic import
130-
export function silenceImportViteIgnoreWarning(logger: Logger): Logger {
130+
export function silenceViteWarnings(logger: Logger, browserMode: boolean): Logger {
131131
return {
132132
...logger,
133133
warn(msg, options) {
134134
if (msg.includes('The above dynamic import cannot be analyzed by Vite')) {
135135
return
136136
}
137+
if (!browserMode && msg.includes('has been externalized for browser compatibility')) {
138+
return
139+
}
137140
logger.warn(msg, options)
138141
},
139142
}

‎packages/vitest/src/utils/graph.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
SharedModuleGraphData,
99
} from '../types/general'
1010
import { getTestFileEnvironment } from './environments'
11+
import { isBuiltin } from './modules'
1112

1213
export async function getModuleGraph(
1314
ctx: Vitest,
@@ -121,6 +122,10 @@ function createModuleGraphCollector(
121122
if (id.startsWith('__vite-browser-external:')) {
122123
return addExternal(id.slice('__vite-browser-external:'.length))
123124
}
125+
if (isBuiltin(id)) {
126+
externalized.add(id)
127+
return id
128+
}
124129
const external = project._resolver.wasExternalized(id)
125130
if (typeof external === 'string') {
126131
return addExternal(external)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { afterEach, expect, test, vi } from 'vitest'
2+
import { getModuleGraph } from '../../../packages/vitest/src/utils/graph.js'
3+
import { runInlineTests } from '../../test-utils'
4+
5+
afterEach(() => {
6+
vi.unstubAllEnvs()
7+
})
8+
9+
test.for(['production', 'test'])(
10+
'node builtins are importable in the client environment when NODE_ENV=%s',
11+
async (nodeEnv) => {
12+
vi.stubEnv('NODE_ENV', nodeEnv)
13+
14+
const { stderr, testTree, ctx, root } = await runInlineTests(
15+
{
16+
'client-env.js': `
17+
export default {
18+
name: 'client-env',
19+
viteEnvironment: 'client',
20+
setup() {
21+
return { teardown() {} }
22+
},
23+
}
24+
`,
25+
'basic.test.js': `
26+
import { timingSafeEqual } from 'node:crypto'
27+
import * as nodePath from 'node:path'
28+
import fs from 'fs'
29+
import { expect, test } from 'vitest'
30+
31+
test('static imports', () => {
32+
expect(timingSafeEqual(Buffer.from('aa'), Buffer.from('aa'))).toBe(true)
33+
expect(nodePath.posix.join('a', 'b')).toBe('a/b')
34+
expect(fs.existsSync(import.meta.filename)).toBe(true)
35+
})
36+
37+
test('dynamic imports', async () => {
38+
const os = await import('node:os')
39+
const { basename } = await import('path')
40+
expect(os.platform()).toBe(process.platform)
41+
expect(basename('/a/b.js')).toBe('b.js')
42+
})
43+
`,
44+
'mock.test.js': `
45+
import os from 'os'
46+
import { platform } from 'node:os'
47+
import { readFileSync } from 'node:fs'
48+
import { expect, test, vi } from 'vitest'
49+
50+
vi.mock('os')
51+
vi.mock('node:fs', () => ({ readFileSync: () => 'mocked' }))
52+
53+
test('automock', () => {
54+
expect(vi.isMockFunction(os.platform)).toBe(true)
55+
expect(vi.isMockFunction(platform)).toBe(true)
56+
})
57+
58+
test('factory', () => {
59+
expect(readFileSync('file')).toBe('mocked')
60+
})
61+
62+
test('importActual', async () => {
63+
const actual = await vi.importActual('node:os')
64+
expect(actual.platform()).toBe(process.platform)
65+
})
66+
`,
67+
},
68+
{ environment: './client-env.js' },
69+
)
70+
71+
expect(stderr).toBe('')
72+
expect(testTree()).toMatchInlineSnapshot(`
73+
{
74+
"basic.test.js": {
75+
"dynamic imports": "passed",
76+
"static imports": "passed",
77+
},
78+
"mock.test.js": {
79+
"automock": "passed",
80+
"factory": "passed",
81+
"importActual": "passed",
82+
},
83+
}
84+
`)
85+
86+
const graph = await getModuleGraph(ctx!, '', `${root}/basic.test.js`, 'client')
87+
expect(graph.externalized.filter((id) => !id.includes('/'))).toMatchInlineSnapshot(`
88+
[
89+
"node:crypto",
90+
"node:path",
91+
"fs",
92+
"node:os",
93+
"path",
94+
]
95+
`)
96+
expect(graph.inlined.map((id) => id.replace(root, '<root>'))).toMatchInlineSnapshot(`
97+
[
98+
"<root>/basic.test.js",
99+
]
100+
`)
101+
},
102+
)

0 commit comments

Comments
 (0)