test: add reusable e2e launcher smoke framework and scripts

This commit is contained in:
digua
2026-04-06 17:39:00 +08:00
committed by digua
parent b532eeef42
commit a8c3b032a7
7 changed files with 388 additions and 161 deletions
+160 -160
View File
@@ -6,92 +6,148 @@
* 支持 TEST_MODE 绕过单实例锁,允许并行运行多个实例
*/
const { spawn } = require('child_process')
const path = require('path')
const fs = require('fs')
const os = require('os')
const { spawn } = require('node:child_process')
const path = require('node:path')
const fs = require('node:fs')
const os = require('node:os')
const net = require('node:net')
/**
* 查找可用的 TCP 端口,并保持预留直到进程启动
*
* 问题修复:
* 1. 原代码递归时使用 null + 1 = 1,应该使用 startPort + 1
* 2. 添加最大重试限制,避免无限递归
* 3. 改进错误处理和超时逻辑
* 4. 返回保留的服务器和端口,避免 TOCTTOU 竞态
*/
async function findAvailablePortWithReservation(startPort = 9222, maxRetries = 100, currentRetry = 0) {
const net = require('net')
const DEFAULT_START_PORT = 9222
const DEFAULT_MAX_PORT_RETRIES = 100
const DEFAULT_PORT_PROBE_TIMEOUT_MS = 100
const DEFAULT_STARTUP_WAIT_MS = 2000
const DEFAULT_FORCE_KILL_TIMEOUT_MS = 5000
function safeCloseServer(server) {
if (!server) return
try {
server.close()
} catch {
// ignore close errors for probing server
}
}
async function findAvailablePortWithReservation(
startPort = DEFAULT_START_PORT,
maxRetries = DEFAULT_MAX_PORT_RETRIES,
currentRetry = 0,
options = {}
) {
const createServer = options.createServer || net.createServer
const listenTimeoutMs = options.listenTimeoutMs ?? DEFAULT_PORT_PROBE_TIMEOUT_MS
const lastErrorCode = options.lastErrorCode
// 最大重试次数检查
if (currentRetry >= maxRetries) {
const errorSuffix = lastErrorCode ? ` Last error: ${lastErrorCode}.` : ''
throw new Error(
`Unable to find available port after ${maxRetries} attempts (tried ports ${startPort}-${startPort + maxRetries - 1})`
`Unable to find available port after ${maxRetries} attempts (tried ports ${startPort}-${startPort + maxRetries - 1}).${errorSuffix}`
)
}
const port = startPort + currentRetry
return new Promise((resolve) => {
const server = net.createServer()
const result = await new Promise((resolve) => {
const server = createServer()
let completed = false
let timer = null
const cleanup = () => {
if (!completed) {
completed = true
// 确保 server 被正确关闭
if (!server.closed) {
server.close()
}
const done = (value) => {
if (completed) return
completed = true
if (timer) clearTimeout(timer)
resolve(value)
}
server.on('error', (error) => {
safeCloseServer(server)
done({ ok: false, errorCode: error?.code || 'UNKNOWN' })
})
server.listen(port, () => {
done({ ok: true, port, reservationServer: server })
})
timer = setTimeout(() => {
safeCloseServer(server)
done({ ok: false, errorCode: 'TIMEOUT' })
}, listenTimeoutMs)
})
if (result.ok) {
return { port: result.port, reservationServer: result.reservationServer }
}
return findAvailablePortWithReservation(startPort, maxRetries, currentRetry + 1, {
...options,
lastErrorCode: result.errorCode,
})
}
async function terminateProcess(proc, { forceKillTimeoutMs = DEFAULT_FORCE_KILL_TIMEOUT_MS } = {}) {
if (!proc) return
if (proc.exitCode !== null || proc.signalCode !== null) {
return
}
await new Promise((resolve) => {
let resolved = false
let forceKillTimer = null
const exitHandler = () => {
if (!resolved) {
resolved = true
if (forceKillTimer) clearTimeout(forceKillTimer)
resolve()
}
}
// 端口可用:成功监听,保持预留直到使用
server.listen(port, () => {
if (!completed) {
completed = true
// 返回保留的服务器和端口,调用方负责在启动进程后关闭
resolve({ port, reservationServer: server })
}
})
proc.once('exit', exitHandler)
proc.kill('SIGTERM')
// 端口被占用或其他错误:标记失败
server.on('error', () => {
cleanup()
resolve(null)
})
// 超时保护:100ms 未响应视为超时
setTimeout(() => {
if (!completed) {
completed = true
// 确保 server 被正确关闭
if (!server.closed) {
server.close()
forceKillTimer = setTimeout(() => {
if (!resolved) {
try {
proc.kill(0)
proc.kill('SIGKILL')
} catch {
// process already exited
}
resolve(null)
}
}, 100)
}).then((result) => {
// 找到可用端口,返回保留结果
if (result) return result
// 未找到,继续尝试下一个端口
return findAvailablePortWithReservation(startPort, maxRetries, currentRetry + 1)
if (!resolved) {
resolved = true
resolve()
}
}, forceKillTimeoutMs)
})
}
function releaseReservation(reservationServer) {
safeCloseServer(reservationServer)
}
/**
* 启动 Electron 应用
*/
async function launchApp(options = {}) {
async function launchApp(options = {}, deps = {}) {
const spawnFn = deps.spawnFn || spawn
const findPortFn = deps.findPortFn || findAvailablePortWithReservation
const sleepFn = deps.sleepFn || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
const fsImpl = deps.fsImpl || fs
let reservationServer = null
let port = options.port
const startPort = options.startPort ?? DEFAULT_START_PORT
const maxPortRetries = options.maxPortRetries ?? DEFAULT_MAX_PORT_RETRIES
const portProbeTimeoutMs = options.portProbeTimeoutMs ?? DEFAULT_PORT_PROBE_TIMEOUT_MS
const startupWaitTime = options.startupWaitTime ?? DEFAULT_STARTUP_WAIT_MS
const forceKillTimeoutMs = options.forceKillTimeoutMs ?? DEFAULT_FORCE_KILL_TIMEOUT_MS
if (!port) {
// 查找可用端口并保持预留,防止 TOCTTOU 竞态
// 两个并行启动不会发现相同的端口
const reservation = await findAvailablePortWithReservation(9222)
const reservation = await findPortFn(startPort, maxPortRetries, 0, {
listenTimeoutMs: portProbeTimeoutMs,
})
if (!reservation) {
throw new Error('[AppLauncher] 无法找到可用端口')
}
@@ -99,84 +155,67 @@ async function launchApp(options = {}) {
reservationServer = reservation.reservationServer
}
// 为并行 E2E 实例创建独立的用户数据目录,避免共享造成的冲突
// 这防止了并发进程的状态泄漏、死锁和数据库冲突
const userDataDir = options.userDataDir || (process.env.CHATLAB_E2E_USER_DATA_DIR ?
path.join(process.env.CHATLAB_E2E_USER_DATA_DIR, `instance-${port}`) :
path.join(os.tmpdir(), `chatlab-e2e-${port}`)
)
const userDataDir =
options.userDataDir ||
(process.env.CHATLAB_E2E_USER_DATA_DIR
? path.join(process.env.CHATLAB_E2E_USER_DATA_DIR, `instance-${port}`)
: path.join(os.tmpdir(), `chatlab-e2e-${port}`))
// 确保用户数据目录存在
if (!fs.existsSync(userDataDir)) {
fs.mkdirSync(userDataDir, { recursive: true })
if (!fsImpl.existsSync(userDataDir)) {
fsImpl.mkdirSync(userDataDir, { recursive: true })
}
const appPath = path.resolve(__dirname, '../../..')
// 验证应用目录存在
if (!fs.existsSync(appPath)) {
if (!fsImpl.existsSync(appPath)) {
throw new Error(`[AppLauncher] 应用目录不存在: ${appPath}`)
}
let electronExe
if (process.platform === 'win32') {
electronExe = path.resolve(appPath, 'node_modules/.bin/electron.cmd')
} else {
electronExe = path.resolve(appPath, 'node_modules/.bin/electron')
}
const electronExe =
process.platform === 'win32'
? path.resolve(appPath, 'node_modules/.bin/electron.cmd')
: path.resolve(appPath, 'node_modules/.bin/electron')
if (!fs.existsSync(electronExe)) {
if (!fsImpl.existsSync(electronExe)) {
throw new Error(`Electron 可执行文件不存在: ${electronExe}`)
}
console.log(`[AppLauncher] 启动 ElectronCDP 端口: ${port}`)
// 构建 Electron 启动参数
// 重要:必须使用 --remote-debugging-port 命令行参数,而不是环境变量
// Electron 不会读取 REMOTE_DEBUGGING_PORT 环境变量
const electronArgs = [
`--remote-debugging-port=${port}`, // 启用 CDP 调试端口
appPath, // 应用路径作为最后的参数
]
const electronArgs = [`--remote-debugging-port=${port}`, appPath]
const proc = spawn(electronExe, electronArgs, {
stdio: 'inherit',
env: {
...process.env,
TEST_MODE: 'true', // E2E 测试模式:允许多个实例
CHATLAB_E2E_USER_DATA_DIR: userDataDir, // 为该实例设置隔离的用户数据目录
ELECTRON_ENABLE_LOGGING: '1',
},
})
let proc
let launchError = null
let exitCode = null
let exitSignal = null
// 进程启动后,立即释放端口预留
// 这样 Electron 可以绑定 --remote-debugging-port,避免其他进程抢占
if (reservationServer) {
reservationServer.close()
try {
proc = spawnFn(electronExe, electronArgs, {
stdio: 'inherit',
env: {
...process.env,
TEST_MODE: 'true',
CHATLAB_E2E_USER_DATA_DIR: userDataDir,
ELECTRON_ENABLE_LOGGING: '1',
},
})
} finally {
releaseReservation(reservationServer)
reservationServer = null
}
// 处理进程启动失败
if (proc.exitCode !== null && proc.exitCode !== 0) {
throw new Error(`[AppLauncher] Electron 启动失败,退出码: ${proc.exitCode}`)
}
// 监听进程错误事件
let launchError = null
let exitCode = null
proc.on('error', (error) => {
console.error(`[AppLauncher] Electron 进程错误:`, error.message)
launchError = error
console.error('[AppLauncher] Electron 进程错误:', error.message)
})
// 监听启动期间的进程退出
// Node.js exit 事件有两个参数:code 和 signal
// - code: null 当进程被信号杀死时;否则是数字退出码
// - signal: 信号名称(如 'SIGKILL');正常退出时为 null
let exitSignal = null
proc.on('exit', (code, signal) => {
exitCode = code
exitSignal = signal
if (code !== null && code !== 0) {
console.error(`[AppLauncher] Electron 进程异常退出,退出码: ${code}`)
}
@@ -185,21 +224,17 @@ async function launchApp(options = {}) {
}
})
// 等待应用就绪
// 注:这个延迟需要等应用真正启动完成,避免立即测试导致测试失败
// TODO: 可以改进为监听应用就绪事件而不是固定延迟
const startupWaitTime = options.startupWaitTime || 2000
await new Promise((resolve) => setTimeout(resolve, startupWaitTime))
await sleepFn(startupWaitTime)
// 检查启动过程中是否出现错误
if (launchError) {
await terminateProcess(proc, { forceKillTimeoutMs })
throw new Error(`[AppLauncher] Electron 启动期间发生错误: ${launchError.message}`)
}
// 检查启动期间是否有非零退出或信号终止
if (exitCode !== null && exitCode !== 0) {
throw new Error(`[AppLauncher] Electron 启动期间异常退出,退出码: ${exitCode}`)
}
if (exitSignal !== null) {
throw new Error(`[AppLauncher] Electron 启动期间被信号杀死: ${exitSignal}`)
}
@@ -210,56 +245,21 @@ async function launchApp(options = {}) {
async close() {
console.log(`[AppLauncher] 关闭应用 (PID: ${proc.pid})`)
// 检查进程是否已经退出(自行退出或被杀死)
// proc.killed 只在我们主动 kill 时为 true,不包括自行退出的情况
// Node.js ChildProcess 使用 signalCode(不是 signalDescription)表示信号退出
if (proc.exitCode !== null || proc.signalCode !== null) {
// 进程已退出,直接返回
console.log(`[AppLauncher] 应用已退出 (exit code: ${proc.exitCode}, signal: ${proc.signalCode})`)
return
}
return new Promise((resolve) => {
let resolved = false
const exitHandler = () => {
if (!resolved) {
resolved = true
clearTimeout(forceKillTimer)
resolve()
}
}
// 监听进程退出事件
proc.once('exit', exitHandler)
// 发送 SIGTERM 信号要求进程正常终止
proc.kill('SIGTERM')
// 强制杀死超时:5秒后强制 SIGKILL
// 防止僵尸进程,确保测试能够顺利清理
// 注:使用活力检查而不是 proc.killed,因为 proc.killed 在 SIGTERM 后立即变为 true
// 但进程可能还未实际退出,需要检查进程是否真的存在
const forceKillTimer = setTimeout(() => {
if (!resolved) {
// 尝试杀死进程:检查进程是否真的还在运行
// 如果进程已退出,kill() 会抛出错误,我们忽略它
try {
proc.kill(0) // 检查进程是否存在(发送信号 0 不会真的杀死)
// 进程存在,发送 SIGKILL
proc.kill('SIGKILL')
} catch (err) {
// 进程不存在,已正常退出
}
}
// 5秒后必须 resolve,防止永久挂起
if (!resolved) {
resolved = true
resolve()
}
}, 5000)
})
await terminateProcess(proc, { forceKillTimeoutMs })
},
}
}
module.exports = { launchApp }
module.exports = {
launchApp,
__test__: {
findAvailablePortWithReservation,
terminateProcess,
releaseReservation,
},
}
+140
View File
@@ -0,0 +1,140 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
const { launchApp, __test__ } = require('./app-launcher')
function createFakeServerFactory(plan) {
let index = 0
const state = {
listenPorts: [],
closeCount: 0,
}
function createServer() {
const behavior = plan[index++] || { type: 'success' }
const handlers = {}
return {
on(event, handler) {
handlers[event] = handler
},
listen(port, callback) {
state.listenPorts.push(port)
setImmediate(() => {
if (behavior.type === 'success') {
callback()
return
}
if (handlers.error) {
handlers.error({ code: behavior.code || 'EADDRINUSE' })
}
})
},
close() {
state.closeCount += 1
},
}
}
return { createServer, state }
}
test('findAvailablePortWithReservation 会重试并返回可用端口', async () => {
const { createServer, state } = createFakeServerFactory([{ type: 'error', code: 'EADDRINUSE' }, { type: 'success' }])
const reservation = await __test__.findAvailablePortWithReservation(9222, 5, 0, {
createServer,
listenTimeoutMs: 20,
})
assert.equal(reservation.port, 9223)
assert.deepEqual(state.listenPorts, [9222, 9223])
__test__.releaseReservation(reservation.reservationServer)
assert.equal(state.closeCount, 2)
})
test('findAvailablePortWithReservation 达到最大重试会抛错', async () => {
const { createServer } = createFakeServerFactory([
{ type: 'error', code: 'EADDRINUSE' },
{ type: 'error', code: 'EADDRINUSE' },
])
await assert.rejects(
() =>
__test__.findAvailablePortWithReservation(9300, 2, 0, {
createServer,
listenTimeoutMs: 20,
}),
/Unable to find available port/
)
})
test('launchApp 支持 startPort 并正确注入 TEST_MODE 环境', async () => {
const captured = {
startPort: null,
spawnCmd: null,
spawnArgs: null,
spawnEnv: null,
reservationClosed: false,
killSignals: [],
}
const proc = new EventEmitter()
proc.pid = 34567
proc.exitCode = null
proc.signalCode = null
proc.kill = (signal) => {
captured.killSignals.push(signal)
if (signal === 'SIGTERM') {
proc.exitCode = 0
proc.emit('exit', 0, null)
}
return true
}
const app = await launchApp(
{
startPort: 9900,
startupWaitTime: 1,
forceKillTimeoutMs: 100,
},
{
fsImpl: {
existsSync: () => true,
mkdirSync: () => {},
},
sleepFn: async () => {},
findPortFn: async (startPort) => {
captured.startPort = startPort
return {
port: 9901,
reservationServer: {
close: () => {
captured.reservationClosed = true
},
},
}
},
spawnFn: (cmd, args, options) => {
captured.spawnCmd = cmd
captured.spawnArgs = args
captured.spawnEnv = options.env
return proc
},
}
)
assert.equal(captured.startPort, 9900)
assert.equal(captured.reservationClosed, true)
assert.match(captured.spawnCmd, /electron(\.cmd)?$/)
assert.deepEqual(captured.spawnArgs, ['--remote-debugging-port=9901', captured.spawnArgs[1]])
assert.equal(captured.spawnEnv.TEST_MODE, 'true')
assert.match(captured.spawnEnv.CHATLAB_E2E_USER_DATA_DIR, /chatlab-e2e-9901$/)
await app.close()
assert.deepEqual(captured.killSignals, ['SIGTERM'])
})
+16
View File
@@ -0,0 +1,16 @@
'use strict'
const { spawnSync } = require('node:child_process')
process.env.CHATLAB_RUN_E2E_SMOKE = '1'
const result = spawnSync(process.execPath, ['--test', 'tests/e2e/smoke/app-launcher.smoke.test.js'], {
stdio: 'inherit',
env: process.env,
})
if (typeof result.status === 'number') {
process.exit(result.status)
}
process.exit(1)
@@ -0,0 +1,59 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { launchApp } = require('../helpers/app-launcher')
const shouldRunSmoke = process.env.CHATLAB_RUN_E2E_SMOKE === '1'
async function waitForCdpReady(port, timeoutMs = 15000) {
const start = Date.now()
const endpoint = `http://127.0.0.1:${port}/json/version`
let lastError = null
while (Date.now() - start < timeoutMs) {
try {
const response = await fetch(endpoint)
if (response.ok) {
const payload = await response.json()
if (payload && payload.webSocketDebuggerUrl) {
return payload
}
}
} catch (error) {
lastError = error
}
await new Promise((resolve) => setTimeout(resolve, 200))
}
const suffix = lastError ? ` Last error: ${lastError.message}` : ''
throw new Error(`CDP endpoint not ready within ${timeoutMs}ms.${suffix}`)
}
test('E2E smoke: launchApp 可以真实拉起 Electron 并连接 CDP', { skip: !shouldRunSmoke }, async () => {
let app = null
try {
app = await launchApp({
startPort: 9222,
startupWaitTime: 3000,
})
const cdp = await waitForCdpReady(app.port)
assert.ok(cdp.webSocketDebuggerUrl)
// Electron 的 CDP /json/version 在不同版本下 Browser 字段可能是 Chrome/*。
// 因此这里接受两种特征之一:
// 1) Browser 为 Chrome/*Chromium 内核)
// 2) User-Agent 含 Electron(部分版本会暴露)
const browser = String(cdp.Browser || '')
const userAgent = String(cdp['User-Agent'] || '')
const isElectronCdp = /chrome/i.test(browser) || /electron/i.test(userAgent)
assert.ok(isElectronCdp, `Unexpected CDP identity. Browser=${browser}; User-Agent=${userAgent}`)
} finally {
if (app) {
await app.close()
}
}
})