Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

646 строки
33 KiB

  1. import {
  2. request as playwrightRequest,
  3. type APIRequestContext,
  4. type Page,
  5. type TestInfo,
  6. } from '@playwright/test'
  7. import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
  8. import { authHeaders, envelopeData, fillFormItem, formItem, loginAsAdmin } from './helpers'
  9. type JsonRecord = Record<string, unknown>
  10. type Headers = Record<string, string>
  11. interface VersionedTerminal {
  12. id: string
  13. dataVersion: number
  14. }
  15. interface RuntimeActivity {
  16. module: string
  17. action: string
  18. result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED'
  19. httpStatus?: number
  20. resourceId?: string
  21. detail?: string
  22. }
  23. interface ResponseLike {
  24. status(): number
  25. json(): Promise<unknown>
  26. headers(): Record<string, string>
  27. }
  28. // 本地 Vite 目前只代理 /api;需要验证 /open/v1 运行端协议时可把
  29. // E2E_RUNTIME_API_URL 指向同库启动、且已注入进程级 pepper 的真实 API。
  30. // 部署环境的 BASE_URL 同时代理 /api 与 /open 时无需单独配置。
  31. const runtimeApiBaseURL = (
  32. process.env.E2E_RUNTIME_API_URL
  33. || process.env.BASE_URL
  34. || 'http://127.0.0.1:8003'
  35. ).replace(/\/$/, '')
  36. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object'
  37. ? value as JsonRecord
  38. : {}
  39. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value)
  40. ? value.map(asRecord)
  41. : []
  42. const assertStatus = (response: ResponseLike, expected: number | number[], label: string) => {
  43. const allowed = Array.isArray(expected) ? expected : [expected]
  44. expect(allowed, `${label}:HTTP ${response.status()}`).toContain(response.status())
  45. }
  46. async function responseData<T = JsonRecord>(
  47. response: ResponseLike,
  48. expected: number | number[],
  49. label: string,
  50. ): Promise<T> {
  51. assertStatus(response, expected, label)
  52. let body: unknown
  53. try {
  54. body = await response.json()
  55. } catch {
  56. throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`)
  57. }
  58. return envelopeData<T>(body)
  59. }
  60. async function selectBoundAgent(page: Page, dialog: ReturnType<Page['locator']>, excluded = '') {
  61. const select = formItem(dialog, /业务用途/).locator('.el-select')
  62. await select.click()
  63. const dropdown = page.locator('.el-select-dropdown:visible').last()
  64. await expect(dropdown).toBeVisible()
  65. const options = dropdown.locator('.el-select-dropdown__item:not(.is-disabled)')
  66. await expect(options.first()).toBeVisible()
  67. const labels = (await options.allInnerTexts()).map((value) => value.trim()).filter(Boolean)
  68. const selected = labels.find((value) => value !== excluded)
  69. if (!selected) throw new Error(excluded ? '没有第二个可绑定的真实智能体' : '没有可绑定的真实智能体')
  70. await options.filter({ hasText: selected }).first().click()
  71. return selected
  72. }
  73. async function setSliderValue(slider: ReturnType<Page['locator']>, target: number) {
  74. await slider.focus()
  75. let current = Number(await slider.getAttribute('aria-valuenow'))
  76. if (!Number.isFinite(current)) throw new Error('终端音量滑块缺少 aria-valuenow')
  77. const key = target > current ? 'ArrowRight' : 'ArrowLeft'
  78. while (current !== target) {
  79. await slider.press(key)
  80. current = Number(await slider.getAttribute('aria-valuenow'))
  81. }
  82. await expect(slider).toHaveAttribute('aria-valuenow', String(target))
  83. }
  84. async function setSwitchChecked(control: ReturnType<Page['locator']>, checked: boolean) {
  85. const current = (await control.getAttribute('class'))?.includes('is-checked') ?? false
  86. if (current !== checked) await control.click()
  87. if (checked) await expect(control).toHaveClass(/is-checked/)
  88. else await expect(control).not.toHaveClass(/is-checked/)
  89. }
  90. async function consumeOneTimeSecret(page: Page, title: string | RegExp) {
  91. const box = page.locator('.el-message-box:visible').last()
  92. await box.waitFor({ state: 'visible' })
  93. // 在同一个浏览器求值中读取后立即覆盖正文。这样后续任一步骤失败时,
  94. // Playwright 自动生成的错误上下文、DOM 快照和失败截图都只能看到遮罩值。
  95. const displayed = await box.evaluate((element) => {
  96. const titleElement = element.querySelector('.el-message-box__title')
  97. const messageElement = element.querySelector('.el-message-box__message')
  98. const value = {
  99. title: titleElement?.textContent?.trim() || '',
  100. secret: messageElement?.textContent?.trim() || '',
  101. }
  102. if (messageElement) messageElement.textContent = '••••••••(一次性密钥已由测试进程安全接收并立即遮罩)'
  103. return value
  104. })
  105. await box.getByRole('button', { name: '我已安全保存' }).click()
  106. await box.waitFor({ state: 'hidden' })
  107. const matchesTitle = typeof title === 'string' ? displayed.title === title : title.test(displayed.title)
  108. if (!matchesTitle) throw new Error('一次性密钥弹窗标题不符合预期')
  109. if (displayed.secret.length < 24) throw new Error('页面没有展示有效的一次性终端接入密钥')
  110. return displayed.secret
  111. }
  112. const recordPass = (
  113. activities: RuntimeActivity[],
  114. action: string,
  115. response: ResponseLike,
  116. resourceId?: string,
  117. detail?: string,
  118. ) => activities.push({
  119. module: '远控终端运行协议',
  120. action,
  121. result: 'PASS',
  122. httpStatus: response.status(),
  123. resourceId,
  124. detail,
  125. })
  126. async function openTerminalPage(page: Page, terminalName: string) {
  127. await page.goto('/agents/remote-control')
  128. await expect(page.getByText('远程控制', { exact: true }).first()).toBeVisible()
  129. const keyword = page.getByPlaceholder('搜索终端名称、部署位置或业务用途')
  130. await expect(keyword).toBeVisible()
  131. await keyword.fill(terminalName)
  132. const card = page.locator('.terminal-card').filter({ hasText: terminalName }).first()
  133. await expect(card).toBeVisible()
  134. return card
  135. }
  136. async function captureTerminalCard(
  137. page: Page,
  138. testInfo: TestInfo,
  139. terminalName: string,
  140. label: string,
  141. connectionStatus?: string,
  142. ) {
  143. const card = await openTerminalPage(page, terminalName)
  144. if (connectionStatus) await expect(card.getByText(connectionStatus, { exact: true })).toBeVisible()
  145. await captureScreenshot(page, testInfo, label, [page.locator('code:visible')])
  146. }
  147. async function captureTerminalCommand(
  148. page: Page,
  149. testInfo: TestInfo,
  150. terminalName: string,
  151. label: string,
  152. commandStatus: string,
  153. ) {
  154. const card = await openTerminalPage(page, terminalName)
  155. await card.getByRole('button', { name: /业务控制|查看详情/ }).click()
  156. const dialog = page.locator('.el-dialog:visible').last()
  157. await expect(dialog).toContainText(terminalName)
  158. const status = dialog.getByText(commandStatus, { exact: true })
  159. await status.scrollIntoViewIfNeeded()
  160. await expect(status).toBeVisible()
  161. await captureScreenshot(page, testInfo, label, [dialog.locator('code:visible')])
  162. }
  163. async function captureDeletedState(
  164. page: Page,
  165. testInfo: TestInfo,
  166. terminalName: string,
  167. ) {
  168. await page.goto('/agents/remote-control')
  169. await expect(page.getByText('远程控制', { exact: true }).first()).toBeVisible()
  170. const keyword = page.getByPlaceholder('搜索终端名称、部署位置或业务用途')
  171. await keyword.fill(terminalName)
  172. await expect(page.locator('.terminal-card').filter({ hasText: terminalName })).toHaveCount(0)
  173. await expect(page.getByText('没有符合条件的远程终端')).toBeVisible()
  174. await captureScreenshot(page, testInfo, '远控终端-13-页面删除后无残留')
  175. }
  176. async function fallbackCleanup(
  177. api: APIRequestContext,
  178. headers: Headers,
  179. terminal: VersionedTerminal | null,
  180. activities: RuntimeActivity[],
  181. cleanupErrors: string[],
  182. ) {
  183. if (!terminal) return
  184. try {
  185. const detail = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers })
  186. if (detail.status() === 404) {
  187. activities.push({
  188. module: '远控终端运行协议',
  189. action: '失败兜底清理',
  190. result: 'CLEANED',
  191. resourceId: terminal.id,
  192. detail: '终端已不存在',
  193. })
  194. return
  195. }
  196. if (detail.status() !== 200) {
  197. const message = `远控终端清理读取失败:HTTP ${detail.status()}`
  198. cleanupErrors.push(message)
  199. activities.push({
  200. module: '远控终端运行协议',
  201. action: '失败兜底清理',
  202. result: 'CLEANUP_FAILED',
  203. httpStatus: detail.status(),
  204. resourceId: terminal.id,
  205. detail: message,
  206. })
  207. return
  208. }
  209. const current = asRecord(envelopeData(await detail.json()))
  210. const dataVersion = Number(current.dataVersion)
  211. const removed = await api.delete(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, {
  212. headers,
  213. params: { dataVersion: String(dataVersion) },
  214. })
  215. if (![200, 404].includes(removed.status())) {
  216. const message = `远控终端清理删除失败:HTTP ${removed.status()}`
  217. cleanupErrors.push(message)
  218. activities.push({
  219. module: '远控终端运行协议',
  220. action: '失败兜底清理',
  221. result: 'CLEANUP_FAILED',
  222. httpStatus: removed.status(),
  223. resourceId: terminal.id,
  224. detail: message,
  225. })
  226. return
  227. }
  228. const absent = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers })
  229. if (absent.status() !== 404) {
  230. const message = `远控终端清理后仍可读取:HTTP ${absent.status()}`
  231. cleanupErrors.push(message)
  232. activities.push({
  233. module: '远控终端运行协议',
  234. action: '失败兜底清理',
  235. result: 'CLEANUP_FAILED',
  236. httpStatus: absent.status(),
  237. resourceId: terminal.id,
  238. detail: message,
  239. })
  240. return
  241. }
  242. activities.push({
  243. module: '远控终端运行协议',
  244. action: '失败兜底清理',
  245. result: 'CLEANED',
  246. httpStatus: removed.status(),
  247. resourceId: terminal.id,
  248. })
  249. } catch {
  250. const message = '远控终端清理发生异常(敏感上下文未写入报告)'
  251. cleanupErrors.push(message)
  252. activities.push({
  253. module: '远控终端运行协议',
  254. action: '失败兜底清理',
  255. result: 'CLEANUP_FAILED',
  256. resourceId: terminal.id,
  257. detail: message,
  258. })
  259. }
  260. }
  261. test.describe('真实远控终端运行协议', () => {
  262. test('页面创建编辑停启、运行端鉴权、页面下发、状态事件、页面轮换与删除闭环', async ({ page }, testInfo) => {
  263. test.setTimeout(180_000)
  264. const api = await playwrightRequest.newContext({ baseURL: runtimeApiBaseURL })
  265. const activities: RuntimeActivity[] = []
  266. const cleanupErrors: string[] = []
  267. let headers: Headers = {}
  268. let terminal: VersionedTerminal | null = null
  269. let terminalDeleted = false
  270. let activeStage = '初始化管理员会话'
  271. try {
  272. headers = await authHeaders(api)
  273. await loginAsAdmin(page)
  274. const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.toUpperCase()
  275. // 页面业务约束为 36 字符;保留 APE2E 标记和随机尾缀,避免浏览器按 maxlength
  276. // 截断后测试仍拿未截断值做后验。
  277. const terminalName = `${runPrefix.slice(0, 22)}远控终端-${suffix.slice(-6)}`
  278. const terminalCode = `APE2E-RT-${suffix}`.slice(0, 64)
  279. const initialLocation = '智能装备实训中心 A203'
  280. const editedLocation = '智能装备实训中心 B306'
  281. activeStage = '通过页面创建终端并接收一次性凭据'
  282. await page.goto('/agents/remote-control')
  283. await expect(page.getByRole('button', { name: '新增终端' })).toBeVisible()
  284. await page.getByRole('button', { name: '新增终端' }).click()
  285. let editor = page.locator('.el-dialog:visible').last()
  286. await expect(editor).toContainText('新增远程终端')
  287. await fillFormItem(editor, '终端名称', terminalName)
  288. await fillFormItem(editor, '部署位置', initialLocation)
  289. const initialAgent = await selectBoundAgent(page, editor)
  290. await setSwitchChecked(editor.locator('.option-grid .el-switch').nth(0), true)
  291. await setSwitchChecked(editor.locator('.option-grid .el-switch').nth(1), true)
  292. await editor.locator('.technical-collapse .el-collapse-item__header').click()
  293. await fillFormItem(editor, '终端编码', terminalCode)
  294. await fillFormItem(editor, '控制服务完整 URL', 'wss://ape2e.invalid/control')
  295. const createdResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST'
  296. && /\/api\/v1\/remote-terminals(?:\?|$)/.test(response.url()))
  297. await editor.getByRole('button', { name: '保存终端' }).click()
  298. const createdResponse = await createdResponsePromise
  299. const created = asRecord(envelopeData(await createdResponse.json()))
  300. const createdTerminal = asRecord(created.terminal)
  301. const credential = asRecord(created.credential)
  302. const terminalId = String(createdTerminal.id ?? '')
  303. const initialSecret = String(credential.secret ?? '')
  304. const initialVersion = Number(createdTerminal.dataVersion)
  305. const displayedInitialSecret = initialSecret
  306. ? await consumeOneTimeSecret(page, '请立即保存终端接入密钥')
  307. : ''
  308. assertStatus(createdResponse, 201, '创建远控终端')
  309. if (!terminalId || !Number.isInteger(initialVersion)) throw new Error('创建响应缺少终端标识或数据版本')
  310. if (!initialSecret || credential.shownOnce !== true) throw new Error('创建响应没有生成仅显示一次的终端凭据')
  311. if (displayedInitialSecret !== initialSecret) throw new Error('页面一次性终端密钥与创建响应不一致')
  312. terminal = { id: terminalId, dataVersion: initialVersion }
  313. const createdDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers })
  314. const createdDetail = asRecord(await responseData(createdDetailResponse, 200, '创建终端 API 后验'))
  315. expect(createdDetail).toMatchObject({
  316. name: terminalName,
  317. code: terminalCode,
  318. location: initialLocation,
  319. boundAgent: initialAgent,
  320. enabled: true,
  321. volume: 60,
  322. allowInterrupt: true,
  323. })
  324. recordPass(activities, '页面创建启用终端、绑定真实智能体并接收一次性凭据', createdResponse, terminalId, '页面展示值与响应一致;凭据只保存在测试进程内,未写入附件或截图')
  325. await captureTerminalCard(page, testInfo, terminalName, '远控终端-01-创建后离线', '离线')
  326. activeStage = '通过页面编辑绑定智能体、音量与打断设置'
  327. let card = await openTerminalPage(page, terminalName)
  328. await card.getByRole('button', { name: '编辑', exact: true }).click()
  329. editor = page.locator('.el-dialog:visible').last()
  330. await expect(editor).toContainText('编辑远程终端')
  331. await fillFormItem(editor, '部署位置', editedLocation)
  332. const editedAgent = await selectBoundAgent(page, editor, initialAgent)
  333. await setSliderValue(editor.getByRole('slider'), 72)
  334. await setSwitchChecked(editor.locator('.option-grid .el-switch').nth(0), false)
  335. const editResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT'
  336. && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`)
  337. && !response.url().includes('/status'))
  338. await editor.getByRole('button', { name: '保存终端' }).click()
  339. const editResponse = await editResponsePromise
  340. const editedTerminal = asRecord(await responseData(editResponse, 200, '页面编辑远控终端'))
  341. terminal.dataVersion = Number(editedTerminal.dataVersion)
  342. const editedDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers })
  343. const editedDetail = asRecord(await responseData(editedDetailResponse, 200, '编辑终端 API 后验'))
  344. expect(editedDetail).toMatchObject({
  345. location: editedLocation,
  346. boundAgent: editedAgent,
  347. enabled: true,
  348. volume: 72,
  349. allowInterrupt: false,
  350. })
  351. expect(String(editedDetail.boundAgentId ?? '')).not.toBe('')
  352. card = await openTerminalPage(page, terminalName)
  353. await expect(card).toContainText(editedLocation)
  354. await expect(card).toContainText(editedAgent)
  355. await card.getByRole('button', { name: '编辑', exact: true }).click()
  356. editor = page.locator('.el-dialog:visible').last()
  357. await expect(editor.getByRole('slider')).toHaveAttribute('aria-valuenow', '72')
  358. await expect(editor.locator('.option-grid .el-switch').nth(0)).not.toHaveClass(/is-checked/)
  359. await captureScreenshot(page, testInfo, '远控终端-02-编辑绑定音量打断刷新持久化')
  360. await editor.getByRole('button', { name: '取消' }).click()
  361. recordPass(activities, '页面编辑并刷新验证绑定智能体、音量与语音打断设置', editResponse, terminalId, `${initialAgent} → ${editedAgent},音量 72%,禁用语音打断`)
  362. activeStage = '通过页面停用并重新启用终端'
  363. card = await openTerminalPage(page, terminalName)
  364. let statusSwitch = card.locator('footer .el-switch')
  365. const disableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT'
  366. && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/status`))
  367. await statusSwitch.click()
  368. const disableResponse = await disableResponsePromise
  369. const disabledTerminal = asRecord(await responseData(disableResponse, 200, '页面停用远控终端'))
  370. terminal.dataVersion = Number(disabledTerminal.dataVersion)
  371. await expect(card.getByText('已停用', { exact: true })).toBeVisible()
  372. const disabledDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers })
  373. expect(asRecord(await responseData(disabledDetailResponse, 200, '停用终端 API 后验')).enabled).toBe(false)
  374. await captureScreenshot(page, testInfo, '远控终端-03-页面停用')
  375. card = await openTerminalPage(page, terminalName)
  376. statusSwitch = card.locator('footer .el-switch')
  377. const enableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT'
  378. && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/status`))
  379. await statusSwitch.click()
  380. const enableResponse = await enableResponsePromise
  381. const enabledTerminal = asRecord(await responseData(enableResponse, 200, '页面重新启用远控终端'))
  382. terminal.dataVersion = Number(enabledTerminal.dataVersion)
  383. const enabledDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers })
  384. expect(asRecord(await responseData(enabledDetailResponse, 200, '启用终端 API 后验')).enabled).toBe(true)
  385. recordPass(activities, '页面停用终端', disableResponse, terminalId)
  386. recordPass(activities, '页面重新启用终端', enableResponse, terminalId)
  387. const runtimePath = `/open/v1/remote-terminals/${encodeURIComponent(terminalId)}`
  388. activeStage = '拒绝缺失和错误凭据的心跳'
  389. const missingHeartbeat = await api.post(`${runtimePath}/heartbeat`, {
  390. data: { runtimeVersion: 'APE2E-1.0' },
  391. })
  392. assertStatus(missingHeartbeat, 401, '缺失凭据心跳必须拒绝')
  393. recordPass(activities, '缺失凭据心跳被拒绝', missingHeartbeat, terminalId)
  394. const wrongHeartbeat = await api.post(`${runtimePath}/heartbeat`, {
  395. headers: { 'X-Terminal-Secret': 'ape2e-intentionally-invalid' },
  396. data: { runtimeVersion: 'APE2E-1.0' },
  397. })
  398. assertStatus(wrongHeartbeat, 401, '错误凭据心跳必须拒绝')
  399. recordPass(activities, '错误凭据心跳被拒绝', wrongHeartbeat, terminalId)
  400. await captureTerminalCard(page, testInfo, terminalName, '远控终端-04-非法心跳拒绝后仍离线', '离线')
  401. activeStage = '使用正确凭据上报心跳'
  402. const runtimeHeaders = { 'X-Terminal-Secret': initialSecret }
  403. const heartbeatResponse = await api.post(`${runtimePath}/heartbeat`, {
  404. headers: runtimeHeaders,
  405. data: { runtimeVersion: 'APE2E-1.0' },
  406. })
  407. const heartbeat = asRecord(await responseData(heartbeatResponse, 200, '终端正确心跳'))
  408. expect(heartbeat.online).toBe(true)
  409. expect(heartbeatResponse.headers()['cache-control']).toBe('no-store')
  410. recordPass(activities, '正确凭据心跳使终端在线', heartbeatResponse, terminalId, '响应带 Cache-Control: no-store')
  411. await captureTerminalCard(page, testInfo, terminalName, '远控终端-05-正确心跳后在线', '在线')
  412. activeStage = '通过管理页面下发唤醒指令'
  413. card = await openTerminalPage(page, terminalName)
  414. await card.getByRole('button', { name: /业务控制/ }).click()
  415. let detailDialog = page.locator('.el-dialog:visible').last()
  416. await expect(detailDialog).toContainText('终端在线')
  417. const commandResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST'
  418. && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/commands`))
  419. await detailDialog.locator('.command-grid button').filter({ hasText: '远程唤醒' }).click()
  420. const commandResponse = await commandResponsePromise
  421. const command = asRecord(await responseData(commandResponse, 202, '下发远控指令'))
  422. const commandId = String(command.id ?? '')
  423. if (!commandId) throw new Error('指令下发响应缺少指令标识')
  424. expect(command.statusCode).toBe('QUEUED')
  425. await expect(detailDialog.locator('.command-history')).toContainText('远程唤醒')
  426. await expect(detailDialog.locator('.command-history')).toContainText('等待终端确认')
  427. recordPass(activities, '管理页面下发唤醒指令并立即显示历史', commandResponse, commandId, '状态 QUEUED')
  428. await captureScreenshot(page, testInfo, '远控终端-06-页面下发指令已排队', [detailDialog.locator('code:visible')])
  429. activeStage = '运行端拉取指令并生成投递事件'
  430. const pullResponse = await api.post(`${runtimePath}/commands/pull`, {
  431. headers: runtimeHeaders,
  432. data: { limit: 10, waitSeconds: 0 },
  433. })
  434. const pulled = asRecord(await responseData(pullResponse, 200, '运行端拉取指令'))
  435. const items = asRecords(pulled.items)
  436. expect(items).toHaveLength(1)
  437. const delivery = items[0]!
  438. expect(String(delivery.id ?? '')).toBe(commandId)
  439. expect(delivery.deliveryAttempt).toBe(1)
  440. expect(delivery.redelivery).toBe(false)
  441. const deliveryId = String(delivery.deliveryId ?? '')
  442. if (deliveryId.length < 8) throw new Error('指令拉取响应缺少有效投递标识')
  443. recordPass(activities, '运行端拉取指令并产生 SENT 投递事件', pullResponse, commandId, '首次投递且非重投')
  444. await captureTerminalCommand(page, testInfo, terminalName, '远控终端-07-运行端已拉取指令', '等待终端确认')
  445. const reportPath = `${runtimePath}/commands/${encodeURIComponent(commandId)}/reports`
  446. const ackReport = `report-${suffix}-ack`.slice(0, 64)
  447. const executingReport = `report-${suffix}-executing`.slice(0, 64)
  448. const succeededReport = `report-${suffix}-succeeded`.slice(0, 64)
  449. activeStage = '上报 ACK 并验证 reportId 幂等'
  450. const ackPayload = { reportId: ackReport, deliveryId, status: 'ACK' }
  451. const ackResponse = await api.post(reportPath, { headers: runtimeHeaders, data: ackPayload })
  452. const ack = asRecord(await responseData(ackResponse, 200, '上报 ACK'))
  453. expect(ack.idempotent).toBe(false)
  454. expect(ack.status).toBe('ACK')
  455. const replayResponse = await api.post(reportPath, { headers: runtimeHeaders, data: ackPayload })
  456. const replay = asRecord(await responseData(replayResponse, 200, '重复上报 ACK'))
  457. expect(replay.idempotent).toBe(true)
  458. expect(replay.status).toBe('ACK')
  459. recordPass(activities, '上报 ACK 事件', ackResponse, commandId, '状态 ACKNOWLEDGED')
  460. recordPass(activities, '相同 reportId 重放保持幂等', replayResponse, commandId, 'idempotent=true,未重复推进状态')
  461. await captureTerminalCommand(page, testInfo, terminalName, '远控终端-08-ACK与幂等重放', '已送达')
  462. activeStage = '上报 EXECUTING 事件'
  463. const executingResponse = await api.post(reportPath, {
  464. headers: runtimeHeaders,
  465. data: {
  466. reportId: executingReport,
  467. deliveryId,
  468. status: 'EXECUTING',
  469. result: { message: '开始执行唤醒', durationMs: 8 },
  470. },
  471. })
  472. const executing = asRecord(await responseData(executingResponse, 200, '上报 EXECUTING'))
  473. expect(executing.idempotent).toBe(false)
  474. expect(executing.status).toBe('EXECUTING')
  475. recordPass(activities, '上报 EXECUTING 事件', executingResponse, commandId, '状态 EXECUTING')
  476. await captureTerminalCommand(page, testInfo, terminalName, '远控终端-09-指令执行中', '执行中')
  477. activeStage = '上报 SUCCEEDED 事件'
  478. const succeededResponse = await api.post(reportPath, {
  479. headers: runtimeHeaders,
  480. data: {
  481. reportId: succeededReport,
  482. deliveryId,
  483. status: 'SUCCEEDED',
  484. result: { message: '唤醒执行完成', durationMs: 28 },
  485. },
  486. })
  487. const succeeded = asRecord(await responseData(succeededResponse, 200, '上报 SUCCEEDED'))
  488. expect(succeeded.idempotent).toBe(false)
  489. expect(succeeded.status).toBe('SUCCEEDED')
  490. recordPass(activities, '上报 SUCCEEDED 事件', succeededResponse, commandId, '状态 SUCCEEDED')
  491. await captureTerminalCommand(page, testInfo, terminalName, '远控终端-10-指令执行成功', '执行成功')
  492. activeStage = '管理端详情和历史聚合校验事件链'
  493. const commandDetailResponse = await api.get(`/api/v1/remote-commands/${encodeURIComponent(commandId)}`, { headers })
  494. const commandDetail = asRecord(await responseData(commandDetailResponse, 200, '读取远控指令详情'))
  495. expect(commandDetail.statusCode).toBe('SUCCEEDED')
  496. expect(commandDetail.deliveryAttempts).toBe(1)
  497. expect(String(commandDetail.ackAt ?? '')).not.toBe('')
  498. const historyResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/commands`, {
  499. headers,
  500. params: { page: '1', pageSize: '20' },
  501. })
  502. const history = asRecord(await responseData(historyResponse, 200, '读取远控指令历史'))
  503. const historyItem = asRecords(history.items).find((item) => String(item.id ?? '') === commandId)
  504. expect(historyItem?.statusCode).toBe('SUCCEEDED')
  505. recordPass(activities, '管理端详情与历史聚合校验完整事件链', historyResponse, commandId, 'SENT → ACKNOWLEDGED → EXECUTING → SUCCEEDED')
  506. await captureTerminalCommand(page, testInfo, terminalName, '远控终端-11-页面历史与事件链', '执行成功')
  507. activeStage = '通过页面轮换凭据并验证新旧凭据边界'
  508. card = await openTerminalPage(page, terminalName)
  509. await card.getByRole('button', { name: /业务控制/ }).click()
  510. detailDialog = page.locator('.el-dialog:visible').last()
  511. await detailDialog.locator('.console-technical-collapse .el-collapse-item__header').click()
  512. const rotateResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST'
  513. && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/rotate-secret`))
  514. await detailDialog.getByRole('button', { name: '轮换接入密钥' }).click()
  515. let messageBox = page.locator('.el-message-box:visible').last()
  516. await expect(messageBox).toContainText('轮换后旧密钥将立即失效')
  517. await messageBox.getByRole('button', { name: '确认轮换' }).click()
  518. const rotateResponse = await rotateResponsePromise
  519. const rotated = asRecord(envelopeData(await rotateResponse.json()))
  520. const rotatedCredential = asRecord(rotated.credential)
  521. const rotatedSecret = String(rotatedCredential.secret ?? '')
  522. const rotatedVersion = Number(rotated.dataVersion)
  523. const displayedRotatedSecret = rotatedSecret
  524. ? await consumeOneTimeSecret(page, '新终端接入密钥(仅显示一次)')
  525. : ''
  526. assertStatus(rotateResponse, 200, '轮换终端凭据')
  527. if (!rotatedSecret || rotatedSecret === initialSecret || rotatedCredential.shownOnce !== true) {
  528. throw new Error('终端凭据轮换未生成独立且仅显示一次的新凭据')
  529. }
  530. if (!Number.isInteger(rotatedVersion) || rotatedVersion <= terminal.dataVersion) throw new Error('凭据轮换后数据版本没有递增')
  531. if (displayedRotatedSecret !== rotatedSecret) throw new Error('页面一次性新密钥与轮换响应不一致')
  532. terminal.dataVersion = rotatedVersion
  533. const oldSecretHeartbeat = await api.post(`${runtimePath}/heartbeat`, {
  534. headers: runtimeHeaders,
  535. data: { runtimeVersion: 'APE2E-1.0' },
  536. })
  537. assertStatus(oldSecretHeartbeat, 401, '轮换后旧凭据必须失效')
  538. const rotatedHeaders = { 'X-Terminal-Secret': rotatedSecret }
  539. const newSecretHeartbeat = await api.post(`${runtimePath}/heartbeat`, {
  540. headers: rotatedHeaders,
  541. data: { runtimeVersion: 'APE2E-1.0' },
  542. })
  543. const newHeartbeat = asRecord(await responseData(newSecretHeartbeat, 200, '轮换后新凭据心跳'))
  544. expect(newHeartbeat.online).toBe(true)
  545. recordPass(activities, '页面轮换终端一次性凭据', rotateResponse, terminalId, '新凭据仅保存在测试进程内,未写入附件或截图')
  546. recordPass(activities, '轮换后旧凭据被拒绝', oldSecretHeartbeat, terminalId)
  547. recordPass(activities, '轮换后新凭据可用', newSecretHeartbeat, terminalId)
  548. await captureTerminalCard(page, testInfo, terminalName, '远控终端-12-页面轮换后新凭据在线', '在线')
  549. activeStage = '通过页面删除终端并确认资源不可访问'
  550. card = await openTerminalPage(page, terminalName)
  551. const deletedResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE'
  552. && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`))
  553. await card.getByRole('button', { name: '删除', exact: true }).click()
  554. messageBox = page.locator('.el-message-box:visible').last()
  555. await expect(messageBox).toContainText(`确定删除远程终端“${terminalName}”吗`)
  556. await messageBox.getByRole('button', { name: '确认删除' }).click()
  557. const deletedResponse = await deletedResponsePromise
  558. const deleted = asRecord(await responseData(deletedResponse, 200, '删除远控终端'))
  559. expect(deleted.deleted).toBe(true)
  560. const absentDetail = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers })
  561. assertStatus(absentDetail, 404, '删除后终端详情必须不可访问')
  562. const absentCommand = await api.get(`/api/v1/remote-commands/${encodeURIComponent(commandId)}`, { headers })
  563. assertStatus(absentCommand, 404, '删除终端后关联指令详情必须不可访问')
  564. const absentListResponse = await api.get('/api/v1/remote-terminals', {
  565. headers,
  566. params: { page: '1', pageSize: '20', keyword: terminalName },
  567. })
  568. const absentList = asRecord(await responseData(absentListResponse, 200, '删除后检索远控终端'))
  569. expect(absentList.total).toBe(0)
  570. const deletedRuntimeHeartbeat = await api.post(`${runtimePath}/heartbeat`, {
  571. headers: rotatedHeaders,
  572. data: { runtimeVersion: 'APE2E-1.0' },
  573. })
  574. assertStatus(deletedRuntimeHeartbeat, 401, '删除后运行端凭据必须失效')
  575. terminalDeleted = true
  576. recordPass(activities, '页面删除终端并确认管理端无残留', deletedResponse, terminalId, '详情与关联指令均为 404,列表检索为 0')
  577. recordPass(activities, '删除后运行端凭据失效', deletedRuntimeHeartbeat, terminalId)
  578. await captureDeletedState(page, testInfo, terminalName)
  579. } catch (error) {
  580. activities.push({
  581. module: '远控终端运行协议',
  582. action: activeStage,
  583. result: 'FAIL',
  584. resourceId: terminal?.id,
  585. detail: '阶段断言未通过;敏感请求上下文未写入活动附件。',
  586. })
  587. throw error
  588. } finally {
  589. if (!terminalDeleted) await fallbackCleanup(api, headers, terminal, activities, cleanupErrors)
  590. await attachJson(testInfo, '远控终端运行协议-生命周期与清理', {
  591. runId,
  592. runPrefix,
  593. apiBase: '本机正式 API(地址已省略)',
  594. activities,
  595. cleanupComplete: cleanupErrors.length === 0,
  596. cleanupErrors,
  597. verifiedEventSequence: ['SENT', 'ACKNOWLEDGED', 'EXECUTING', 'SUCCEEDED'],
  598. managementSubmission: '创建、编辑、绑定智能体、音量/打断设置、停启、指令下发、凭据轮换与删除均由真实页面控件提交;API 仅承担后验和运行端协议。',
  599. security: '管理员密码、访问令牌、终端一次性凭据与请求头均未写入日志、活动附件或截图;一次性密钥警告框也纳入失败截图遮罩;Playwright trace/video 已禁用。',
  600. })
  601. await api.dispose()
  602. }
  603. expect(cleanupErrors, '远控终端测试数据必须清理干净').toEqual([])
  604. })
  605. })