Files
Tim NeutkensandGitHub 8141dcf12e Convert test/integration to isolated tests (#93247)
### What?

Converts every test under `test/integration/` to an isolated test
running through `nextTestSetup` (under `test/e2e/`, `test/production/`,
`test/development/`, or `test/unit/`), then deletes `test/integration/`
along with the legacy CI orchestration that was specific to it.

- `test/integration/` removed entirely (~327 test suites)
- New isolated suites added across the existing folders:
  - `test/e2e/` — 175
  - `test/production/` — 130
  - `test/development/` — 43
  - `test/unit/` — 1
- `.github/workflows/build_and_test.yml` and `run-tests.js` no longer
have any `integration` branches
- `nextTestSetup` gained a `baseUrl` option on `next.browser()` so a
small number of tests that drive their own proxy/static-export server
can keep using `next.browser(...)` instead of importing `next-webdriver`
directly

### Why?

`test/integration/` predated `nextTestSetup` and ran tests directly
against the source checkout via custom helpers (`launchApp`,
`nextBuild`, `nextStart`, `runNextCommand`, `webdriver`, `fetchViaHTTP`,
…). Each suite hand-rolled its own dev/start/build orchestration,
fixture mutation, and process management.

The isolated test model used by the rest of the repo gives each suite an
isolated working directory containing a packed `next.tgz` install, a
uniform `next.start()` / `next.build()` / `next.fetch()` /
`next.browser()` API, and the same lifecycle for dev, start, and deploy
modes — so a single set of assertions covers all three. Deploy-mode
skips and per-feature gates are expressed declaratively
(`skipDeployment`, `disableAutoSkewProtection`, `if (skipped) return`)
instead of branching on `process.env`.

Removing `test/integration/` lets us:

- Delete the bespoke orchestration code in the CI workflow and
`run-tests.js`
- Run every converted suite consistently in dev, start, and deploy modes
(where applicable)
- Reproduce every test locally with the same `pnpm
test-{dev,start}-{turbo,webpack}` commands; no separate `integration`
path
- Open the door to running `test/production` against deployments in the
future (the converted suites already declare `skipDeployment` so they
can be flipped on)

### How?

Mechanical conversion per suite, with targeted clean-ups:

1. **Per-suite conversion.** Each
`test/integration/<name>/test/index.test.{js,ts}` was rewritten into a
single `<name>.test.ts` under the right folder based on what the
original exercised:
   - `launchApp` / dev-only assertions → `test/development/`
- `nextBuild` + `nextStart` / start-only assertions → `test/production/`
   - Both → `test/e2e/`
- The one pure jsdom render check (`link-without-router`) → `test/unit/`

2. **API mapping.** Custom helpers were replaced by `nextTestSetup`
equivalents: `launchApp` → `next.start()`, `nextBuild` → `next.build()`,
`runNextCommand` → `next.runCommand`, `fetchViaHTTP` → `next.fetch`,
`webdriver(...)` → `next.browser(...)`. Fixture mutations switched from
raw `fs.writeFile`/`fs.rename` to `next.patchFile` (with the 3-arg
`runWithTempContent` callback when the change has a defined scope) and
`next.deleteFile`.

3. **Deploy-mode handling.** Suites that can't run in deploy mode (use
`patchFile` / `next.build()` / depend on local CLI output) declare
`skipDeployment: true` and early-return on the `skipped` boolean. Suites
where Vercel's edge mutates URLs (`&dpl=`, immutable assets) declare
`disableAutoSkewProtection: true`.

4. **`next.browser({ baseUrl })`.** A handful of tests
(`prerender-export`, `cdn-cache-busting`, `preload-viewport`, both
`react-virtualized` suites) need to drive a separate server (a
static-export server or an `http-proxy` instance) rather than the
Next.js process. Instead of importing `next-webdriver` directly, those
tests now pass `{ baseUrl: <port|url> }` to `next.browser()`. For the
proxy cases, the proxy was moved into `server.js` inside the fixture and
`http-proxy` declared via the `dependencies` option of `nextTestSetup`,
so the test runs with a fully isolated dependency graph.

5. **CI clean-up.** With `test/integration` gone, the `test
integration*` jobs and `integration-tests-manifest`-related logic in
`.github/workflows/build_and_test.yml` were removed, and `run-tests.js`
no longer has the `integration` test-folder branch.

6. **Validation.** The PR was iterated against multiple full CI runs;
the remaining failures on the latest run are pre-existing flakes
(segment-cache 60s `act` timeouts in turbopack-prod) or transient
infrastructure issues unrelated to the conversion.
2026-05-11 13:55:47 +02:00

1250 lines
36 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 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 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
}
})
})