diff --git a/src/config.ts b/src/config.ts index 525ec39ca..8c5ecec5b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,7 +11,7 @@ import { createResolver, findPath } from '@nuxt/kit' import { resolveModulePath } from 'exsolve' import { getPackageInfoSync } from 'local-pkg' -import { applyEnv, loadKit } from './utils.ts' +import { applyEnv, deepCopy, loadKit } from './utils.ts' import { NuxtVitestEnvironmentOptionsPlugin } from './module/plugins/options.ts' interface GetVitestConfigOptions { @@ -192,7 +192,7 @@ export async function getVitestConfigFromNuxt( }, test: { environmentOptions: { - nuxtRuntimeConfig: applyEnv(structuredClone(options.nuxt.options.runtimeConfig), { + nuxtRuntimeConfig: applyEnv(deepCopy(options.nuxt.options.runtimeConfig), { prefix: 'NUXT_', env: await setupDotenv(defu(loadNuxtOptions.dotenv, { cwd: rootDir, @@ -426,7 +426,7 @@ async function resolveConfig }, ) as T & { test: NonNullable } diff --git a/src/utils.ts b/src/utils.ts index c184d17ab..dbd3d350e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -55,6 +55,46 @@ export function applyEnv(obj: Record, opts: EnvOptions, parentKey = return obj } +/** + * Deep-copy plain objects and arrays, passing anything else through by reference. + * + * `structuredClone` throws `DataCloneError` on proxies and functions, both of which turn up in + * `nuxt.options` (module mutation tracking wraps options in proxies) and in user-provided config + * overrides. Only plain containers need copying here; the clone exists to avoid mutating the + * caller's objects. + */ +export function deepCopy(input: T, seen = new WeakMap()): T { + if (typeof input !== 'object' || input === null) { + return input + } + + const proto = Object.getPrototypeOf(input) + if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) { + return input + } + + const existing = seen.get(input) + if (existing) { + return existing + } + + if (Array.isArray(input)) { + const copy: any[] = [] + seen.set(input, copy) + for (const item of input) { + copy.push(deepCopy(item, seen)) + } + return copy as T + } + + const copy: Record = {} + seen.set(input, copy) + for (const key in input) { + copy[key] = deepCopy((input as Record)[key], seen) + } + return copy as T +} + export async function loadKit(rootDir: string): Promise { try { const kitPath = resolveModulePath('@nuxt/kit', { from: tryResolveNuxt(rootDir) || rootDir }) diff --git a/test/unit/config.spec.ts b/test/unit/config.spec.ts new file mode 100644 index 000000000..041e5801b --- /dev/null +++ b/test/unit/config.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import type { Nuxt, ViteConfig } from '@nuxt/schema' + +import { getVitestConfigFromNuxt } from '../../src/config.ts' +import { deepCopy } from '../../src/utils.ts' + +describe('deepCopy', () => { + it('should copy through proxies', () => { + const source = new Proxy({ nested: new Proxy({ a: 1 }, {}) }, {}) + const copy = deepCopy(source) + expect(copy).toEqual({ nested: { a: 1 } }) + copy.nested.a = 2 + expect(source.nested.a).toBe(1) + }) + + it('should preserve functions and class instances by reference', () => { + const fn = () => 'hi' + const date = new Date(0) + const copy = deepCopy({ fn, date, list: [fn] }) + expect(copy.fn).toBe(fn) + expect(copy.date).toBe(date) + expect(copy.list[0]).toBe(fn) + }) + + it('should handle circular references', () => { + const source: Record = { a: 1 } + source.self = source + const copy = deepCopy(source) + expect(copy.self).toBe(copy) + }) +}) + +describe('getVitestConfigFromNuxt', () => { + it('should resolve a runtimeConfig containing a proxy', async () => { + const nuxt = { + options: { + appDir: process.cwd(), + modulesDir: [process.cwd()], + runtimeConfig: { + public: new Proxy({ debug: { hydration: true } }, {}), + }, + routeRules: {}, + app: {}, + build: { transpile: [] }, + }, + } as unknown as Nuxt + + const config = await getVitestConfigFromNuxt({ + nuxt, + viteConfig: { plugins: [] } as unknown as ViteConfig, + }) + + expect(config.test.environmentOptions!.nuxtRuntimeConfig).toEqual({ + public: { debug: { hydration: true } }, + }) + }) +})