mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
090f1b7275
## What - capture stdout and stderr for both direct and shell-fallback package-manager version probes - keep successful version output unchanged and return `N/A` when a probe fails - add deterministic cross-platform regression coverage with a failing Yarn shim Closes #97932 ## Why PR #71134 added an `execSync` fallback so package-manager command shims can be resolved on Windows. When `stdio` is omitted, Node mirrors stderr from failed synchronous child processes to the parent, so an unavailable Yarn or pnpm can print raw shell errors before `next info` reports `N/A`. Passing `{ stdio: "pipe" }` to both probes preserves captured stdout and the Windows fallback while keeping expected probe failures silent. The change stays scoped to package-manager version detection; shared registry discovery is unchanged. ## Testing - `pnpm --filter next typescript` - `pnpm --filter next build` - `pnpm test-types --pretty false` - ESLint and Prettier on both changed files - `pnpm test-start test/e2e/cli/cli.test.ts` (66 passed, 1 skipped) - built-CLI reproduction with npm available and Yarn/pnpm absent: both report `N/A` with no Yarn/pnpm command-not-found output - autoreview with GPT-5.6 Sol xhigh and Claude Opus 5 xhigh: zero findings --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
1289 lines
38 KiB
TypeScript
1289 lines
38 KiB
TypeScript
import { nextTestSetup } from 'e2e-utils'
|
|
import { findPort, killApp, retry } from 'next-test-utils'
|
|
import path, { join } from 'path'
|
|
// @ts-expect-error
|
|
import pkg from 'next/package'
|
|
import http from 'http'
|
|
import { promises as fs } from 'fs'
|
|
import stripAnsi from 'strip-ansi'
|
|
import type { ChildProcess } from 'child_process'
|
|
|
|
const itCI = process.env.NEXT_TEST_CI ? it : it.skip
|
|
|
|
const reactDependencies = {
|
|
react: '19.3.0-canary-fef12a01-20260413',
|
|
'react-dom': '19.3.0-canary-fef12a01-20260413',
|
|
}
|
|
|
|
describe('CLI Usage', () => {
|
|
const { next, isNextStart } = nextTestSetup({
|
|
files: join(__dirname, 'basic'),
|
|
skipStart: true,
|
|
dependencies: reactDependencies,
|
|
skipDeployment: true,
|
|
})
|
|
|
|
/**
|
|
* Start a long-running Next.js CLI command (e.g. `next dev`, `next start`)
|
|
* and return a handle for externally killing it.
|
|
*
|
|
* Waits for `readyPattern` to match in stdout before resolving so callers can
|
|
* make assertions against a running server. If the process exits before the
|
|
* pattern matches, the returned promise resolves anyway and the caller can
|
|
* inspect the captured output.
|
|
*/
|
|
async function launchDevServer(
|
|
args: string[],
|
|
opts: {
|
|
env?: Record<string, string>
|
|
cwd?: string
|
|
onStdout?: (msg: string) => void
|
|
onStderr?: (msg: string) => void
|
|
/** Pattern signaling the server is ready. */
|
|
readyPattern?: RegExp
|
|
} = {}
|
|
): Promise<{ child: ChildProcess; exit: Promise<any> }> {
|
|
const readyPattern = opts.readyPattern ?? /- Local:|✓ Ready/i
|
|
let child!: ChildProcess
|
|
let ready = false
|
|
let resolveReady!: () => void
|
|
const readyPromise = new Promise<void>((r) => {
|
|
resolveReady = () => {
|
|
if (!ready) {
|
|
ready = true
|
|
r()
|
|
}
|
|
}
|
|
})
|
|
|
|
const exit = next
|
|
.runCommand(args, {
|
|
env: opts.env,
|
|
cwd: opts.cwd,
|
|
onStdout: (msg) => {
|
|
opts.onStdout?.(msg)
|
|
if (readyPattern.test(stripAnsi(msg))) resolveReady()
|
|
},
|
|
onStderr: (msg) => {
|
|
opts.onStderr?.(msg)
|
|
if (readyPattern.test(stripAnsi(msg))) resolveReady()
|
|
},
|
|
instance: (p) => {
|
|
child = p
|
|
},
|
|
})
|
|
.finally(() => {
|
|
resolveReady()
|
|
})
|
|
|
|
await readyPromise
|
|
return { child, exit }
|
|
}
|
|
|
|
const runAndCaptureOutput = async ({ port }: { port: number }) => {
|
|
let stdout = ''
|
|
let stderr = ''
|
|
|
|
let app = http.createServer((_, res) => {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
res.end('OK')
|
|
})
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
app.on('error', reject)
|
|
app.on('listening', () => resolve())
|
|
app.listen(port)
|
|
})
|
|
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '-p', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
stdout += msg
|
|
},
|
|
onStderr(msg) {
|
|
stderr += msg
|
|
},
|
|
}
|
|
)
|
|
|
|
try {
|
|
// Give the server a moment to emit the port-in-use error if it hasn't
|
|
// already been emitted before the ready pattern matched.
|
|
await retry(
|
|
() => {
|
|
if (!stderr.includes('already in use') && child.exitCode === null) {
|
|
throw new Error('waiting for port conflict error')
|
|
}
|
|
},
|
|
5000,
|
|
100
|
|
).catch(() => {})
|
|
} finally {
|
|
await killApp(child).catch(() => {})
|
|
await exit.catch(() => {})
|
|
}
|
|
|
|
await new Promise((resolve) => app.close(resolve))
|
|
|
|
return { stdout, stderr }
|
|
}
|
|
|
|
const testExitSignal = async (
|
|
killSignal: NodeJS.Signals | '' = '',
|
|
args: string[] = [],
|
|
readyRegex = /Creating an optimized production/,
|
|
expectedCode = 0
|
|
) => {
|
|
let instance: ChildProcess | undefined
|
|
let output = ''
|
|
|
|
const cmdPromise = next
|
|
.runCommand(args, {
|
|
ignoreFail: true,
|
|
instance: (p) => {
|
|
instance = p
|
|
},
|
|
onStdout: (msg) => {
|
|
output += stripAnsi(msg)
|
|
},
|
|
})
|
|
.catch((err) => expect.fail(err.message))
|
|
|
|
await retry(() => {
|
|
expect(output).toMatch(readyRegex)
|
|
})
|
|
instance!.kill(killSignal as NodeJS.Signals)
|
|
|
|
const { code, signal } = await cmdPromise
|
|
// Node can only partially emulate signals on Windows. Our signal handlers won't affect the exit code.
|
|
// See: https://nodejs.org/api/process.html#process_signal_events
|
|
const expectedExitSignal = process.platform === `win32` ? killSignal : null
|
|
expect(signal).toBe(expectedExitSignal)
|
|
expect(code).toBe(expectedCode)
|
|
}
|
|
|
|
;(isNextStart ? describe : describe.skip)('production mode', () => {
|
|
describe('start', () => {
|
|
test('should exit when SIGINT is signalled', async () => {
|
|
require('console').log('before build')
|
|
await next.deleteFile('.next')
|
|
await next.build()
|
|
require('console').log('build finished')
|
|
|
|
const port = await findPort()
|
|
await testExitSignal(
|
|
'SIGINT',
|
|
['start', next.testDir, '-p', String(port)],
|
|
/- Local:/,
|
|
130 // 128 + 2 (SIGINT)
|
|
)
|
|
})
|
|
test('should exit when SIGTERM is signalled', async () => {
|
|
await next.deleteFile('.next')
|
|
await next.build()
|
|
const port = await findPort()
|
|
await testExitSignal(
|
|
'SIGTERM',
|
|
['start', next.testDir, '-p', String(port)],
|
|
/- Local:/,
|
|
143 // 128 + 15 (SIGTERM)
|
|
)
|
|
})
|
|
|
|
test('--help', async () => {
|
|
const help = await next.runCommand(['start', '--help'])
|
|
expect(help.stdout).toMatch(/Starts Next.js in production mode/)
|
|
})
|
|
|
|
test('-h', async () => {
|
|
const help = await next.runCommand(['start', '-h'])
|
|
expect(help.stdout).toMatch(/Starts Next.js in production mode/)
|
|
})
|
|
|
|
test('should format IPv6 addresses correctly', async () => {
|
|
await next.build()
|
|
const port = await findPort()
|
|
|
|
let stdout = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['start', next.testDir, '--hostname', '::', '--port', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
stdout += msg
|
|
},
|
|
}
|
|
)
|
|
|
|
try {
|
|
await retry(() => {
|
|
// Only display when hostname is provided
|
|
expect(stdout).toMatch(
|
|
new RegExp(`Network:\\s*http://\\[::\\]:${port}`)
|
|
)
|
|
expect(stdout).toMatch(new RegExp(`http://\\[::1\\]:${port}`))
|
|
})
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('should warn when unknown argument provided', async () => {
|
|
const { stderr } = await next.runCommand(['start', '--random'])
|
|
expect(stderr).toEqual(`error: unknown option '--random'\n`)
|
|
})
|
|
test('should not throw UnhandledPromiseRejectionWarning', async () => {
|
|
const { stderr } = await next.runCommand(['start', '--random'])
|
|
expect(stderr).not.toContain('UnhandledPromiseRejectionWarning')
|
|
})
|
|
|
|
test('invalid directory', async () => {
|
|
const output = await next.runCommand(['start', 'non-existent'])
|
|
expect(output.stderr).toContain(
|
|
'Invalid project directory provided, no such directory'
|
|
)
|
|
})
|
|
|
|
test('--keepAliveTimeout string arg', async () => {
|
|
const { stderr } = await next.runCommand([
|
|
'start',
|
|
'--keepAliveTimeout',
|
|
'string',
|
|
])
|
|
expect(stderr).toContain(
|
|
`error: option '--keepAliveTimeout <keepAliveTimeout>' argument 'string' is invalid. 'string' is not a non-negative number.`
|
|
)
|
|
})
|
|
|
|
test('--keepAliveTimeout negative number', async () => {
|
|
const { stderr } = await next.runCommand([
|
|
'start',
|
|
'--keepAliveTimeout=-100',
|
|
])
|
|
expect(stderr).toContain(
|
|
`error: option '--keepAliveTimeout <keepAliveTimeout>' argument '-100' is invalid. '-100' is not a non-negative number.`
|
|
)
|
|
})
|
|
|
|
test('--keepAliveTimeout Infinity', async () => {
|
|
const { stderr } = await next.runCommand([
|
|
'start',
|
|
'--keepAliveTimeout',
|
|
'Infinity',
|
|
])
|
|
expect(stderr).toContain(
|
|
`error: option '--keepAliveTimeout <keepAliveTimeout>' argument 'Infinity' is invalid. 'Infinity' is not a non-negative number.`
|
|
)
|
|
})
|
|
|
|
test('--keepAliveTimeout happy path', async () => {
|
|
await next.build()
|
|
const port = await findPort()
|
|
|
|
let stderr = ''
|
|
const { child, exit } = await launchDevServer(
|
|
[
|
|
'start',
|
|
next.testDir,
|
|
'--keepAliveTimeout',
|
|
'100',
|
|
'--port',
|
|
String(port),
|
|
],
|
|
{
|
|
onStderr(msg) {
|
|
stderr += msg
|
|
},
|
|
}
|
|
)
|
|
|
|
try {
|
|
expect(stderr).not.toContain(
|
|
`error: option '--keepAliveTimeout <keepAliveTimeout>' argument '100' is invalid. '100' is not a non-negative number.`
|
|
)
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('should not start on a port out of range', async () => {
|
|
const invalidPort = '300001'
|
|
const { stderr } = await next.runCommand([
|
|
'start',
|
|
'--port',
|
|
invalidPort,
|
|
])
|
|
|
|
expect(stderr).toContain(`options.port should be >= 0 and < 65536.`)
|
|
})
|
|
|
|
test('should not start on a reserved port', async () => {
|
|
const reservedPort = '4045'
|
|
const { stderr } = await next.runCommand([
|
|
'start',
|
|
'--port',
|
|
reservedPort,
|
|
])
|
|
|
|
expect(stderr).toContain(
|
|
`Bad port: "${reservedPort}" is reserved for npp`
|
|
)
|
|
})
|
|
|
|
test('--inspect', async () => {
|
|
await next.build()
|
|
const port = await findPort()
|
|
|
|
let output = ''
|
|
let errOutput = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['start', next.testDir, '--port', String(port), '--inspect'],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
onStderr(msg) {
|
|
errOutput += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
await retry(() => {
|
|
expect(output).toMatch(/- Debugger port:\s+9229/)
|
|
})
|
|
await retry(() => {
|
|
expect(errOutput).toMatch(/Debugger listening on/)
|
|
})
|
|
expect(errOutput).not.toContain('address already in use')
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('telemetry', () => {
|
|
test('--help', async () => {
|
|
const help = await next.runCommand(['telemetry', '--help'])
|
|
expect(help.stdout).toMatch(/Allows you to enable or disable Next\.js'/)
|
|
})
|
|
|
|
test('-h', async () => {
|
|
const help = await next.runCommand(['telemetry', '-h'])
|
|
expect(help.stdout).toMatch(/Allows you to enable or disable Next\.js'/)
|
|
})
|
|
|
|
test('should warn when unknown argument provided', async () => {
|
|
const { stderr } = await next.runCommand(['telemetry', '--random'])
|
|
expect(stderr).toEqual(`error: unknown option '--random'\n`)
|
|
})
|
|
test('should not throw UnhandledPromiseRejectionWarning', async () => {
|
|
const { stderr } = await next.runCommand(['telemetry', '--random'])
|
|
expect(stderr).not.toContain('UnhandledPromiseRejectionWarning')
|
|
})
|
|
})
|
|
|
|
describe('build', () => {
|
|
test('--help', async () => {
|
|
const help = await next.runCommand(['build', '--help'])
|
|
expect(help.stdout).toMatch(/Creates an optimized production build/)
|
|
})
|
|
|
|
test('-h', async () => {
|
|
const help = await next.runCommand(['build', '-h'])
|
|
expect(help.stdout).toMatch(/Creates an optimized production build/)
|
|
})
|
|
|
|
test('should warn when unknown argument provided', async () => {
|
|
const { stderr } = await next.runCommand(['build', '--random'])
|
|
expect(stderr).toEqual(`error: unknown option '--random'\n`)
|
|
})
|
|
test('should not throw UnhandledPromiseRejectionWarning', async () => {
|
|
const { stderr } = await next.runCommand(['build', '--random'])
|
|
expect(stderr).not.toContain('UnhandledPromiseRejectionWarning')
|
|
})
|
|
|
|
test('should exit when SIGINT is signalled', async () => {
|
|
await testExitSignal('SIGINT', ['build', next.testDir], undefined, 130)
|
|
})
|
|
|
|
test('should exit when SIGTERM is signalled', async () => {
|
|
await testExitSignal('SIGTERM', ['build', next.testDir], undefined, 143)
|
|
})
|
|
|
|
test('invalid directory', async () => {
|
|
const output = await next.runCommand(['build', 'non-existent'])
|
|
expect(output.stderr).toContain(
|
|
'Invalid project directory provided, no such directory'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('no command', () => {
|
|
test('--help', async () => {
|
|
const help = await next.runCommand(['--help'])
|
|
expect(help.stdout).toMatch(
|
|
/The Next.js CLI allows you to develop, build, start/
|
|
)
|
|
})
|
|
|
|
test('-h', async () => {
|
|
const help = await next.runCommand(['-h'])
|
|
expect(help.stdout).toMatch(
|
|
/The Next.js CLI allows you to develop, build, start/
|
|
)
|
|
})
|
|
|
|
test('--version', async () => {
|
|
const output = await next.runCommand(['--version'])
|
|
expect(output.stdout).toMatch(
|
|
new RegExp(`Next\\.js v${pkg.version.replace(/\./g, '\\.')}`)
|
|
)
|
|
})
|
|
|
|
test('-v', async () => {
|
|
const output = await next.runCommand(['--version'])
|
|
expect(output.stdout).toMatch(
|
|
new RegExp(`Next\\.js v${pkg.version.replace(/\./g, '\\.')}`)
|
|
)
|
|
})
|
|
|
|
test('invalid directory', async () => {
|
|
const output = await next.runCommand(['non-existent'])
|
|
expect(output.stderr).toContain(
|
|
'Invalid project directory provided, no such directory'
|
|
)
|
|
})
|
|
|
|
test('detects command typos', async () => {
|
|
const typos = [
|
|
['buidl', 'build'],
|
|
['buill', 'build'],
|
|
['biild', 'build'],
|
|
['starr', 'start'],
|
|
['dee', 'dev'],
|
|
]
|
|
|
|
for (const typo of typos) {
|
|
const output = await next.runCommand([typo[0]])
|
|
expect(output.stderr).toContain(
|
|
`"next ${typo[0]}" does not exist. Did you mean "next ${typo[1]}"?`
|
|
)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('dev', () => {
|
|
test('--help', async () => {
|
|
const help = await next.runCommand(['dev', '--help'])
|
|
expect(help.stdout).toMatch(/Starts Next.js in development mode/)
|
|
})
|
|
|
|
test('-h', async () => {
|
|
const help = await next.runCommand(['dev', '-h'])
|
|
expect(help.stdout).toMatch(/Starts Next.js in development mode/)
|
|
})
|
|
|
|
test('custom directory', async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--port', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(/- Local:/i)
|
|
})
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('--port', async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--port', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
await retry(() => {
|
|
expect(output).toMatch(
|
|
/Network:\s*http:\/\/[\d]{1,}\.[\d]{1,}\.[\d]{1,}/
|
|
)
|
|
})
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('--port 0', async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--port', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
await retry(() => {
|
|
expect(output).toMatch(
|
|
/Network:\s*http:\/\/[\d]{1,}\.[\d]{1,}\.[\d]{1,}/
|
|
)
|
|
})
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
const matches = /- Local/.exec(output)
|
|
expect(matches).not.toBe(null)
|
|
|
|
const _port = parseInt(matches![1])
|
|
// Regression test: port 0 was interpreted as if no port had been
|
|
// provided, falling back to 3000.
|
|
expect(_port).not.toBe(3000)
|
|
})
|
|
|
|
test('PORT=0', async () => {
|
|
let output = ''
|
|
const { child, exit } = await launchDevServer(['dev', next.testDir], {
|
|
env: {
|
|
PORT: '0',
|
|
},
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
})
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(/- Local:/)
|
|
})
|
|
// without --hostname, do not log Network: xxx
|
|
const matches = /Network:\s*http:\/\/\[::\]:(\d+)/.exec(output)
|
|
const _port = parseInt('' + matches)
|
|
expect(matches).toBe(null)
|
|
// Regression test: port 0 was interpreted as if no port had been
|
|
// provided, falling back to 3000.
|
|
expect(_port).not.toBe(3000)
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test("NODE_OPTIONS='--inspect'", async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
let errOutput = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--port', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
onStderr(msg) {
|
|
errOutput += stripAnsi(msg)
|
|
},
|
|
env: { NODE_OPTIONS: '--inspect' },
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
await retry(() => {
|
|
expect(output).toMatch(/- Debugger port:\s+\d+/)
|
|
})
|
|
await retry(() => {
|
|
expect(errOutput).toMatch(/Debugger listening on/)
|
|
})
|
|
expect(errOutput).not.toContain('address already in use')
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('--inspect', async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
let errOutput = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--port', String(port), '--inspect'],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
onStderr(msg) {
|
|
errOutput += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
await retry(() => {
|
|
expect(output).toMatch(/- Debugger port:\s+9229/)
|
|
})
|
|
await retry(() => {
|
|
expect(errOutput).toMatch(/Debugger listening on/)
|
|
})
|
|
expect(errOutput).not.toContain('address already in use')
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('--inspect 0', async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
let errOutput = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--port', String(port), '--inspect', '0'],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
onStderr(msg) {
|
|
errOutput += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
await retry(() => {
|
|
expect(output).toMatch(/- Debugger port:\s+\d+/)
|
|
})
|
|
await retry(() => {
|
|
expect(errOutput).toMatch(/Debugger listening on/)
|
|
})
|
|
expect(errOutput).not.toContain('address already in use')
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('--inspect [port]', async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
let errOutput = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--port', String(port), '--inspect', '9230'],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
onStderr(msg) {
|
|
errOutput += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
await retry(() => {
|
|
expect(output).toMatch(/- Debugger port:\s+9230/)
|
|
})
|
|
await retry(() => {
|
|
expect(errOutput).toMatch(/Debugger listening on/)
|
|
})
|
|
expect(errOutput).not.toContain('address already in use')
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test("NODE_OPTIONS='--inspect=:0'", async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
let errOutput = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--port', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
onStderr(msg) {
|
|
errOutput += stripAnsi(msg)
|
|
},
|
|
env: { NODE_OPTIONS: '--inspect=:0' },
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
await retry(() => {
|
|
expect(output).toMatch(/- Debugger port:\s+\d+/)
|
|
})
|
|
await retry(() => {
|
|
expect(errOutput).toMatch(/Debugger listening on/)
|
|
})
|
|
expect(errOutput).not.toContain('address already in use')
|
|
expect(errOutput).toContain('Debugger listening on')
|
|
console.log(output)
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test("NODE_OPTIONS='--require=file with spaces to-require-with-node-require-option.js'", async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
let errOutput = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--port', String(port)],
|
|
{
|
|
cwd: next.testDir,
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
onStderr(msg) {
|
|
errOutput += stripAnsi(msg)
|
|
},
|
|
env: {
|
|
NODE_OPTIONS:
|
|
'--require "./file with spaces to-require-with-node-require-option.js"',
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
expect(output).toContain(
|
|
'FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION'
|
|
)
|
|
expect(errOutput).toBe('')
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test("NODE_OPTIONS='--require=file with spaces to --require.js'", async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
let errOutput = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--port', String(port)],
|
|
{
|
|
cwd: next.testDir,
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
onStderr(msg) {
|
|
errOutput += stripAnsi(msg)
|
|
},
|
|
env: {
|
|
NODE_OPTIONS: '--require "./file with spaces to --require.js"',
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
expect(output).toContain(
|
|
'FILE_WITH_SPACES_TO_REQUIRE_WITH_NODE_REQUIRE_OPTION'
|
|
)
|
|
expect(errOutput).toBe('')
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('-p', async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '-p', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
env: { NODE_OPTIONS: '--inspect' },
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('-p conflict', async () => {
|
|
const port = await findPort()
|
|
const { stderr, stdout } = await runAndCaptureOutput({ port })
|
|
|
|
expect(stderr).toMatch('already in use')
|
|
expect(stdout).not.toMatch(/ready/i)
|
|
expect(stdout).not.toMatch('started')
|
|
expect(stdout).not.toMatch(`${port}`)
|
|
expect(stripAnsi(stdout).trim()).toBeFalsy()
|
|
})
|
|
|
|
test('Allow retry if default port is already in use', async () => {
|
|
let output = ''
|
|
let one: { child: ChildProcess; exit: Promise<any> } | undefined
|
|
let two: { child: ChildProcess; exit: Promise<any> } | undefined
|
|
|
|
try {
|
|
one = await launchDevServer(['dev', next.testDir], {})
|
|
two = await launchDevServer(['dev', next.testDir], {
|
|
onStderr(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
})
|
|
} finally {
|
|
if (one) {
|
|
await killApp(one.child).catch(console.error)
|
|
await one.exit.catch(() => {})
|
|
}
|
|
if (two) {
|
|
await killApp(two.child).catch(console.error)
|
|
await two.exit.catch(() => {})
|
|
}
|
|
}
|
|
|
|
// Depending on the environment Next may or may not be able to resolve
|
|
// the process holding the port; accept both wordings.
|
|
expect(output).toMatch(
|
|
/⚠ Port 3000 is in use by (an unknown process|process \d+), using available port \d+ instead\./
|
|
)
|
|
})
|
|
|
|
test('-p reserved', async () => {
|
|
const TCP_MUX_PORT = 1
|
|
// Don't pre-bind a server to port 1 here: Next.js rejects reserved
|
|
// ports during CLI argument parsing, before attempting to bind, so the
|
|
// pre-bind step is unnecessary and would also fail on CI where binding
|
|
// privileged ports (< 1024) requires root.
|
|
let stdout = ''
|
|
let stderr = ''
|
|
await next.runCommand(['dev', next.testDir, '-p', String(TCP_MUX_PORT)], {
|
|
ignoreFail: true,
|
|
onStdout: (msg) => {
|
|
stdout += msg
|
|
},
|
|
onStderr: (msg) => {
|
|
stderr += msg
|
|
},
|
|
})
|
|
|
|
expect(stdout).toMatch('')
|
|
expect(stderr).toMatch(
|
|
`Bad port: "${TCP_MUX_PORT}" is reserved for tcpmux`
|
|
)
|
|
})
|
|
|
|
test('--hostname', async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--hostname', '0.0.0.0', '--port', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(
|
|
new RegExp(`Network:\\s*http://0.0.0.0:${port}`)
|
|
)
|
|
})
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('-H', async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '-H', '0.0.0.0', '--port', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(
|
|
new RegExp(`Network:\\s*http://0.0.0.0:${port}`)
|
|
)
|
|
})
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://localhost:${port}`))
|
|
})
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
itCI('--experimental-https', async () => {
|
|
// only runs on CI as it requires administrator privileges
|
|
const port = await findPort()
|
|
let output = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--experimental-https', '--port', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(/Network:\s*https:\/\//)
|
|
})
|
|
expect(output).toMatch(/Local:\s*https:\/\/localhost:(\d+)/)
|
|
expect(output).toContain('Certificates created in')
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('--experimental-https with provided key/cert', async () => {
|
|
const keyFile = path.resolve(__dirname, 'certificates/localhost-key.pem')
|
|
const certFile = path.resolve(__dirname, 'certificates/localhost.pem')
|
|
const port = await findPort()
|
|
let output = ''
|
|
const { child, exit } = await launchDevServer(
|
|
[
|
|
'dev',
|
|
next.testDir,
|
|
'--experimental-https',
|
|
'--experimental-https-key',
|
|
keyFile,
|
|
'--experimental-https-cert',
|
|
certFile,
|
|
'--port',
|
|
String(port),
|
|
],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(/https:\/\/localhost:(\d+)/)
|
|
})
|
|
} finally {
|
|
await killApp(child)
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('should format IPv6 addresses correctly', async () => {
|
|
const port = await findPort()
|
|
let output = ''
|
|
const { child, exit } = await launchDevServer(
|
|
['dev', next.testDir, '--hostname', '::', '--port', String(port)],
|
|
{
|
|
onStdout(msg) {
|
|
output += stripAnsi(msg)
|
|
},
|
|
}
|
|
)
|
|
try {
|
|
// Only display when hostname is provided
|
|
await retry(() => {
|
|
expect(output).toMatch(
|
|
new RegExp(`Network:\\s*\\http://\\[::\\]:${port}`)
|
|
)
|
|
})
|
|
await retry(() => {
|
|
expect(output).toMatch(new RegExp(`http://\\[::1\\]:${port}`))
|
|
})
|
|
} finally {
|
|
await killApp(child).catch(() => {})
|
|
await exit.catch(() => {})
|
|
}
|
|
})
|
|
|
|
test('should warn when unknown argument provided', async () => {
|
|
const { stderr } = await next.runCommand(['dev', '--random'])
|
|
expect(stderr).toEqual(`error: unknown option '--random'\n`)
|
|
})
|
|
test('should not throw UnhandledPromiseRejectionWarning', async () => {
|
|
const { stderr } = await next.runCommand(['dev', '--random'])
|
|
expect(stderr).not.toContain('UnhandledPromiseRejectionWarning')
|
|
})
|
|
|
|
test('should exit when SIGINT is signalled', async () => {
|
|
const port = await findPort()
|
|
await testExitSignal(
|
|
'SIGINT',
|
|
['dev', next.testDir, '-p', String(port)],
|
|
/- Local:/
|
|
)
|
|
})
|
|
test('should exit when SIGTERM is signalled', async () => {
|
|
const port = await findPort()
|
|
await testExitSignal(
|
|
'SIGTERM',
|
|
['dev', next.testDir, '-p', String(port)],
|
|
/- Local:/
|
|
)
|
|
})
|
|
|
|
test('invalid directory', async () => {
|
|
const output = await next.runCommand(['dev', 'non-existent'])
|
|
expect(output.stderr).toContain(
|
|
'Invalid project directory provided, no such directory'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('export', () => {
|
|
test('--help', async () => {
|
|
const help = await next.runCommand(['export', '--help'])
|
|
expect(help.stderr).toMatch(
|
|
`error: unknown option '--help'\n(Did you mean --help?)`
|
|
)
|
|
expect(help.code).toBe(1)
|
|
})
|
|
|
|
test('run export command', async () => {
|
|
const help = await next.runCommand(['export'])
|
|
expect(help.stderr).toMatch(
|
|
`\`next export\` has been removed in favor of 'output: export' in next.config.js`
|
|
)
|
|
expect(help.code).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('info', () => {
|
|
function matchInfoOutput(stdout, { nextConfigOutput = '.*' } = {}) {
|
|
expect(stdout).toMatch(
|
|
new RegExp(`
|
|
Operating System:
|
|
Platform: .*
|
|
Arch: .*
|
|
Version: .*
|
|
Available memory \\(MB\\): .*
|
|
Available CPU cores: .*
|
|
Binaries:
|
|
Node: .*
|
|
npm: .*
|
|
Yarn: .*
|
|
pnpm: .*
|
|
Relevant Packages:
|
|
next: .*
|
|
eslint-config-next: .*
|
|
react: .*
|
|
react-dom: .*
|
|
typescript: .*${process.env.NEXT_RSPACK ? '\n next-rspack: .*' : ''}
|
|
Next.js Config:
|
|
output: ${nextConfigOutput}
|
|
`)
|
|
)
|
|
}
|
|
|
|
test('--help', async () => {
|
|
const help = await next.runCommand(['info', '--help'])
|
|
expect(help.stdout).toMatch(
|
|
/Prints relevant details about the current system which can be used to report/
|
|
)
|
|
})
|
|
|
|
test('-h', async () => {
|
|
const help = await next.runCommand(['info', '-h'])
|
|
expect(help.stdout).toMatch(
|
|
/Prints relevant details about the current system which can be used to report/
|
|
)
|
|
})
|
|
|
|
test('should print output', async () => {
|
|
const info = await next.runCommand(['info'])
|
|
|
|
expect((info.stderr || '').toLowerCase()).not.toContain('error')
|
|
matchInfoOutput(info.stdout)
|
|
})
|
|
|
|
test('should not leak package manager command errors', async () => {
|
|
const marker = 'next-info-yarn-command-not-found'
|
|
const binDir = await fs.mkdtemp(join(next.testDir, 'next-info-bin-'))
|
|
const pathKey =
|
|
Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ??
|
|
'PATH'
|
|
const yarnPath = join(
|
|
binDir,
|
|
process.platform === 'win32' ? 'yarn.cmd' : 'yarn'
|
|
)
|
|
|
|
try {
|
|
await fs.writeFile(
|
|
yarnPath,
|
|
process.platform === 'win32'
|
|
? `@echo off\r\necho ${marker} 1>&2\r\nexit /b 1\r\n`
|
|
: `#!/bin/sh\necho ${marker} >&2\nexit 1\n`
|
|
)
|
|
if (process.platform !== 'win32') {
|
|
await fs.chmod(yarnPath, 0o755)
|
|
}
|
|
|
|
const info = await next.runCommand(['info'], {
|
|
env: {
|
|
[pathKey]: `${binDir}${path.delimiter}${process.env[pathKey] ?? ''}`,
|
|
// Match issue #97932's npm project so the fake Yarn only exercises
|
|
// binary version detection, not registry discovery.
|
|
npm_config_user_agent: 'npm',
|
|
},
|
|
})
|
|
|
|
expect(info.stdout).toContain('Yarn: N/A')
|
|
expect(info.stderr).not.toContain(marker)
|
|
} finally {
|
|
await fs.rm(binDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('should print output with next.config.mjs', async () => {
|
|
let info = { stdout: '', stderr: '' }
|
|
const originalPkg = await next.readFile('package.json')
|
|
|
|
try {
|
|
await next.patchFile(
|
|
'next.config.mjs',
|
|
`export default { output: 'standalone' }`
|
|
)
|
|
// Merge `type: 'module'` into the existing package.json so we keep
|
|
// the `packageManager` field that nextTestSetup generated. Without
|
|
// it corepack auto-fetches the latest pnpm, which is incompatible
|
|
// with the runner's Node version on CI.
|
|
await next.patchFile(
|
|
'package.json',
|
|
JSON.stringify({ ...JSON.parse(originalPkg), type: 'module' })
|
|
)
|
|
info = await next.runCommand(['info'], {
|
|
cwd: next.testDir,
|
|
})
|
|
} finally {
|
|
await next.deleteFile('next.config.mjs')
|
|
await next.patchFile('package.json', originalPkg)
|
|
}
|
|
|
|
expect((info.stderr || '').toLowerCase()).not.toContain('error')
|
|
matchInfoOutput(info.stdout, { nextConfigOutput: 'standalone' })
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('CLI Usage: duplicate sass dependencies', () => {
|
|
const { next, isNextStart, skipped } = nextTestSetup({
|
|
files: join(__dirname, 'duplicate-sass'),
|
|
skipStart: true,
|
|
dependencies: reactDependencies,
|
|
skipDeployment: true,
|
|
})
|
|
if (skipped) return
|
|
|
|
// The original integration test relied on pre-existing fake `sass` and
|
|
// `node-sass` modules in `duplicate-sass/node_modules/`. In e2e mode the
|
|
// isolated `pnpm install` moves any pre-existing `node_modules` to
|
|
// `.ignored`, so we recreate the fake modules and reference them in
|
|
// `package.json` after install, before running `next dev`.
|
|
beforeAll(async () => {
|
|
if (!isNextStart) return
|
|
const pkg = await next.readJSON('package.json')
|
|
pkg.dependencies = {
|
|
...pkg.dependencies,
|
|
sass: '1.0.0',
|
|
'node-sass': '1.0.0',
|
|
}
|
|
await next.patchFile('package.json', JSON.stringify(pkg, null, 2))
|
|
for (const name of ['sass', 'node-sass']) {
|
|
await next.patchFile(
|
|
`node_modules/${name}/package.json`,
|
|
JSON.stringify({ name, version: '1.0.0' })
|
|
)
|
|
await next.patchFile(
|
|
`node_modules/${name}/index.js`,
|
|
'module.exports = {}\n'
|
|
)
|
|
}
|
|
})
|
|
;(isNextStart ? test : test.skip)('duplicate sass deps', async () => {
|
|
const port = await findPort()
|
|
|
|
let output = ''
|
|
let child: ChildProcess | undefined
|
|
const exit = next
|
|
.runCommand(['dev', next.testDir, '-p', String(port)], {
|
|
onStdout(msg) {
|
|
output += msg
|
|
},
|
|
onStderr(msg) {
|
|
output += msg
|
|
},
|
|
instance: (p) => {
|
|
child = p
|
|
},
|
|
})
|
|
.catch(() => {})
|
|
|
|
try {
|
|
await retry(() => {
|
|
expect(output).toMatch(/both `sass` and `node-sass` installed/)
|
|
})
|
|
} finally {
|
|
if (child) {
|
|
await killApp(child).catch(() => {})
|
|
}
|
|
await exit
|
|
}
|
|
})
|
|
})
|