|
- import { randomBytes } from 'node:crypto'
- import { spawn, spawnSync } from 'node:child_process'
- import fs from 'node:fs'
- import net from 'node:net'
- import path from 'node:path'
-
- const requested = process.env.APE2E_RUN_ID?.trim()
- const runId = (requested || new Date().toISOString().replace(/[:.]/g, '-'))
- .replace(/[^A-Za-z0-9._-]+/g, '-')
- .slice(0, 80)
- const runDirectory = path.resolve('artifacts', 'runs', runId)
- if (!runDirectory.startsWith(path.resolve('artifacts', 'runs') + path.sep)) throw new Error('非法运行目录')
- fs.rmSync(runDirectory, { recursive: true, force: true })
- fs.mkdirSync(runDirectory, { recursive: true })
-
- const env = { ...process.env, APE2E_RUN_ID: runId }
- const packageRunner = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
- const execute = (args) => {
- // Windows 不能通过 CreateProcess 直接执行 .cmd;这些参数均由脚本内固定分支产生,
- // 因此只在 Windows 上交给系统命令解释器启动 pnpm。
- const result = spawnSync(packageRunner, args, {
- env,
- stdio: 'inherit',
- shell: process.platform === 'win32',
- })
- if (result.error) console.error(`命令启动失败:${result.error.message}`)
- return result
- }
-
- const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
-
- function seedDurableDemoData() {
- if (env.E2E_SEED_DEMO_DATA === 'false') {
- console.log('持久演示数据:调用者已显式跳过自动补齐')
- return
- }
- const apiDirectory = path.resolve('..', 'ai_person_api')
- const seedScript = path.join(apiDirectory, 'scripts', 'seed_demo_business_data.py')
- if (!fs.existsSync(seedScript)) throw new Error(`持久演示数据脚本不存在:${seedScript}`)
- const python = env.E2E_PYTHON_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'python.exe' : 'python3')
- const result = spawnSync(python, [seedScript, '--summary-json'], {
- cwd: apiDirectory,
- env: process.env,
- encoding: 'utf8',
- windowsHide: true,
- })
- if (result.error) throw new Error(`持久演示数据脚本启动失败:${result.error.message}`)
- if (result.status !== 0) {
- const detail = String(result.stderr || result.stdout || '').trim().slice(0, 2_000)
- throw new Error(`持久演示数据补齐失败(exit ${result.status}):${detail}`)
- }
- const summary = String(result.stdout || '').trim()
- if (!summary) throw new Error('持久演示数据脚本没有返回验收摘要')
- fs.writeFileSync(path.join(runDirectory, 'demo-seed-summary.json'), `${summary}\n`, 'utf8')
- console.log('持久演示数据:已按 manifest 幂等补齐并完成素材校验')
- }
-
- async function probeRuntimeApi(baseURL, timeoutMs = 3_000) {
- try {
- const response = await fetch(`${baseURL.replace(/\/$/, '')}/open/v1/remote-terminals/ape2e-runtime-probe/heartbeat`, {
- method: 'POST',
- headers: { 'content-type': 'application/json; charset=utf-8' },
- body: JSON.stringify({ runtimeVersion: 'APE2E-PROBE' }),
- signal: AbortSignal.timeout(timeoutMs),
- })
- // 未知终端 + 已配置 pepper 必须走恒定时间鉴权并返回 401。
- // pepper 缺失时为 503;Vite 未代理 /open 时通常返回 SPA 的 200 HTML。
- return response.status === 401
- } catch {
- return false
- }
- }
-
- async function nextFreePort() {
- return await new Promise((resolve, reject) => {
- const server = net.createServer()
- server.unref()
- server.once('error', reject)
- server.listen(0, '127.0.0.1', () => {
- const address = server.address()
- if (!address || typeof address === 'string') {
- server.close()
- reject(new Error('无法分配临时远控 API 端口'))
- return
- }
- server.close((error) => error ? reject(error) : resolve(address.port))
- })
- })
- }
-
- async function waitForTemporaryRuntime(runtime, baseURL, timeoutMs = 30_000) {
- const deadline = Date.now() + timeoutMs
- while (Date.now() < deadline) {
- if (runtime.startError) throw runtime.startError
- if (runtime.child.exitCode !== null || runtime.child.signalCode !== null) {
- throw new Error(`临时远控 API 提前退出:${runtime.child.exitCode ?? runtime.child.signalCode}`)
- }
- try {
- const health = await fetch(`${baseURL}/api/v1/health`, { signal: AbortSignal.timeout(2_000) })
- if (health.ok && await probeRuntimeApi(baseURL, 2_000)) return
- } catch {
- // Uvicorn 和数据库连接仍在启动,短暂等待后重试。
- }
- await delay(250)
- }
- throw new Error('临时远控 API 在 30 秒内未就绪')
- }
-
- async function startTemporaryRuntimeApi() {
- const apiDirectory = path.resolve('..', 'ai_person_api')
- const appFile = path.join(apiDirectory, 'app', 'main.py')
- if (!fs.existsSync(appFile)) throw new Error(`无法启动临时远控 API:不存在 ${appFile}`)
-
- const port = await nextFreePort()
- const baseURL = `http://127.0.0.1:${port}`
- const python = env.E2E_PYTHON_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'python.exe' : 'python3')
- const childEnv = {
- ...process.env,
- // 只存在于子进程环境;不写文件、不打印,也不传递给报告生成器。
- DH_REMOTE_TERMINAL_PEPPER: randomBytes(48).toString('base64url'),
- DH_OPERATIONS_IMPORT_SECRET: randomBytes(48).toString('base64url'),
- }
- const child = spawn(python, [
- '-m', 'uvicorn', 'app.main:app',
- '--host', '127.0.0.1',
- '--port', String(port),
- '--app-dir', apiDirectory,
- '--log-level', 'warning',
- ], {
- cwd: apiDirectory,
- env: childEnv,
- stdio: 'ignore',
- windowsHide: true,
- detached: false,
- })
- const runtime = { child, baseURL, startError: null }
- child.once('error', (error) => { runtime.startError = error })
- await waitForTemporaryRuntime(runtime, baseURL)
- console.log(`远控运行 API:已启动本次测试专用进程(${baseURL},PID ${child.pid})`)
- return runtime
- }
-
- async function waitForTemporaryWeb(web, baseURL, timeoutMs = 45_000) {
- const deadline = Date.now() + timeoutMs
- while (Date.now() < deadline) {
- if (web.startError) throw web.startError
- if (web.child.exitCode !== null || web.child.signalCode !== null) {
- throw new Error(`临时 Web 提前退出:${web.child.exitCode ?? web.child.signalCode}`)
- }
- try {
- const login = await fetch(`${baseURL}/login`, { signal: AbortSignal.timeout(2_000) })
- const config = await fetch(`${baseURL}/api/auth/v1/system-config/public`, { signal: AbortSignal.timeout(2_000) })
- if (login.ok && config.ok) return
- } catch {
- // Vite 依赖预构建或 API 代理仍在启动,短暂等待后重试。
- }
- await delay(250)
- }
- throw new Error('临时 Web 在 45 秒内未就绪')
- }
-
- async function startTemporaryWeb(apiBaseURL) {
- const webDirectory = path.resolve('..', 'ai_person_web')
- const viteEntry = path.join(webDirectory, 'node_modules', 'vite', 'bin', 'vite.js')
- if (!fs.existsSync(viteEntry)) throw new Error(`无法启动临时 Web:不存在 ${viteEntry}`)
-
- const port = await nextFreePort()
- const baseURL = `http://127.0.0.1:${port}`
- const child = spawn(process.execPath, [
- viteEntry,
- '--host', '127.0.0.1',
- '--port', String(port),
- '--strictPort',
- ], {
- cwd: webDirectory,
- env: {
- ...process.env,
- AI_PERSON_API_PROXY: apiBaseURL,
- },
- stdio: 'ignore',
- windowsHide: true,
- detached: false,
- })
- const web = { child, baseURL, startError: null }
- child.once('error', (error) => { web.startError = error })
- await waitForTemporaryWeb(web, baseURL)
- console.log(`E2E Web:已启动本次测试专用进程(${baseURL},PID ${child.pid})`)
- return web
- }
-
- async function stopTemporaryRuntimeApi(runtime) {
- if (!runtime || runtime.child.exitCode !== null || runtime.child.signalCode !== null) return
- const exited = new Promise((resolve) => runtime.child.once('exit', resolve))
- runtime.child.kill('SIGTERM')
- await Promise.race([exited, delay(5_000)])
- if (runtime.child.exitCode === null && runtime.child.signalCode === null) {
- runtime.child.kill('SIGKILL')
- await Promise.race([exited, delay(5_000)])
- }
- if (runtime.child.exitCode === null && runtime.child.signalCode === null) {
- throw new Error(`无法停止本次测试启动的临时远控 API(PID ${runtime.child.pid})`)
- }
- console.log(`远控运行 API:已停止本次测试专用进程(PID ${runtime.child.pid})`)
- }
-
- async function stopTemporaryWeb(web) {
- if (!web || web.child.exitCode !== null || web.child.signalCode !== null) return
- const exited = new Promise((resolve) => web.child.once('exit', resolve))
- web.child.kill('SIGTERM')
- await Promise.race([exited, delay(5_000)])
- if (web.child.exitCode === null && web.child.signalCode === null) {
- web.child.kill('SIGKILL')
- await Promise.race([exited, delay(5_000)])
- }
- if (web.child.exitCode === null && web.child.signalCode === null) {
- throw new Error(`无法停止本次测试启动的临时 Web(PID ${web.child.pid})`)
- }
- console.log(`E2E Web:已停止本次测试专用进程(PID ${web.child.pid})`)
- }
-
- async function prepareRuntimeApi() {
- const configured = env.E2E_RUNTIME_API_URL?.trim()
- if (configured) {
- env.E2E_RUNTIME_API_URL = configured.replace(/\/$/, '')
- console.log('远控运行 API:使用调用者显式配置的 E2E_RUNTIME_API_URL')
- return null
- }
-
- const pageBaseURL = (env.BASE_URL || 'http://127.0.0.1:8003').replace(/\/$/, '')
- const candidates = [...new Set([pageBaseURL, 'http://127.0.0.1:8001'])]
- for (const candidate of candidates) {
- if (await probeRuntimeApi(candidate)) {
- env.E2E_RUNTIME_API_URL = candidate
- console.log(`远控运行 API:使用已就绪服务 ${candidate}`)
- return null
- }
- }
-
- const runtime = await startTemporaryRuntimeApi()
- env.E2E_RUNTIME_API_URL = runtime.baseURL
- return runtime
- }
-
- async function prepareTestStack() {
- if (env.E2E_USE_EXISTING_STACK === 'true') {
- return { runtime: await prepareRuntimeApi(), web: null }
- }
-
- const runtime = await startTemporaryRuntimeApi()
- try {
- const web = await startTemporaryWeb(runtime.baseURL)
- env.E2E_RUNTIME_API_URL = runtime.baseURL
- env.BASE_URL = web.baseURL
- return { runtime, web }
- } catch (error) {
- await stopTemporaryRuntimeApi(runtime)
- throw error
- }
- }
-
- console.log(`APE2E runId: ${runId}`)
- const typecheck = execute(['exec', 'tsc', '--noEmit'])
- let tests = { status: typecheck.status ?? 1 }
- let runtime = null
- let web = null
- let runtimeLifecycleStatus = 0
-
- if (typecheck.status === 0) {
- try {
- const stack = await prepareTestStack()
- runtime = stack.runtime
- web = stack.web
- seedDurableDemoData()
- const requestedFiles = (env.APE2E_TEST_FILES || '')
- .split(',')
- .map((item) => item.trim().replaceAll('\\', '/'))
- .filter(Boolean)
- if (requestedFiles.some((item) => !/^tests\/[A-Za-z0-9._/-]+\.spec\.ts$/.test(item))) {
- throw new Error('APE2E_TEST_FILES 只能包含 tests/ 下以 .spec.ts 结尾的相对路径')
- }
- const selectedFiles = env.APE2E_REMOTE_RUNTIME_ONLY === 'true'
- ? ['tests/remote-terminal-runtime.spec.ts']
- : requestedFiles
- const playwrightArgs = ['exec', 'playwright', 'test', ...selectedFiles, '--project=chromium']
- tests = execute(playwrightArgs)
- } catch (error) {
- runtimeLifecycleStatus = 1
- console.error(`E2E 临时运行环境准备失败:${error instanceof Error ? error.message : String(error)}`)
- }
- }
-
- const verification = spawnSync(process.execPath, ['scripts/verify-run.mjs'], { env, stdio: 'inherit' })
- const report = spawnSync(process.execPath, ['scripts/generate-report.mjs'], { env, stdio: 'inherit' })
- if (report.status !== 0) console.error('Markdown 报告生成失败')
- try {
- await stopTemporaryWeb(web)
- await stopTemporaryRuntimeApi(runtime)
- } catch (error) {
- runtimeLifecycleStatus = 1
- console.error(`E2E 临时运行环境清理失败:${error instanceof Error ? error.message : String(error)}`)
- }
- process.exitCode = tests.status || runtimeLifecycleStatus || verification.status || report.status || 0
|