25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 

321 satır
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/v2/terminals/heartbeat`, {
  56. method: 'POST',
  57. headers: {
  58. 'content-type': 'application/json; charset=utf-8',
  59. 'X-Terminal-Id': 'ape2e-runtime-probe',
  60. 'X-Device-Secret': 'ape2e-intentionally-invalid',
  61. 'X-Terminal-Page-Origin': new URL(baseURL).origin,
  62. 'X-Runtime-Instance-Id': 'runtime-ape2e-api-probe',
  63. },
  64. body: JSON.stringify({
  65. runtimeVersion: 'APE2E-PROBE',
  66. displayMode: 'STANDALONE',
  67. capabilities: ['WAKE'],
  68. actualVolume: 50,
  69. allowInterrupt: true,
  70. visibility: 'VISIBLE',
  71. sessionStatus: 'IDLE',
  72. playbackStatus: 'IDLE',
  73. pageUrl: `${baseURL.replace(/\/$/, '')}/live/ape2e-probe`,
  74. pageOrigin: new URL(baseURL).origin,
  75. platform: process.platform,
  76. }),
  77. signal: AbortSignal.timeout(timeoutMs),
  78. })
  79. // 未知终端 + 已配置 pepper 必须走恒定时间鉴权并返回 401。
  80. // pepper 缺失时为 503;Vite 未代理 /open 时通常返回 SPA 的 200 HTML。
  81. return response.status === 401
  82. } catch {
  83. return false
  84. }
  85. }
  86. async function nextFreePort() {
  87. return await new Promise((resolve, reject) => {
  88. const server = net.createServer()
  89. server.unref()
  90. server.once('error', reject)
  91. server.listen(0, '127.0.0.1', () => {
  92. const address = server.address()
  93. if (!address || typeof address === 'string') {
  94. server.close()
  95. reject(new Error('无法分配临时远控 API 端口'))
  96. return
  97. }
  98. server.close((error) => error ? reject(error) : resolve(address.port))
  99. })
  100. })
  101. }
  102. async function waitForTemporaryRuntime(runtime, baseURL, timeoutMs = 30_000) {
  103. const deadline = Date.now() + timeoutMs
  104. while (Date.now() < deadline) {
  105. if (runtime.startError) throw runtime.startError
  106. if (runtime.child.exitCode !== null || runtime.child.signalCode !== null) {
  107. throw new Error(`临时远控 API 提前退出:${runtime.child.exitCode ?? runtime.child.signalCode}`)
  108. }
  109. try {
  110. const health = await fetch(`${baseURL}/api/v1/health`, { signal: AbortSignal.timeout(2_000) })
  111. if (health.ok && await probeRuntimeApi(baseURL, 2_000)) return
  112. } catch {
  113. // Uvicorn 和数据库连接仍在启动,短暂等待后重试。
  114. }
  115. await delay(250)
  116. }
  117. throw new Error('临时远控 API 在 30 秒内未就绪')
  118. }
  119. async function startTemporaryRuntimeApi() {
  120. const apiDirectory = path.resolve('..', 'ai_person_api')
  121. const appFile = path.join(apiDirectory, 'app', 'main.py')
  122. if (!fs.existsSync(appFile)) throw new Error(`无法启动临时远控 API:不存在 ${appFile}`)
  123. const port = await nextFreePort()
  124. const baseURL = `http://127.0.0.1:${port}`
  125. const python = env.E2E_PYTHON_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'python.exe' : 'python3')
  126. const childEnv = {
  127. ...process.env,
  128. // 只存在于子进程环境;不写文件、不打印,也不传递给报告生成器。
  129. DH_REMOTE_TERMINAL_PEPPER: randomBytes(48).toString('base64url'),
  130. DH_OPERATIONS_IMPORT_SECRET: randomBytes(48).toString('base64url'),
  131. }
  132. const child = spawn(python, [
  133. '-m', 'uvicorn', 'app.main:app',
  134. '--host', '127.0.0.1',
  135. '--port', String(port),
  136. '--app-dir', apiDirectory,
  137. '--log-level', 'warning',
  138. ], {
  139. cwd: apiDirectory,
  140. env: childEnv,
  141. stdio: 'ignore',
  142. windowsHide: true,
  143. detached: false,
  144. })
  145. const runtime = { child, baseURL, startError: null }
  146. child.once('error', (error) => { runtime.startError = error })
  147. await waitForTemporaryRuntime(runtime, baseURL)
  148. console.log(`远控运行 API:已启动本次测试专用进程(${baseURL},PID ${child.pid})`)
  149. return runtime
  150. }
  151. async function waitForTemporaryWeb(web, baseURL, timeoutMs = 45_000) {
  152. const deadline = Date.now() + timeoutMs
  153. while (Date.now() < deadline) {
  154. if (web.startError) throw web.startError
  155. if (web.child.exitCode !== null || web.child.signalCode !== null) {
  156. throw new Error(`临时 Web 提前退出:${web.child.exitCode ?? web.child.signalCode}`)
  157. }
  158. try {
  159. const login = await fetch(`${baseURL}/login`, { signal: AbortSignal.timeout(2_000) })
  160. const config = await fetch(`${baseURL}/api/auth/v1/system-config/public`, { signal: AbortSignal.timeout(2_000) })
  161. if (login.ok && config.ok) return
  162. } catch {
  163. // Vite 依赖预构建或 API 代理仍在启动,短暂等待后重试。
  164. }
  165. await delay(250)
  166. }
  167. throw new Error('临时 Web 在 45 秒内未就绪')
  168. }
  169. async function startTemporaryWeb(apiBaseURL) {
  170. const webDirectory = path.resolve('..', 'ai_person_web')
  171. const viteEntry = path.join(webDirectory, 'node_modules', 'vite', 'bin', 'vite.js')
  172. if (!fs.existsSync(viteEntry)) throw new Error(`无法启动临时 Web:不存在 ${viteEntry}`)
  173. const port = await nextFreePort()
  174. const baseURL = `http://127.0.0.1:${port}`
  175. const child = spawn(process.execPath, [
  176. viteEntry,
  177. '--host', '127.0.0.1',
  178. '--port', String(port),
  179. '--strictPort',
  180. ], {
  181. cwd: webDirectory,
  182. env: {
  183. ...process.env,
  184. AI_PERSON_API_PROXY: apiBaseURL,
  185. },
  186. stdio: 'ignore',
  187. windowsHide: true,
  188. detached: false,
  189. })
  190. const web = { child, baseURL, startError: null }
  191. child.once('error', (error) => { web.startError = error })
  192. await waitForTemporaryWeb(web, baseURL)
  193. console.log(`E2E Web:已启动本次测试专用进程(${baseURL},PID ${child.pid})`)
  194. return web
  195. }
  196. async function stopTemporaryRuntimeApi(runtime) {
  197. if (!runtime || runtime.child.exitCode !== null || runtime.child.signalCode !== null) return
  198. const exited = new Promise((resolve) => runtime.child.once('exit', resolve))
  199. runtime.child.kill('SIGTERM')
  200. await Promise.race([exited, delay(5_000)])
  201. if (runtime.child.exitCode === null && runtime.child.signalCode === null) {
  202. runtime.child.kill('SIGKILL')
  203. await Promise.race([exited, delay(5_000)])
  204. }
  205. if (runtime.child.exitCode === null && runtime.child.signalCode === null) {
  206. throw new Error(`无法停止本次测试启动的临时远控 API(PID ${runtime.child.pid})`)
  207. }
  208. console.log(`远控运行 API:已停止本次测试专用进程(PID ${runtime.child.pid})`)
  209. }
  210. async function stopTemporaryWeb(web) {
  211. if (!web || web.child.exitCode !== null || web.child.signalCode !== null) return
  212. const exited = new Promise((resolve) => web.child.once('exit', resolve))
  213. web.child.kill('SIGTERM')
  214. await Promise.race([exited, delay(5_000)])
  215. if (web.child.exitCode === null && web.child.signalCode === null) {
  216. web.child.kill('SIGKILL')
  217. await Promise.race([exited, delay(5_000)])
  218. }
  219. if (web.child.exitCode === null && web.child.signalCode === null) {
  220. throw new Error(`无法停止本次测试启动的临时 Web(PID ${web.child.pid})`)
  221. }
  222. console.log(`E2E Web:已停止本次测试专用进程(PID ${web.child.pid})`)
  223. }
  224. async function prepareRuntimeApi() {
  225. const configured = env.E2E_RUNTIME_API_URL?.trim()
  226. if (configured) {
  227. env.E2E_RUNTIME_API_URL = configured.replace(/\/$/, '')
  228. console.log('远控运行 API:使用调用者显式配置的 E2E_RUNTIME_API_URL')
  229. return null
  230. }
  231. const pageBaseURL = (env.BASE_URL || 'http://127.0.0.1:8003').replace(/\/$/, '')
  232. const candidates = [...new Set([pageBaseURL, 'http://127.0.0.1:8001'])]
  233. for (const candidate of candidates) {
  234. if (await probeRuntimeApi(candidate)) {
  235. env.E2E_RUNTIME_API_URL = candidate
  236. console.log(`远控运行 API:使用已就绪服务 ${candidate}`)
  237. return null
  238. }
  239. }
  240. const runtime = await startTemporaryRuntimeApi()
  241. env.E2E_RUNTIME_API_URL = runtime.baseURL
  242. return runtime
  243. }
  244. async function prepareTestStack() {
  245. if (env.E2E_USE_EXISTING_STACK === 'true') {
  246. return { runtime: await prepareRuntimeApi(), web: null }
  247. }
  248. const runtime = await startTemporaryRuntimeApi()
  249. try {
  250. const web = await startTemporaryWeb(runtime.baseURL)
  251. env.E2E_RUNTIME_API_URL = runtime.baseURL
  252. env.BASE_URL = web.baseURL
  253. return { runtime, web }
  254. } catch (error) {
  255. await stopTemporaryRuntimeApi(runtime)
  256. throw error
  257. }
  258. }
  259. console.log(`APE2E runId: ${runId}`)
  260. const typecheck = execute(['exec', 'tsc', '--noEmit'])
  261. let tests = { status: typecheck.status ?? 1 }
  262. let runtime = null
  263. let web = null
  264. let runtimeLifecycleStatus = 0
  265. if (typecheck.status === 0) {
  266. try {
  267. const stack = await prepareTestStack()
  268. runtime = stack.runtime
  269. web = stack.web
  270. seedDurableDemoData()
  271. const requestedFiles = (env.APE2E_TEST_FILES || '')
  272. .split(',')
  273. .map((item) => item.trim().replaceAll('\\', '/'))
  274. .filter(Boolean)
  275. if (requestedFiles.some((item) => !/^tests\/[A-Za-z0-9._/-]+\.spec\.ts$/.test(item))) {
  276. throw new Error('APE2E_TEST_FILES 只能包含 tests/ 下以 .spec.ts 结尾的相对路径')
  277. }
  278. const selectedFiles = env.APE2E_REMOTE_RUNTIME_ONLY === 'true'
  279. ? ['tests/remote-terminal-v2-ui.spec.ts', 'tests/remote-terminal-v2-lifecycle.spec.ts']
  280. : requestedFiles
  281. const playwrightArgs = ['exec', 'playwright', 'test', ...selectedFiles, '--project=chromium']
  282. tests = execute(playwrightArgs)
  283. } catch (error) {
  284. runtimeLifecycleStatus = 1
  285. console.error(`E2E 临时运行环境准备失败:${error instanceof Error ? error.message : String(error)}`)
  286. }
  287. }
  288. const verification = spawnSync(process.execPath, ['scripts/verify-run.mjs'], { env, stdio: 'inherit' })
  289. const report = spawnSync(process.execPath, ['scripts/generate-report.mjs'], { env, stdio: 'inherit' })
  290. if (report.status !== 0) console.error('Markdown 报告生成失败')
  291. try {
  292. await stopTemporaryWeb(web)
  293. await stopTemporaryRuntimeApi(runtime)
  294. } catch (error) {
  295. runtimeLifecycleStatus = 1
  296. console.error(`E2E 临时运行环境清理失败:${error instanceof Error ? error.message : String(error)}`)
  297. }
  298. process.exitCode = tests.status || runtimeLifecycleStatus || verification.status || report.status || 0