import { fileURLToPath } from 'url'
import { dirname } from 'path'
import { spawn, spawnSync } from 'child_process'
import { createServer } from 'node:http'
import httpProxy from 'http-proxy'
import process from 'node:process'
const dir = dirname(fileURLToPath(import.meta.url))
function getEnv(id, mode) {
const base = {
...process.env,
DIST_DIR: id,
// Required so the build includes the __NEXT_HYDRATED callback used by
// webdriver() to detect hydration. Without this, the hydration check
// always times out and tests may interact with the page before the
// React app router is initialized.
__NEXT_TEST_MODE: 'e2e',
}
if (mode === 'BUILD_ID') {
return {
...base,
NEXT_PUBLIC_BUILD_ID: id,
}
} else if (mode === 'DEPLOYMENT_ID') {
return {
...base,
NEXT_DEPLOYMENT_ID: id,
}
} else {
throw new Error('invalid mode ' + mode)
}
}
async function spawnNext(id, mode, port, extraEnv = {}) {
const child = spawn('pnpm', ['next', 'start', '-p', port, dir], {
env: {
...getEnv(id, mode),
...extraEnv,
},
stdio: ['inherit', 'pipe', 'inherit'],
})
child.stdout.pipe(process.stdout)
// Wait until the server is listening.
return new Promise((resolve, reject) => {
child.stdout.on('data', (data) => {
if (data.toString().includes('Ready')) {
resolve(child)
}
})
child.on('exit', (code) => {
if (code === 0) {
resolve(child)
} else {
reject(new Error(`Next.js server exited with code ${code}`))
}
})
})
}
export function buildNext(id, mode) {
spawnSync('pnpm', ['next', 'build', dir], {
env: getEnv(id, mode),
stdio: 'inherit',
})
}
export function build(mode) {
buildNext('1', mode)
buildNext('2', mode)
}
export async function start(
mainPort = 3000,
nextPort1 = mainPort + 1,
nextPort2 = mainPort + 2,
mode = 'BUILD_ID'
) {
const extraEnv = {
TEST_PROXY_ORIGIN: `http://localhost:${mainPort}`,
}
// Start two different Next.js servers, one with BUILD_ID=1 and one
// with BUILD_ID=2
const [next1, next2] = await Promise.all([
spawnNext('1', mode, nextPort1, extraEnv),
spawnNext('2', mode, nextPort2, extraEnv),
])
// Create a proxy server. If search params include `deployment=2`, proxy to
// to the second next server. Otherwise, proxy to the first.
const proxy = httpProxy.createProxyServer()
// Simulate deployment skew for deployment ID-based action responses. When a
// POST (server action) is handled by deployment 1, inject a foreign
// x-nextjs-deployment-id header so the client detects a mismatch and
// triggers an MPA navigation. BUILD_ID mode intentionally skips this so the
// test exercises the response.b fallback instead.
proxy.on('proxyRes', (proxyRes, req) => {
if (mode === 'DEPLOYMENT_ID' && req.method === 'POST') {
proxyRes.headers['x-nextjs-deployment-id'] = 'foreign-deployment'
}
})
const server = createServer((req, res) => {
let port = nextPort1
if (req.url) {
const searchParams = new URL(req.url, 'http://localhost').searchParams
if (searchParams.get('deployment') === '2') {
port = nextPort2
}
}
proxy.web(req, res, { target: `http://localhost:${port}` })
})
const onTerminate = () => {
server.close()
next1.kill()
next2.kill()
process.exit(0)
}
process.on('SIGINT', onTerminate)
process.on('SIGTERM', onTerminate)
const cleanup = async () => {
next1.kill()
next2.kill()
await new Promise((resolve) => server.close(resolve))
}
return new Promise((resolve, reject) => {
server.on('error', reject)
server.listen(mainPort, () => {
resolve(cleanup)
})
})
}