From 01204965f72b5967f3a41435dae9b9604da8d9bb Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 30 Sep 2026 17:45:20 +0200 Subject: [PATCH] fix: resolve node builtins in client environments --- packages/vitest/src/node/plugins/config.ts | 6 +- .../vitest/src/node/plugins/vitestResolver.ts | 24 +++++ packages/vitest/src/node/viteLogger.ts | 5 +- packages/vitest/src/utils/graph.ts | 5 + test/e2e/test/client-builtins.test.ts | 102 ++++++++++++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 test/e2e/test/client-builtins.test.ts diff --git a/packages/vitest/src/node/plugins/config.ts b/packages/vitest/src/node/plugins/config.ts index f3e5a5b43..d6ea3c93e 100644 --- a/packages/vitest/src/node/plugins/config.ts +++ b/packages/vitest/src/node/plugins/config.ts @@ -4,9 +4,10 @@ import type { ResolvedApiConfig } from '../types/config' import { relative } from 'pathe' import * as vite from 'vite' import { generateScopedClassName } from '../../integrations/css/css-modules' -import { createViteLogger, silenceImportViteIgnoreWarning } from '../viteLogger' +import { createViteLogger, silenceViteWarnings } from '../viteLogger' import { ModuleRunnerTransform } from './runnerTransform' import { getDefaultResolveOptions } from './utils' +import { VitestBuiltinResolver } from './vitestResolver' export function ViteConfigPlugin(harness: PluginHarness): Plugin[] { let root: string @@ -103,7 +104,7 @@ export function ViteConfigPlugin(harness: PluginHarness): Plugin[] { config.customLogger = createViteLogger(harness.logger, viteConfig.logLevel || 'warn', { allowClearScreen: false, }) - config.customLogger = silenceImportViteIgnoreWarning(config.customLogger) + config.customLogger = silenceViteWarnings(config.customLogger, browserEnabled) return config }, @@ -141,5 +142,6 @@ export function ViteConfigPlugin(harness: PluginHarness): Plugin[] { }, }, ModuleRunnerTransform(), + VitestBuiltinResolver(), ] } diff --git a/packages/vitest/src/node/plugins/vitestResolver.ts b/packages/vitest/src/node/plugins/vitestResolver.ts index d6fc88067..31e551d37 100644 --- a/packages/vitest/src/node/plugins/vitestResolver.ts +++ b/packages/vitest/src/node/plugins/vitestResolver.ts @@ -2,6 +2,7 @@ import type { Plugin } from 'vite' import type { PluginHarness } from '../config/pluginHarness' import { join, resolve } from 'pathe' import { distDir } from '../../paths' +import { isBrowserExternal, isBuiltin } from '../../utils/modules' export function VitestProjectResolver(harness: PluginHarness): Plugin { let browserEnabled = false @@ -73,3 +74,26 @@ export function VitestCoreResolver(): Plugin { }, } } + +export function VitestBuiltinResolver(): Plugin { + return { + name: 'vitest:resolve-builtin', + enforce: 'pre', + applyToEnvironment(environment) { + return ( + environment.config.consumer === 'client' && environment.config.dev.moduleRunnerTransform + ) + }, + async resolveId(id, importer, options) { + if (!isBuiltin(id)) { + return + } + const resolved = await this.resolve(id, importer, { ...options, skipSelf: true }) + // Vite replaces builtins with a browser shim, but the module runner can import them + if (resolved && isBrowserExternal(resolved.id)) { + return { id, external: true } + } + return resolved + }, + } +} diff --git a/packages/vitest/src/node/viteLogger.ts b/packages/vitest/src/node/viteLogger.ts index 5325333b3..348499c8e 100644 --- a/packages/vitest/src/node/viteLogger.ts +++ b/packages/vitest/src/node/viteLogger.ts @@ -127,13 +127,16 @@ export function createViteLogger( } // silence warning by Vite for statically not analyzable dynamic import -export function silenceImportViteIgnoreWarning(logger: Logger): Logger { +export function silenceViteWarnings(logger: Logger, browserMode: boolean): Logger { return { ...logger, warn(msg, options) { if (msg.includes('The above dynamic import cannot be analyzed by Vite')) { return } + if (!browserMode && msg.includes('has been externalized for browser compatibility')) { + return + } logger.warn(msg, options) }, } diff --git a/packages/vitest/src/utils/graph.ts b/packages/vitest/src/utils/graph.ts index 5f8acbbe6..e8598cc4c 100644 --- a/packages/vitest/src/utils/graph.ts +++ b/packages/vitest/src/utils/graph.ts @@ -2,6 +2,7 @@ import type { DevEnvironment, EnvironmentModuleNode } from 'vite' import type { Vitest } from '../node/core' import type { ModuleGraphData } from '../types/general' import { getTestFileEnvironment } from './environments' +import { isBuiltin } from './modules' export async function getModuleGraph( ctx: Vitest, @@ -54,6 +55,10 @@ export async function getModuleGraph( externalized.add(external) return external } + if (isBuiltin(id)) { + externalized.add(id) + return id + } const external = project._resolver.wasExternalized(id) if (typeof external === 'string') { externalized.add(external) diff --git a/test/e2e/test/client-builtins.test.ts b/test/e2e/test/client-builtins.test.ts new file mode 100644 index 000000000..bcb9dc215 --- /dev/null +++ b/test/e2e/test/client-builtins.test.ts @@ -0,0 +1,102 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { getModuleGraph } from '../../../packages/vitest/src/utils/graph.js' +import { runInlineTests } from '../../test-utils' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +test.for(['production', 'test'])( + 'node builtins are importable in the client environment when NODE_ENV=%s', + async (nodeEnv) => { + vi.stubEnv('NODE_ENV', nodeEnv) + + const { stderr, testTree, ctx, root } = await runInlineTests( + { + 'client-env.js': ` +export default { + name: 'client-env', + viteEnvironment: 'client', + setup() { + return { teardown() {} } + }, +} +`, + 'basic.test.js': ` +import { timingSafeEqual } from 'node:crypto' +import * as nodePath from 'node:path' +import fs from 'fs' +import { expect, test } from 'vitest' + +test('static imports', () => { + expect(timingSafeEqual(Buffer.from('aa'), Buffer.from('aa'))).toBe(true) + expect(nodePath.posix.join('a', 'b')).toBe('a/b') + expect(fs.existsSync(import.meta.filename)).toBe(true) +}) + +test('dynamic imports', async () => { + const os = await import('node:os') + const { basename } = await import('path') + expect(os.platform()).toBe(process.platform) + expect(basename('/a/b.js')).toBe('b.js') +}) +`, + 'mock.test.js': ` +import os from 'os' +import { platform } from 'node:os' +import { readFileSync } from 'node:fs' +import { expect, test, vi } from 'vitest' + +vi.mock('os') +vi.mock('node:fs', () => ({ readFileSync: () => 'mocked' })) + +test('automock', () => { + expect(vi.isMockFunction(os.platform)).toBe(true) + expect(vi.isMockFunction(platform)).toBe(true) +}) + +test('factory', () => { + expect(readFileSync('file')).toBe('mocked') +}) + +test('importActual', async () => { + const actual = await vi.importActual('node:os') + expect(actual.platform()).toBe(process.platform) +}) +`, + }, + { environment: './client-env.js' }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "dynamic imports": "passed", + "static imports": "passed", + }, + "mock.test.js": { + "automock": "passed", + "factory": "passed", + "importActual": "passed", + }, + } + `) + + const graph = await getModuleGraph(ctx!, '', `${root}/basic.test.js`, 'client') + expect(graph.externalized.filter((id) => !id.includes('/'))).toMatchInlineSnapshot(` + [ + "node:crypto", + "node:path", + "fs", + "node:os", + "path", + ] + `) + expect(graph.inlined.map((id) => id.replace(root, ''))).toMatchInlineSnapshot(` + [ + "/basic.test.js", + ] + `) + }, +) -- 2.51.2