diff --git a/examples/react/test/basic.test.tsx b/examples/react/test/basic.test.tsx index 6daec92b4de3..578869c7d1bd 100644 --- a/examples/react/test/basic.test.tsx +++ b/examples/react/test/basic.test.tsx @@ -1,6 +1,7 @@ import React from 'react' import renderer from 'react-test-renderer' import Link from '../components/Link.jsx' +import * as linkModule from '../components/Link.jsx' function toJson(component: renderer.ReactTestRenderer) { const result = component.toJSON() @@ -29,3 +30,19 @@ test('Link changes the class when hovered', () => { tree = toJson(component) expect(tree).toMatchSnapshot() }) + +test('Link can be spied', () => { + vi.spyOn(linkModule, 'default').mockImplementation(() => { + return
Hello
+ }) + + const component = renderer.create( + Anthony Fu, + ) + + const tree = toJson(component) + console.warn('tree', tree) + + expect(tree.type).toBe('div') + expect(tree.children).toStrictEqual(['Hello']) +}) diff --git a/examples/react/vitest.config.ts b/examples/react/vitest.config.ts index 85fb2c4ab28e..caa7c0074497 100644 --- a/examples/react/vitest.config.ts +++ b/examples/react/vitest.config.ts @@ -6,5 +6,14 @@ export default defineConfig({ test: { globals: true, environment: 'happy-dom', + browser: { + enabled: true, + name: process.env.BROWSER || 'chrome', + headless: false, + provider: process.env.PROVIDER || 'webdriverio', + isolate: false, + proxyHijackESM: true, + }, + }, }) diff --git a/packages/browser/src/client/fakeModule.ts b/packages/browser/src/client/fakeModule.ts new file mode 100644 index 000000000000..5e0f57cbda2b --- /dev/null +++ b/packages/browser/src/client/fakeModule.ts @@ -0,0 +1,62 @@ +// this is just here so Proxy knows we're wrapping a function +function neverFunctionForProxy() { + throw new Error(`should not get here`) +} + +// allows replacing 'function' types on this module: passes through to the 'current value' +function prepareFakeModuleForSpy(fakeModule: any, contents: any) { + for (const key in contents) { + if (typeof contents[key] !== 'function') { + fakeModule[key] = contents[key] + continue + } + + // uses Reflect to implement Proxy, but use 'current value' and not the original target + // this is a Proxy pointing _to_ Reflect, which means it "supports all operations" but on the dynamic target + const currentValueReflect = new Proxy(Reflect, { + get(_reflect, reflectKey) { + return (...args: any[]) => (Reflect as any)[reflectKey](contents[key], ...args.slice(1)) + }, + }) + fakeModule[key] = new Proxy(neverFunctionForProxy, currentValueReflect) + } +} + +// this enables bound-like named imports with the import rewriting scheme +export function buildFakeModule>(module: T): T { + const fakeModule: Record = { [Symbol.toStringTag]: 'Module' } + const contents: Record = { ...module } + + prepareFakeModuleForSpy(fakeModule, contents) + + // this intercepts tinyspy which uses Object.defineProperty to rewrite an object (even a "module") + return new Proxy({}, { + ownKeys(_target) { + return Reflect.ownKeys(fakeModule) + }, + + set(_target, _property) { + return false + }, + + get(_target, property) { + return fakeModule[property] + }, + + getOwnPropertyDescriptor(_target, p) { + return Reflect.getOwnPropertyDescriptor(contents, p) + }, + + defineProperty(_target, property, attributes) { + if (attributes.get || attributes.set) + throw new Error(`can't defineProperty with get/set on fake module`) + + if ('value' in attributes) { + contents[property] = attributes.value + return true + } + + return false + }, + }) as T +} diff --git a/packages/browser/src/client/index.html b/packages/browser/src/client/index.html index 796f831ab884..ce356f36248e 100644 --- a/packages/browser/src/client/index.html +++ b/packages/browser/src/client/index.html @@ -63,6 +63,11 @@ } } + // blindly load all code before mocker arrives + window.__vitest_mocker__ = { + wrap: (fn) => fn(), + } + window.__vi_export_all__ = exportAll // TODO: allow easier rewriting of import.meta.env diff --git a/packages/browser/src/client/main.ts b/packages/browser/src/client/main.ts index 1e8420e0867b..4a730c642181 100644 --- a/packages/browser/src/client/main.ts +++ b/packages/browser/src/client/main.ts @@ -18,6 +18,7 @@ export const ENTRY_URL = `${ let config: ResolvedConfig | undefined let runner: VitestRunner | undefined +let mocker: VitestBrowserClientMocker const browserHashMap = new Map() const url = new URL(location.href) @@ -173,8 +174,10 @@ ws.addEventListener('open', async () => { }, providedContext: await client.rpc.getProvidedContext(), } + + mocker = new VitestBrowserClientMocker(config!) // @ts-expect-error mocking vitest apis - globalThis.__vitest_mocker__ = new VitestBrowserClientMocker() + globalThis.__vitest_mocker__ = mocker const paths = getQueryPaths() @@ -224,6 +227,7 @@ async function runTests(paths: string[], config: ResolvedConfig) { preparedData = await prepareTestEnvironment(config) } catch (err) { + console.warn('Could not prepareTestEnvironment', err) location.reload() return } @@ -250,8 +254,14 @@ async function runTests(paths: string[], config: ResolvedConfig) { runningTests = true - for (const file of files) - await startTests([file], runner) + for (const file of files) { + try { + await startTests([file], runner) + } + finally { + mocker.resetAfterFile() + } + } } finally { runningTests = false diff --git a/packages/browser/src/client/mocker.ts b/packages/browser/src/client/mocker.ts index f076413d52e9..0093e7ba2a2e 100644 --- a/packages/browser/src/client/mocker.ts +++ b/packages/browser/src/client/mocker.ts @@ -1,8 +1,40 @@ +import type { ResolvedConfig } from 'vitest' +import { buildFakeModule } from './fakeModule' + function throwNotImplemented(name: string) { throw new Error(`[vitest] ${name} is not implemented in browser environment yet.`) } export class VitestBrowserClientMocker { + constructor(public config: ResolvedConfig) {} + + private wrappedImports = new WeakMap() + + /** + * Browser tests don't run in parallel. This clears all mocks after each run. + */ + public resetAfterFile() { + this.resetModules() + } + + public resetModules() { + this.wrappedImports = new WeakMap() + } + + public async wrap(fn: () => Promise) { + if (!this.config.browser.proxyHijackESM) + throw new Error(`hijackESM disabled but mocker invoked`) + + const module = await fn() + + let wrapped = this.wrappedImports.get(module) + if (wrapped === undefined) { + wrapped = buildFakeModule(module) + this.wrappedImports.set(module, wrapped) + } + return wrapped + } + public importActual() { throwNotImplemented('importActual') } diff --git a/packages/browser/src/node/esmProxy.ts b/packages/browser/src/node/esmProxy.ts new file mode 100644 index 000000000000..664a1cd77ced --- /dev/null +++ b/packages/browser/src/node/esmProxy.ts @@ -0,0 +1,54 @@ +import MagicString from 'magic-string' +import type { PluginContext } from 'rollup' +import type { Expression } from 'estree' +import type { Positioned } from './esmWalker' +import { esmWalker } from './esmWalker' + +// don't allow mocking vitest itself +// (this *also* changes the position of user code in the getImporter() helper) +const skipImports = [ + '^vitest$', + '^@vitest/', +] + +export async function insertEsmProxy( + code: string, + id: string, + parse: PluginContext['parse'], +) { + const s = new MagicString(code) + + let ast: any + try { + ast = parse(code) + } + catch (err) { + console.error(`Cannot parse ${id}:\n${(err as any).message}`) + return + } + + esmWalker(ast, { + onDynamicImport(node) { + const expression = (node.source as Positioned) + if (!(expression.type === 'Literal' && typeof expression.value === 'string')) + // this is a non-string import so vite won't change it anyway + return + + const value = expression.value as string + + if (skipImports.some(i => value.match(i))) + return + + const replace = `__vitest_mocker__.wrap(() => import(${JSON.stringify(value)}))` + s.overwrite(node.start, node.end, replace) + }, + onIdentifier() {}, + onImportMeta() {}, + }) + + return { + ast, + code: s.toString(), + map: s.generateMap({ hires: 'boundary', source: id }), + } +} diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index efbffad20262..6b83619ce2ce 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -6,8 +6,9 @@ import sirv from 'sirv' import type { Plugin } from 'vite' import type { WorkspaceProject } from 'vitest/node' import { injectVitestModule } from './esmInjector' +import { insertEsmProxy } from './esmProxy' -export default (project: WorkspaceProject, base = '/'): Plugin[] => { +export default (existingPlugins: Plugin[], project: WorkspaceProject, base = '/'): Plugin[] => { const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') const distRoot = resolve(pkgRoot, 'dist') @@ -98,5 +99,19 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { return injectVitestModule(source, id, this.parse) }, }, + + // MocksPlugin needs to be placed here for proxyHijackESM to work + ...existingPlugins, + + { + name: 'vitest:browser:esm-proxy', + enforce: 'post', + async transform(source, id) { + const proxyHijackESM = project.config.browser.proxyHijackESM ?? false + if (!proxyHijackESM) + return + return insertEsmProxy(source, id, this.parse) + }, + }, ] } diff --git a/packages/vitest/src/integrations/browser/server.ts b/packages/vitest/src/integrations/browser/server.ts index 2485b2fe7479..3e9ea244dc05 100644 --- a/packages/vitest/src/integrations/browser/server.ts +++ b/packages/vitest/src/integrations/browser/server.ts @@ -1,4 +1,5 @@ import { createServer } from 'vite' +import type { Plugin } from 'vite' import { defaultBrowserPort } from '../../constants' import { ensurePackageInstalled } from '../../node/pkg' import { resolveApiServerConfig } from '../../node/config' @@ -8,11 +9,54 @@ import { MocksPlugin } from '../../node/plugins/mocks' import { resolveFsAllow } from '../../node/plugins/utils' export async function createBrowserServer(project: WorkspaceProject, configFile: string | undefined) { + if (project.config.browser.proxyHijackESM && project.config.browser.slowHijackESM) + throw new Error(`cannot set both proxyHijackESM and slowHijackESM`) + const root = project.config.root await ensurePackageInstalled('@vitest/browser', root) const configPath = typeof configFile === 'string' ? configFile : false + const alwaysMock = Boolean(project.config.browser.proxyHijackESM) + + const existingPlugins: Plugin[] = [ + CoverageTransform(project.ctx), + { + enforce: 'post', + name: 'vitest:browser:config', + async config(config) { + const server = resolveApiServerConfig(config.test?.browser || {}) || { + port: defaultBrowserPort, + } + + // browser never runs in middleware mode + server.middlewareMode = false + + config.server = { + ...config.server, + ...server, + } + config.server.fs ??= {} + config.server.fs.allow = config.server.fs.allow || [] + config.server.fs.allow.push( + ...resolveFsAllow( + project.ctx.config.root, + project.ctx.server.config.configFile, + ), + ) + + return { + resolve: { + alias: config.test?.alias, + }, + server: { + watch: null, + }, + } + }, + }, + MocksPlugin({ always: alwaysMock }), + ] const server = await createServer({ logLevel: 'error', @@ -25,45 +69,7 @@ export async function createBrowserServer(project: WorkspaceProject, configFile: ignored: ['**/**'], }, }, - plugins: [ - (await import('@vitest/browser')).default(project, '/'), - CoverageTransform(project.ctx), - { - enforce: 'post', - name: 'vitest:browser:config', - async config(config) { - const server = resolveApiServerConfig(config.test?.browser || {}) || { - port: defaultBrowserPort, - } - - // browser never runs in middleware mode - server.middlewareMode = false - - config.server = { - ...config.server, - ...server, - } - config.server.fs ??= {} - config.server.fs.allow = config.server.fs.allow || [] - config.server.fs.allow.push( - ...resolveFsAllow( - project.ctx.config.root, - project.ctx.server.config.configFile, - ), - ) - - return { - resolve: { - alias: config.test?.alias, - }, - server: { - watch: null, - }, - } - }, - }, - MocksPlugin(), - ], + plugins: (await import('@vitest/browser')).default(existingPlugins, project, '/'), }) await server.listen() diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index 46af9c23fc5e..81770b954e3a 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -576,6 +576,7 @@ function createVitest(): VitestUtils { }, resetModules() { + _mocker.resetModules() resetModules(workerState.moduleCache) return utils }, diff --git a/packages/vitest/src/node/hoistMocks.ts b/packages/vitest/src/node/hoistMocks.ts index b0e0371cccb2..a310e0faefdf 100644 --- a/packages/vitest/src/node/hoistMocks.ts +++ b/packages/vitest/src/node/hoistMocks.ts @@ -49,11 +49,13 @@ const regexpHoistable = /^[ \t]*\b(vi|vitest)\s*\.\s*(mock|unmock|hoisted)\(/m const regexpAssignedHoisted = /=[ \t]*(\bawait|)[ \t]*\b(vi|vitest)\s*\.\s*hoisted\(/ const hashbangRE = /^#!.*\n/ -export function hoistMocks(code: string, id: string, parse: PluginContext['parse']) { - const hasMocks = regexpHoistable.test(code) || regexpAssignedHoisted.test(code) +export function hoistMocks(code: string, id: string, parse: PluginContext['parse'], always?: boolean) { + if (!always) { + const hasMocks = regexpHoistable.test(code) || regexpAssignedHoisted.test(code) - if (!hasMocks) - return + if (!hasMocks) + return + } const s = new MagicString(code) diff --git a/packages/vitest/src/node/plugins/coverageTransform.ts b/packages/vitest/src/node/plugins/coverageTransform.ts index eb368f570471..7e8c90262c8f 100644 --- a/packages/vitest/src/node/plugins/coverageTransform.ts +++ b/packages/vitest/src/node/plugins/coverageTransform.ts @@ -3,7 +3,7 @@ import { normalizeRequestId } from 'vite-node/utils' import type { Vitest } from '../core' -export function CoverageTransform(ctx: Vitest): VitePlugin | null { +export function CoverageTransform(ctx: Vitest): VitePlugin { return { name: 'vitest:coverage-transform', transform(srcCode, id) { diff --git a/packages/vitest/src/node/plugins/mocks.ts b/packages/vitest/src/node/plugins/mocks.ts index 318beb864409..908a22c79a61 100644 --- a/packages/vitest/src/node/plugins/mocks.ts +++ b/packages/vitest/src/node/plugins/mocks.ts @@ -1,12 +1,12 @@ import type { Plugin } from 'vite' import { hoistMocks } from '../hoistMocks' -export function MocksPlugin(): Plugin { +export function MocksPlugin(arg?: { always?: boolean }): Plugin { return { name: 'vitest:mocks', enforce: 'post', transform(code, id) { - return hoistMocks(code, id, this.parse) + return hoistMocks(code, id, this.parse, arg?.always ?? false) }, } } diff --git a/packages/vitest/src/runtime/mocker.ts b/packages/vitest/src/runtime/mocker.ts index 26ce9ff2f2c5..bdd700db26da 100644 --- a/packages/vitest/src/runtime/mocker.ts +++ b/packages/vitest/src/runtime/mocker.ts @@ -88,6 +88,8 @@ export class VitestMocker { this.spyModule = await this.executor.executeId(spyModulePath) } + public resetModules() {} + private deleteCachedItem(id: string) { const mockId = this.getMockPath(id) if (this.moduleCache.has(mockId)) diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 04254ebb505e..0c73b22ac62f 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -77,6 +77,16 @@ export interface BrowserConfigOptions { */ slowHijackESM?: boolean + /** + * Update ESM imports with a Proxy so they can be spied/stubbed with vi.spyOn. + * + * This is an alternative design to `slowHijackESM` and should not be set at the same time. + * + * @default false + * @experimental + */ + proxyHijackESM?: boolean + /** * Isolate test environment after each test * diff --git a/test/browser/vitest.config.mts b/test/browser/vitest.config.mts index baefd42c8b25..0c57cdf1b63f 100644 --- a/test/browser/vitest.config.mts +++ b/test/browser/vitest.config.mts @@ -18,7 +18,7 @@ export default defineConfig({ headless: false, provider: process.env.PROVIDER || 'webdriverio', isolate: false, - slowHijackESM: true, + proxyHijackESM: true, }, alias: { '#src': resolve(dir, './src'),