diff --git a/examples/app-vitest/test/e2e/dev-concurrent.spec.ts b/examples/app-vitest/test/e2e/dev-concurrent.spec.ts new file mode 100644 index 000000000..714d335b2 --- /dev/null +++ b/examples/app-vitest/test/e2e/dev-concurrent.spec.ts @@ -0,0 +1,25 @@ +import { fileURLToPath } from 'node:url' +import { createTest, setTestContext } from '@nuxt/test-utils/e2e' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const rootDir = fileURLToPath(new URL('../../', import.meta.url)) +const servers = [createTest({ rootDir, dev: true }), createTest({ rootDir, dev: true })] + +for (const server of servers) { + beforeAll(async () => { + setTestContext(server.ctx) + await server.beforeAll() + }, server.ctx.options.setupTimeout) + afterAll(server.afterAll, server.ctx.options.teardownTimeout) +} + +describe('concurrent dev servers', () => { + it('runs two dev servers for the same fixture', async () => { + const [first, second] = servers.map(server => server.ctx) + expect(first!.nuxt!.options.buildDir).not.toBe(second!.nuxt!.options.buildDir) + for (const ctx of [first!, second!]) { + const html = await fetch(ctx.url!).then(res => res.text()) + expect(html).toContain('My title') + } + }) +}) diff --git a/examples/app-vitest/test/e2e/dev-config.spec.ts b/examples/app-vitest/test/e2e/dev-config.spec.ts new file mode 100644 index 000000000..fb8d6a018 --- /dev/null +++ b/examples/app-vitest/test/e2e/dev-config.spec.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from 'node:url' +import { $fetch, setup } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +await setup({ + rootDir: fileURLToPath(new URL('../../', import.meta.url)), + dev: true, + nuxtConfig: { + ssr: false, + app: { head: { title: 'Overridden in dev' } }, + }, +}) + +describe('server (dev) with nuxtConfig overrides', () => { + it('applies the overrides to the dev server', async () => { + const html = await $fetch('/') + expect(html).toContain('Overridden in dev') + expect(html).not.toContain('My title') + }) +}) diff --git a/src/e2e/dev-server.ts b/src/e2e/dev-server.ts new file mode 100644 index 000000000..1781d9b09 --- /dev/null +++ b/src/e2e/dev-server.ts @@ -0,0 +1,15 @@ +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +interface NuxtCli { + runCommand: (name: string, argv?: string[], data?: { overrides?: Record }) => Promise +} + +const overrides = JSON.parse(process.env.__NUXT_TEST_DEV_OVERRIDES__ || '{}') +delete process.env.__NUXT_TEST_DEV_OVERRIDES__ + +process.on('unhandledRejection', err => console.error('[unhandledRejection]', err)) +process.on('uncaughtException', err => console.error('[uncaughtException]', err)) + +const { runCommand }: NuxtCli = await import(pathToFileURL(process.argv[2]!).href) +await runCommand('_dev', [], { overrides }) diff --git a/src/e2e/nuxt.ts b/src/e2e/nuxt.ts index d085120c7..14af02f4c 100644 --- a/src/e2e/nuxt.ts +++ b/src/e2e/nuxt.ts @@ -41,11 +41,11 @@ export async function loadFixture() { ctx.options.rootDir = resolveRootDir() + const randomId = Math.random().toString(36).slice(2, 8) + const buildDir = ctx.options.buildDir || resolve(ctx.options.rootDir, '.nuxt', 'test', randomId) + ctx.options.nuxtConfig = defu(ctx.options.nuxtConfig, { buildDir }) if (!ctx.options.dev) { - const randomId = Math.random().toString(36).slice(2, 8) - const buildDir = ctx.options.buildDir || resolve(ctx.options.rootDir, '.nuxt', 'test', randomId) ctx.options.nuxtConfig = defu(ctx.options.nuxtConfig, { - buildDir, nitro: { output: { dir: resolve(buildDir, 'output'), @@ -56,6 +56,8 @@ export async function loadFixture() { // TODO: share Nuxt instance with running Nuxt if possible if (ctx.options.build) { + // avoid creating / deleting build dirs that already exist - avoids misconfiguration deletes + const buildDirExists = existsSync(resolve(ctx.options.rootDir, ctx.options.nuxtConfig.buildDir!)) const { loadNuxt, logger } = await loadKit(ctx.options.rootDir) ctx.nuxt = await loadNuxt({ cwd: ctx.options.rootDir, @@ -67,8 +69,7 @@ export async function loadFixture() { }) const buildDir = ctx.nuxt.options.buildDir - // avoid creating / deleting build dirs that already exist - avoids misconfiguration deletes - if (!existsSync(buildDir)) { + if (!buildDirExists) { await fsp.mkdir(buildDir, { recursive: true }) ctx.teardown = ctx.teardown || [] ctx.teardown.push(() => fsp.rm(buildDir, { recursive: true, force: true })) diff --git a/src/e2e/server.ts b/src/e2e/server.ts index 7be822e6d..f1903bec9 100644 --- a/src/e2e/server.ts +++ b/src/e2e/server.ts @@ -1,3 +1,4 @@ +import { distDir } from '#dirs' import { x, xSync } from 'tinyexec' import { getRandomPort, waitForPort } from 'get-port-please' import { isWindows } from 'std-env' @@ -6,6 +7,7 @@ import { fetch as _fetch, createFetch } from 'ofetch' import { resolve } from 'pathe' import { joinURL } from 'ufo' import { useTestContext } from './context.ts' +import { resolveNuxtCli, toSerializable } from '../utils.ts' import type { TestContext } from './types.ts' const globalFetch = globalThis.fetch || _fetch @@ -41,10 +43,11 @@ export async function startServer(options: StartServerOptions = {}) { const logLevel = String(options.logLevel ?? ctx.options.logLevel) const startedAt = Date.now() if (ctx.options.dev) { - ctx.serverProcess = x('nuxi', ['_dev'], { + const rootDir = ctx.nuxt!.options.rootDir + ctx.serverProcess = x('node', [resolve(distDir, 'e2e/dev-server.mjs'), resolveNuxtCli(rootDir)], { throwOnError: true, nodeOptions: { - cwd: ctx.nuxt!.options.rootDir, + cwd: rootDir, stdio, env: { ...process.env, @@ -55,6 +58,7 @@ export async function startServer(options: StartServerOptions = {}) { CONSOLA_LEVEL: logLevel, ...ctx.options.env, ...options.env, + __NUXT_TEST_DEV_OVERRIDES__: serializeDevOverrides(ctx.options.nuxtConfig), }, }, }) @@ -105,6 +109,14 @@ export async function startServer(options: StartServerOptions = {}) { await waitForServer({ host, port, startedAt }) } +function serializeDevOverrides(nuxtConfig: TestContext['options']['nuxtConfig']) { + const { value, omitted } = toSerializable(nuxtConfig) + if (omitted.length) { + console.warn(`[@nuxt/test-utils] These \`nuxtConfig\` values cannot be passed to the dev server and will be ignored: ${omitted.map(path => `\`${path}\``).join(', ')}`) + } + return JSON.stringify(value ?? {}) +} + interface WaitForServerOptions { host: string port: number @@ -233,9 +245,8 @@ async function waitForServer({ host, port, startedAt }: WaitForServerOptions) { } /** - * On Windows there are no process groups, and tinyexec routes commands that - * resolve to a `.cmd` shim (such as `nuxi`) through `cmd.exe /d /s /c`. - * `taskkill /T /F` walks the tree and terminates the descendants too. + * On Windows there are no process groups, so `taskkill /T /F` is used to + * terminate the server process together with its descendants. */ function killProcessTree(pid: number) { try { diff --git a/src/utils.ts b/src/utils.ts index 40f6a5c7d..5898c8429 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -159,6 +159,59 @@ export async function loadKit(rootDir: string): Promise): unknown { + if (input === undefined) { + return OMITTED + } + if (input === null || typeof input === 'string' || typeof input === 'boolean' || (typeof input === 'number' && Number.isFinite(input))) { + return input + } + const isContainer = Array.isArray(input) || (typeof input === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(input))) + if (!isContainer || ancestors.has(input)) { + omitted.push(path) + return OMITTED + } + ancestors.add(input) + let copy: unknown[] | Record + if (Array.isArray(input)) { + copy = input + .map((item, index) => serializableCopy(item, `${path}[${index}]`, omitted, ancestors)) + .filter(item => item !== OMITTED) + } + else { + copy = {} + for (const key in input as Record) { + const value = serializableCopy((input as Record)[key], path ? `${path}.${key}` : key, omitted, ancestors) + if (value !== OMITTED) { + copy[key] = value + } + } + } + ancestors.delete(input) + return copy +} + function tryResolveNuxt(rootDir: string) { for (const pkg of ['nuxt-nightly', 'nuxt', 'nuxt3', 'nuxt-edge']) { const path = resolveModulePath(pkg, { from: rootDir, try: true }) diff --git a/test/unit/to-serializable.spec.ts b/test/unit/to-serializable.spec.ts new file mode 100644 index 000000000..38abd13c1 --- /dev/null +++ b/test/unit/to-serializable.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { toSerializable } from '../../src/utils.ts' + +describe('toSerializable', () => { + it('should keep plain values and report the paths of values that cannot be serialized', () => { + const source: Record = { + ssr: false, + app: { head: { title: 'title' } }, + hooks: { ready: () => {} }, + modules: ['a', () => {}, undefined, 'b'], + build: { transpile: [/foo/] }, + } + source.self = source + + expect(toSerializable(source)).toEqual({ + value: { + ssr: false, + app: { head: { title: 'title' } }, + hooks: {}, + modules: ['a', 'b'], + build: { transpile: [] }, + }, + omitted: ['hooks.ready', 'modules[1]', 'build.transpile[0]', 'self'], + }) + }) +}) diff --git a/tsdown.config.ts b/tsdown.config.ts index 76d8b630a..43ce573c8 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -20,6 +20,7 @@ export default defineConfig([ exports: false, entry: [ 'src/e2e.ts', + 'src/e2e/dev-server.ts', 'src/playwright.ts', 'src/experimental.ts', 'src/config.ts',