Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 

303 Zeilen
12 KiB

  1. import { randomBytes } from 'node:crypto'
  2. import { spawn, spawnSync } from 'node:child_process'
  3. import fs from 'node:fs'
  4. import net from 'node:net'
  5. import path from 'node:path'
  6. const requested = process.env.APE2E_RUN_ID?.trim()
  7. const runId = (requested || new Date().toISOString().replace(/[:.]/g, '-'))
  8. .replace(/[^A-Za-z0-9._-]+/g, '-')
  9. .slice(0, 80)
  10. const runDirectory = path.resolve('artifacts', 'runs', runId)
  11. if (!runDirectory.startsWith(path.resolve('artifacts', 'runs') + path.sep)) throw new Error('非法运行目录')
  12. fs.rmSync(runDirectory, { recursive: true, force: true })
  13. fs.mkdirSync(runDirectory, { recursive: true })
  14. const env = { ...process.env, APE2E_RUN_ID: runId }
  15. const packageRunner = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
  16. const execute = (args) => {
  17. // Windows 不能通过 CreateProcess 直接执行 .cmd;这些参数均由脚本内固定分支产生,
  18. // 因此只在 Windows 上交给系统命令解释器启动 pnpm。
  19. const result = spawnSync(packageRunner, args, {
  20. env,
  21. stdio: 'inherit',
  22. shell: process.platform === 'win32',
  23. })
  24. if (result.error) console.error(`命令启动失败:${result.error.message}`)
  25. return result
  26. }
  27. const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
  28. function seedDurableDemoData() {
  29. if (env.E2E_SEED_DEMO_DATA === 'false') {
  30. console.log('持久演示数据:调用者已显式跳过自动补齐')
  31. return
  32. }
  33. const apiDirectory = path.resolve('..', 'ai_person_api')
  34. const seedScript = path.join(apiDirectory, 'scripts', 'seed_demo_business_data.py')
  35. if (!fs.existsSync(seedScript)) throw new Error(`持久演示数据脚本不存在:${seedScript}`)
  36. const python = env.E2E_PYTHON_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'python.exe' : 'python3')
  37. const result = spawnSync(python, [seedScript, '--summary-json'], {
  38. cwd: apiDirectory,
  39. env: process.env,
  40. encoding: 'utf8',
  41. windowsHide: true,
  42. })
  43. if (result.error) throw new Error(`持久演示数据脚本启动失败:${result.error.message}`)
  44. if (result.status !== 0) {
  45. const detail = String(result.stderr || result.stdout || '').trim().slice(0, 2_000)
  46. throw new Error(`持久演示数据补齐失败(exit ${result.status}):${detail}`)
  47. }
  48. const summary = String(result.stdout || '').trim()
  49. if (!summary) throw new Error('持久演示数据脚本没有返回验收摘要')
  50. fs.writeFileSync(path.join(runDirectory, 'demo-seed-summary.json'), `${summary}\n`, 'utf8')
  51. console.log('持久演示数据:已按 manifest 幂等补齐并完成素材校验')
  52. }
  53. async function probeRuntimeApi(baseURL, timeoutMs = 3_000) {
  54. try {
  55. const response = await fetch(`${baseURL.replace(/\/$/, '')}/open/v1/remote-terminals/ape2e-runtime-probe/heartbeat`, {
  56. method: 'POST',
  57. headers: { 'content-type': 'application/json; charset=utf-8' },
  58. body: JSON.stringify({ runtimeVersion: 'APE2E-PROBE' }),
  59. signal: AbortSignal.timeout(timeoutMs),
  60. })
  61. // 未知终端 + 已配置 pepper 必须走恒定时间鉴权并返回 401。
  62. // pepper 缺失时为 503;Vite 未代理 /open 时通常返回 SPA 的 200 HTML。
  63. return response.status === 401
  64. } catch {
  65. return false
  66. }
  67. }
  68. async function nextFreePort() {
  69. return await new Promise((resolve, reject) => {
  70. const server = net.createServer()
  71. server.unref()
  72. server.once('error', reject)
  73. server.listen(0, '127.0.0.1', () => {
  74. const address = server.address()
  75. if (!address || typeof address === 'string') {
  76. server.close()
  77. reject(new Error('无法分配临时远控 API 端口'))
  78. return
  79. }
  80. server.close((error) => error ? reject(error) : resolve(address.port))
  81. })
  82. })
  83. }
  84. async function waitForTemporaryRuntime(runtime, baseURL, timeoutMs = 30_000) {
  85. const deadline = Date.now() + timeoutMs
  86. while (Date.now() < deadline) {
  87. if (runtime.startError) throw runtime.startError
  88. if (runtime.child.exitCode !== null || runtime.child.signalCode !== null) {
  89. throw new Error(`临时远控 API 提前退出:${runtime.child.exitCode ?? runtime.child.signalCode}`)
  90. }
  91. try {
  92. const health = await fetch(`${baseURL}/api/v1/health`, { signal: AbortSignal.timeout(2_000) })
  93. if (health.ok && await probeRuntimeApi(baseURL, 2_000)) return
  94. } catch {
  95. // Uvicorn 和数据库连接仍在启动,短暂等待后重试。
  96. }
  97. await delay(250)
  98. }
  99. throw new Error('临时远控 API 在 30 秒内未就绪')
  100. }
  101. async function startTemporaryRuntimeApi() {
  102. const apiDirectory = path.resolve('..', 'ai_person_api')
  103. const appFile = path.join(apiDirectory, 'app', 'main.py')
  104. if (!fs.existsSync(appFile)) throw new Error(`无法启动临时远控 API:不存在 ${appFile}`)
  105. const port = await nextFreePort()
  106. const baseURL = `http://127.0.0.1:${port}`
  107. const python = env.E2E_PYTHON_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'python.exe' : 'python3')
  108. const childEnv = {
  109. ...process.env,
  110. // 只存在于子进程环境;不写文件、不打印,也不传递给报告生成器。
  111. DH_REMOTE_TERMINAL_PEPPER: randomBytes(48).toString('base64url'),
  112. DH_OPERATIONS_IMPORT_SECRET: randomBytes(48).toString('base64url'),
  113. }
  114. const child = spawn(python, [
  115. '-m', 'uvicorn', 'app.main:app',
  116. '--host', '127.0.0.1',
  117. '--port', String(port),
  118. '--app-dir', apiDirectory,
  119. '--log-level', 'warning',
  120. ], {
  121. cwd: apiDirectory,
  122. env: childEnv,
  123. stdio: 'ignore',
  124. windowsHide: true,
  125. detached: false,
  126. })
  127. const runtime = { child, baseURL, startError: null }
  128. child.once('error', (error) => { runtime.startError = error })
  129. await waitForTemporaryRuntime(runtime, baseURL)
  130. console.log(`远控运行 API:已启动本次测试专用进程(${baseURL},PID ${child.pid})`)
  131. return runtime
  132. }
  133. async function waitForTemporaryWeb(web, baseURL, timeoutMs = 45_000) {
  134. const deadline = Date.now() + timeoutMs
  135. while (Date.now() < deadline) {
  136. if (web.startError) throw web.startError
  137. if (web.child.exitCode !== null || web.child.signalCode !== null) {
  138. throw new Error(`临时 Web 提前退出:${web.child.exitCode ?? web.child.signalCode}`)
  139. }
  140. try {
  141. const login = await fetch(`${baseURL}/login`, { signal: AbortSignal.timeout(2_000) })
  142. const config = await fetch(`${baseURL}/api/auth/v1/system-config/public`, { signal: AbortSignal.timeout(2_000) })
  143. if (login.ok && config.ok) return
  144. } catch {
  145. // Vite 依赖预构建或 API 代理仍在启动,短暂等待后重试。
  146. }
  147. await delay(250)
  148. }
  149. throw new Error('临时 Web 在 45 秒内未就绪')
  150. }
  151. async function startTemporaryWeb(apiBaseURL) {
  152. const webDirectory = path.resolve('..', 'ai_person_web')
  153. const viteEntry = path.join(webDirectory, 'node_modules', 'vite', 'bin', 'vite.js')
  154. if (!fs.existsSync(viteEntry)) throw new Error(`无法启动临时 Web:不存在 ${viteEntry}`)
  155. const port = await nextFreePort()
  156. const baseURL = `http://127.0.0.1:${port}`
  157. const child = spawn(process.execPath, [
  158. viteEntry,
  159. '--host', '127.0.0.1',
  160. '--port', String(port),
  161. '--strictPort',
  162. ], {
  163. cwd: webDirectory,
  164. env: {
  165. ...process.env,
  166. AI_PERSON_API_PROXY: apiBaseURL,
  167. },
  168. stdio: 'ignore',
  169. windowsHide: true,
  170. detached: false,
  171. })
  172. const web = { child, baseURL, startError: null }
  173. child.once('error', (error) => { web.startError = error })
  174. await waitForTemporaryWeb(web, baseURL)
  175. console.log(`E2E Web:已启动本次测试专用进程(${baseURL},PID ${child.pid})`)
  176. return web
  177. }
  178. async function stopTemporaryRuntimeApi(runtime) {
  179. if (!runtime || runtime.child.exitCode !== null || runtime.child.signalCode !== null) return
  180. const exited = new Promise((resolve) => runtime.child.once('exit', resolve))
  181. runtime.child.kill('SIGTERM')
  182. await Promise.race([exited, delay(5_000)])
  183. if (runtime.child.exitCode === null && runtime.child.signalCode === null) {
  184. runtime.child.kill('SIGKILL')
  185. await Promise.race([exited, delay(5_000)])
  186. }
  187. if (runtime.child.exitCode === null && runtime.child.signalCode === null) {
  188. throw new Error(`无法停止本次测试启动的临时远控 API(PID ${runtime.child.pid})`)
  189. }
  190. console.log(`远控运行 API:已停止本次测试专用进程(PID ${runtime.child.pid})`)
  191. }
  192. async function stopTemporaryWeb(web) {
  193. if (!web || web.child.exitCode !== null || web.child.signalCode !== null) return
  194. const exited = new Promise((resolve) => web.child.once('exit', resolve))
  195. web.child.kill('SIGTERM')
  196. await Promise.race([exited, delay(5_000)])
  197. if (web.child.exitCode === null && web.child.signalCode === null) {
  198. web.child.kill('SIGKILL')
  199. await Promise.race([exited, delay(5_000)])
  200. }
  201. if (web.child.exitCode === null && web.child.signalCode === null) {
  202. throw new Error(`无法停止本次测试启动的临时 Web(PID ${web.child.pid})`)
  203. }
  204. console.log(`E2E Web:已停止本次测试专用进程(PID ${web.child.pid})`)
  205. }
  206. async function prepareRuntimeApi() {
  207. const configured = env.E2E_RUNTIME_API_URL?.trim()
  208. if (configured) {
  209. env.E2E_RUNTIME_API_URL = configured.replace(/\/$/, '')
  210. console.log('远控运行 API:使用调用者显式配置的 E2E_RUNTIME_API_URL')
  211. return null
  212. }
  213. const pageBaseURL = (env.BASE_URL || 'http://127.0.0.1:8003').replace(/\/$/, '')
  214. const candidates = [...new Set([pageBaseURL, 'http://127.0.0.1:8001'])]
  215. for (const candidate of candidates) {
  216. if (await probeRuntimeApi(candidate)) {
  217. env.E2E_RUNTIME_API_URL = candidate
  218. console.log(`远控运行 API:使用已就绪服务 ${candidate}`)
  219. return null
  220. }
  221. }
  222. const runtime = await startTemporaryRuntimeApi()
  223. env.E2E_RUNTIME_API_URL = runtime.baseURL
  224. return runtime
  225. }
  226. async function prepareTestStack() {
  227. if (env.E2E_USE_EXISTING_STACK === 'true') {
  228. return { runtime: await prepareRuntimeApi(), web: null }
  229. }
  230. const runtime = await startTemporaryRuntimeApi()
  231. try {
  232. const web = await startTemporaryWeb(runtime.baseURL)
  233. env.E2E_RUNTIME_API_URL = runtime.baseURL
  234. env.BASE_URL = web.baseURL
  235. return { runtime, web }
  236. } catch (error) {
  237. await stopTemporaryRuntimeApi(runtime)
  238. throw error
  239. }
  240. }
  241. console.log(`APE2E runId: ${runId}`)
  242. const typecheck = execute(['exec', 'tsc', '--noEmit'])
  243. let tests = { status: typecheck.status ?? 1 }
  244. let runtime = null
  245. let web = null
  246. let runtimeLifecycleStatus = 0
  247. if (typecheck.status === 0) {
  248. try {
  249. const stack = await prepareTestStack()
  250. runtime = stack.runtime
  251. web = stack.web
  252. seedDurableDemoData()
  253. const requestedFiles = (env.APE2E_TEST_FILES || '')
  254. .split(',')
  255. .map((item) => item.trim().replaceAll('\\', '/'))
  256. .filter(Boolean)
  257. if (requestedFiles.some((item) => !/^tests\/[A-Za-z0-9._/-]+\.spec\.ts$/.test(item))) {
  258. throw new Error('APE2E_TEST_FILES 只能包含 tests/ 下以 .spec.ts 结尾的相对路径')
  259. }
  260. const selectedFiles = env.APE2E_REMOTE_RUNTIME_ONLY === 'true'
  261. ? ['tests/remote-terminal-runtime.spec.ts']
  262. : requestedFiles
  263. const playwrightArgs = ['exec', 'playwright', 'test', ...selectedFiles, '--project=chromium']
  264. tests = execute(playwrightArgs)
  265. } catch (error) {
  266. runtimeLifecycleStatus = 1
  267. console.error(`E2E 临时运行环境准备失败:${error instanceof Error ? error.message : String(error)}`)
  268. }
  269. }
  270. const verification = spawnSync(process.execPath, ['scripts/verify-run.mjs'], { env, stdio: 'inherit' })
  271. const report = spawnSync(process.execPath, ['scripts/generate-report.mjs'], { env, stdio: 'inherit' })
  272. if (report.status !== 0) console.error('Markdown 报告生成失败')
  273. try {
  274. await stopTemporaryWeb(web)
  275. await stopTemporaryRuntimeApi(runtime)
  276. } catch (error) {
  277. runtimeLifecycleStatus = 1
  278. console.error(`E2E 临时运行环境清理失败:${error instanceof Error ? error.message : String(error)}`)
  279. }
  280. process.exitCode = tests.status || runtimeLifecycleStatus || verification.status || report.status || 0