diff --git a/.gitignore b/.gitignore index f0502f0ab..857ada14a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,6 @@ nuxt-app .pnpm-store coverage stats.json -playground-bun -playground-deno -playground-node +playground-bun-* +playground-deno-* +playground-node-* diff --git a/knip.json b/knip.json index 88a08f5a9..cb954048d 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,7 @@ "semver", "srvx", "ufo", + "undici", "youch" ] } diff --git a/packages/nuxt-cli/test/e2e/runtimes.spec.ts b/packages/nuxt-cli/test/e2e/runtimes.spec.ts index c9b6e2314..e743d7d41 100644 --- a/packages/nuxt-cli/test/e2e/runtimes.spec.ts +++ b/packages/nuxt-cli/test/e2e/runtimes.spec.ts @@ -1,4 +1,5 @@ import type { ChildProcess } from 'node:child_process' +import type { MessageEvent } from 'undici' import type { TestOptions } from 'vitest' import { spawn, spawnSync } from 'node:child_process' import { cpSync, rmSync } from 'node:fs' @@ -9,7 +10,7 @@ import { fileURLToPath } from 'node:url' import { getPort, waitForPort } from 'get-port-please' import { isCI, isLinux, isMacOS, isWindows } from 'std-env' import { WebSocket } from 'undici' -import { it as _it, afterAll, describe, expect, vi } from 'vitest' +import { it as _it, afterAll, beforeAll, describe, expect, vi } from 'vitest' const playgroundDir = fileURLToPath(new URL('../../../../playground', import.meta.url)) const nuxiPath = join(fileURLToPath(new URL('../..', import.meta.url)), 'bin/nuxi.mjs') @@ -32,34 +33,32 @@ type SupportStatus = boolean | { start: boolean fetching: boolean websockets: boolean - websocketClose: boolean } -const supports: Record = { - node: true, - bun: { - start: !platform.windows, - fetching: !platform.windows, - websockets: false, - websocketClose: false, - }, - deno: { - start: !platform.windows, - fetching: !platform.windows, - websockets: platform.linux && !platform.windows, - websocketClose: false, - }, -} - -function createIt(status: SupportStatus) { +function createIt(runtimeName: typeof runtimes[number], _socketsEnabled: boolean) { function it(description: string, fn: () => Promise): void function it(description: string, options: TestOptions, fn: () => Promise): void function it(description: string, _options: TestOptions | (() => Promise), _fn?: () => Promise): void { + const supportMatrix: Record = { + node: true, + bun: { + start: !platform.windows, + fetching: !platform.windows, + websockets: false, + }, + deno: { + start: !platform.windows, + fetching: !platform.windows, + websockets: !platform.windows && !platform.macos, + }, + } + const status = supportMatrix[runtimeName] + const fn = typeof _options === 'function' ? _options : _fn! const options = typeof _options === 'function' ? {} : _options if (status === false) { - return _it.fails(description, options, fn) + return _it.fails(`${description} [expected to fail with ${runtimeName}]`, options, fn) } if (status === true) { return _it(description, options, fn) @@ -68,20 +67,14 @@ function createIt(status: SupportStatus) { if (!status.start) { return _it.fails(description, options, fn) } - return _it(description, options, fn) + return beforeAll(fn, options.timeout) } if (!status.start) { return _it.todo(description) } - if (description.includes('websocket connection close gracefully')) { - if (!status.websocketClose) { - return _it.fails(description, options, fn) - } - return _it(description, options, fn) - } if (description.includes('websocket')) { if (!status.websockets) { - return _it.fails(description, options, fn) + return _it.fails(`${description} [expected to fail with ${runtimeName}]`, options, fn) } return _it(description, options, fn) } @@ -95,240 +88,176 @@ function createIt(status: SupportStatus) { return it } -describe.sequential.each(runtimes)('dev server (%s)', (runtimeName) => { - let server: DevServerInstance - - if (!isCI && !runtime[runtimeName]) { - console.warn(`Not testing locally with ${runtimeName} as it is not installed.`) - _it.skip(`should pass with ${runtimeName}`) - return - } +const socketConfigs = [ + { enabled: true, label: 'with sockets' }, + { enabled: false, label: 'without sockets' }, +] as const - const cwd = resolve(playgroundDir, `../playground-${runtimeName}`) +describe.sequential.each(runtimes)('dev server (%s)', (runtimeName) => { + describe.sequential.each(socketConfigs)('$label', ({ enabled: socketsEnabled }) => { + let server: DevServerInstance - afterAll(async () => { - await server?.close() - await rm(cwd, { recursive: true, force: true }).catch(() => null) - }) + if (!isCI && !runtime[runtimeName]) { + console.warn(`Not testing locally with ${runtimeName} as it is not installed.`) + _it.skip(`should pass with ${runtimeName}`) + return + } - const it = createIt(supports[runtimeName]) + const cwd = resolve(playgroundDir, `../playground-${runtimeName}-${socketsEnabled ? 'sockets' : 'nosockets'}`) - it('should start dev server', { timeout: isCI ? 120_000 : 30_000 }, async () => { - rmSync(cwd, { recursive: true, force: true }) - cpSync(playgroundDir, cwd, { - recursive: true, - filter: src => !src.includes('.nuxt') && !src.includes('.output') && !src.includes('node_modules'), + afterAll(async () => { + await server?.close() + await rm(cwd, { recursive: true, force: true }).catch(() => null) }) - server = await startDevServer({ cwd, runtime: runtimeName }) - }) - it('should serve the main page', async () => { - const response = await fetch(server.url) - expect(response.status).toBe(200) + const it = createIt(runtimeName, socketsEnabled) - const html = await response.text() - expect(html).toContain('Welcome to the Nuxt CLI playground') - expect(html).toContain('') - }) - - it('should serve static assets', async () => { - const response = await fetch(`${server.url}/favicon.ico`) - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toContain('image/') - }) - - it('should handle API routes', async () => { - const response = await fetch(`${server.url}/api/hello`) - expect(response.status).toBe(200) - }) - - it('should handle POST requests', async () => { - const response = await fetch(`${server.url}/api/echo`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ test: 'data' }), + it('should start dev server', { timeout: isCI ? 120_000 : 30_000 }, async () => { + rmSync(cwd, { recursive: true, force: true }) + cpSync(playgroundDir, cwd, { + recursive: true, + filter: src => !src.includes('.nuxt') && !src.includes('.output') && !src.includes('node_modules'), + }) + server = await startDevServer({ + cwd, + runtime: runtimeName, + socketsEnabled, + }) }) - expect(response.status).toBe(200) - }) - - it('should preserve request headers', async () => { - const headers = { - 'X-Custom-Header': 'test-value', - 'User-Agent': 'vitest', - } - - const res = await fetch(`${server.url}/api/echo`, { headers }) - const { headers: receivedHeaders } = await res.json() + it('should serve the main page', async () => { + const response = await fetch(server.url) + expect(response.status).toBe(200) - expect(receivedHeaders).toMatchObject({ - 'user-agent': 'vitest', - 'x-custom-header': 'test-value', + const html = await response.text() + expect(html).toContain('Welcome to the Nuxt CLI playground') + expect(html).toContain('') }) - expect(res.status).toBe(200) - }) - - it('should handle concurrent requests', async () => { - const requests = Array.from({ length: 5 }, () => fetch(server.url)) - const responses = await Promise.all(requests) - - for (const response of responses) { + it('should serve static assets', async () => { + const response = await fetch(`${server.url}/favicon.ico`) expect(response.status).toBe(200) - expect(await response.text()).toContain('Welcome to the Nuxt CLI playground') - } - }) - - it('should handle large request payloads', async () => { - const largePayload = { data: 'x'.repeat(10_000) } - const response = await fetch(`${server.url}/api/echo`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(largePayload), + expect(response.headers.get('content-type')).toContain('image/') }) - expect(response.status).toBe(200) - const result = await response.json() - expect(result.echoed.data).toBe(largePayload.data) - }) + it('should handle API routes', async () => { + const response = await fetch(`${server.url}/api/hello`) + expect(response.status).toBe(200) + }) - it('should handle different HTTP methods', async () => { - const methods = ['GET', 'POST', 'PUT', 'DELETE'] + it('should handle POST requests', async () => { + const response = await fetch(`${server.url}/api/echo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ test: 'data' }), + }) - for (const method of methods) { - const response = await fetch(`${server.url}/api/hello`, { method }) expect(response.status).toBe(200) + }) - const result = await response.json() - expect(result.method).toBe(method) - } - }) - - it('should establish websocket connection and handle ping/pong', { timeout: 20_000 }, async () => { - const wsUrl = `${server.url.replace('http', 'ws')}/_ws` + it('should preserve request headers', async () => { + const headers = { + 'X-Custom-Header': 'test-value', + 'User-Agent': 'vitest', + } - // Create a promise that resolves when the websocket test is complete - const wsTest = new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl) + const res = await fetch(`${server.url}/api/echo`, { headers }) + const { headers: receivedHeaders } = await res.json() - let isConnected = false - let receivedPong = false + expect(receivedHeaders).toMatchObject({ + 'user-agent': 'vitest', + 'x-custom-header': 'test-value', + }) - const timeout = setTimeout(() => { - if (!isConnected) { - reject(new Error('WebSocket connection timeout')) - } - else if (!receivedPong) { - reject(new Error('Did not receive pong response')) - } - ws.close() - }, 20_000) + expect(res.status).toBe(200) + }) - ws.addEventListener('open', () => { - isConnected = true - // Send ping message to test echo functionality - ws.send('ping test message') - }) + it('should handle concurrent requests', async () => { + const requests = Array.from({ length: 5 }, () => fetch(server.url)) + const responses = await Promise.all(requests) - ws.addEventListener('message', (event) => { - const message = event.data.toString() - if (message === 'pong') { - receivedPong = true - clearTimeout(timeout) - ws.close() - resolve() - } - }) + for (const response of responses) { + expect(response.status).toBe(200) + expect(await response.text()).toContain('Welcome to the Nuxt CLI playground') + } + }) - ws.addEventListener('error', (error) => { - clearTimeout(timeout) - reject(new Error(`WebSocket error: ${error}`)) + it('should handle large request payloads', async () => { + const largePayload = { data: 'x'.repeat(10_000) } + const response = await fetch(`${server.url}/api/echo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(largePayload), }) - ws.addEventListener('close', () => { - if (isConnected && receivedPong) { - resolve() - } - }) + expect(response.status).toBe(200) + const result = await response.json() + expect(result.echoed.data).toBe(largePayload.data) }) - await wsTest - }) + it('should handle different HTTP methods', async () => { + const methods = ['GET', 'POST', 'PUT', 'DELETE'] - it('should handle multiple concurrent websocket connections', { timeout: 20_000 }, async () => { - const wsUrl = `${server.url.replace('http', 'ws')}/_ws` - const connectionCount = 3 + for (const method of methods) { + const response = await fetch(`${server.url}/api/hello`, { method }) + expect(response.status).toBe(200) - const connectionPromises = Array.from({ length: connectionCount }, (_, index) => { - return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl) + const result = await response.json() + expect(result.method).toBe(method) + } + }) - const timeout = setTimeout(() => { - reject(new Error(`WebSocket ${index} connection timeout`)) - ws.close() - }, 5000) + it('should establish websocket connection and handle ping/pong', async () => { + const wsUrl = `${server.url.replace('http', 'ws')}/_ws` - ws.addEventListener('open', () => { - ws.send(`ping from connection ${index}`) - }) + let isConnected = false + let receivedPong = false - ws.addEventListener('message', (event) => { + await createWebSocketTest({ + url: wsUrl, + timeout: 2_000, + testId: 'ping/pong', + onOpen: (ws) => { + isConnected = true + ws.send('ping test message') + }, + onMessage: (ws, event) => { const message = event.data.toString() if (message === 'pong') { - clearTimeout(timeout) + receivedPong = true ws.close() - resolve() } - }) - - ws.addEventListener('error', (error) => { - clearTimeout(timeout) - reject(new Error(`WebSocket ${index} error: ${error}`)) - }) + }, + onClose: () => isConnected && receivedPong, }) }) - await Promise.all(connectionPromises) - }) - - it('should handle websocket connection close gracefully', { timeout: 10_000 }, async () => { - const wsUrl = `${server.url.replace('http', 'ws')}/_ws` - - const wsTest = new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl) - - let isConnected = false - - const timeout = setTimeout(() => { - reject(new Error('WebSocket close test timeout')) - }, 5000) - - ws.addEventListener('open', () => { - isConnected = true - // Immediately close the connection to test graceful handling - ws.close(1000, 'Test close') - }) - - ws.addEventListener('close', (event) => { - clearTimeout(timeout) - try { - expect(isConnected).toBe(true) - expect(event.code).toBe(1000) - expect(event.reason).toBe('Test close') - resolve() - } - catch (error) { - reject(error) - } + it('should handle multiple concurrent websocket connections', async () => { + const wsUrl = `${server.url.replace('http', 'ws')}/_ws` + const connectionCount = 2 + + const connectionPromises = Array.from({ length: connectionCount }, (_, index) => { + let receivedPong = false + + return createWebSocketTest({ + url: wsUrl, + timeout: 2_000, + testId: `concurrent connection ${index}`, + onOpen: (ws) => { + ws.send(`ping from connection ${index}`) + }, + onMessage: (ws, event) => { + if (event.data.toString() === 'pong') { + receivedPong = true + ws.close() + } + }, + onClose: () => receivedPong, + }) }) - ws.addEventListener('error', (error) => { - clearTimeout(timeout) - reject(new Error(`WebSocket close test error: ${error}`)) - }) + await Promise.all(connectionPromises) }) - - await wsTest }) }) @@ -344,8 +273,9 @@ async function startDevServer(options: { port?: number runtime?: 'node' | 'bun' | 'deno' env?: Record + socketsEnabled?: boolean }): Promise { - const { cwd, port: preferredPort, runtime = 'node', env = {} } = options + const { cwd, port: preferredPort, runtime = 'node', env = {}, socketsEnabled = true } = options const port = preferredPort || await getPort({ port: 3100 }) const host = '127.0.0.1' const url = `http://${host}:${port}` @@ -374,6 +304,7 @@ async function startDevServer(options: { NUXT_TELEMETRY_DISABLED: '1', PORT: String(port), HOST: host, + NUXT_SOCKET: socketsEnabled ? '1' : '0', }, }) @@ -408,3 +339,119 @@ async function startDevServer(options: { }, } } + +interface WebSocketTestOptions { + url: string + timeout?: number + onOpen?: (ws: WebSocket) => void + onMessage?: (ws: WebSocket, event: MessageEvent) => void + onClose?: (ws: WebSocket, event: CloseEvent) => boolean // return true if test should complete successfully + onError?: (ws: WebSocket, error: Event) => void + testId?: string +} + +function createWebSocketTest(options: WebSocketTestOptions): Promise { + const { + url, + timeout = 15_000, + onOpen, + onMessage, + onClose, + onError, + testId = '', + } = options + + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + let testCompleted = false + let timeoutId: NodeJS.Timeout + + function completeTest(error?: Error) { + if (testCompleted) { + return + } + testCompleted = true + clearTimeout(timeoutId) + + // Ensure WebSocket is closed + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close() + } + + if (error) { + reject(error) + } + else { + resolve() + } + } + + timeoutId = setTimeout(() => { + const state = ws.readyState === WebSocket.OPEN + ? 'open' + : ws.readyState === WebSocket.CONNECTING + ? 'connecting' + : ws.readyState === WebSocket.CLOSING + ? 'closing' + : 'closed' + completeTest(new Error(`WebSocket test timeout${testId ? ` for ${testId}` : ''} (state: ${state})`)) + }, timeout) + + ws.addEventListener('open', async () => { + if (onOpen) { + try { + await vi.waitFor(() => ws.readyState === WebSocket.OPEN) + onOpen(ws) + } + catch (error) { + completeTest(error instanceof Error ? error : new Error(String(error))) + } + } + }) + + ws.addEventListener('message', (event) => { + if (onMessage) { + try { + onMessage(ws, event) + } + catch (error) { + completeTest(error instanceof Error ? error : new Error(String(error))) + } + } + }) + + ws.addEventListener('close', (event) => { + if (onClose) { + try { + const shouldComplete = onClose(ws, event) + if (shouldComplete) { + completeTest() + } + else { + completeTest(new Error(`WebSocket test failed${testId ? ` for ${testId}` : ''} (close code: ${event.code})`)) + } + } + catch (error) { + completeTest(error instanceof Error ? error : new Error(String(error))) + } + } + else { + completeTest() + } + }) + + ws.addEventListener('error', (error) => { + if (onError) { + try { + onError(ws, error) + } + catch (err) { + completeTest(err instanceof Error ? err : new Error(String(err))) + } + } + else { + completeTest(new Error(`WebSocket error${testId ? ` for ${testId}` : ''}: ${error}`)) + } + }) + }) +}