diff --git a/electron/main/index.ts b/electron/main/index.ts index 8d3f6bfd..8b764ef2 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -26,8 +26,10 @@ class MainProcess { // E2E 测试隔离:为并行测试实例设置独立的用户数据目录 // 这防止了并发进程的状态泄漏、死锁和数据库冲突 const e2eUserDataDir = process.env.CHATLAB_E2E_USER_DATA_DIR - if (e2eUserDataDir) { + if (this.isTestMode && e2eUserDataDir) { app.setPath('userData', e2eUserDataDir) + } else if (!this.isTestMode && e2eUserDataDir) { + console.warn('[Main] Ignored CHATLAB_E2E_USER_DATA_DIR because TEST_MODE is not enabled') } // 设置应用程序名称 diff --git a/eslint.config.mjs b/eslint.config.mjs index 1ce32c31..d0359525 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -59,5 +59,13 @@ export default defineConfigWithVueTs( '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-var-requires': 'off', }, + }, + + // E2E Node helper/test 使用 CommonJS,允许 require + { + files: ['tests/e2e/**/*.js'], + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, } ) diff --git a/package.json b/package.json index 5a4462b6..89aa695b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "type-check:all": "npm run type-check:web && npm run type-check:node", "type-check": "vue-tsc --noEmit -p tsconfig.web.json", "test:agent-context": "node --experimental-strip-types --test electron/main/ai/context/sessionLog.test.mjs", + "test:e2e:launcher": "node --test tests/e2e/helpers/app-launcher.test.js", + "test:e2e:smoke": "node tests/e2e/run-smoke.js", "postinstall": "electron-rebuild" }, "dependencies": { diff --git a/tests/e2e/helpers/app-launcher.js b/tests/e2e/helpers/app-launcher.js index d5bcc574..771606b0 100644 --- a/tests/e2e/helpers/app-launcher.js +++ b/tests/e2e/helpers/app-launcher.js @@ -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] 启动 Electron,CDP 端口: ${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, + }, +} diff --git a/tests/e2e/helpers/app-launcher.test.js b/tests/e2e/helpers/app-launcher.test.js new file mode 100644 index 00000000..8cd3800c --- /dev/null +++ b/tests/e2e/helpers/app-launcher.test.js @@ -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']) +}) diff --git a/tests/e2e/run-smoke.js b/tests/e2e/run-smoke.js new file mode 100644 index 00000000..f2bd9265 --- /dev/null +++ b/tests/e2e/run-smoke.js @@ -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) diff --git a/tests/e2e/smoke/app-launcher.smoke.test.js b/tests/e2e/smoke/app-launcher.smoke.test.js new file mode 100644 index 00000000..5161d63d --- /dev/null +++ b/tests/e2e/smoke/app-launcher.smoke.test.js @@ -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() + } + } +})