|
| 1 | +const assert = require('node:assert/strict') |
| 2 | +const { spawn, execFileSync } = require('node:child_process') |
| 3 | +const { once } = require('node:events') |
| 4 | +const net = require('node:net') |
| 5 | +const path = require('node:path') |
| 6 | +const { setTimeout: delay } = require('node:timers/promises') |
| 7 | +const { test } = require('node:test') |
| 8 | + |
| 9 | +const feRoot = path.resolve(__dirname, '..') |
| 10 | +const ports = [[5173, 'localhost'], [7788, '127.0.0.1']] |
| 11 | + |
| 12 | +async function bind(port, host) { |
| 13 | + const server = net.createServer() |
| 14 | + server.listen(port, host) |
| 15 | + await once(server, 'listening') |
| 16 | + return server |
| 17 | +} |
| 18 | + |
| 19 | +async function portsAreFree(entries = ports) { |
| 20 | + const held = [] |
| 21 | + try { |
| 22 | + for (const [port, host] of entries) held.push(await bind(port, host)) |
| 23 | + return true |
| 24 | + } |
| 25 | + catch { return false } |
| 26 | + finally { for (const server of held) server.close() } |
| 27 | +} |
| 28 | + |
| 29 | +async function until(check, message, timeout = 15000) { |
| 30 | + const deadline = Date.now() + timeout |
| 31 | + while (!await check()) { |
| 32 | + assert.ok(Date.now() < deadline, message()) |
| 33 | + await delay(25) |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +function descendants(pid) { |
| 38 | + const rows = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8' }) |
| 39 | + .trim().split('\n').map(line => line.trim().split(/\s+/).map(Number)) |
| 40 | + const found = new Set([pid]) |
| 41 | + let changed = true |
| 42 | + while (changed) { |
| 43 | + changed = false |
| 44 | + for (const [child, parent] of rows) { |
| 45 | + if (found.has(parent) && !found.has(child)) { found.add(child); changed = true } |
| 46 | + } |
| 47 | + } |
| 48 | + return [...found] |
| 49 | +} |
| 50 | + |
| 51 | +function signal(pid, name) { |
| 52 | + try { process.kill(pid, name) } |
| 53 | + catch (error) { if (error.code !== 'ESRCH') throw error } |
| 54 | +} |
| 55 | + |
| 56 | +function launch(script = 'dev') { |
| 57 | + const pnpm = process.env.npm_execpath || 'pnpm' |
| 58 | + const nodeEntry = /\.[cm]?js$/.test(pnpm) |
| 59 | + const child = spawn(nodeEntry ? process.execPath : pnpm, |
| 60 | + nodeEntry ? [pnpm, 'run', script] : ['run', script], { |
| 61 | + cwd: feRoot, detached: true, env: { ...process.env, BROWSER: 'none', NO_COLOR: '1' }, |
| 62 | + stdio: ['ignore', 'pipe', 'pipe'], |
| 63 | + }) |
| 64 | + let output = '' |
| 65 | + let exited = false |
| 66 | + let code |
| 67 | + let owned = [] |
| 68 | + child.stdout.on('data', data => { output += data }) |
| 69 | + child.stderr.on('data', data => { output += data }) |
| 70 | + child.on('exit', value => { exited = true; code = value }) |
| 71 | + child.on('error', error => { output += error.message; exited = true; code = -1 }) |
| 72 | + return { |
| 73 | + async ready() { |
| 74 | + await until(() => exited || (output.includes('Local:') && output.includes('Dimina proxy is listening')), |
| 75 | + () => `Development services did not start:\n${output}`) |
| 76 | + assert.equal(exited, false, output) |
| 77 | + owned = descendants(child.pid) |
| 78 | + }, |
| 79 | + async stop(name) { |
| 80 | + signal(-child.pid, name) |
| 81 | + await until(() => exited, () => `Development command did not exit:\n${output}`) |
| 82 | + await until(portsAreFree, () => `Development services retained their ports after ${name}:\n${output}`, 3000) |
| 83 | + }, |
| 84 | + async fails() { |
| 85 | + await until(() => { |
| 86 | + if (!exited && child.pid) owned = [...new Set([...owned, ...descendants(child.pid)])] |
| 87 | + assert.doesNotMatch(output, /trying another one/) |
| 88 | + return exited |
| 89 | + }, () => `Conflicting development server did not exit:\n${output}`) |
| 90 | + assert.notEqual(code, 0, output) |
| 91 | + assert.match(output, /Port 5173 is already in use/) |
| 92 | + assert.doesNotMatch(output, /trying another one/) |
| 93 | + }, |
| 94 | + cleanup() { |
| 95 | + // Only this test's recorded process tree, never unrelated port owners. |
| 96 | + for (const pid of owned.reverse()) signal(pid, 'SIGTERM') |
| 97 | + if (!exited && child.pid) signal(-child.pid, 'SIGTERM') |
| 98 | + child.stdout.destroy() |
| 99 | + child.stderr.destroy() |
| 100 | + }, |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +test('development services release their ports and reject conflicting launches', { |
| 105 | + skip: process.platform === 'win32' ? 'POSIX process group signals' : false, |
| 106 | + timeout: 60000, |
| 107 | +}, async (t) => { |
| 108 | + if (!await portsAreFree()) { t.skip('Development ports are already occupied'); return } |
| 109 | + for (const name of ['SIGINT', 'SIGTERM']) { |
| 110 | + await t.test(`pnpm dev can stop with ${name} and restart`, async () => { |
| 111 | + const app = launch() |
| 112 | + try { await app.ready(); await app.stop(name) } |
| 113 | + finally { app.cleanup() } |
| 114 | + }) |
| 115 | + } |
| 116 | + await t.test('a Vite port conflict also shuts down the proxy', async () => { |
| 117 | + const blocker = await bind(5173, 'localhost') |
| 118 | + const app = launch('dev:services') |
| 119 | + try { |
| 120 | + await app.fails() |
| 121 | + await until(() => portsAreFree([ports[1]]), () => 'The proxy outlived the failed Vite startup', 3000) |
| 122 | + } |
| 123 | + finally { app.cleanup(); await new Promise(resolve => blocker.close(resolve)) } |
| 124 | + }) |
| 125 | +}) |
0 commit comments