'use strict' /** * Electron 应用启动器 * 通过 CDP 端口启动 Electron 实例以供 E2E 测试使用 * 支持 TEST_MODE 绕过单实例锁,允许并行运行多个实例 */ 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') 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}).${errorSuffix}` ) } const port = startPort + currentRetry const result = await new Promise((resolve) => { const server = createServer() let completed = false let timer = null 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() } } proc.once('exit', exitHandler) proc.kill('SIGTERM') forceKillTimer = setTimeout(() => { if (!resolved) { try { proc.kill(0) proc.kill('SIGKILL') } catch { // process already exited } } if (!resolved) { resolved = true resolve() } }, forceKillTimeoutMs) }) } function releaseReservation(reservationServer) { safeCloseServer(reservationServer) } /** * 启动 Electron 应用 */ 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) { const reservation = await findPortFn(startPort, maxPortRetries, 0, { listenTimeoutMs: portProbeTimeoutMs, }) if (!reservation) { throw new Error('[AppLauncher] 无法找到可用端口') } port = reservation.port reservationServer = reservation.reservationServer } 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 (!fsImpl.existsSync(userDataDir)) { fsImpl.mkdirSync(userDataDir, { recursive: true }) } const appPath = path.resolve(__dirname, '../../..') if (!fsImpl.existsSync(appPath)) { throw new Error(`[AppLauncher] 应用目录不存在: ${appPath}`) } const electronExe = process.platform === 'win32' ? path.resolve(appPath, 'node_modules/.bin/electron.cmd') : path.resolve(appPath, 'node_modules/.bin/electron') if (!fsImpl.existsSync(electronExe)) { throw new Error(`Electron 可执行文件不存在: ${electronExe}`) } console.log(`[AppLauncher] 启动 Electron,CDP 端口: ${port}`) const electronArgs = [`--remote-debugging-port=${port}`, appPath] let proc let launchError = null let exitCode = null let exitSignal = null 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}`) } proc.on('error', (error) => { launchError = error console.error('[AppLauncher] Electron 进程错误:', error.message) }) proc.on('exit', (code, signal) => { exitCode = code exitSignal = signal if (code !== null && code !== 0) { console.error(`[AppLauncher] Electron 进程异常退出,退出码: ${code}`) } if (signal !== null) { console.error(`[AppLauncher] Electron 进程被信号杀死: ${signal}`) } }) 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}`) } return { proc, port, async close() { console.log(`[AppLauncher] 关闭应用 (PID: ${proc.pid})`) if (proc.exitCode !== null || proc.signalCode !== null) { console.log(`[AppLauncher] 应用已退出 (exit code: ${proc.exitCode}, signal: ${proc.signalCode})`) return } await terminateProcess(proc, { forceKillTimeoutMs }) }, } } module.exports = { launchApp, __test__: { findAvailablePortWithReservation, terminateProcess, releaseReservation, }, }