import { request as playwrightRequest, type APIRequestContext, type Page, type TestInfo, } from '@playwright/test' import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' import { authHeaders, envelopeData, fillFormItem, formItem, loginAsAdmin } from './helpers' type JsonRecord = Record type Headers = Record interface VersionedTerminal { id: string dataVersion: number } interface RuntimeActivity { module: string action: string result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' httpStatus?: number resourceId?: string detail?: string } interface ResponseLike { status(): number json(): Promise headers(): Record } // 本地 Vite 目前只代理 /api;需要验证 /open/v1 运行端协议时可把 // E2E_RUNTIME_API_URL 指向同库启动、且已注入进程级 pepper 的真实 API。 // 部署环境的 BASE_URL 同时代理 /api 与 /open 时无需单独配置。 const runtimeApiBaseURL = ( process.env.E2E_RUNTIME_API_URL || process.env.BASE_URL || 'http://127.0.0.1:8003' ).replace(/\/$/, '') const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {} const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] const assertStatus = (response: ResponseLike, expected: number | number[], label: string) => { const allowed = Array.isArray(expected) ? expected : [expected] expect(allowed, `${label}:HTTP ${response.status()}`).toContain(response.status()) } async function responseData( response: ResponseLike, expected: number | number[], label: string, ): Promise { assertStatus(response, expected, label) let body: unknown try { body = await response.json() } catch { throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`) } return envelopeData(body) } async function selectBoundAgent(page: Page, dialog: ReturnType, excluded = '') { const select = formItem(dialog, /业务用途/).locator('.el-select') await select.click() const dropdown = page.locator('.el-select-dropdown:visible').last() await expect(dropdown).toBeVisible() const options = dropdown.locator('.el-select-dropdown__item:not(.is-disabled)') await expect(options.first()).toBeVisible() const labels = (await options.allInnerTexts()).map((value) => value.trim()).filter(Boolean) const selected = labels.find((value) => value !== excluded) if (!selected) throw new Error(excluded ? '没有第二个可绑定的真实智能体' : '没有可绑定的真实智能体') await options.filter({ hasText: selected }).first().click() return selected } async function setSliderValue(slider: ReturnType, target: number) { await slider.focus() let current = Number(await slider.getAttribute('aria-valuenow')) if (!Number.isFinite(current)) throw new Error('终端音量滑块缺少 aria-valuenow') const key = target > current ? 'ArrowRight' : 'ArrowLeft' while (current !== target) { await slider.press(key) current = Number(await slider.getAttribute('aria-valuenow')) } await expect(slider).toHaveAttribute('aria-valuenow', String(target)) } async function setSwitchChecked(control: ReturnType, checked: boolean) { const current = (await control.getAttribute('class'))?.includes('is-checked') ?? false if (current !== checked) await control.click() if (checked) await expect(control).toHaveClass(/is-checked/) else await expect(control).not.toHaveClass(/is-checked/) } async function consumeOneTimeSecret(page: Page, title: string | RegExp) { const box = page.locator('.el-message-box:visible').last() await box.waitFor({ state: 'visible' }) // 在同一个浏览器求值中读取后立即覆盖正文。这样后续任一步骤失败时, // Playwright 自动生成的错误上下文、DOM 快照和失败截图都只能看到遮罩值。 const displayed = await box.evaluate((element) => { const titleElement = element.querySelector('.el-message-box__title') const messageElement = element.querySelector('.el-message-box__message') const value = { title: titleElement?.textContent?.trim() || '', secret: messageElement?.textContent?.trim() || '', } if (messageElement) messageElement.textContent = '••••••••(一次性密钥已由测试进程安全接收并立即遮罩)' return value }) await box.getByRole('button', { name: '我已安全保存' }).click() await box.waitFor({ state: 'hidden' }) const matchesTitle = typeof title === 'string' ? displayed.title === title : title.test(displayed.title) if (!matchesTitle) throw new Error('一次性密钥弹窗标题不符合预期') if (displayed.secret.length < 24) throw new Error('页面没有展示有效的一次性终端接入密钥') return displayed.secret } const recordPass = ( activities: RuntimeActivity[], action: string, response: ResponseLike, resourceId?: string, detail?: string, ) => activities.push({ module: '远控终端运行协议', action, result: 'PASS', httpStatus: response.status(), resourceId, detail, }) async function openTerminalPage(page: Page, terminalName: string) { await page.goto('/agents/remote-control') await expect(page.getByText('远程控制', { exact: true }).first()).toBeVisible() const keyword = page.getByPlaceholder('搜索终端名称、部署位置或业务用途') await expect(keyword).toBeVisible() await keyword.fill(terminalName) const card = page.locator('.terminal-card').filter({ hasText: terminalName }).first() await expect(card).toBeVisible() return card } async function captureTerminalCard( page: Page, testInfo: TestInfo, terminalName: string, label: string, connectionStatus?: string, ) { const card = await openTerminalPage(page, terminalName) if (connectionStatus) await expect(card.getByText(connectionStatus, { exact: true })).toBeVisible() await captureScreenshot(page, testInfo, label, [page.locator('code:visible')]) } async function captureTerminalCommand( page: Page, testInfo: TestInfo, terminalName: string, label: string, commandStatus: string, ) { const card = await openTerminalPage(page, terminalName) await card.getByRole('button', { name: /业务控制|查看详情/ }).click() const dialog = page.locator('.el-dialog:visible').last() await expect(dialog).toContainText(terminalName) const status = dialog.getByText(commandStatus, { exact: true }) await status.scrollIntoViewIfNeeded() await expect(status).toBeVisible() await captureScreenshot(page, testInfo, label, [dialog.locator('code:visible')]) } async function captureDeletedState( page: Page, testInfo: TestInfo, terminalName: string, ) { await page.goto('/agents/remote-control') await expect(page.getByText('远程控制', { exact: true }).first()).toBeVisible() const keyword = page.getByPlaceholder('搜索终端名称、部署位置或业务用途') await keyword.fill(terminalName) await expect(page.locator('.terminal-card').filter({ hasText: terminalName })).toHaveCount(0) await expect(page.getByText('没有符合条件的远程终端')).toBeVisible() await captureScreenshot(page, testInfo, '远控终端-13-页面删除后无残留') } async function fallbackCleanup( api: APIRequestContext, headers: Headers, terminal: VersionedTerminal | null, activities: RuntimeActivity[], cleanupErrors: string[], ) { if (!terminal) return try { const detail = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers }) if (detail.status() === 404) { activities.push({ module: '远控终端运行协议', action: '失败兜底清理', result: 'CLEANED', resourceId: terminal.id, detail: '终端已不存在', }) return } if (detail.status() !== 200) { const message = `远控终端清理读取失败:HTTP ${detail.status()}` cleanupErrors.push(message) activities.push({ module: '远控终端运行协议', action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: detail.status(), resourceId: terminal.id, detail: message, }) return } const current = asRecord(envelopeData(await detail.json())) const dataVersion = Number(current.dataVersion) const removed = await api.delete(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers, params: { dataVersion: String(dataVersion) }, }) if (![200, 404].includes(removed.status())) { const message = `远控终端清理删除失败:HTTP ${removed.status()}` cleanupErrors.push(message) activities.push({ module: '远控终端运行协议', action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: removed.status(), resourceId: terminal.id, detail: message, }) return } const absent = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers }) if (absent.status() !== 404) { const message = `远控终端清理后仍可读取:HTTP ${absent.status()}` cleanupErrors.push(message) activities.push({ module: '远控终端运行协议', action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: absent.status(), resourceId: terminal.id, detail: message, }) return } activities.push({ module: '远控终端运行协议', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: terminal.id, }) } catch { const message = '远控终端清理发生异常(敏感上下文未写入报告)' cleanupErrors.push(message) activities.push({ module: '远控终端运行协议', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: terminal.id, detail: message, }) } } test.describe('真实远控终端运行协议', () => { test('页面创建编辑停启、运行端鉴权、页面下发、状态事件、页面轮换与删除闭环', async ({ page }, testInfo) => { test.setTimeout(180_000) const api = await playwrightRequest.newContext({ baseURL: runtimeApiBaseURL }) const activities: RuntimeActivity[] = [] const cleanupErrors: string[] = [] let headers: Headers = {} let terminal: VersionedTerminal | null = null let terminalDeleted = false let activeStage = '初始化管理员会话' try { headers = await authHeaders(api) await loginAsAdmin(page) const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.toUpperCase() // 页面业务约束为 36 字符;保留 APE2E 标记和随机尾缀,避免浏览器按 maxlength // 截断后测试仍拿未截断值做后验。 const terminalName = `${runPrefix.slice(0, 22)}远控终端-${suffix.slice(-6)}` const terminalCode = `APE2E-RT-${suffix}`.slice(0, 64) const initialLocation = '智能装备实训中心 A203' const editedLocation = '智能装备实训中心 B306' activeStage = '通过页面创建终端并接收一次性凭据' await page.goto('/agents/remote-control') await expect(page.getByRole('button', { name: '新增终端' })).toBeVisible() await page.getByRole('button', { name: '新增终端' }).click() let editor = page.locator('.el-dialog:visible').last() await expect(editor).toContainText('新增远程终端') await fillFormItem(editor, '终端名称', terminalName) await fillFormItem(editor, '部署位置', initialLocation) const initialAgent = await selectBoundAgent(page, editor) await setSwitchChecked(editor.locator('.option-grid .el-switch').nth(0), true) await setSwitchChecked(editor.locator('.option-grid .el-switch').nth(1), true) await editor.locator('.technical-collapse .el-collapse-item__header').click() await fillFormItem(editor, '终端编码', terminalCode) await fillFormItem(editor, '控制服务完整 URL', 'wss://ape2e.invalid/control') const createdResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/remote-terminals(?:\?|$)/.test(response.url())) await editor.getByRole('button', { name: '保存终端' }).click() const createdResponse = await createdResponsePromise const created = asRecord(envelopeData(await createdResponse.json())) const createdTerminal = asRecord(created.terminal) const credential = asRecord(created.credential) const terminalId = String(createdTerminal.id ?? '') const initialSecret = String(credential.secret ?? '') const initialVersion = Number(createdTerminal.dataVersion) const displayedInitialSecret = initialSecret ? await consumeOneTimeSecret(page, '请立即保存终端接入密钥') : '' assertStatus(createdResponse, 201, '创建远控终端') if (!terminalId || !Number.isInteger(initialVersion)) throw new Error('创建响应缺少终端标识或数据版本') if (!initialSecret || credential.shownOnce !== true) throw new Error('创建响应没有生成仅显示一次的终端凭据') if (displayedInitialSecret !== initialSecret) throw new Error('页面一次性终端密钥与创建响应不一致') terminal = { id: terminalId, dataVersion: initialVersion } const createdDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers }) const createdDetail = asRecord(await responseData(createdDetailResponse, 200, '创建终端 API 后验')) expect(createdDetail).toMatchObject({ name: terminalName, code: terminalCode, location: initialLocation, boundAgent: initialAgent, enabled: true, volume: 60, allowInterrupt: true, }) recordPass(activities, '页面创建启用终端、绑定真实智能体并接收一次性凭据', createdResponse, terminalId, '页面展示值与响应一致;凭据只保存在测试进程内,未写入附件或截图') await captureTerminalCard(page, testInfo, terminalName, '远控终端-01-创建后离线', '离线') activeStage = '通过页面编辑绑定智能体、音量与打断设置' let card = await openTerminalPage(page, terminalName) await card.getByRole('button', { name: '编辑', exact: true }).click() editor = page.locator('.el-dialog:visible').last() await expect(editor).toContainText('编辑远程终端') await fillFormItem(editor, '部署位置', editedLocation) const editedAgent = await selectBoundAgent(page, editor, initialAgent) await setSliderValue(editor.getByRole('slider'), 72) await setSwitchChecked(editor.locator('.option-grid .el-switch').nth(0), false) const editResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`) && !response.url().includes('/status')) await editor.getByRole('button', { name: '保存终端' }).click() const editResponse = await editResponsePromise const editedTerminal = asRecord(await responseData(editResponse, 200, '页面编辑远控终端')) terminal.dataVersion = Number(editedTerminal.dataVersion) const editedDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers }) const editedDetail = asRecord(await responseData(editedDetailResponse, 200, '编辑终端 API 后验')) expect(editedDetail).toMatchObject({ location: editedLocation, boundAgent: editedAgent, enabled: true, volume: 72, allowInterrupt: false, }) expect(String(editedDetail.boundAgentId ?? '')).not.toBe('') card = await openTerminalPage(page, terminalName) await expect(card).toContainText(editedLocation) await expect(card).toContainText(editedAgent) await card.getByRole('button', { name: '编辑', exact: true }).click() editor = page.locator('.el-dialog:visible').last() await expect(editor.getByRole('slider')).toHaveAttribute('aria-valuenow', '72') await expect(editor.locator('.option-grid .el-switch').nth(0)).not.toHaveClass(/is-checked/) await captureScreenshot(page, testInfo, '远控终端-02-编辑绑定音量打断刷新持久化') await editor.getByRole('button', { name: '取消' }).click() recordPass(activities, '页面编辑并刷新验证绑定智能体、音量与语音打断设置', editResponse, terminalId, `${initialAgent} → ${editedAgent},音量 72%,禁用语音打断`) activeStage = '通过页面停用并重新启用终端' card = await openTerminalPage(page, terminalName) let statusSwitch = card.locator('footer .el-switch') const disableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/status`)) await statusSwitch.click() const disableResponse = await disableResponsePromise const disabledTerminal = asRecord(await responseData(disableResponse, 200, '页面停用远控终端')) terminal.dataVersion = Number(disabledTerminal.dataVersion) await expect(card.getByText('已停用', { exact: true })).toBeVisible() const disabledDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers }) expect(asRecord(await responseData(disabledDetailResponse, 200, '停用终端 API 后验')).enabled).toBe(false) await captureScreenshot(page, testInfo, '远控终端-03-页面停用') card = await openTerminalPage(page, terminalName) statusSwitch = card.locator('footer .el-switch') const enableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/status`)) await statusSwitch.click() const enableResponse = await enableResponsePromise const enabledTerminal = asRecord(await responseData(enableResponse, 200, '页面重新启用远控终端')) terminal.dataVersion = Number(enabledTerminal.dataVersion) const enabledDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers }) expect(asRecord(await responseData(enabledDetailResponse, 200, '启用终端 API 后验')).enabled).toBe(true) recordPass(activities, '页面停用终端', disableResponse, terminalId) recordPass(activities, '页面重新启用终端', enableResponse, terminalId) const runtimePath = `/open/v1/remote-terminals/${encodeURIComponent(terminalId)}` activeStage = '拒绝缺失和错误凭据的心跳' const missingHeartbeat = await api.post(`${runtimePath}/heartbeat`, { data: { runtimeVersion: 'APE2E-1.0' }, }) assertStatus(missingHeartbeat, 401, '缺失凭据心跳必须拒绝') recordPass(activities, '缺失凭据心跳被拒绝', missingHeartbeat, terminalId) const wrongHeartbeat = await api.post(`${runtimePath}/heartbeat`, { headers: { 'X-Terminal-Secret': 'ape2e-intentionally-invalid' }, data: { runtimeVersion: 'APE2E-1.0' }, }) assertStatus(wrongHeartbeat, 401, '错误凭据心跳必须拒绝') recordPass(activities, '错误凭据心跳被拒绝', wrongHeartbeat, terminalId) await captureTerminalCard(page, testInfo, terminalName, '远控终端-04-非法心跳拒绝后仍离线', '离线') activeStage = '使用正确凭据上报心跳' const runtimeHeaders = { 'X-Terminal-Secret': initialSecret } const heartbeatResponse = await api.post(`${runtimePath}/heartbeat`, { headers: runtimeHeaders, data: { runtimeVersion: 'APE2E-1.0' }, }) const heartbeat = asRecord(await responseData(heartbeatResponse, 200, '终端正确心跳')) expect(heartbeat.online).toBe(true) expect(heartbeatResponse.headers()['cache-control']).toBe('no-store') recordPass(activities, '正确凭据心跳使终端在线', heartbeatResponse, terminalId, '响应带 Cache-Control: no-store') await captureTerminalCard(page, testInfo, terminalName, '远控终端-05-正确心跳后在线', '在线') activeStage = '通过管理页面下发唤醒指令' card = await openTerminalPage(page, terminalName) await card.getByRole('button', { name: /业务控制/ }).click() let detailDialog = page.locator('.el-dialog:visible').last() await expect(detailDialog).toContainText('终端在线') const commandResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/commands`)) await detailDialog.locator('.command-grid button').filter({ hasText: '远程唤醒' }).click() const commandResponse = await commandResponsePromise const command = asRecord(await responseData(commandResponse, 202, '下发远控指令')) const commandId = String(command.id ?? '') if (!commandId) throw new Error('指令下发响应缺少指令标识') expect(command.statusCode).toBe('QUEUED') await expect(detailDialog.locator('.command-history')).toContainText('远程唤醒') await expect(detailDialog.locator('.command-history')).toContainText('等待终端确认') recordPass(activities, '管理页面下发唤醒指令并立即显示历史', commandResponse, commandId, '状态 QUEUED') await captureScreenshot(page, testInfo, '远控终端-06-页面下发指令已排队', [detailDialog.locator('code:visible')]) activeStage = '运行端拉取指令并生成投递事件' const pullResponse = await api.post(`${runtimePath}/commands/pull`, { headers: runtimeHeaders, data: { limit: 10, waitSeconds: 0 }, }) const pulled = asRecord(await responseData(pullResponse, 200, '运行端拉取指令')) const items = asRecords(pulled.items) expect(items).toHaveLength(1) const delivery = items[0]! expect(String(delivery.id ?? '')).toBe(commandId) expect(delivery.deliveryAttempt).toBe(1) expect(delivery.redelivery).toBe(false) const deliveryId = String(delivery.deliveryId ?? '') if (deliveryId.length < 8) throw new Error('指令拉取响应缺少有效投递标识') recordPass(activities, '运行端拉取指令并产生 SENT 投递事件', pullResponse, commandId, '首次投递且非重投') await captureTerminalCommand(page, testInfo, terminalName, '远控终端-07-运行端已拉取指令', '等待终端确认') const reportPath = `${runtimePath}/commands/${encodeURIComponent(commandId)}/reports` const ackReport = `report-${suffix}-ack`.slice(0, 64) const executingReport = `report-${suffix}-executing`.slice(0, 64) const succeededReport = `report-${suffix}-succeeded`.slice(0, 64) activeStage = '上报 ACK 并验证 reportId 幂等' const ackPayload = { reportId: ackReport, deliveryId, status: 'ACK' } const ackResponse = await api.post(reportPath, { headers: runtimeHeaders, data: ackPayload }) const ack = asRecord(await responseData(ackResponse, 200, '上报 ACK')) expect(ack.idempotent).toBe(false) expect(ack.status).toBe('ACK') const replayResponse = await api.post(reportPath, { headers: runtimeHeaders, data: ackPayload }) const replay = asRecord(await responseData(replayResponse, 200, '重复上报 ACK')) expect(replay.idempotent).toBe(true) expect(replay.status).toBe('ACK') recordPass(activities, '上报 ACK 事件', ackResponse, commandId, '状态 ACKNOWLEDGED') recordPass(activities, '相同 reportId 重放保持幂等', replayResponse, commandId, 'idempotent=true,未重复推进状态') await captureTerminalCommand(page, testInfo, terminalName, '远控终端-08-ACK与幂等重放', '已送达') activeStage = '上报 EXECUTING 事件' const executingResponse = await api.post(reportPath, { headers: runtimeHeaders, data: { reportId: executingReport, deliveryId, status: 'EXECUTING', result: { message: '开始执行唤醒', durationMs: 8 }, }, }) const executing = asRecord(await responseData(executingResponse, 200, '上报 EXECUTING')) expect(executing.idempotent).toBe(false) expect(executing.status).toBe('EXECUTING') recordPass(activities, '上报 EXECUTING 事件', executingResponse, commandId, '状态 EXECUTING') await captureTerminalCommand(page, testInfo, terminalName, '远控终端-09-指令执行中', '执行中') activeStage = '上报 SUCCEEDED 事件' const succeededResponse = await api.post(reportPath, { headers: runtimeHeaders, data: { reportId: succeededReport, deliveryId, status: 'SUCCEEDED', result: { message: '唤醒执行完成', durationMs: 28 }, }, }) const succeeded = asRecord(await responseData(succeededResponse, 200, '上报 SUCCEEDED')) expect(succeeded.idempotent).toBe(false) expect(succeeded.status).toBe('SUCCEEDED') recordPass(activities, '上报 SUCCEEDED 事件', succeededResponse, commandId, '状态 SUCCEEDED') await captureTerminalCommand(page, testInfo, terminalName, '远控终端-10-指令执行成功', '执行成功') activeStage = '管理端详情和历史聚合校验事件链' const commandDetailResponse = await api.get(`/api/v1/remote-commands/${encodeURIComponent(commandId)}`, { headers }) const commandDetail = asRecord(await responseData(commandDetailResponse, 200, '读取远控指令详情')) expect(commandDetail.statusCode).toBe('SUCCEEDED') expect(commandDetail.deliveryAttempts).toBe(1) expect(String(commandDetail.ackAt ?? '')).not.toBe('') const historyResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/commands`, { headers, params: { page: '1', pageSize: '20' }, }) const history = asRecord(await responseData(historyResponse, 200, '读取远控指令历史')) const historyItem = asRecords(history.items).find((item) => String(item.id ?? '') === commandId) expect(historyItem?.statusCode).toBe('SUCCEEDED') recordPass(activities, '管理端详情与历史聚合校验完整事件链', historyResponse, commandId, 'SENT → ACKNOWLEDGED → EXECUTING → SUCCEEDED') await captureTerminalCommand(page, testInfo, terminalName, '远控终端-11-页面历史与事件链', '执行成功') activeStage = '通过页面轮换凭据并验证新旧凭据边界' card = await openTerminalPage(page, terminalName) await card.getByRole('button', { name: /业务控制/ }).click() detailDialog = page.locator('.el-dialog:visible').last() await detailDialog.locator('.console-technical-collapse .el-collapse-item__header').click() const rotateResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/rotate-secret`)) await detailDialog.getByRole('button', { name: '轮换接入密钥' }).click() let messageBox = page.locator('.el-message-box:visible').last() await expect(messageBox).toContainText('轮换后旧密钥将立即失效') await messageBox.getByRole('button', { name: '确认轮换' }).click() const rotateResponse = await rotateResponsePromise const rotated = asRecord(envelopeData(await rotateResponse.json())) const rotatedCredential = asRecord(rotated.credential) const rotatedSecret = String(rotatedCredential.secret ?? '') const rotatedVersion = Number(rotated.dataVersion) const displayedRotatedSecret = rotatedSecret ? await consumeOneTimeSecret(page, '新终端接入密钥(仅显示一次)') : '' assertStatus(rotateResponse, 200, '轮换终端凭据') if (!rotatedSecret || rotatedSecret === initialSecret || rotatedCredential.shownOnce !== true) { throw new Error('终端凭据轮换未生成独立且仅显示一次的新凭据') } if (!Number.isInteger(rotatedVersion) || rotatedVersion <= terminal.dataVersion) throw new Error('凭据轮换后数据版本没有递增') if (displayedRotatedSecret !== rotatedSecret) throw new Error('页面一次性新密钥与轮换响应不一致') terminal.dataVersion = rotatedVersion const oldSecretHeartbeat = await api.post(`${runtimePath}/heartbeat`, { headers: runtimeHeaders, data: { runtimeVersion: 'APE2E-1.0' }, }) assertStatus(oldSecretHeartbeat, 401, '轮换后旧凭据必须失效') const rotatedHeaders = { 'X-Terminal-Secret': rotatedSecret } const newSecretHeartbeat = await api.post(`${runtimePath}/heartbeat`, { headers: rotatedHeaders, data: { runtimeVersion: 'APE2E-1.0' }, }) const newHeartbeat = asRecord(await responseData(newSecretHeartbeat, 200, '轮换后新凭据心跳')) expect(newHeartbeat.online).toBe(true) recordPass(activities, '页面轮换终端一次性凭据', rotateResponse, terminalId, '新凭据仅保存在测试进程内,未写入附件或截图') recordPass(activities, '轮换后旧凭据被拒绝', oldSecretHeartbeat, terminalId) recordPass(activities, '轮换后新凭据可用', newSecretHeartbeat, terminalId) await captureTerminalCard(page, testInfo, terminalName, '远控终端-12-页面轮换后新凭据在线', '在线') activeStage = '通过页面删除终端并确认资源不可访问' card = await openTerminalPage(page, terminalName) const deletedResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE' && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`)) await card.getByRole('button', { name: '删除', exact: true }).click() messageBox = page.locator('.el-message-box:visible').last() await expect(messageBox).toContainText(`确定删除远程终端“${terminalName}”吗`) await messageBox.getByRole('button', { name: '确认删除' }).click() const deletedResponse = await deletedResponsePromise const deleted = asRecord(await responseData(deletedResponse, 200, '删除远控终端')) expect(deleted.deleted).toBe(true) const absentDetail = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers }) assertStatus(absentDetail, 404, '删除后终端详情必须不可访问') const absentCommand = await api.get(`/api/v1/remote-commands/${encodeURIComponent(commandId)}`, { headers }) assertStatus(absentCommand, 404, '删除终端后关联指令详情必须不可访问') const absentListResponse = await api.get('/api/v1/remote-terminals', { headers, params: { page: '1', pageSize: '20', keyword: terminalName }, }) const absentList = asRecord(await responseData(absentListResponse, 200, '删除后检索远控终端')) expect(absentList.total).toBe(0) const deletedRuntimeHeartbeat = await api.post(`${runtimePath}/heartbeat`, { headers: rotatedHeaders, data: { runtimeVersion: 'APE2E-1.0' }, }) assertStatus(deletedRuntimeHeartbeat, 401, '删除后运行端凭据必须失效') terminalDeleted = true recordPass(activities, '页面删除终端并确认管理端无残留', deletedResponse, terminalId, '详情与关联指令均为 404,列表检索为 0') recordPass(activities, '删除后运行端凭据失效', deletedRuntimeHeartbeat, terminalId) await captureDeletedState(page, testInfo, terminalName) } catch (error) { activities.push({ module: '远控终端运行协议', action: activeStage, result: 'FAIL', resourceId: terminal?.id, detail: '阶段断言未通过;敏感请求上下文未写入活动附件。', }) throw error } finally { if (!terminalDeleted) await fallbackCleanup(api, headers, terminal, activities, cleanupErrors) await attachJson(testInfo, '远控终端运行协议-生命周期与清理', { runId, runPrefix, apiBase: '本机正式 API(地址已省略)', activities, cleanupComplete: cleanupErrors.length === 0, cleanupErrors, verifiedEventSequence: ['SENT', 'ACKNOWLEDGED', 'EXECUTING', 'SUCCEEDED'], managementSubmission: '创建、编辑、绑定智能体、音量/打断设置、停启、指令下发、凭据轮换与删除均由真实页面控件提交;API 仅承担后验和运行端协议。', security: '管理员密码、访问令牌、终端一次性凭据与请求头均未写入日志、活动附件或截图;一次性密钥警告框也纳入失败截图遮罩;Playwright trace/video 已禁用。', }) await api.dispose() } expect(cleanupErrors, '远控终端测试数据必须清理干净').toEqual([]) }) })