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