Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -426,7 +426,7 @@ async function resolveConfig<T extends ViteUserConfig & { test?: VitestConfig }
await getVitestConfigFromNuxt(undefined, {
dotenv: config.test?.environmentOptions?.nuxt?.dotenv,
nitroEnvironment: config.test?.environmentOptions?.nuxt?.nitroEnvironment,
overrides: structuredClone(overrides),
overrides: deepCopy(overrides),
}) satisfies ViteUserConfig & { test: NonNullable<T['test']> },
) as T & { test: NonNullable<T['test']> }

Expand Down
40 changes: 40 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,46 @@ export function applyEnv(obj: Record<string, any>, 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<T>(input: T, seen = new WeakMap<object, any>()): 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<string, any> = {}
seen.set(input, copy)
for (const key in input) {
copy[key] = deepCopy((input as Record<string, any>)[key], seen)
}
Comment on lines +90 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve null prototypes and own __proto__ keys.

Line 90 always creates {}. Line 93 assigns an own __proto__ key through the inherited setter. A null-prototype object therefore changes prototype, and a JSON-derived __proto__ property is not copied as data.

Create the target with Object.create(proto). Iterate own keys with Object.keys. Define each property instead of assigning it.

Proposed fix
-  const copy: Record<string, any> = {}
+  const copy: Record<string, any> = Object.create(proto)
   seen.set(input, copy)
-  for (const key in input) {
-    copy[key] = deepCopy((input as Record<string, any>)[key], seen)
+  for (const key of Object.keys(input as Record<string, any>)) {
+    Object.defineProperty(copy, key, {
+      value: deepCopy((input as Record<string, any>)[key], seen),
+      enumerable: true,
+      writable: true,
+      configurable: true,
+    })
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils.ts` around lines 90 - 94, Update the deep-copy logic around the
copy target to preserve the input object's prototype by creating it with
Object.create(input's prototype). Replace the for-in iteration with
Object.keys(input), recursively copy each own property, and define properties on
the target so an own __proto__ key remains data rather than changing the
prototype.

return copy as T
}

export async function loadKit(rootDir: string): Promise<typeof import('@nuxt/kit')> {
try {
const kitPath = resolveModulePath('@nuxt/kit', { from: tryResolveNuxt(rootDir) || rootDir })
Expand Down
57 changes: 57 additions & 0 deletions test/unit/config.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { 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 } },
})
})
})
Loading