mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-26 16:40:17 +08:00
test: add reusable e2e launcher smoke framework and scripts
This commit is contained in:
+160
-160
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user