From 56ecec05cd266d089b97e0d36d72a36b7b429543 Mon Sep 17 00:00:00 2001 From: leiyun Date: Sun, 23 Aug 2026 22:52:43 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E9=87=8D=E5=BB=BA=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=20V2=20=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/run-full.mjs | 26 +- tests/nonempty.ts | 4 +- tests/remote-terminal-runtime-contract.ts | 121 ++ tests/remote-terminal-runtime.spec.ts | 645 ------- tests/remote-terminal-v2-lifecycle.spec.ts | 1889 ++++++++++++++++++++ tests/remote-terminal-v2-ui.spec.ts | 955 ++++++++++ 6 files changed, 2990 insertions(+), 650 deletions(-) create mode 100644 tests/remote-terminal-runtime-contract.ts delete mode 100644 tests/remote-terminal-runtime.spec.ts create mode 100644 tests/remote-terminal-v2-lifecycle.spec.ts create mode 100644 tests/remote-terminal-v2-ui.spec.ts diff --git a/scripts/run-full.mjs b/scripts/run-full.mjs index cb951f5..730503e 100644 --- a/scripts/run-full.mjs +++ b/scripts/run-full.mjs @@ -57,10 +57,28 @@ function seedDurableDemoData() { async function probeRuntimeApi(baseURL, timeoutMs = 3_000) { try { - const response = await fetch(`${baseURL.replace(/\/$/, '')}/open/v1/remote-terminals/ape2e-runtime-probe/heartbeat`, { + const response = await fetch(`${baseURL.replace(/\/$/, '')}/open/v2/terminals/heartbeat`, { method: 'POST', - headers: { 'content-type': 'application/json; charset=utf-8' }, - body: JSON.stringify({ runtimeVersion: 'APE2E-PROBE' }), + headers: { + 'content-type': 'application/json; charset=utf-8', + 'X-Terminal-Id': 'ape2e-runtime-probe', + 'X-Device-Secret': 'ape2e-intentionally-invalid', + 'X-Terminal-Page-Origin': new URL(baseURL).origin, + 'X-Runtime-Instance-Id': 'runtime-ape2e-api-probe', + }, + body: JSON.stringify({ + runtimeVersion: 'APE2E-PROBE', + displayMode: 'STANDALONE', + capabilities: ['WAKE'], + actualVolume: 50, + allowInterrupt: true, + visibility: 'VISIBLE', + sessionStatus: 'IDLE', + playbackStatus: 'IDLE', + pageUrl: `${baseURL.replace(/\/$/, '')}/live/ape2e-probe`, + pageOrigin: new URL(baseURL).origin, + platform: process.platform, + }), signal: AbortSignal.timeout(timeoutMs), }) // 未知终端 + 已配置 pepper 必须走恒定时间鉴权并返回 401。 @@ -279,7 +297,7 @@ if (typecheck.status === 0) { throw new Error('APE2E_TEST_FILES 只能包含 tests/ 下以 .spec.ts 结尾的相对路径') } const selectedFiles = env.APE2E_REMOTE_RUNTIME_ONLY === 'true' - ? ['tests/remote-terminal-runtime.spec.ts'] + ? ['tests/remote-terminal-v2-ui.spec.ts', 'tests/remote-terminal-v2-lifecycle.spec.ts'] : requestedFiles const playwrightArgs = ['exec', 'playwright', 'test', ...selectedFiles, '--project=chromium'] tests = execute(playwrightArgs) diff --git a/tests/nonempty.ts b/tests/nonempty.ts index c503f9d..6925ef6 100644 --- a/tests/nonempty.ts +++ b/tests/nonempty.ts @@ -94,7 +94,9 @@ const routeAcceptance: Record = { 'agent-projects': { datasets: [{ label: '智能体组别', selector: tableRows, manifestEntityKey: 'agentProjects', markerScopeSelector: '.projects-panel' }] }, 'chat-history': { datasets: [{ label: '会话记录', selector: tableRows, manifestEntityKey: 'chatSessions', markerScopeSelector: '.history-panel' }] }, 'wake-words': { datasets: [{ label: '唤醒词', selector: tableRows, manifestEntityKey: 'wakeWords', markerScopeSelector: '.wake-panel' }] }, - 'remote-control': { datasets: [{ label: '远程终端', selector: '.terminal-grid > .terminal-card', manifestEntityKey: 'remoteTerminals', markerScopeSelector: '.terminal-panel' }] }, + // 021 迁移会主动清除不可安全转换的旧终端;此页验收 V2 表格容器, + // 真实终端数据由专用生命周期用例创建、激活并精确清理,不再依赖 demo manifest。 + 'remote-control': { datasets: [{ label: '远程终端 V2 表格', selector: '.desktop-terminal-table .el-table' }] }, 'video-create': { datasets: [{ label: '视频制作可选数字形象', selector: '.studio-avatar-list > button' }] }, videos: { datasets: [ diff --git a/tests/remote-terminal-runtime-contract.ts b/tests/remote-terminal-runtime-contract.ts new file mode 100644 index 0000000..0b5fcb5 --- /dev/null +++ b/tests/remote-terminal-runtime-contract.ts @@ -0,0 +1,121 @@ +import type { Page, Request } from '@playwright/test' + +const RUNTIME_INSTANCE_ID = /^[A-Za-z0-9._:-]{16,128}$/ + +type RuntimeEndpoint = 'heartbeat' | 'pull' | 'report' | 'release' + +export interface RuntimeTransportMonitor { + heartbeatRequests: () => number + heartbeatSuccesses: () => number + pullRequests: () => number + reportRequests: () => number + releaseRequests: () => number + releaseStatuses: () => number[] + runtimeInstanceIds: () => string[] + lastRuntimeInstanceId: () => string + endpointInstances: () => Record + headersValid: () => boolean + pullStartedAfterHeartbeat: () => boolean + runtimeInstanceOmittedFromUrls: () => boolean + releaseBodyEmpty: () => boolean +} + +function endpointFor(request: Request): RuntimeEndpoint | null { + const pathname = new URL(request.url()).pathname + if (request.method() !== 'POST') return null + if (pathname === '/open/v2/terminals/heartbeat') return 'heartbeat' + if (pathname === '/open/v2/terminals/commands/pull') return 'pull' + if (/^\/open\/v2\/terminals\/commands\/[^/]+\/reports$/.test(pathname)) return 'report' + if (pathname === '/open/v2/terminals/runtime/release') return 'release' + return null +} + +/** + * 只记录运行租约契约的计数与非敏感 instanceId;设备密钥只检查“存在”,绝不读取值。 + * 每次主文档导航都允许生成新的 instanceId,但同一文档内四个运行端点必须完全一致。 + */ +export function observeRuntimeTransport( + page: Page, + terminalCode: string, + trustedPageOrigin: string, +): RuntimeTransportMonitor { + const counts: Record = { heartbeat: 0, pull: 0, report: 0, release: 0 } + const instances: Record = { heartbeat: [], pull: [], report: [], release: [] } + const successfulHeartbeatInstances = new Set() + const releaseResponseStatuses: number[] = [] + let activeDocumentInstance = '' + let headerContractValid = true + let pullGateValid = true + let instanceUrlsValid = true + let releasesHaveNoBody = true + + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame()) activeDocumentInstance = '' + }) + page.on('request', (request) => { + const endpoint = endpointFor(request) + if (!endpoint) return + counts[endpoint] += 1 + const headers = request.headers() + const instanceId = headers['x-runtime-instance-id'] || '' + const safeInstance = RUNTIME_INSTANCE_ID.test(instanceId) + headerContractValid = headerContractValid + && headers['x-terminal-id'] === terminalCode + && Boolean(headers['x-device-secret']) + && headers['x-terminal-page-origin'] === trustedPageOrigin + && safeInstance + && !headers['x-terminal-secret'] + if (safeInstance) { + instances[endpoint].push(instanceId) + if (!activeDocumentInstance) activeDocumentInstance = instanceId + else headerContractValid = headerContractValid && activeDocumentInstance === instanceId + instanceUrlsValid = instanceUrlsValid && !request.url().includes(instanceId) + } + if (endpoint === 'pull' && !successfulHeartbeatInstances.has(instanceId)) pullGateValid = false + if (endpoint === 'release') releasesHaveNoBody = releasesHaveNoBody && !request.postData() + }) + page.on('response', (response) => { + const endpoint = endpointFor(response.request()) + if (!endpoint) return + const instanceId = response.request().headers()['x-runtime-instance-id'] || '' + if (endpoint === 'heartbeat' && response.status() === 200 && RUNTIME_INSTANCE_ID.test(instanceId)) { + successfulHeartbeatInstances.add(instanceId) + } + if (endpoint === 'release') releaseResponseStatuses.push(response.status()) + }) + + return { + heartbeatRequests: () => counts.heartbeat, + heartbeatSuccesses: () => successfulHeartbeatInstances.size, + pullRequests: () => counts.pull, + reportRequests: () => counts.report, + releaseRequests: () => counts.release, + releaseStatuses: () => [...releaseResponseStatuses], + runtimeInstanceIds: () => [...new Set(Object.values(instances).flat())], + lastRuntimeInstanceId: () => [...instances.heartbeat].at(-1) || '', + endpointInstances: () => ({ + heartbeat: [...instances.heartbeat], + pull: [...instances.pull], + report: [...instances.report], + release: [...instances.release], + }), + headersValid: () => headerContractValid, + pullStartedAfterHeartbeat: () => pullGateValid, + runtimeInstanceOmittedFromUrls: () => instanceUrlsValid, + releaseBodyEmpty: () => releasesHaveNoBody, + } +} + +/** 返回布尔值而不是 storage 内容,避免设备凭据或租约 owner 进入测试进程与附件。 */ +export async function runtimeInstancesAbsentFromBrowserState(page: Page, instanceIds: string[]) { + return page.evaluate((values) => { + const locations = [window.location.href] + for (const storage of [window.localStorage, window.sessionStorage]) { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index) || '' + locations.push(key, storage.getItem(key) || '') + } + } + return values.every((value) => value && locations.every((entry) => !entry.includes(value))) + }, instanceIds) +} diff --git a/tests/remote-terminal-runtime.spec.ts b/tests/remote-terminal-runtime.spec.ts deleted file mode 100644 index 04567d9..0000000 --- a/tests/remote-terminal-runtime.spec.ts +++ /dev/null @@ -1,645 +0,0 @@ -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([]) - }) -}) diff --git a/tests/remote-terminal-v2-lifecycle.spec.ts b/tests/remote-terminal-v2-lifecycle.spec.ts new file mode 100644 index 0000000..fac9939 --- /dev/null +++ b/tests/remote-terminal-v2-lifecycle.spec.ts @@ -0,0 +1,1889 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { + request as playwrightRequest, + type APIRequestContext, + type Browser, + type BrowserContext, + type Page, + type Response, + type Route, +} from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' +import { + authHeaders, + envelopeData, + fillFormItem, + formItem, + loginAsAdmin, + tableRowByText, + visibleDialog, +} from './helpers' +import { + observeRuntimeTransport, + runtimeInstancesAbsentFromBrowserState, + type RuntimeTransportMonitor, +} from './remote-terminal-runtime-contract' + +type JsonRecord = Record +type Headers = Record + +interface ResponseLike { + status(): number + json(): Promise + headers(): Record +} + +interface TerminalRef { + id: string + code: string + name: string + dataVersion: number +} + +interface PublishedAgent { + id: string + dataVersion: number + name: string + slug: string + accessMode: string + voiceCapabilityId: string +} + +interface LifecycleEvidence { + phase: string + result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' + terminalId?: string + commandId?: string + status?: string + detail?: string +} + +interface RuntimeHandle { + context: BrowserContext + page: Page + publicPath: string + transport: RuntimeTransportMonitor + activationRequests: () => number + heartbeatRequests: () => number + terminalConfigRequests: () => number + terminalSessionRequests: () => number + legacyPublicConfigRequests: () => number + legacyPublicSessionRequests: () => number + terminalConfigHeadersValid: () => boolean + terminalSessionHeadersValid: () => boolean + terminalFollowupRequests: () => Record<'chat' | 'asr' | 'feedback' | 'end', number> + terminalFollowupHeadersValid: () => boolean +} + +interface TerminalScopedAccessEvidence { + configRequests: number + sessionRequests: number + legacyPublicConfigRequests: number + legacyPublicSessionRequests: number + configDeviceHeadersValid: boolean + sessionDeviceHeadersValid: boolean + followupRequests: Record<'chat' | 'asr' | 'feedback' | 'end', number> + followupDeviceHeadersValid: boolean +} + +interface DirectRuntimeResponse { + status: number + code: number + data: JsonRecord +} + +interface DirectSessionFenceEvidence { + ownerSessionCreated: boolean + ownerSessionNoStore: boolean + websocketOpened: boolean + ownerReleaseStatus: number + contenderClaimStatus: number + oldOwnerHttpStatus: number + oldOwnerHttpCode: number + oldOwnerWebSocketCloseCode: number + contenderSessionCreated: boolean + contenderSessionEnded: boolean + staleOwnerReleaseStatus: number + staleOwnerReleaseCode: number +} + +const baseURL = (process.env.BASE_URL || 'http://127.0.0.1:8003').replace(/\/$/, '') +const artifactRoot = path.resolve('artifacts', 'runs', runId) +const terminalCapabilities = ['WAKE', 'PAUSE', 'RESUME', 'STOP', 'VOLUME', 'RESTART'] +const immutableCommandIds: string[] = [] + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {} +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] +const textValue = (value: unknown) => value == null ? '' : String(value) +const stringList = (value: unknown) => Array.isArray(value) ? value.map(textValue).filter(Boolean) : [] +const numberValue = (value: unknown) => { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +function pageItems(value: unknown) { + const source = asRecord(value) + return asRecords(source.items ?? source.records ?? source.rows) +} + +function responseStatus(response: ResponseLike, expected: number | number[], label: string) { + const statuses = Array.isArray(expected) ? expected : [expected] + expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) +} + +async function responseData(response: ResponseLike, expected: number | number[], label: string): Promise { + responseStatus(response, expected, label) + const body = await response.json().catch(() => { + throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`) + }) + return envelopeData(body) +} + +function commandStatus(command: JsonRecord) { + return textValue(command.statusCode ?? command.status).toUpperCase() +} + +function terminalConnection(terminal: JsonRecord) { + return textValue(terminal.connectionStatus ?? terminal.onlineStatus).toUpperCase() +} + +function terminalLifecycle(terminal: JsonRecord) { + return textValue(terminal.lifecycleStatus ?? terminal.status).toUpperCase() +} + +function terminalSession(terminal: JsonRecord) { + const reported = asRecord(terminal.reportedState) + return textValue(terminal.sessionStatus ?? reported.sessionStatus ?? asRecord(terminal.session).status).toUpperCase() +} + +function terminalPlayback(terminal: JsonRecord) { + const reported = asRecord(terminal.reportedState) + return textValue(terminal.playbackStatus ?? reported.playbackStatus).toUpperCase() +} + +function terminalActualVolume(terminal: JsonRecord) { + const reported = asRecord(terminal.reportedState) + return numberValue(terminal.actualVolume ?? reported.volume ?? reported.actualVolume) +} + +function activationParts(value: unknown) { + const source = asRecord(value) + const terminal = asRecord(source.terminal ?? source) + const activation = asRecord(source.activation ?? source.credential ?? source) + return { + terminal, + activationCode: textValue(activation.activationCode ?? activation.code), + expiresAt: textValue(activation.expiresAt), + shownOnce: Boolean(activation.shownOnce), + } +} + +function recursiveKeys(value: unknown, output: string[] = []): string[] { + if (!value || typeof value !== 'object') return output + if (Array.isArray(value)) { + for (const item of value) recursiveKeys(item, output) + return output + } + for (const [key, item] of Object.entries(value as JsonRecord)) { + output.push(key) + recursiveKeys(item, output) + } + return output +} + +function terminalRuntimeVersion(value: JsonRecord) { + const runtime = asRecord(value.runtime) + return textValue(value.runtimeVersion ?? runtime.version ?? runtime.runtimeVersion) +} + +async function getTerminal(api: APIRequestContext, headers: Headers, id: string) { + const response = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(id)}`, { headers }) + return responseData(response, 200, '读取远程终端详情') +} + +async function waitTerminal( + api: APIRequestContext, + headers: Headers, + id: string, + predicate: (terminal: JsonRecord) => boolean, + label: string, + timeout = 70_000, +) { + let latest: JsonRecord = {} + await expect.poll(async () => { + latest = await getTerminal(api, headers, id) + return predicate(latest) + }, { message: label, timeout, intervals: [500, 1_000, 2_000, 5_000] }).toBe(true) + return latest +} + +async function getCommand(api: APIRequestContext, headers: Headers, id: string) { + const response = await api.get(`/api/v1/remote-commands/${encodeURIComponent(id)}`, { headers }) + return responseData(response, 200, '读取远控命令详情') +} + +async function waitCommand( + api: APIRequestContext, + headers: Headers, + id: string, + expected: string | string[], + timeout = 120_000, +) { + const statuses = (Array.isArray(expected) ? expected : [expected]).map((item) => item.toUpperCase()) + let latest: JsonRecord = {} + await expect.poll(async () => { + latest = await getCommand(api, headers, id) + return statuses.includes(commandStatus(latest)) + }, { + message: `命令 ${id} 应收敛到 ${statuses.join(' / ')}`, + timeout, + intervals: [500, 1_000, 2_000, 5_000], + }).toBe(true) + return latest +} + +async function findPublishedAgent(api: APIRequestContext, headers: Headers): Promise { + const response = await api.get('/api/v1/realtime-agents', { + headers, + params: { page: '1', pageSize: '100', status: 'published' }, + }) + const data = await responseData(response, 200, '查询已发布智能体') + const currentTime = Date.now() + const found = pageItems(data).find((item) => { + const status = textValue(item.status).toLowerCase() + const expiresAt = textValue(item.expiresAt) + const expiry = expiresAt ? new Date(expiresAt).getTime() : Number.POSITIVE_INFINITY + const interactionModes = stringList(item.interactionModes).map((mode) => mode.toLowerCase()) + return status === 'published' && textValue(item.id) && textValue(item.slug) + && interactionModes.includes('text') && interactionModes.includes('voice') + && textValue(item.ttsModel) && textValue(item.voiceCapabilityId) + && (!Number.isFinite(expiry) || expiry > currentTime) + }) + if (!found) throw new Error('真实环境没有可绑定且未过期的已发布智能体') + return { + id: textValue(found.id), + dataVersion: numberValue(found.dataVersion), + name: textValue(found.name), + slug: textValue(found.slug), + accessMode: textValue(found.accessMode), + voiceCapabilityId: textValue(found.voiceCapabilityId), + } +} + +async function createRemoteControlSpeech(api: APIRequestContext, headers: Headers, voiceCapabilityId: string) { + const text = Array.from( + { length: 14 }, + () => '远程控制媒体链路验收正在进行,请保持设备在线,并依次核对暂停、继续和结束播报操作。', + ).join('') + const response = await api.post('/api/v1/tts', { + headers, + data: { + text, + voiceCapabilityId, + speed: 0.7, + }, + timeout: 180_000, + }) + const speech = await responseData(response, 200, '生成远控媒体控制验收音频') + const audioUrl = textValue(speech.audioUrl) + if (!audioUrl) throw new Error('真实 TTS 未返回可播放音频地址') + expect(textValue(speech.mimeType)).toBe('audio/wav') + expect(numberValue(speech.duration)).toBeGreaterThanOrEqual(45) + expect(textValue(speech.voiceCapabilityId)).toBe(voiceCapabilityId) + const media = await api.get(audioUrl, { timeout: 30_000 }) + responseStatus(media, 200, '读取真实 TTS 音频') + expect(media.headers()['content-type'] || '').toContain('audio') + const audio = await media.body() + expect(audio.byteLength).toBeGreaterThan(44) + expect(audio.subarray(0, 4).toString('ascii')).toBe('RIFF') + return { + id: textValue(speech.id), + audioUrl, + duration: numberValue(speech.duration), + voiceCapabilityId: textValue(speech.voiceCapabilityId), + mediaBytes: audio.byteLength, + } +} + +async function chooseAgent(page: Page, dialog: ReturnType, agent: PublishedAgent) { + await formItem(dialog, '绑定智能体').locator('.el-select').click() + const dropdown = page.locator('.el-select-dropdown:visible').last() + await expect(dropdown).toBeVisible() + await dropdown.locator('.el-select-dropdown__item').filter({ hasText: agent.slug }).click() +} + +async function setSliderValue(slider: ReturnType, target: number) { + const control = slider.getByRole('slider') + await expect(control).toBeVisible() + await control.focus() + let current = Number(await control.getAttribute('aria-valuenow')) + if (!Number.isFinite(current)) throw new Error('音量滑块缺少 aria-valuenow') + for (let attempt = 0; current !== target && attempt <= 100; attempt += 1) { + await control.press(target > current ? 'ArrowRight' : 'ArrowLeft') + current = Number(await control.getAttribute('aria-valuenow')) + } + await expect(control).toHaveAttribute('aria-valuenow', String(target)) +} + +async function scrubActivationDialog(page: Page) { + const dialog = page.locator('.activation-dialog:visible') + await expect(dialog).toBeVisible() + const code = dialog.locator('.activation-link-row code') + if (await code.count()) { + await code.evaluate((element) => { + element.textContent = '[一次性激活链接已由测试进程安全接收并遮蔽]' + element.setAttribute('data-secret-scrubbed', 'true') + }) + } + return dialog +} + +async function injectActivation(page: Page, code: string) { + const activationResponse = page.waitForResponse((response) => response.request().method() === 'POST' + && new URL(response.url()).pathname.endsWith('/open/v2/terminals/activate'), { timeout: 40_000 }) + await page.evaluate((secret) => { + const fragment = new URLSearchParams({ 'terminal-activation': secret }).toString() + window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}#${fragment}`) + }, code) + await page.reload({ waitUntil: 'domcontentloaded' }) + const response = await activationResponse + await expect.poll(() => new URL(page.url()).hash, { + message: '运行页必须在兑换前清除 URL fragment 中的一次性激活码', + timeout: 12_000, + }).toBe('') + return response +} + +/** + * 在已激活浏览器上下文内直接验证服务端租约 fencing。设备密钥只在浏览器 + * evaluate 内从既有凭据读取并用于 fetch,不返回 Node 测试进程或附件。 + */ +async function directRuntimeRequest( + page: Page, + terminalCode: string, + runtimeInstanceId: string, + pathname: string, + data?: JsonRecord, +): Promise { + return page.evaluate(async ({ code, instanceId, path, payload }) => { + const key = `ai-person:remote-terminal-runtime:v2:${encodeURIComponent(code.trim().toUpperCase())}` + const stored = JSON.parse(window.localStorage.getItem(key) || 'null') as { + terminalId?: string + secret?: string + pageOrigin?: string + } | null + if (!stored?.terminalId || !stored.secret || !stored.pageOrigin) { + throw new Error('浏览器内不存在可用的远程终端设备凭据') + } + const headers: Record = { + Accept: 'application/json', + 'X-Terminal-Id': stored.terminalId, + 'X-Device-Secret': stored.secret, + 'X-Terminal-Page-Origin': stored.pageOrigin, + 'X-Runtime-Instance-Id': instanceId, + } + if (payload !== undefined) headers['Content-Type'] = 'application/json' + const response = await window.fetch(path, { + method: 'POST', + headers, + body: payload === undefined ? undefined : JSON.stringify(payload), + cache: 'no-store', + }) + const body = await response.json().catch(() => ({})) as Record + return { + status: response.status, + code: Number(body.code || 0), + data: body.data && typeof body.data === 'object' ? body.data as Record : {}, + } + }, { code: terminalCode, instanceId: runtimeInstanceId, path: pathname, payload: data }) +} + +/** + * 设备密钥、会话令牌与 websocket ticket 始终留在浏览器 evaluate 闭包内;Node 侧只接收 + * HTTP 状态、错误码和布尔后验,避免任何授权材料进入 Playwright 附件或失败日志。 + */ +async function directRuntimeSessionFence( + page: Page, + terminalCode: string, + runtimeA: string, + runtimeB: string, + agentSlug: string, + heartbeatState: JsonRecord, +): Promise { + return page.evaluate(async ({ code, ownerInstance, contenderInstance, slug, state }) => { + const key = `ai-person:remote-terminal-runtime:v2:${encodeURIComponent(code.trim().toUpperCase())}` + const stored = JSON.parse(window.localStorage.getItem(key) || 'null') as { + terminalId?: string + secret?: string + pageOrigin?: string + } | null + if (!stored?.terminalId || !stored.secret || !stored.pageOrigin) { + throw new Error('浏览器内不存在可用于会话租约验证的设备凭据') + } + + const headersFor = (instanceId: string, token = '') => { + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Terminal-Id': stored.terminalId!, + 'X-Device-Secret': stored.secret!, + 'X-Terminal-Page-Origin': stored.pageOrigin!, + 'X-Runtime-Instance-Id': instanceId, + } + if (token) headers.Authorization = `Bearer ${token}` + return headers + } + const post = async (pathname: string, instanceId: string, payload: Record | undefined, token = '') => { + const response = await window.fetch(pathname, { + method: 'POST', + headers: headersFor(instanceId, token), + body: payload === undefined ? undefined : JSON.stringify(payload), + cache: 'no-store', + }) + const body = await response.json().catch(() => ({})) as Record + const data = body.data && typeof body.data === 'object' + ? body.data as Record + : {} + return { + status: response.status, + code: Number(body.code || 0), + data, + noStore: (response.headers.get('cache-control') || '').includes('no-store'), + } + } + + const sessionPath = `/open/v2/terminals/agents/${encodeURIComponent(slug)}/sessions` + const ownerSession = await post(sessionPath, ownerInstance, {}) + const ownerToken = String(ownerSession.data.sessionToken || '') + const rawSocketUrl = String(ownerSession.data.websocketUrl || '') + if (ownerSession.status !== 201 || !ownerToken || !rawSocketUrl) { + throw new Error(`owner scoped session 创建失败:HTTP ${ownerSession.status}`) + } + const socketUrl = new URL(rawSocketUrl, window.location.href) + if (socketUrl.protocol === 'http:') socketUrl.protocol = 'ws:' + if (socketUrl.protocol === 'https:') socketUrl.protocol = 'wss:' + const socket = new WebSocket(socketUrl.toString()) + await new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error('owner websocket 建连超时')), 15_000) + socket.addEventListener('open', () => { + window.clearTimeout(timer) + resolve() + }, { once: true }) + socket.addEventListener('error', () => { + window.clearTimeout(timer) + reject(new Error('owner websocket 建连失败')) + }, { once: true }) + }) + const socketClosed = new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error('旧 owner websocket 未在接管后关闭')), 15_000) + socket.addEventListener('close', (event) => { + window.clearTimeout(timer) + resolve(event.code) + }, { once: true }) + }) + + const ownerRelease = await post('/open/v2/terminals/runtime/release', ownerInstance, undefined) + const contenderClaim = await post('/open/v2/terminals/heartbeat', contenderInstance, state) + const oldOwnerChat = await post( + `/open/v1/realtime/${encodeURIComponent(slug)}/chat`, + ownerInstance, + { message: '旧 owner 会话必须被服务端拒绝' }, + ownerToken, + ) + const oldOwnerWebSocketCloseCode = await socketClosed + + const contenderSession = await post(sessionPath, contenderInstance, {}) + const contenderToken = String(contenderSession.data.sessionToken || '') + if (contenderSession.status !== 201 || !contenderToken) { + throw new Error(`contender scoped session 创建失败:HTTP ${contenderSession.status}`) + } + const contenderEnd = await post( + `/open/v1/realtime/${encodeURIComponent(slug)}/end`, + contenderInstance, + {}, + contenderToken, + ) + const staleOwnerRelease = await post('/open/v2/terminals/runtime/release', ownerInstance, undefined) + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close() + + return { + ownerSessionCreated: ownerSession.status === 201, + ownerSessionNoStore: ownerSession.noStore, + websocketOpened: true, + ownerReleaseStatus: ownerRelease.status, + contenderClaimStatus: contenderClaim.status, + oldOwnerHttpStatus: oldOwnerChat.status, + oldOwnerHttpCode: oldOwnerChat.code, + oldOwnerWebSocketCloseCode, + contenderSessionCreated: contenderSession.status === 201, + contenderSessionEnded: contenderEnd.status === 200, + staleOwnerReleaseStatus: staleOwnerRelease.status, + staleOwnerReleaseCode: staleOwnerRelease.code, + } + }, { + code: terminalCode, + ownerInstance: runtimeA, + contenderInstance: runtimeB, + slug: agentSlug, + state: heartbeatState, + }) +} + +function directRuntimeState(publicPath: string, actualVolume = 43): JsonRecord { + return { + runtimeVersion: 'ape2e/remote-v2-runtime-lease', + displayMode: 'STANDALONE', + capabilities: terminalCapabilities, + actualVolume, + allowInterrupt: true, + visibility: 'VISIBLE', + sessionStatus: 'IDLE', + playbackStatus: 'IDLE', + pageUrl: `${baseURL}${publicPath}`, + pageOrigin: new URL(baseURL).origin, + platform: 'ape2e', + } +} + +async function activateRuntime( + browser: Browser, + agent: PublishedAgent, + terminal: TerminalRef, + code: string, +): Promise { + const context = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const page = await context.newPage() + const publicPath = `/live/${encodeURIComponent(agent.slug)}?terminal=${encodeURIComponent(terminal.code)}` + const terminalConfigPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/config` + const terminalSessionPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/sessions` + const legacyPublicConfigPath = `/open/v1/realtime/${encodeURIComponent(agent.slug)}` + const legacyPublicSessionPath = `${legacyPublicConfigPath}/sessions` + const pageOrigin = new URL(baseURL).origin + const transport = observeRuntimeTransport(page, terminal.code, pageOrigin) + let activationCount = 0 + let terminalConfigCount = 0 + let terminalSessionCount = 0 + let legacyPublicConfigCount = 0 + let legacyPublicSessionCount = 0 + let terminalConfigHeadersAreValid = true + let terminalSessionHeadersAreValid = true + const terminalFollowupCounts = { chat: 0, asr: 0, feedback: 0, end: 0 } + let terminalFollowupHeadersAreValid = true + page.on('request', (request) => { + const pathname = new URL(request.url()).pathname + const method = request.method() + if (pathname.endsWith('/open/v2/terminals/activate')) activationCount += 1 + if (pathname === terminalConfigPath && method === 'GET') { + terminalConfigCount += 1 + const headers = request.headers() + terminalConfigHeadersAreValid = terminalConfigHeadersAreValid + && headers['x-terminal-id'] === terminal.code + && Boolean(headers['x-device-secret']) + && headers['x-terminal-page-origin'] === pageOrigin + && !headers['x-runtime-instance-id'] + && !headers['x-terminal-secret'] + } + const followupMatch = pathname.match(new RegExp(`^${legacyPublicConfigPath}/(chat|asr|feedback|end)$`)) + if (followupMatch && method === 'POST') { + const endpoint = followupMatch[1] as keyof typeof terminalFollowupCounts + terminalFollowupCounts[endpoint] += 1 + const headers = request.headers() + terminalFollowupHeadersAreValid = terminalFollowupHeadersAreValid + && headers['x-terminal-id'] === terminal.code + && Boolean(headers['x-device-secret']) + && headers['x-terminal-page-origin'] === pageOrigin + && headers['x-runtime-instance-id'] === transport.lastRuntimeInstanceId() + && /^Bearer\s+\S+$/i.test(headers.authorization || '') + && !headers['x-terminal-secret'] + } + if (pathname === terminalSessionPath && method === 'POST') { + terminalSessionCount += 1 + const headers = request.headers() + terminalSessionHeadersAreValid = terminalSessionHeadersAreValid + && headers['x-terminal-id'] === terminal.code + && Boolean(headers['x-device-secret']) + && headers['x-terminal-page-origin'] === pageOrigin + && headers['x-runtime-instance-id'] === transport.lastRuntimeInstanceId() + && !headers['x-terminal-secret'] + } + if (pathname === legacyPublicConfigPath && method === 'GET') legacyPublicConfigCount += 1 + if (pathname === legacyPublicSessionPath && method === 'POST') legacyPublicSessionCount += 1 + }) + await page.goto(publicPath) + await expect(page.locator('.live-agent-page')).toBeVisible() + const activated = await injectActivation(page, code) + responseStatus(activated, 200, '公开运行页兑换一次性激活码') + expect(activated.headers()['cache-control'] || '').toContain('no-store') + await expect(page.locator('.terminal-runtime-notice')).toContainText('终端激活成功,已开始连接远程控制服务') + await expect.poll(transport.heartbeatSuccesses, { timeout: 25_000 }).toBeGreaterThan(0) + return { + context, + page, + publicPath, + transport, + activationRequests: () => activationCount, + heartbeatRequests: transport.heartbeatRequests, + terminalConfigRequests: () => terminalConfigCount, + terminalSessionRequests: () => terminalSessionCount, + legacyPublicConfigRequests: () => legacyPublicConfigCount, + legacyPublicSessionRequests: () => legacyPublicSessionCount, + terminalConfigHeadersValid: () => terminalConfigHeadersAreValid, + terminalSessionHeadersValid: () => terminalSessionHeadersAreValid, + terminalFollowupRequests: () => ({ ...terminalFollowupCounts }), + terminalFollowupHeadersValid: () => terminalFollowupHeadersAreValid, + } +} + +function terminalScopedAccess(runtime: RuntimeHandle): TerminalScopedAccessEvidence { + return { + configRequests: runtime.terminalConfigRequests(), + sessionRequests: runtime.terminalSessionRequests(), + legacyPublicConfigRequests: runtime.legacyPublicConfigRequests(), + legacyPublicSessionRequests: runtime.legacyPublicSessionRequests(), + configDeviceHeadersValid: runtime.terminalConfigHeadersValid(), + sessionDeviceHeadersValid: runtime.terminalSessionHeadersValid(), + followupRequests: runtime.terminalFollowupRequests(), + followupDeviceHeadersValid: runtime.terminalFollowupHeadersValid(), + } +} + +async function verifyConsumedActivation( + browser: Browser, + agent: PublishedAgent, + terminal: TerminalRef, + consumedCode: string, +) { + const context = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const page = await context.newPage() + try { + const publicPath = `/live/${encodeURIComponent(agent.slug)}?terminal=${encodeURIComponent(terminal.code)}` + await page.goto(publicPath) + await expect(page.locator('.live-agent-page')).toBeVisible() + const replay = await injectActivation(page, consumedCode) + responseStatus(replay, [400, 401, 403, 409, 410, 422], '已消费激活码重放必须被拒绝') + await expect(page.locator('.terminal-runtime-notice.is-error')).toBeVisible() + } finally { + await context.close() + } +} + +async function searchTerminal(page: Page, name: string) { + await page.goto('/agents/remote-control') + await expect(page.getByRole('heading', { name: '远程控制', exact: true })).toBeVisible() + const keyword = page.getByRole('textbox', { name: '搜索终端' }) + await keyword.fill(name) + await page.getByRole('button', { name: '搜索', exact: true }).click() + const row = tableRowByText(page, name) + await expect(row).toBeVisible() + return row +} + +async function openTerminalDrawer(page: Page, name: string) { + const existing = page.locator('.remote-detail-drawer:visible') + if (await existing.count()) { + await existing.getByRole('button', { name: '关闭', exact: true }).click() + await expect(existing).toBeHidden() + } + const row = await searchTerminal(page, name) + await row.getByRole('button', { name: '详情', exact: true }).click() + const drawer = page.locator('.remote-detail-drawer:visible') + await expect(drawer).toBeVisible() + return drawer +} + +async function parseCommandResponse(response: ResponseLike, label: string) { + const command = await responseData(response, [200, 202], label) + const id = textValue(command.id ?? command.commandId) + if (!id) throw new Error(`${label}未返回命令编号`) + if (!immutableCommandIds.includes(id)) immutableCommandIds.push(id) + return { id, command } +} + +async function sendUiCommand( + page: Page, + drawer: ReturnType, + buttonName: string, + expectedType: string, + confirmName?: string, +) { + const responsePromise = page.waitForResponse((response) => { + if (response.request().method() !== 'POST') return false + if (!/\/api\/v1\/remote-terminals\/[^/]+\/commands$/.test(new URL(response.url()).pathname)) return false + const payload = asRecord(response.request().postDataJSON()) + return textValue(payload.type).toUpperCase() === expectedType.toUpperCase() + }, { timeout: 20_000 }) + await drawer.getByRole('button', { name: buttonName, exact: true }).click() + if (confirmName) { + const box = page.locator('.el-message-box:visible') + await expect(box).toBeVisible() + await box.getByRole('button', { name: confirmName, exact: true }).click() + } + return parseCommandResponse(await responsePromise, `页面下发${buttonName}`) +} + +async function postCommand( + api: APIRequestContext, + headers: Headers, + terminalId: string, + payload: JsonRecord, + idempotencyKey: string, +) { + return api.post(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/commands`, { + headers: { ...headers, 'Idempotency-Key': idempotencyKey }, + data: payload, + }) +} + +function safeTextFiles(directory: string) { + if (!fs.existsSync(directory)) return [] + const files: string[] = [] + const visit = (current: string) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const filename = path.join(current, entry.name) + if (entry.isDirectory()) visit(filename) + else if (/\.(?:json|txt|md|html|xml|log)$/i.test(entry.name)) files.push(filename) + } + } + visit(directory) + return files +} + +function assertArtifactsExclude(values: string[]) { + const secrets = values.filter(Boolean) + if (!secrets.length) return + for (const filename of safeTextFiles(artifactRoot)) { + const content = fs.readFileSync(filename, 'utf8') + for (const secret of secrets) { + if (content.includes(secret)) throw new Error(`测试附件疑似包含一次性凭据:${path.relative(artifactRoot, filename)}`) + } + } +} + +async function cleanupTerminal( + api: APIRequestContext, + headers: Headers, + terminal: TerminalRef | null, + evidence: LifecycleEvidence[], + cleanupErrors: string[], +) { + if (!terminal) return + try { + const detailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers }) + if (detailResponse.status() === 404) return + const current = await responseData(detailResponse, 200, '失败兜底读取终端') + const commandListResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}/commands`, { + headers, + params: { page: '1', pageSize: '100' }, + }) + if (commandListResponse.status() === 200) { + const commandList = await responseData(commandListResponse, 200, '失败兜底读取活动命令') + for (const command of pageItems(commandList).filter((item) => commandStatus(item) === 'QUEUED')) { + await api.post(`/api/v1/remote-commands/${encodeURIComponent(textValue(command.id))}/cancel`, { + headers, + data: { dataVersion: numberValue(command.dataVersion) }, + }).catch(() => undefined) + } + } + let dataVersion = numberValue(current.dataVersion) + if (terminalLifecycle(current) !== 'SUSPENDED') { + const disabled = await api.put(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}/status`, { + headers, + data: { enabled: false, dataVersion }, + }) + if (disabled.status() === 200) { + const value = await responseData(disabled, 200, '失败兜底停用终端') + dataVersion = numberValue(value.dataVersion) || dataVersion + } + } + const removed = await api.delete(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { + headers, + params: { dataVersion: String(dataVersion) }, + }) + responseStatus(removed, [200, 204, 404], '失败兜底撤销终端') + const absent = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers }) + responseStatus(absent, 404, '失败兜底清理后终端必须不存在') + evidence.push({ phase: '失败兜底精确清理', result: 'CLEANED', terminalId: terminal.id }) + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause) + cleanupErrors.push(message) + evidence.push({ phase: '失败兜底精确清理', result: 'CLEANUP_FAILED', terminalId: terminal.id, detail: message }) + } +} + +test.describe('远程终端 V2 真实双浏览器生命周期', () => { + test('创建绑定、一次性激活、真实页面动作、回执幂等、重连撤销与零活动残留', async ({ page, browser }, testInfo) => { + test.setTimeout(22 * 60_000) + + const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}` + const lifecycleStartedAt = new Date(Date.now() - 2_000).toISOString() + const terminalName = `${runPrefix}-远控终端-${suffix}`.slice(0, 110) + const terminalCode = `RTC-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').toUpperCase().slice(0, 64) + const evidence: LifecycleEvidence[] = [] + const cleanupErrors: string[] = [] + const activationValues: string[] = [] + const commandIds: Record = {} + let terminal: TerminalRef | null = null + let terminalDeleted = false + let primaryRuntime: RuntimeHandle | null = null + let replacementRuntime: RuntimeHandle | null = null + let api: APIRequestContext | null = null + let headers: Headers = {} + let activePhase = '初始化' + let boundAgentEvidence: Pick | null = null + let mediaEvidence: { speechId: string; duration: number; mediaBytes: number } | null = null + let terminalScopedAccessEvidence: TerminalScopedAccessEvidence | null = null + let terminalChatDeviceHeadersValid: boolean | null = null + let runtimeLeaseEvidence: JsonRecord | null = null + + try { + activePhase = '使用账号文件进程内登录并选择已发布智能体' + api = await playwrightRequest.newContext({ baseURL }) + headers = await authHeaders(api) + const agent = await findPublishedAgent(api, headers) + boundAgentEvidence = { + id: agent.id, + slug: agent.slug, + accessMode: agent.accessMode, + voiceCapabilityId: agent.voiceCapabilityId, + } + await loginAsAdmin(page) + + activePhase = '通过管理页面创建独立页面终端并安全接收一次性激活码' + await page.goto('/agents/remote-control') + await page.getByRole('button', { name: '新增终端', exact: true }).click() + const editor = visibleDialog(page) + await fillFormItem(editor, '终端名称', terminalName) + await fillFormItem(editor, '终端编码', terminalCode) + await fillFormItem(editor, '部署位置', `E2E 本地模拟终端 ${suffix}`) + await chooseAgent(page, editor, agent) + await expect(formItem(editor, '页面模式').getByText('独立页面', { exact: true })).toBeVisible() + + const createResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' + && new URL(response.url()).pathname === '/api/v1/remote-terminals', { timeout: 30_000 }) + await editor.getByRole('button', { name: '创建并生成激活链接', exact: true }).click() + const createResponse = await createResponsePromise + const created = activationParts(await responseData(createResponse, 201, '页面创建远程终端')) + expect(createResponse.headers()['cache-control'] || '').toContain('no-store') + expect(created.shownOnce).toBe(true) + expect(new Date(created.expiresAt).getTime()).toBeGreaterThan(Date.now()) + if (!created.activationCode || created.activationCode.length < 12) throw new Error('创建响应未返回有效的一次性激活码') + activationValues.push(created.activationCode) + terminal = { + id: textValue(created.terminal.id), + code: textValue(created.terminal.code) || terminalCode, + name: textValue(created.terminal.name) || terminalName, + dataVersion: numberValue(created.terminal.dataVersion), + } + if (!terminal.id) throw new Error('创建响应未返回终端编号') + const activationDialog = await scrubActivationDialog(page) + await expect(activationDialog).toContainText(`接入终端 · ${terminalName}`) + await expect(activationDialog).toContainText('仅显示一次') + await captureScreenshot(page, testInfo, 'remote-v2-real-01-created-activation-masked', [activationDialog.locator('code')]) + await activationDialog.getByRole('button', { name: '完成', exact: true }).click() + + const initialDetail = await getTerminal(api, headers, terminal.id) + const keys = recursiveKeys(initialDetail).map((key) => key.toLowerCase()) + expect(keys).not.toContain('activationcode') + expect(keys).not.toContain('devicesecret') + expect(keys).not.toContain('secret') + expect(JSON.stringify(initialDetail)).not.toContain(created.activationCode) + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'PENDING_ACTIVATION' }) + + activePhase = '第二浏览器上下文真实激活 embed 页面并验证激活码不可重放' + primaryRuntime = await activateRuntime(browser, agent, terminal, created.activationCode) + await expect.poll(primaryRuntime.terminalConfigRequests, { + message: '带 terminal 参数的运行页必须通过设备凭据读取绑定智能体配置', + timeout: 20_000, + }).toBeGreaterThan(0) + expect(primaryRuntime.terminalConfigHeadersValid(), '终端配置请求必须携带设备头和激活时页面 Origin').toBe(true) + expect(primaryRuntime.legacyPublicConfigRequests(), '受管终端不得回退到旧公开配置接口').toBe(0) + expect(primaryRuntime.legacyPublicSessionRequests(), '激活阶段不得调用旧公开会话接口').toBe(0) + expect(primaryRuntime.transport.headersValid(), '运行租约端点必须携带三项设备头和同页 runtime instance,且不得发送旧头').toBe(true) + expect(primaryRuntime.transport.pullStartedAfterHeartbeat(), '运行页首次 heartbeat 成功前不得启动 pull').toBe(true) + expect(primaryRuntime.transport.runtimeInstanceOmittedFromUrls(), 'runtime instance 不得进入请求 URL').toBe(true) + expect(await runtimeInstancesAbsentFromBrowserState( + primaryRuntime.page, + primaryRuntime.transport.runtimeInstanceIds(), + ), 'runtime instance 只允许存在页面内存,不得写入 URL 或 storage').toBe(true) + await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-02-runtime-activated-fragment-cleared') + await verifyConsumedActivation(browser, agent, terminal, created.activationCode) + const online = await waitTerminal(api, headers, terminal.id, (value) => { + const capabilities = stringList(value.capabilities).map((item) => item.toUpperCase()) + return terminalConnection(value) === 'ONLINE' && value.online === true + && terminalCapabilities.every((item) => capabilities.includes(item)) + }, + '激活后的真实心跳必须上报在线和六项能力') + expect(terminalRuntimeVersion(online)).toContain('remote-v2') + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'ONLINE' }) + + let drawer = await openTerminalDrawer(page, terminalName) + await expect(drawer).toContainText('在线') + await expect(drawer).toContainText('ai-person-web/remote-v2') + await captureScreenshot(page, testInfo, 'remote-v2-real-03-manager-online-heartbeat-capabilities') + + activePhase = '后台 WAKE 下发到真实运行页 DOM 并完成三段回执' + await drawer.getByRole('tab', { name: '实时控制', exact: true }).click() + const wake = await sendUiCommand(page, drawer, '唤醒运行页', 'WAKE') + commandIds.wake = wake.id + const wakeDone = await waitCommand(api, headers, wake.id, 'SUCCEEDED') + expect(asRecord(wakeDone.result).sessionStatus).toBe('ACTIVE') + await expect.poll(primaryRuntime.terminalSessionRequests, { + message: 'WAKE 必须通过设备凭据签发绑定智能体会话', + timeout: 20_000, + }).toBeGreaterThan(0) + expect(primaryRuntime.terminalSessionHeadersValid(), '终端会话请求必须携带设备头和激活时页面 Origin').toBe(true) + expect(primaryRuntime.legacyPublicSessionRequests(), '受管终端不得回退到旧公开会话接口').toBe(0) + terminalScopedAccessEvidence = terminalScopedAccess(primaryRuntime) + await expect(primaryRuntime.page.locator('.live-agent-page')).toBeVisible() + await expect.poll(() => primaryRuntime!.page.evaluate(() => document.hasFocus()), { + message: 'STANDALONE WAKE 必须真实聚焦运行页窗口', timeout: 12_000, + }).toBe(true) + await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'ACTIVE', 'WAKE 后心跳必须上报活动会话') + await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-04-wake-focused-real-live-dom') + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: wake.id, status: 'SUCCEEDED' }) + + activePhase = '后台 VOLUME 修改真实 HTMLAudio/Viewer 输出并由心跳回报' + const volumeSlider = drawer.locator('.volume-command .el-slider') + await setSliderValue(volumeSlider, 37) + const volume = await sendUiCommand(page, drawer, '应用音量', 'VOLUME') + commandIds.volume = volume.id + const volumeDone = await waitCommand(api, headers, volume.id, 'SUCCEEDED') + expect(numberValue(asRecord(volumeDone.result).volume)).toBe(37) + await waitTerminal(api, headers, terminal.id, (value) => terminalActualVolume(value) === 37, 'VOLUME 后心跳必须回报实际音量 37%') + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: volume.id, status: 'SUCCEEDED' }) + + activePhase = '真实服务端 TTS 音频进入运行页后执行 PAUSE / RESUME / STOP DOM 动作' + const speech = await createRemoteControlSpeech(api, headers, agent.voiceCapabilityId) + mediaEvidence = { speechId: speech.id, duration: speech.duration, mediaBytes: speech.mediaBytes } + let controlledChatRequestCount = 0 + const controlledChatHandler = async (route: Route) => { + controlledChatRequestCount += 1 + const requestHeaders = route.request().headers() + terminalChatDeviceHeadersValid = requestHeaders['x-terminal-id'] === terminal!.code + && Boolean(requestHeaders['x-device-secret']) + && requestHeaders['x-terminal-page-origin'] === new URL(baseURL).origin + && requestHeaders['x-runtime-instance-id'] === primaryRuntime!.transport.lastRuntimeInstanceId() + && !requestHeaders['x-terminal-secret'] + await route.fulfill({ + status: 200, + contentType: 'application/json; charset=utf-8', + body: JSON.stringify({ + code: 0, + message: 'success', + data: { + reply: '远程控制媒体链路验收音频已就绪。', + audioUrl: speech.audioUrl, + citations: [], + provider: 'local', + degraded: false, + degradationReason: null, + blocked: false, + sensitiveCategory: null, + requestId: `remote-media-${suffix}`, + }, + }), + }) + } + await primaryRuntime.page.route(`**/open/v1/realtime/${agent.slug}/chat`, controlledChatHandler) + const question = '开始远程媒体控制链路验收。' + await primaryRuntime.page.getByRole('textbox', { name: '维修问题' }).fill(question) + await primaryRuntime.page.getByRole('button', { name: '发送问题', exact: true }).click() + await expect(primaryRuntime.page.locator('.live-speech-toggle.is-speaking')).toBeVisible({ timeout: 180_000 }) + expect(controlledChatRequestCount).toBe(1) + expect(terminalChatDeviceHeadersValid, 'REMOTE_TERMINAL chat 必须同时携带设备头、页面 Origin 且不发送旧头').toBe(true) + expect(primaryRuntime.terminalFollowupRequests().chat, 'REMOTE_TERMINAL chat 必须经过受管会话请求封装').toBe(1) + expect(primaryRuntime.terminalFollowupHeadersValid(), 'REMOTE_TERMINAL chat 必须携带 Bearer、三项设备头与当前 runtime instance').toBe(true) + await primaryRuntime.page.unroute(`**/open/v1/realtime/${agent.slug}/chat`, controlledChatHandler) + await waitTerminal(api, headers, terminal.id, (value) => terminalPlayback(value) === 'PLAYING', '真实 TTS 播放必须由心跳上报 PLAYING', 45_000) + await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-05-real-tts-playing') + evidence.push({ + phase: '真实 TTS 媒体前置', + result: 'PASS', + terminalId: terminal.id, + status: `${speech.voiceCapabilityId}/${speech.duration.toFixed(1)}s/${speech.id}`, + }) + + await expect(drawer.getByRole('button', { name: '暂停播报', exact: true })).toBeEnabled({ timeout: 15_000 }) + const pause = await sendUiCommand(page, drawer, '暂停播报', 'PAUSE') + commandIds.pause = pause.id + await waitCommand(api, headers, pause.id, 'SUCCEEDED') + await expect(primaryRuntime.page.locator('.live-speech-toggle[aria-label="继续播报"]')).toBeVisible() + await waitTerminal(api, headers, terminal.id, (value) => terminalPlayback(value) === 'PAUSED', 'PAUSE 后心跳必须回报 PAUSED', 45_000) + await captureScreenshot(page, testInfo, 'remote-v2-real-06-manager-pause-succeeded') + + await expect(drawer.getByRole('button', { name: '继续播报', exact: true })).toBeEnabled({ timeout: 15_000 }) + const resume = await sendUiCommand(page, drawer, '继续播报', 'RESUME') + commandIds.resume = resume.id + await waitCommand(api, headers, resume.id, 'SUCCEEDED') + await expect(primaryRuntime.page.locator('.live-speech-toggle.is-speaking[aria-label="暂停播报"]')).toBeVisible() + await waitTerminal(api, headers, terminal.id, (value) => terminalPlayback(value) === 'PLAYING', 'RESUME 后心跳必须回报 PLAYING', 45_000) + + await expect(drawer.getByRole('button', { name: '结束会话', exact: true })).toBeEnabled({ timeout: 15_000 }) + const stop = await sendUiCommand(page, drawer, '结束会话', 'STOP', '确认结束会话') + commandIds.stop = stop.id + await waitCommand(api, headers, stop.id, 'SUCCEEDED') + await expect.poll(() => primaryRuntime!.terminalFollowupRequests().end, { + message: 'STOP 必须先调用受管会话 end', + timeout: 20_000, + }).toBeGreaterThan(0) + expect(primaryRuntime.terminalFollowupHeadersValid(), 'REMOTE_TERMINAL end 必须携带 Bearer、三项设备头与当前 runtime instance').toBe(true) + await expect(primaryRuntime.page.locator('.live-speech-toggle.is-speaking')).toHaveCount(0) + await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE' + && terminalPlayback(value) === 'IDLE', 'STOP 后心跳必须回报空闲会话与空闲播放', 45_000) + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: stop.id, status: 'SUCCEEDED' }) + + activePhase = '命令幂等键同载荷重放、不同载荷冲突和空闲态预检拒绝' + const idempotencyKey = `remote-v2-${suffix}-idempotent` + const identicalPayload = { type: 'VOLUME', detail: '幂等设置音量', volume: 43 } + const firstIdempotentResponse = await postCommand(api, headers, terminal.id, identicalPayload, idempotencyKey) + const firstIdempotent = await parseCommandResponse(firstIdempotentResponse, '首次幂等命令') + const replayResponse = await postCommand(api, headers, terminal.id, identicalPayload, idempotencyKey) + const replay = await parseCommandResponse(replayResponse, '相同幂等键同载荷重放') + expect(replay.id).toBe(firstIdempotent.id) + expect(replay.command.idempotent).toBe(true) + const conflictResponse = await postCommand(api, headers, terminal.id, { ...identicalPayload, volume: 44 }, idempotencyKey) + responseStatus(conflictResponse, 409, '相同幂等键不同载荷必须冲突') + commandIds.idempotent = firstIdempotent.id + await waitCommand(api, headers, firstIdempotent.id, 'SUCCEEDED') + + const rejectedPause = await postCommand(api, headers, terminal.id, { type: 'PAUSE', detail: '空闲态预检拒绝验证' }, `remote-v2-${suffix}-failed`) + responseStatus(rejectedPause, 409, '空闲态 PAUSE 必须在入队前拒绝') + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'IDLE_PAUSE_REJECTED' }) + + activePhase = '同源双页面仅单一 leader 执行,刷新时 release 后快速交棒且不重复激活' + const activationCountBeforeReload = primaryRuntime.activationRequests() + const primaryInstanceBeforeReload = primaryRuntime.transport.lastRuntimeInstanceId() + const primaryReleaseBeforeReload = primaryRuntime.transport.releaseRequests() + const primaryReportsBeforeHandoff = primaryRuntime.transport.reportRequests() + const standbyPage = await primaryRuntime.context.newPage() + const standbyTransport = observeRuntimeTransport(standbyPage, terminal.code, new URL(baseURL).origin) + await standbyPage.goto(primaryRuntime.publicPath) + await expect(standbyPage.locator('.live-agent-page')).toBeVisible() + await expect(standbyPage.locator('.terminal-runtime-notice')).toContainText('另一页面运行', { timeout: 20_000 }) + await standbyPage.waitForTimeout(1_200) + expect(standbyTransport.heartbeatRequests(), '同源 standby 页面不得竞争服务端心跳租约').toBe(0) + expect(standbyTransport.pullRequests(), '同源 standby 页面不得拉取命令').toBe(0) + expect(standbyTransport.reportRequests(), '同源 standby 页面不得执行或回报命令').toBe(0) + + const standbyHeartbeat = standbyPage.waitForResponse((response) => response.request().method() === 'POST' + && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat' + && response.status() === 200, { timeout: 35_000 }) + await primaryRuntime.page.reload({ waitUntil: 'domcontentloaded' }) + responseStatus(await standbyHeartbeat, 200, '原 leader 刷新释放租约后 standby 页面快速接管') + await expect.poll(primaryRuntime.transport.releaseRequests, { + message: '页面刷新必须 best-effort 调用 runtime release', + timeout: 12_000, + }).toBeGreaterThan(primaryReleaseBeforeReload) + // unload keepalive 的 response 事件可能在旧 document 销毁后不再被 Playwright 派发; + // 上面的新 instance heartbeat=200 严格证明服务端已完成 release,而非等待 45 秒租约过期。 + expect(primaryRuntime.transport.releaseBodyEmpty(), 'runtime release 必须使用无 body 请求').toBe(true) + await expect(primaryRuntime.page.locator('.terminal-runtime-notice')).toContainText('另一页面运行', { timeout: 20_000 }) + expect(primaryRuntime.activationRequests(), '刷新不得重复兑换一次性激活码').toBe(activationCountBeforeReload) + expect(standbyTransport.headersValid(), '接管页面四类运行请求必须复用同一 runtime instance 与三项设备头').toBe(true) + expect(standbyTransport.pullStartedAfterHeartbeat(), '接管页面必须 heartbeat 成功后才 pull').toBe(true) + const standbyInstance = standbyTransport.lastRuntimeInstanceId() + expect(standbyInstance).not.toBe(primaryInstanceBeforeReload) + expect(await runtimeInstancesAbsentFromBrowserState(standbyPage, [standbyInstance])).toBe(true) + + const standbyReportsBeforeCommand = standbyTransport.reportRequests() + const handoffCommandResponse = await postCommand( + api, + headers, + terminal.id, + { type: 'VOLUME', detail: '双页面 leader 单次执行验证', volume: 46 }, + `remote-v2-${suffix}-leader-handoff`, + ) + const handoffCommand = await parseCommandResponse(handoffCommandResponse, '刷新交棒后下发单次执行命令') + commandIds.handoff = handoffCommand.id + const handoffDone = await waitCommand(api, headers, handoffCommand.id, 'SUCCEEDED', 60_000) + expect(numberValue(asRecord(handoffDone.result).volume)).toBe(46) + expect(standbyTransport.reportRequests()).toBeGreaterThan(standbyReportsBeforeCommand) + expect(primaryRuntime.transport.reportRequests(), 'standby 页面不得重复回报接管页面已执行的命令').toBe(primaryReportsBeforeHandoff) + await waitTerminal(api, headers, terminal.id, (value) => terminalActualVolume(value) === 46, + '交棒后的唯一 leader 必须回报 46% 音量', 45_000) + + const standbyReleaseBeforeClose = standbyTransport.releaseRequests() + const primaryHeartbeatAfterHandoff = primaryRuntime.page.waitForResponse((response) => response.request().method() === 'POST' + && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat' + && response.status() === 200, { timeout: 35_000 }) + await standbyPage.close() + responseStatus(await primaryHeartbeatAfterHandoff, 200, '接管页面关闭释放后刷新页重新成为 leader') + await expect.poll(standbyTransport.releaseRequests, { + message: 'leader 页面关闭必须 best-effort 释放 runtime lease', + timeout: 12_000, + }).toBeGreaterThan(standbyReleaseBeforeClose) + // primaryHeartbeatAfterHandoff=200 严格证明关闭页的 release 已在服务端生效。 + expect(standbyTransport.releaseBodyEmpty()).toBe(true) + const primaryInstanceAfterReload = primaryRuntime.transport.lastRuntimeInstanceId() + expect(primaryInstanceAfterReload).not.toBe(primaryInstanceBeforeReload) + expect(primaryInstanceAfterReload).not.toBe(standbyInstance) + expect(primaryRuntime.transport.headersValid()).toBe(true) + expect(primaryRuntime.transport.pullStartedAfterHeartbeat()).toBe(true) + expect(await runtimeInstancesAbsentFromBrowserState( + primaryRuntime.page, + primaryRuntime.transport.runtimeInstanceIds(), + )).toBe(true) + terminalScopedAccessEvidence = terminalScopedAccess(primaryRuntime) + await expect(primaryRuntime.page.locator('.terminal-runtime-notice.is-error')).toHaveCount(0) + await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-07-refresh-leader-handoff') + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: handoffCommand.id, status: 'LEADER_HANDOFF_SINGLE_EXECUTION' }) + + activePhase = 'RESTART 先结束受管会话再回报终态并刷新,刷新后仍可创建唯一新会话' + const sessionsBeforeRestartWake = primaryRuntime.terminalSessionRequests() + const restartWakeResponse = await postCommand( + api, + headers, + terminal.id, + { type: 'WAKE', detail: 'RESTART 前创建受管会话' }, + `remote-v2-${suffix}-restart-wake`, + ) + const restartWake = await parseCommandResponse(restartWakeResponse, 'RESTART 前唤醒运行页') + commandIds.restartWake = restartWake.id + await waitCommand(api, headers, restartWake.id, 'SUCCEEDED', 60_000) + await expect.poll(primaryRuntime.terminalSessionRequests, { + message: 'RESTART 前 WAKE 必须创建受管会话', + timeout: 20_000, + }).toBeGreaterThan(sessionsBeforeRestartWake) + const activeBeforeRestart = await waitTerminal( + api, + headers, + terminal.id, + (value) => terminalSession(value) === 'ACTIVE', + 'RESTART 前终端必须存在活动会话', + 45_000, + ) + const sessionIdBeforeRestart = textValue(activeBeforeRestart.sessionId ?? asRecord(activeBeforeRestart.session).id) + const restartOrder: string[] = [] + const restartResponseObserver = (response: Response) => { + const pathname = new URL(response.url()).pathname + if (pathname.endsWith(`/open/v1/realtime/${encodeURIComponent(agent.slug)}/end`) && response.status() === 200) { + restartOrder.push('end-response') + return + } + const reportMatch = pathname.match(/^\/open\/v2\/terminals\/commands\/([^/]+)\/reports$/) + if (!reportMatch || response.status() !== 200) return + const payload = asRecord(response.request().postDataJSON()) + if (textValue(payload.status).toUpperCase() === 'SUCCEEDED') { + restartOrder.push(`final-report:${decodeURIComponent(reportMatch[1] || '')}`) + } + } + primaryRuntime.page.on('response', restartResponseObserver) + const heartbeatAfterRestart = primaryRuntime.page.waitForResponse((response) => response.request().method() === 'POST' + && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat' + && response.status() === 200, { timeout: 45_000 }) + const restartResponse = await postCommand( + api, + headers, + terminal.id, + { type: 'RESTART', detail: '验证先结束会话再终态回执与刷新' }, + `remote-v2-${suffix}-restart`, + ) + const restart = await parseCommandResponse(restartResponse, '下发 RESTART 命令') + commandIds.restart = restart.id + await waitCommand(api, headers, restart.id, 'SUCCEEDED', 60_000) + responseStatus(await heartbeatAfterRestart, 200, 'RESTART 后新页面运行实例重新取得租约') + await expect.poll(() => primaryRuntime!.terminalFollowupRequests().end, { + message: 'RESTART 必须调用受管会话 end', + timeout: 20_000, + }).toBeGreaterThan(0) + const endIndex = restartOrder.indexOf('end-response') + const finalReportIndex = restartOrder.indexOf(`final-report:${restart.id}`) + expect(endIndex, 'RESTART 必须观察到会话 end 成功响应').toBeGreaterThanOrEqual(0) + expect(finalReportIndex, 'RESTART 必须观察到 SUCCEEDED 终态回执').toBeGreaterThan(endIndex) + primaryRuntime.page.off('response', restartResponseObserver) + await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE', + 'RESTART 刷新后不得残留 ACTIVE 会话', 45_000) + + const sessionsBeforePostRestartWake = primaryRuntime.terminalSessionRequests() + const postRestartWakeResponse = await postCommand( + api, + headers, + terminal.id, + { type: 'WAKE', detail: 'RESTART 后验证新会话可用' }, + `remote-v2-${suffix}-post-restart-wake`, + ) + const postRestartWake = await parseCommandResponse(postRestartWakeResponse, 'RESTART 后重新唤醒运行页') + commandIds.postRestartWake = postRestartWake.id + await waitCommand(api, headers, postRestartWake.id, 'SUCCEEDED', 60_000) + await expect.poll(primaryRuntime.terminalSessionRequests, { + message: 'RESTART 后 WAKE 必须创建新的受管会话', + timeout: 20_000, + }).toBeGreaterThan(sessionsBeforePostRestartWake) + const activeAfterRestart = await waitTerminal(api, headers, terminal.id, + (value) => terminalSession(value) === 'ACTIVE', 'RESTART 后新会话必须可用', 45_000) + const sessionIdAfterRestart = textValue(activeAfterRestart.sessionId ?? asRecord(activeAfterRestart.session).id) + if (sessionIdBeforeRestart && sessionIdAfterRestart) expect(sessionIdAfterRestart).not.toBe(sessionIdBeforeRestart) + const postRestartStopResponse = await postCommand( + api, + headers, + terminal.id, + { type: 'STOP', detail: 'RESTART 回归完成后结束新会话' }, + `remote-v2-${suffix}-post-restart-stop`, + ) + const postRestartStop = await parseCommandResponse(postRestartStopResponse, '结束 RESTART 后的新会话') + commandIds.postRestartStop = postRestartStop.id + await waitCommand(api, headers, postRestartStop.id, 'SUCCEEDED', 60_000) + await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE', + 'RESTART 后新会话结束必须回到 IDLE', 45_000) + expect(primaryRuntime.terminalFollowupHeadersValid(), 'RESTART/STOP 的 end 必须使用当前 runtime owner 的完整授权头').toBe(true) + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: restart.id, status: 'END_BEFORE_FINAL_REPORT_AND_RELOAD' }) + + activePhase = '真实 API 双实例租约 fencing、owner release 接管与旧 owner 回执拒绝' + const runtimeHarness = await primaryRuntime.context.newPage() + await runtimeHarness.goto('/') + const primaryReleaseBeforeClose = primaryRuntime.transport.releaseRequests() + await primaryRuntime.page.close() + await expect.poll(primaryRuntime.transport.releaseRequests, { + message: '最后一个 leader 页面关闭必须发出 runtime release', + timeout: 12_000, + }).toBeGreaterThan(primaryReleaseBeforeClose) + expect(primaryRuntime.transport.releaseBodyEmpty()).toBe(true) + + const runtimeA = `runtime-ape2e-a-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 128) + const runtimeB = `runtime-ape2e-b-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 128) + const directTerminalCode = terminal.code + const directState = directRuntimeState(primaryRuntime.publicPath, 46) + let claimA: DirectRuntimeResponse = { status: 0, code: 0, data: {} } + await expect.poll(async () => { + claimA = await directRuntimeRequest(runtimeHarness, directTerminalCode, runtimeA, '/open/v2/terminals/heartbeat', directState) + return claimA.status + }, { message: '页面 release 后直接运行实例 A 必须快速取得租约', timeout: 15_000 }).toBe(200) + expect(numberValue(claimA.data.runtimeLeaseSeconds)).toBeGreaterThan(0) + expect(textValue(claimA.data.runtimeLeaseExpiresAt)).not.toBe('') + const renewedA = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeA, '/open/v2/terminals/heartbeat', directState) + expect(renewedA.status).toBe(200) + expect(new Date(textValue(renewedA.data.runtimeLeaseExpiresAt)).getTime()) + .toBeGreaterThanOrEqual(new Date(textValue(claimA.data.runtimeLeaseExpiresAt)).getTime()) + const blockedB = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeB, '/open/v2/terminals/heartbeat', directState) + expect(blockedB.status).toBe(423) + expect(blockedB.code).toBe(42301) + const sessionFence = await directRuntimeSessionFence( + runtimeHarness, + terminal.code, + runtimeA, + runtimeB, + agent.slug, + directState, + ) + expect(sessionFence.ownerSessionCreated).toBe(true) + expect(sessionFence.ownerSessionNoStore).toBe(true) + expect(sessionFence.websocketOpened).toBe(true) + expect(sessionFence.ownerReleaseStatus).toBe(200) + expect(sessionFence.contenderClaimStatus).toBe(200) + expect([401, 423]).toContain(sessionFence.oldOwnerHttpStatus) + expect([40100, 42301]).toContain(sessionFence.oldOwnerHttpCode) + expect(sessionFence.oldOwnerWebSocketCloseCode).toBe(4401) + expect(sessionFence.contenderSessionCreated).toBe(true) + expect(sessionFence.contenderSessionEnded).toBe(true) + expect(sessionFence.staleOwnerReleaseStatus).toBe(423) + expect(sessionFence.staleOwnerReleaseCode).toBe(42301) + + const fencedCommandResponse = await postCommand( + api, + headers, + terminal.id, + { type: 'VOLUME', detail: '旧 runtime owner fencing 验证', volume: 47 }, + `remote-v2-${suffix}-runtime-fence`, + ) + const fencedCommand = await parseCommandResponse(fencedCommandResponse, '为双实例 fencing 创建命令') + commandIds.fenced = fencedCommand.id + const pulledByB = await directRuntimeRequest( + runtimeHarness, + terminal.code, + runtimeB, + '/open/v2/terminals/commands/pull', + { limit: 1, waitSeconds: 0 }, + ) + expect(pulledByB.status).toBe(200) + const fencedDelivery = asRecord(asRecords(pulledByB.data.items)[0]) + expect(textValue(fencedDelivery.id)).toBe(fencedCommand.id) + const staleReportA = await directRuntimeRequest( + runtimeHarness, + terminal.code, + runtimeA, + `/open/v2/terminals/commands/${encodeURIComponent(fencedCommand.id)}/reports`, + { + reportId: `stale-a-${suffix}`, + deliveryId: textValue(fencedDelivery.deliveryId), + status: 'SUCCEEDED', + result: { message: '旧 owner 不应被接受' }, + }, + ) + expect(staleReportA.status).toBe(423) + expect(staleReportA.code).toBe(42301) + const staleReleaseA = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeA, '/open/v2/terminals/runtime/release') + expect(staleReleaseA.status).toBe(423) + expect(staleReleaseA.code).toBe(42301) + const settledByB = await directRuntimeRequest( + runtimeHarness, + terminal.code, + runtimeB, + `/open/v2/terminals/commands/${encodeURIComponent(fencedCommand.id)}/reports`, + { + reportId: `cleanup-b-${suffix}`, + deliveryId: textValue(fencedDelivery.deliveryId), + status: 'FAILED', + errorCode: 'APE2E_RUNTIME_FENCE_CLEANUP', + errorMessage: '租约 fencing 验证完成后的测试收敛', + }, + ) + expect(settledByB.status).toBe(200) + await waitCommand(api, headers, fencedCommand.id, 'FAILED', 30_000) + + activePhase = '运行实例释放前补充验证 QUEUED 取消与 90 秒超时收敛' + const cancellableResponse = await postCommand(api, headers, terminal.id, { type: 'RESTART', detail: '排队取消验证' }, `remote-v2-${suffix}-cancel`) + const cancellable = await parseCommandResponse(cancellableResponse, '创建待取消命令') + commandIds.cancelled = cancellable.id + const cancelResponse = await api.post(`/api/v1/remote-commands/${encodeURIComponent(cancellable.id)}/cancel`, { + headers, + data: { dataVersion: numberValue(cancellable.command.dataVersion) }, + }) + const cancelled = await responseData(cancelResponse, 200, '取消 QUEUED 命令') + expect(commandStatus(cancelled)).toBe('CANCELLED') + + const expiringResponse = await postCommand(api, headers, terminal.id, { type: 'WAKE', detail: '真实九十秒过期验证' }, `remote-v2-${suffix}-timeout`) + const expiring = await parseCommandResponse(expiringResponse, '创建待超时命令') + commandIds.timedOut = expiring.id + const releasedB = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeB, '/open/v2/terminals/runtime/release') + expect(releasedB.status).toBe(200) + runtimeLeaseEvidence = { + ownerClaimAndRenew: claimA.status === 200 && renewedA.status === 200, + contenderBlocked: blockedB.status === 423 && blockedB.code === 42301, + ownerReleased: sessionFence.ownerReleaseStatus === 200, + contenderTookOver: sessionFence.contenderClaimStatus === 200, + ownerSessionInterrupted: [401, 423].includes(sessionFence.oldOwnerHttpStatus) + && sessionFence.oldOwnerWebSocketCloseCode === 4401, + contenderSessionUsable: sessionFence.contenderSessionCreated && sessionFence.contenderSessionEnded, + staleOwnerReportBlocked: staleReportA.status === 423 && staleReportA.code === 42301, + staleOwnerReleaseBlocked: staleReleaseA.status === 423 && staleReleaseA.code === 42301, + newOwnerSettledCommand: settledByB.status === 200, + newOwnerReleased: releasedB.status === 200, + } + await runtimeHarness.close() + evidence.push({ phase: '真实 API 双实例租约 fencing、owner release 接管与旧 owner 回执拒绝', result: 'PASS', terminalId: terminal.id, commandId: fencedCommand.id, status: 'LEASE_FENCING_AND_RELEASE_PASS' }) + await waitCommand(api, headers, expiring.id, 'TIMED_OUT', 110_000) + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'CANCELLED/TIMED_OUT' }) + + const resumedPage = await primaryRuntime.context.newPage() + primaryRuntime.page = resumedPage + primaryRuntime.transport = observeRuntimeTransport(resumedPage, terminal.code, new URL(baseURL).origin) + const resumedHeartbeatPromise = resumedPage.waitForResponse((response) => response.request().method() === 'POST' + && new URL(response.url()).pathname.endsWith('/open/v2/terminals/heartbeat'), + { timeout: 30_000 }) + await resumedPage.goto(primaryRuntime.publicPath) + responseStatus(await resumedHeartbeatPromise, 200, '关闭页面后同一设备上下文持久重连') + expect(primaryRuntime.transport.headersValid()).toBe(true) + expect(primaryRuntime.transport.pullStartedAfterHeartbeat()).toBe(true) + await waitTerminal(api, headers, terminal.id, (value) => terminalConnection(value) === 'ONLINE', '重新打开运行页后恢复在线') + + activePhase = '页面重新激活使旧设备凭据失效,新浏览器接管运行通道' + drawer = await openTerminalDrawer(page, terminalName) + await drawer.getByRole('tab', { name: '接入与安全', exact: true }).click() + const regenerationResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' + && new URL(response.url()).pathname.endsWith(`/api/v1/remote-terminals/${encodeURIComponent(terminal!.id)}/activation`), + { timeout: 30_000 }) + await drawer.getByRole('button', { name: '重新生成激活链接', exact: true }).click() + const confirmation = page.locator('.el-message-box:visible') + await confirmation.getByRole('button', { name: '确认重新生成', exact: true }).click() + const regenerationResponse = await regenerationResponsePromise + const regenerated = activationParts(await responseData(regenerationResponse, 200, '页面重新生成激活链接')) + expect(regenerationResponse.headers()['cache-control'] || '').toContain('no-store') + expect(regenerated.shownOnce).toBe(true) + if (!regenerated.activationCode || regenerated.activationCode === created.activationCode) { + throw new Error('重新激活没有生成独立的一次性激活码') + } + activationValues.push(regenerated.activationCode) + terminal.dataVersion = numberValue(regenerated.terminal.dataVersion) || terminal.dataVersion + const regeneratedDialog = await scrubActivationDialog(page) + await expect(resumedPage.locator('.terminal-runtime-notice.is-error')).toContainText('终端凭据已失效', { timeout: 40_000 }) + replacementRuntime = await activateRuntime(browser, agent, terminal, regenerated.activationCode) + await regeneratedDialog.getByRole('button', { name: '完成', exact: true }).click() + await waitTerminal(api, headers, terminal.id, (value) => terminalConnection(value) === 'ONLINE', '新设备激活后必须重新在线') + await captureScreenshot(page, testInfo, 'remote-v2-real-08-reactivated-new-device-online') + await captureScreenshot(resumedPage, testInfo, 'remote-v2-real-09-old-device-credential-revoked') + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'OLD_REVOKED/NEW_ONLINE' }) + + activePhase = '停止真实运行页超过离线阈值后后台呈现离线' + await primaryRuntime.context.close() + primaryRuntime = null + await replacementRuntime.context.close() + replacementRuntime = null + const runtimesClosedAt = Date.now() + await waitTerminal(api, headers, terminal.id, (value) => terminalConnection(value) === 'OFFLINE' + && value.online === false + && Date.now() - runtimesClosedAt >= 110_000, + '关闭运行页并超过服务端离线窗口后必须真实离线', 140_000) + drawer = await openTerminalDrawer(page, terminalName) + await expect(drawer).toContainText('离线') + await captureScreenshot(page, testInfo, 'remote-v2-real-10-runtime-stopped-real-offline') + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'OFFLINE' }) + + activePhase = '通过页面停用并撤销终端,活动资源清零而命令事件与审计留痕保留' + await drawer.getByRole('tab', { name: '接入与安全', exact: true }).click() + const statusResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' + && new URL(response.url()).pathname.endsWith(`/api/v1/remote-terminals/${encodeURIComponent(terminal!.id)}/status`), + { timeout: 30_000 }) + await drawer.getByRole('button', { name: '停用终端', exact: true }).click() + const stopConfirmation = page.locator('.el-message-box:visible') + await stopConfirmation.getByRole('button', { name: '确认停用', exact: true }).click() + const statusResponse = await statusResponsePromise + const suspended = await responseData(statusResponse, 200, '页面停用终端') + expect(terminalLifecycle(suspended)).toBe('SUSPENDED') + terminal.dataVersion = numberValue(suspended.dataVersion) || terminal.dataVersion + await captureScreenshot(page, testInfo, 'remote-v2-real-11-terminal-suspended') + + const deleteResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE' + && new URL(response.url()).pathname.endsWith(`/api/v1/remote-terminals/${encodeURIComponent(terminal!.id)}`), + { timeout: 30_000 }) + await drawer.getByRole('button', { name: '撤销终端', exact: true }).click() + const deleteConfirmation = page.locator('.el-message-box:visible') + await deleteConfirmation.getByRole('button', { name: '确认撤销', exact: true }).click() + const deleteResponse = await deleteResponsePromise + responseStatus(deleteResponse, [200, 204], '页面撤销终端') + terminalDeleted = true + + const absentDetail = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers }) + responseStatus(absentDetail, 404, '撤销后终端详情必须不可访问') + const absentListResponse = await api.get('/api/v1/remote-terminals', { + headers, + params: { page: '1', pageSize: '20', keyword: terminalName }, + }) + const absentList = await responseData(absentListResponse, 200, '撤销后按精确名称检索终端') + expect(pageItems(absentList)).toHaveLength(0) + expect(numberValue(absentList.total)).toBe(0) + + const immutableProbeId = commandIds.wake || Object.values(commandIds)[0] + if (!immutableProbeId) throw new Error('真实生命周期没有产生可核验的命令审计编号') + const retainedCommandResponse = await api.get(`/api/v1/remote-commands/${encodeURIComponent(immutableProbeId)}`, { headers }) + responseStatus(retainedCommandResponse, 200, '撤销终端后命令审计历史必须保留') + const retainedEventsResponse = await api.get(`/api/v1/remote-commands/${encodeURIComponent(immutableProbeId)}/events`, { headers }) + const retainedEvents = await responseData(retainedEventsResponse, 200, '撤销终端后命令事件账本必须保留') + const eventItems = Array.isArray(retainedEvents) ? retainedEvents : pageItems(retainedEvents) + expect(eventItems.length).toBeGreaterThanOrEqual(3) + const retainedStatuses = eventItems.map((item) => textValue(item.toStatus ?? item.status).toUpperCase()) + for (const status of ['ACKNOWLEDGED', 'EXECUTING', 'SUCCEEDED']) { + expect(retainedStatuses, `命令事件账本应包含 ${status}`).toContain(status) + } + + const auditResponse = await api.get('/api/auth/v1/audit-logs', { + headers, + params: { page: '1', size: '100', keyword: terminalName, start: lifecycleStartedAt }, + }) + const auditData = await responseData(auditResponse, 200, '查询远控系统审计') + const terminalAudits = pageItems(auditData) + const terminalAuditCodes = terminalAudits.map((item) => textValue(item.actionCode)) + for (const actionCode of ['ai.remote.create', 'ai.remote.activation', 'ai.remote.status', 'ai.remote.delete']) { + expect(terminalAuditCodes, `审计日志应包含 ${actionCode}`).toContain(actionCode) + } + const commandAuditResponse = await api.get('/api/auth/v1/audit-logs', { + headers, + params: { page: '1', size: '100', action: 'ai.remote.command', keyword: immutableProbeId, start: lifecycleStartedAt }, + }) + const commandAuditData = await responseData(commandAuditResponse, 200, '查询远控命令系统审计') + const commandAudits = pageItems(commandAuditData) + expect(commandAudits.some((item) => textValue(item.actionCode) === 'ai.remote.command' + && textValue(item.targetId) === immutableProbeId)).toBe(true) + const serializedAudit = JSON.stringify({ terminalAudits, commandAudits }) + for (const secret of activationValues) expect(serializedAudit).not.toContain(secret) + expect(serializedAudit).not.toContain(idempotencyKey) + evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'DELETED_WITH_IMMUTABLE_AUDIT' }) + + await page.goto('/agents/remote-control') + await page.getByRole('textbox', { name: '搜索终端' }).fill(terminalName) + await page.getByRole('button', { name: '搜索', exact: true }).click() + await expect(page.getByText('没有符合条件的远程终端', { exact: true })).toBeVisible() + await captureScreenshot(page, testInfo, 'remote-v2-real-12-deleted-zero-active-residual') + } catch (cause) { + evidence.push({ + phase: activePhase, + result: 'FAIL', + terminalId: terminal?.id, + detail: cause instanceof Error ? cause.message.slice(0, 500) : '未知失败', + }) + throw cause + } finally { + await page.locator('.activation-dialog code').evaluateAll((elements) => { + for (const element of elements) element.textContent = '[已遮蔽]' + }).catch(() => undefined) + if (primaryRuntime) await primaryRuntime.context.close().catch(() => undefined) + if (replacementRuntime) await replacementRuntime.context.close().catch(() => undefined) + if (api && !terminalDeleted) await cleanupTerminal(api, headers, terminal, evidence, cleanupErrors) + + await attachJson(testInfo, 'remote-terminal-v2-real-lifecycle-evidence', { + runId, + terminalId: terminal?.id || null, + boundPublishedAgent: boundAgentEvidence, + mediaIsolation: { + chatTransport: 'CONTROLLED', + tts: 'REAL', + media: 'REAL', + remoteControl: 'REAL', + speech: mediaEvidence, + terminalChatDeviceHeadersValid, + signedAudioUrlAttached: false, + }, + terminalScopedAccess: terminalScopedAccessEvidence, + runtimeLease: runtimeLeaseEvidence, + commandIds, + evidence, + expectedImmutableHistory: { + commandAndEventRowsRetainedAfterTerminalDeletion: terminalDeleted, + systemAuditRetainedAfterTerminalDeletion: terminalDeleted, + immutableCommandIds, + }, + activeTerminalResidualCount: terminalDeleted || cleanupErrors.length === 0 ? 0 : null, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + security: { + accountPasswordOrBearerTokenAttached: false, + activationCodeAttached: false, + deviceSecretReadByTestProcess: false, + traceAndVideoDisabledByPlaywrightConfig: true, + runtimeFragmentClearedBeforeScreenshots: true, + }, + }) + assertArtifactsExclude(activationValues) + await api?.dispose() + } + + expect(cleanupErrors, '活动远程终端必须精确清理为零').toEqual([]) + }) + + test('短链路 WIDGET 在受控宿主 iframe 激活、执行命令并在宿主刷新后复用凭据', async ({ page, browser }, testInfo) => { + test.setTimeout(8 * 60_000) + + const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}` + const terminalName = `${runPrefix}-WIDGET-终端-${suffix}`.slice(0, 110) + const terminalCode = `RTC-WIDGET-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').toUpperCase().slice(0, 64) + const baseOrigin = new URL(baseURL).origin + const hostPath = `/ape2e/remote-widget-host-${encodeURIComponent(suffix)}` + const activationValues: string[] = [] + const evidence: LifecycleEvidence[] = [] + const cleanupErrors: string[] = [] + let terminal: TerminalRef | null = null + let runtimeContext: BrowserContext | null = null + let api: APIRequestContext | null = null + let headers: Headers = {} + let activationRequests = 0 + let runtimeTransport: RuntimeTransportMonitor | null = null + let terminalConfigRequests = 0 + let terminalSessionRequests = 0 + let legacyPublicConfigRequests = 0 + let legacyPublicSessionRequests = 0 + let terminalConfigHeadersValid = true + let terminalSessionHeadersValid = true + const terminalFollowupRequests = { chat: 0, asr: 0, feedback: 0, end: 0 } + let terminalFollowupHeadersValid = true + let widgetCommandId = '' + let widgetCommandStatus = '' + let hostRefreshReusedCredential = false + let releasedSessionBeforeReconnect = false + let createdSessionAfterReconnect = false + let widgetFailure = '' + + try { + api = await playwrightRequest.newContext({ baseURL }) + headers = await authHeaders(api) + const agent = await findPublishedAgent(api, headers) + await loginAsAdmin(page) + + const createResponse = await api.post('/api/v1/remote-terminals', { + headers, + data: { + name: terminalName, + code: terminalCode, + location: `E2E WIDGET 宿主 ${suffix}`, + boundAgentId: agent.id, + displayMode: 'WIDGET', + allowedOrigins: [baseOrigin], + defaultVolume: 60, + allowInterrupt: true, + }, + }) + const created = activationParts(await responseData(createResponse, 201, '创建真实 WIDGET 终端')) + expect(createResponse.headers()['cache-control'] || '').toContain('no-store') + if (!created.activationCode || created.activationCode.length < 12) throw new Error('WIDGET 创建响应未返回有效的一次性激活码') + activationValues.push(created.activationCode) + terminal = { + id: textValue(created.terminal.id), + code: textValue(created.terminal.code) || terminalCode, + name: textValue(created.terminal.name) || terminalName, + dataVersion: numberValue(created.terminal.dataVersion), + } + expect(stringList(created.terminal.allowedOrigins)).toEqual([baseOrigin]) + expect(textValue(created.terminal.displayMode)).toBe('WIDGET') + + runtimeContext = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const hostPage = await runtimeContext.newPage() + runtimeTransport = observeRuntimeTransport(hostPage, terminal.code, baseOrigin) + const cleanEmbedPath = `/embed/${encodeURIComponent(agent.slug)}?terminal=${encodeURIComponent(terminal.code)}` + const terminalConfigPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/config` + const terminalSessionPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/sessions` + const legacyPublicConfigPath = `/open/v1/realtime/${encodeURIComponent(agent.slug)}` + const legacyPublicSessionPath = `${legacyPublicConfigPath}/sessions` + let includeActivation = true + runtimeContext.on('request', (request) => { + const pathname = new URL(request.url()).pathname + const method = request.method() + if (pathname === '/open/v2/terminals/activate') activationRequests += 1 + if (pathname === terminalConfigPath && method === 'GET') { + terminalConfigRequests += 1 + const requestHeaders = request.headers() + terminalConfigHeadersValid = terminalConfigHeadersValid + && requestHeaders['x-terminal-id'] === terminal!.code + && Boolean(requestHeaders['x-device-secret']) + && requestHeaders['x-terminal-page-origin'] === baseOrigin + && !requestHeaders['x-runtime-instance-id'] + && !requestHeaders['x-terminal-secret'] + } + if (pathname === terminalSessionPath && method === 'POST') { + terminalSessionRequests += 1 + const requestHeaders = request.headers() + terminalSessionHeadersValid = terminalSessionHeadersValid + && requestHeaders['x-terminal-id'] === terminal!.code + && Boolean(requestHeaders['x-device-secret']) + && requestHeaders['x-terminal-page-origin'] === baseOrigin + && requestHeaders['x-runtime-instance-id'] === runtimeTransport!.lastRuntimeInstanceId() + && !requestHeaders['x-terminal-secret'] + } + const followupMatch = pathname.match(new RegExp(`^${legacyPublicConfigPath}/(chat|asr|feedback|end)$`)) + if (followupMatch && method === 'POST') { + const endpoint = followupMatch[1] as keyof typeof terminalFollowupRequests + terminalFollowupRequests[endpoint] += 1 + const requestHeaders = request.headers() + terminalFollowupHeadersValid = terminalFollowupHeadersValid + && requestHeaders['x-terminal-id'] === terminal!.code + && Boolean(requestHeaders['x-device-secret']) + && requestHeaders['x-terminal-page-origin'] === baseOrigin + && requestHeaders['x-runtime-instance-id'] === runtimeTransport!.lastRuntimeInstanceId() + && /^Bearer\s+\S+$/i.test(requestHeaders.authorization || '') + && !requestHeaders['x-terminal-secret'] + } + if (pathname === legacyPublicConfigPath && method === 'GET') legacyPublicConfigRequests += 1 + if (pathname === legacyPublicSessionPath && method === 'POST') legacyPublicSessionRequests += 1 + }) + + await hostPage.route(`**${hostPath}`, async (route) => { + if (new URL(route.request().url()).pathname !== hostPath) return route.continue() + const fragment = includeActivation + ? `#${new URLSearchParams({ 'terminal-activation': created.activationCode }).toString()}` + : '' + const iframeSource = `${cleanEmbedPath}${fragment}` + .replaceAll('&', '&') + .replaceAll('"', '"') + await route.fulfill({ + status: 200, + contentType: 'text/html; charset=utf-8', + headers: { 'Cache-Control': 'no-store' }, + body: `APE2E WIDGET 宿主

受控内网业务宿主

宿主 Origin:${baseOrigin}

`, + }) + }) + + const activationResponsePromise = hostPage.waitForResponse((response) => response.request().method() === 'POST' + && new URL(response.url()).pathname === '/open/v2/terminals/activate', { timeout: 40_000 }) + await hostPage.goto(hostPath) + const activationResponse = await activationResponsePromise + responseStatus(activationResponse, 200, '宿主 iframe 兑换一次性激活码') + expect(activationResponse.headers()['cache-control'] || '').toContain('no-store') + includeActivation = false + + const widget = hostPage.frameLocator('#terminal-widget') + await expect(widget.locator('.embed-widget')).toBeVisible({ timeout: 30_000 }) + await expect.poll(() => { + const frame = hostPage.frames().find((item) => { + try { return new URL(item.url()).pathname === `/embed/${encodeURIComponent(agent.slug)}` } catch { return false } + }) + return frame ? new URL(frame.url()).hash : '#waiting' + }, { message: 'iframe 必须在激活请求前清除 URL 中的一次性激活码', timeout: 15_000 }).toBe('') + await expect.poll(runtimeTransport.heartbeatSuccesses, { timeout: 30_000 }).toBeGreaterThan(0) + await expect.poll(() => terminalConfigRequests, { + message: 'WIDGET 必须使用设备凭据读取绑定智能体配置', + timeout: 20_000, + }).toBeGreaterThan(0) + expect(activationRequests).toBe(1) + expect(runtimeTransport.headersValid(), '运行端点必须携带设备头、runtime instance、可信宿主 Origin 且不再发送旧头').toBe(true) + expect(runtimeTransport.pullStartedAfterHeartbeat(), 'WIDGET 必须首次 heartbeat 成功后才启动 pull').toBe(true) + expect(runtimeTransport.runtimeInstanceOmittedFromUrls()).toBe(true) + expect(await runtimeInstancesAbsentFromBrowserState(hostPage, runtimeTransport.runtimeInstanceIds())).toBe(true) + expect(terminalConfigHeadersValid, 'WIDGET 配置请求必须携带设备头和可信宿主 Origin').toBe(true) + expect(legacyPublicConfigRequests, 'WIDGET 受管终端不得回退到旧公开配置接口').toBe(0) + expect(legacyPublicSessionRequests, 'WIDGET 激活阶段不得调用旧公开会话接口').toBe(0) + + await waitTerminal(api, headers, terminal.id, (value) => value.online === true + && terminalConnection(value) === 'ONLINE' + && stringList(value.capabilities).includes('VOLUME'), 'WIDGET 激活后必须心跳在线并上报 VOLUME 能力') + await captureScreenshot(hostPage, testInfo, 'remote-v2-widget-01-iframe-activated-no-secret') + evidence.push({ phase: 'WIDGET iframe 激活与心跳', result: 'PASS', terminalId: terminal.id, status: 'ONLINE' }) + + const sessionRequestsBeforeWake = terminalSessionRequests + const wakeResponse = await postCommand( + api, + headers, + terminal.id, + { type: 'WAKE', detail: 'WIDGET 首次受管会话签发验证' }, + `remote-v2-widget-${suffix}-wake`, + ) + const wake = await parseCommandResponse(wakeResponse, '向 WIDGET 下发 WAKE 命令') + await waitCommand(api, headers, wake.id, 'SUCCEEDED', 60_000) + await expect.poll(() => terminalSessionRequests, { + message: 'WIDGET WAKE 必须通过当前 runtime owner 签发 scoped session', + timeout: 20_000, + }).toBeGreaterThan(sessionRequestsBeforeWake) + expect(terminalSessionHeadersValid, 'WIDGET scoped session 必须携带三项设备头和当前 runtime instance').toBe(true) + expect(legacyPublicSessionRequests, 'WIDGET WAKE 不得回退旧公开 session 路径').toBe(0) + await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'ACTIVE', + 'WIDGET WAKE 后必须进入 ACTIVE 会话', 45_000) + evidence.push({ phase: 'WIDGET runtime-owner scoped session', result: 'PASS', terminalId: terminal.id, commandId: wake.id, status: 'ACTIVE' }) + + const reportsBeforeCommand = runtimeTransport.reportRequests() + const commandResponse = await postCommand( + api, + headers, + terminal.id, + { type: 'VOLUME', detail: 'WIDGET 短链路设置音量', volume: 41 }, + `remote-v2-widget-${suffix}-volume`, + ) + const command = await parseCommandResponse(commandResponse, '向 WIDGET 下发 VOLUME 命令') + const completed = await waitCommand(api, headers, command.id, 'SUCCEEDED', 60_000) + widgetCommandId = command.id + widgetCommandStatus = commandStatus(completed) + expect(numberValue(asRecord(completed.result).volume)).toBe(41) + expect(runtimeTransport.reportRequests()).toBeGreaterThan(reportsBeforeCommand) + expect(runtimeTransport.headersValid()).toBe(true) + await waitTerminal(api, headers, terminal.id, (value) => terminalActualVolume(value) === 41, + 'WIDGET 命令执行后心跳必须回报 41% 音量', 45_000) + evidence.push({ phase: 'WIDGET 真实命令与终态回执', result: 'PASS', terminalId: terminal.id, commandId: command.id, status: 'SUCCEEDED' }) + + const activationCountBeforeRefresh = activationRequests + const terminalConfigCountBeforeRefresh = terminalConfigRequests + const releaseCountBeforeRefresh = runtimeTransport.releaseRequests() + const instanceBeforeRefresh = runtimeTransport.lastRuntimeInstanceId() + const refreshedHeartbeat = hostPage.waitForResponse((response) => response.request().method() === 'POST' + && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat' + && response.status() === 200, { timeout: 35_000 }) + await hostPage.reload() + responseStatus(await refreshedHeartbeat, 200, '宿主刷新后 iframe 复用本地设备凭据心跳') + await expect(hostPage.frameLocator('#terminal-widget').locator('.embed-widget')).toBeVisible({ timeout: 30_000 }) + expect(activationRequests, '宿主刷新不得重复兑换一次性激活码').toBe(activationCountBeforeRefresh) + await expect.poll(runtimeTransport.releaseRequests, { + message: '宿主刷新必须先由旧 iframe runtime best-effort 释放租约', + timeout: 12_000, + }).toBeGreaterThan(releaseCountBeforeRefresh) + // refreshedHeartbeat=200 且发生在旧租约窗口内,证明服务端接受了刷新前 release。 + expect(runtimeTransport.releaseBodyEmpty()).toBe(true) + expect(runtimeTransport.lastRuntimeInstanceId()).not.toBe(instanceBeforeRefresh) + expect(runtimeTransport.headersValid()).toBe(true) + expect(runtimeTransport.pullStartedAfterHeartbeat()).toBe(true) + expect(await runtimeInstancesAbsentFromBrowserState(hostPage, runtimeTransport.runtimeInstanceIds())).toBe(true) + await expect.poll(() => terminalConfigRequests, { + message: '宿主刷新后必须复用设备凭据重新读取绑定智能体配置', + timeout: 20_000, + }).toBeGreaterThan(terminalConfigCountBeforeRefresh) + expect(terminalConfigHeadersValid, '宿主刷新后的配置请求仍必须携带设备头和原宿主 Origin').toBe(true) + expect(legacyPublicConfigRequests, '宿主刷新不得回退到旧公开配置接口').toBe(0) + expect(legacyPublicSessionRequests, 'WIDGET 短链路不得调用旧公开会话接口').toBe(0) + const persistedWithoutReadingSecret = await hostPage.frames() + .find((item) => { + try { return new URL(item.url()).pathname === `/embed/${encodeURIComponent(agent.slug)}` } catch { return false } + }) + ?.evaluate((code) => Boolean(localStorage.getItem(`ai-person:remote-terminal-runtime:v2:${encodeURIComponent(code.trim().toUpperCase())}`)), terminal.code) + expect(persistedWithoutReadingSecret, '宿主刷新前设备凭据必须已持久化,测试不读取其内容').toBe(true) + hostRefreshReusedCredential = true + await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE', + 'WIDGET 旧 runtime release 后服务端必须中断旧 ACTIVE 会话', 45_000) + releasedSessionBeforeReconnect = true + + const sessionsBeforeReconnectWake = terminalSessionRequests + const reconnectWakeResponse = await postCommand( + api, + headers, + terminal.id, + { type: 'WAKE', detail: 'WIDGET 刷新接管后新会话验证' }, + `remote-v2-widget-${suffix}-wake-after-refresh`, + ) + const reconnectWake = await parseCommandResponse(reconnectWakeResponse, 'WIDGET 刷新后重新 WAKE') + await waitCommand(api, headers, reconnectWake.id, 'SUCCEEDED', 60_000) + await expect.poll(() => terminalSessionRequests, { + message: 'WIDGET 新 runtime owner 必须能签发新 scoped session', + timeout: 20_000, + }).toBeGreaterThan(sessionsBeforeReconnectWake) + await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'ACTIVE', + 'WIDGET 刷新接管后新会话必须 ACTIVE', 45_000) + createdSessionAfterReconnect = true + + const endRequestsBeforeStop = terminalFollowupRequests.end + const reconnectStopResponse = await postCommand( + api, + headers, + terminal.id, + { type: 'STOP', detail: 'WIDGET 刷新接管会话收敛' }, + `remote-v2-widget-${suffix}-stop-after-refresh`, + ) + const reconnectStop = await parseCommandResponse(reconnectStopResponse, '结束 WIDGET 刷新后的 scoped session') + await waitCommand(api, headers, reconnectStop.id, 'SUCCEEDED', 60_000) + await expect.poll(() => terminalFollowupRequests.end, { + message: 'WIDGET STOP 必须调用受管会话 end', + timeout: 20_000, + }).toBeGreaterThan(endRequestsBeforeStop) + expect(terminalFollowupHeadersValid, 'WIDGET end 必须携带 Bearer、三项设备头与当前 runtime instance').toBe(true) + await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE', + 'WIDGET STOP 后不得残留 ACTIVE 会话', 45_000) + await captureScreenshot(hostPage, testInfo, 'remote-v2-widget-02-host-refreshed-credential-reused') + evidence.push({ phase: 'WIDGET 宿主刷新持久重连', result: 'PASS', terminalId: terminal.id, status: 'RECONNECTED_WITHOUT_ACTIVATION' }) + } catch (cause) { + widgetFailure = cause instanceof Error ? cause.message.slice(0, 500) : '未知失败' + evidence.push({ phase: 'WIDGET 真实短链路', result: 'FAIL', terminalId: terminal?.id, detail: widgetFailure }) + throw cause + } finally { + await runtimeContext?.close().catch(() => undefined) + if (api) await cleanupTerminal(api, headers, terminal, evidence, cleanupErrors) + await attachJson(testInfo, 'remote-terminal-v2-widget-short-lifecycle-evidence', { + runId, + terminalId: terminal?.id || null, + allowedOrigin: baseOrigin, + displayMode: 'WIDGET', + activationRequestCount: activationRequests, + runtimeTransport: { + heartbeatObserved: (runtimeTransport?.heartbeatSuccesses() || 0) > 0, + pullObserved: (runtimeTransport?.pullRequests() || 0) > 0, + reportObserved: (runtimeTransport?.reportRequests() || 0) > 0, + releaseObserved: (runtimeTransport?.releaseRequests() || 0) > 0, + finalCredentialHeadersObserved: runtimeTransport?.headersValid() ?? false, + pullStartedAfterHeartbeat: runtimeTransport?.pullStartedAfterHeartbeat() ?? false, + releaseBodyEmpty: runtimeTransport?.releaseBodyEmpty() ?? false, + runtimeInstanceIdsAttached: false, + }, + terminalScopedAccess: { + configRequests: terminalConfigRequests, + sessionRequests: terminalSessionRequests, + configDeviceHeadersValid: terminalConfigHeadersValid, + sessionDeviceHeadersValid: terminalSessionHeadersValid, + followupRequests: terminalFollowupRequests, + followupDeviceHeadersValid: terminalFollowupHeadersValid, + legacyPublicConfigRequests, + legacyPublicSessionRequests, + }, + command: widgetCommandId ? { id: widgetCommandId, type: 'VOLUME', status: widgetCommandStatus } : null, + hostRefreshReusedCredential, + releasedSessionBeforeReconnect, + createdSessionAfterReconnect, + evidence, + failure: widgetFailure || null, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + security: { + activationCodeAttached: false, + deviceSecretReadByTestProcess: false, + screenshotsCapturedAfterActivationFragmentCleared: true, + }, + }) + assertArtifactsExclude(activationValues) + await api?.dispose() + } + + expect(cleanupErrors, 'WIDGET 远程终端必须精确清理为零').toEqual([]) + }) +}) diff --git a/tests/remote-terminal-v2-ui.spec.ts b/tests/remote-terminal-v2-ui.spec.ts new file mode 100644 index 0000000..d8bac54 --- /dev/null +++ b/tests/remote-terminal-v2-ui.spec.ts @@ -0,0 +1,955 @@ +import type { Page, Route } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, test } from './fixtures' +import { fillFormItem, formItem, tableRowByText, visibleDialog } from './helpers' +import { observeRuntimeTransport, runtimeInstancesAbsentFromBrowserState } from './remote-terminal-runtime-contract' + +type JsonRecord = Record +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {} + +const now = new Date().toISOString() +const activationCode = 'controlled-once-only-activation-value' +const controlledAppOrigin = new URL(process.env.BASE_URL ?? 'http://127.0.0.1:8003').origin + +const publishedAgent = { + id: 'remote-agent-published', + dataVersion: 7, + name: '设备点检数字教员', + slug: 'remote-agent-published', + description: '受控远程终端回归使用的已发布智能体', + status: 'published', + expiresAt: null, + interactionModes: ['text'], + uiConfig: { components: [{ key: 'input', enabled: true, order: 1 }], suggestions: [] }, +} + +const agentBinding = { + id: publishedAgent.id, + dataVersion: publishedAgent.dataVersion, + name: publishedAgent.name, + slug: publishedAgent.slug, + status: publishedAgent.status, +} + +function terminal( + id: string, + name: string, + overrides: JsonRecord = {}, +) { + return { + id, + dataVersion: 1, + name, + code: id.toUpperCase(), + location: '综合实训楼 B-02', + displayMode: 'WIDGET', + lifecycleStatus: 'ACTIVE', + connectionStatus: 'ONLINE', + enabled: true, + online: true, + boundAgentId: publishedAgent.id, + boundAgent: agentBinding, + expectedVolume: 60, + actualVolume: 60, + allowInterrupt: true, + allowedOrigins: ['https://portal.example.test'], + capabilities: ['WAKE', 'PAUSE', 'RESUME', 'STOP', 'VOLUME', 'RESTART'], + runtimeVersion: 'ai-person-web/remote-v2', + platform: 'Controlled Chromium', + pageUrl: `https://portal.example.test/embed/${publishedAgent.slug}`, + origin: 'https://portal.example.test', + deviceId: `device-${id}`, + sessionId: '', + sessionStatus: 'IDLE', + playbackStatus: 'IDLE', + visibilityState: 'visible', + lastHeartbeatAt: now, + createdAt: now, + updatedAt: now, + ...overrides, + } +} + +const initialTerminals = [ + terminal('rt-online', '电气实训区在线终端'), + terminal('rt-pending', '液压实训区待激活终端', { + lifecycleStatus: 'PENDING_ACTIVATION', connectionStatus: 'NEVER_CONNECTED', online: false, + actualVolume: null, capabilities: [], runtimeVersion: '', deviceId: '', lastHeartbeatAt: '', + }), + terminal('rt-offline', '装配实训区离线终端', { + connectionStatus: 'OFFLINE', online: false, lastHeartbeatAt: '2026-08-23T08:00:00+08:00', + }), + terminal('rt-suspended', '仓储区停用终端', { + lifecycleStatus: 'SUSPENDED', connectionStatus: 'OFFLINE', enabled: false, online: false, + }), +] + +const envelope = (data: unknown, status = 200) => ({ + code: status >= 400 ? status * 100 : 0, + message: status >= 400 ? '请求失败' : '成功', + data, + timestamp: Date.now(), + requestId: 'remote-terminal-v2-controlled', +}) + +async function fulfill(route: Route, data: unknown, status = 200, headers: Record = {}) { + await route.fulfill({ + status, + headers, + contentType: 'application/json', + body: JSON.stringify(envelope(data, status)), + }) +} + +function profile(permissions: string[]) { + const superAdmin = permissions.includes('*') + return { + user: { + id: superAdmin ? 'remote-admin' : 'remote-viewer', + username: superAdmin ? 'remote-admin' : 'remote-viewer', + displayName: superAdmin ? '系统管理员' : '远控审阅员', + departmentId: 'system', + departmentName: '数字人平台', + enabled: true, + mustChangePassword: false, + version: 1, + }, + roles: [{ + id: superAdmin ? 'admin-role' : 'viewer-role', + code: superAdmin ? 'ADMIN' : 'REMOTE_VIEWER', + name: superAdmin ? '系统管理员' : '远控审阅员', + shortName: superAdmin ? '系统' : '审阅', + enabled: true, + builtIn: false, + isSuperAdmin: superAdmin, + dataScope: 'ALL', + }], + activeRoleId: superAdmin ? 'admin-role' : 'viewer-role', + permissions, + authorizationMode: 'SINGLE_ACTIVE', + loginTime: now, + } +} + +async function installRemoteMocks(page: Page, permissions: string[]) { + const terminals = initialTerminals.map((item) => ({ ...item })) + const commands: JsonRecord[] = [] + const submissions: JsonRecord[] = [] + + await page.addInitScript(() => { + window.sessionStorage.setItem('ai-person:web:access-token:v1', 'controlled-remote-v2-token') + }) + + await page.route('**/api/auth/v1/**', async (route) => { + const pathname = new URL(route.request().url()).pathname + if (pathname.endsWith('/auth/me')) return fulfill(route, profile(permissions)) + if (pathname.endsWith('/menus/navigation')) return fulfill(route, []) + if (pathname.endsWith('/system-config/public')) return fulfill(route, { systemName: '虚拟教员系统', shortName: '数字人平台' }) + return fulfill(route, {}) + }) + + await page.route('**/api/v1/**', async (route) => { + const request = route.request() + const url = new URL(request.url()) + const pathname = url.pathname + const method = request.method() + + if (pathname.endsWith('/realtime-agents')) { + return fulfill(route, { items: [publishedAgent], page: 1, pageSize: 100, total: 1, pages: 1 }) + } + + if (pathname === '/api/v1/remote-terminals' && method === 'GET') { + const keyword = (url.searchParams.get('keyword') || '').toLowerCase() + const filtered = terminals.filter((item) => !keyword + || [item.name, item.code, item.location].join('\n').toLowerCase().includes(keyword)) + return fulfill(route, { items: filtered, page: 1, pageSize: 20, total: filtered.length, pages: 1 }) + } + + if (pathname === '/api/v1/remote-terminals' && method === 'POST') { + const payload = request.postDataJSON() as JsonRecord + submissions.push({ method, pathname, payload }) + const created = terminal('rt-created', String(payload.name || '受控新终端'), { + dataVersion: 1, + code: String(payload.code || 'RTC-CREATED'), + location: String(payload.location || ''), + displayMode: payload.displayMode, + expectedVolume: payload.defaultVolume, + actualVolume: null, + allowInterrupt: payload.allowInterrupt, + allowedOrigins: payload.allowedOrigins, + lifecycleStatus: 'PENDING_ACTIVATION', + connectionStatus: 'NEVER_CONNECTED', + online: false, + capabilities: [], + runtimeVersion: '', + deviceId: '', + lastHeartbeatAt: '', + }) + terminals.unshift(created) + return fulfill(route, { + terminal: created, + activation: { + activationCode, + expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(), + shownOnce: true, + activationPath: `/embed/${publishedAgent.slug}`, + }, + }, 201, { 'Cache-Control': 'no-store' }) + } + + const terminalMatch = pathname.match(/^\/api\/v1\/remote-terminals\/([^/]+)$/) + if (terminalMatch && method === 'GET') { + const found = terminals.find((item) => item.id === decodeURIComponent(terminalMatch[1]!)) + return found ? fulfill(route, found) : fulfill(route, {}, 404) + } + + const commandListMatch = pathname.match(/^\/api\/v1\/remote-terminals\/([^/]+)\/commands$/) + if (commandListMatch && method === 'POST') { + const payload = request.postDataJSON() as JsonRecord + const command = { + id: `controlled-command-${commands.length + 1}`, + terminalId: decodeURIComponent(commandListMatch[1]!), + type: String(payload.type || 'wake').toUpperCase(), + label: payload.type === 'wake' || payload.type === 'WAKE' ? '唤醒' : String(payload.type || ''), + detail: String(payload.detail || ''), + operator: '系统管理员', + status: 'SUCCEEDED', + statusLabel: '执行成功', + deliveryAttempts: 1, + volume: payload.volume ?? null, + queuedAt: now, + sentAt: now, + acknowledgedAt: now, + executingAt: now, + completedAt: now, + expiresAt: new Date(Date.now() + 90_000).toISOString(), + durationMs: 42, + errorCode: '', + errorMessage: '', + result: { expanded: true }, + } + commands.unshift(command) + submissions.push({ + method, + pathname, + payload, + idempotencyHeaderPresent: Boolean(request.headers()['idempotency-key']), + }) + return fulfill(route, command, 202) + } + if (commandListMatch && method === 'GET') { + const terminalId = decodeURIComponent(commandListMatch[1]!) + const items = commands.filter((item) => item.terminalId === terminalId) + return fulfill(route, { items, page: 1, pageSize: 10, total: items.length, pages: items.length ? 1 : 0 }) + } + + const commandEventsMatch = pathname.match(/^\/api\/v1\/remote-commands\/([^/]+)\/events$/) + if (commandEventsMatch && method === 'GET') { + const commandId = decodeURIComponent(commandEventsMatch[1]!) + return fulfill(route, { items: [ + { id: `${commandId}-queued`, status: 'QUEUED', statusLabel: '等待下发', occurredAt: now, message: '命令已排队' }, + { id: `${commandId}-ack`, status: 'ACKNOWLEDGED', statusLabel: '终端已确认', occurredAt: now, message: '运行页已确认' }, + { id: `${commandId}-done`, status: 'SUCCEEDED', statusLabel: '执行成功', occurredAt: now, message: '真实页面动作已完成' }, + ] }) + } + + const commandMatch = pathname.match(/^\/api\/v1\/remote-commands\/([^/]+)$/) + if (commandMatch && method === 'GET') { + const found = commands.find((item) => item.id === decodeURIComponent(commandMatch[1]!)) + return found ? fulfill(route, found) : fulfill(route, {}, 404) + } + + return fulfill(route, {}) + }) + + return { terminals, commands, submissions } +} + +async function chooseSelect(page: Page, scope: ReturnType, label: string, option: string) { + const item = formItem(scope, label) + await item.locator('.el-select').click() + const dropdown = page.locator('.el-select-dropdown:visible').last() + await expect(dropdown).toBeVisible() + await dropdown.locator('.el-select-dropdown__item').filter({ hasText: option }).click() +} + +function collectPageFailures(page: Page) { + const consoleErrors: string[] = [] + const pageErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + page.on('pageerror', (error) => pageErrors.push(error.message)) + return { consoleErrors, pageErrors } +} + +test.describe('远程终端 V2 受控页面回归', () => { + test('表格状态、创建激活、详情控制、命令事件形成完整 UI 闭环', async ({ page }, testInfo) => { + const failures = collectPageFailures(page) + const mock = await installRemoteMocks(page, ['*']) + + await page.goto('/agents/remote-control') + await expect(page.getByRole('heading', { name: '远程控制', exact: true })).toBeVisible() + const table = page.locator('.desktop-terminal-table .el-table') + await expect(table).toBeVisible() + for (const heading of ['终端', '连接与心跳', '绑定智能体', '当前会话', '音量', '操作']) { + await expect(table.locator('.el-table__header')).toContainText(heading) + } + for (const label of ['待激活', '在线', '离线', '已停用']) await expect(table.getByText(label, { exact: true })).toBeVisible() + await captureScreenshot(page, testInfo, 'remote-v2-01-table-four-real-statuses') + + await page.getByRole('button', { name: '新增终端', exact: true }).click() + const editor = visibleDialog(page) + await expect(editor).toContainText('新增远程终端') + await fillFormItem(editor, '终端名称', '受控回归悬浮终端') + await fillFormItem(editor, '终端编码', 'RTC-CONTROLLED-01') + await fillFormItem(editor, '部署位置', '综合实训楼 C-01') + await chooseSelect(page, editor, '绑定智能体', publishedAgent.name) + await formItem(editor, '页面模式').getByText('悬浮图标', { exact: true }).click() + + await editor.getByRole('button', { name: '创建并生成激活链接', exact: true }).click() + await expect(page.getByText(/至少填写一个允许嵌入来源/).last()).toBeVisible() + await page.waitForTimeout(400) + expect(mock.submissions, '悬浮模式空来源时不得发起创建请求').toHaveLength(0) + + await formItem(editor, /允许嵌入来源/).getByPlaceholder('https://portal.example.local').first().fill('https://portal.example.test') + + const createdResponse = page.waitForResponse((response) => response.request().method() === 'POST' + && new URL(response.url()).pathname === '/api/v1/remote-terminals') + await editor.getByRole('button', { name: '创建并生成激活链接', exact: true }).click() + const response = await createdResponse + expect(response.status()).toBe(201) + expect(response.headers()['cache-control']).toContain('no-store') + + const activation = visibleDialog(page) + await expect(activation).toContainText('接入终端 · 受控回归悬浮终端') + await expect(activation).toContainText('仅显示一次') + await expect(activation).toContainText('此 iframe 嵌入代码包含一次性激活码') + await expect(activation.getByRole('button', { name: '复制 iframe 代码', exact: true })).toBeVisible() + await expect(activation.getByRole('button', { name: '重新生成激活链接', exact: true })).toBeVisible() + await captureScreenshot(page, testInfo, 'remote-v2-02-one-time-activation-masked', [ + activation.getByTestId('terminal-activation-embed-code'), + ]) + await activation.getByRole('button', { name: '完成', exact: true }).click() + + expect(mock.submissions[0]?.payload).toMatchObject({ + name: '受控回归悬浮终端', + code: 'RTC-CONTROLLED-01', + location: '综合实训楼 C-01', + boundAgentId: publishedAgent.id, + displayMode: 'WIDGET', + defaultVolume: 60, + allowInterrupt: true, + allowedOrigins: ['https://portal.example.test'], + }) + + const row = tableRowByText(page, '电气实训区在线终端') + await row.getByRole('button', { name: '详情', exact: true }).click() + const drawer = page.locator('.remote-detail-drawer:visible') + await expect(drawer).toBeVisible() + for (const tab of ['概览', '实时控制', '命令记录', '接入与安全']) { + await expect(drawer.getByRole('tab', { name: tab, exact: true })).toBeVisible() + } + await expect(drawer).toContainText('ai-person-web/remote-v2') + await captureScreenshot(page, testInfo, 'remote-v2-03-detail-overview') + + await drawer.getByRole('tab', { name: '实时控制', exact: true }).click() + await expect(drawer.getByRole('button', { name: '唤醒运行页', exact: true })).toBeEnabled() + await expect(drawer.getByRole('button', { name: '暂停播报', exact: true })).toBeDisabled() + await expect(drawer.getByRole('button', { name: '继续播报', exact: true })).toBeDisabled() + await expect(drawer.getByRole('button', { name: '结束会话', exact: true })).toBeDisabled() + await expect(drawer.getByRole('button', { name: '重载运行页', exact: true })).toBeEnabled() + await expect(drawer.getByRole('button', { name: '应用音量', exact: true })).toBeDisabled() + + await drawer.getByRole('button', { name: '唤醒运行页', exact: true }).click() + await expect(page.getByText('唤醒执行成功', { exact: true })).toBeVisible() + const commandSubmission = mock.submissions.at(-1) || {} + expect(String(asRecord(commandSubmission.payload).type || '').toUpperCase()).toBe('WAKE') + expect(commandSubmission.idempotencyHeaderPresent).toBe(true) + + await drawer.getByRole('tab', { name: '命令记录', exact: true }).click() + const commandRow = drawer.locator('.el-table__row').filter({ hasText: '唤醒' }).first() + await expect(commandRow).toContainText('执行成功') + await commandRow.getByRole('button', { name: '事件', exact: true }).click() + const events = visibleDialog(page) + await expect(events).toContainText('命令事件 · 唤醒') + await expect(events).toContainText('等待下发') + await expect(events).toContainText('终端已确认') + await expect(events).toContainText('执行成功') + await captureScreenshot(page, testInfo, 'remote-v2-04-command-history-and-events') + + expect(failures.consoleErrors, '受控远控页面不应产生 console.error').toEqual([]) + expect(failures.pageErrors, '受控远控页面不应产生 pageerror').toEqual([]) + await attachJson(testInfo, 'remote-v2-controlled-contract', { + statesCovered: ['PENDING_ACTIVATION', 'ONLINE', 'OFFLINE', 'SUSPENDED'], + createPayloadVerified: true, + oneTimeActivationMasked: true, + commandIdempotencyHeaderVerified: true, + eventChain: ['QUEUED', 'ACKNOWLEDGED', 'SUCCEEDED'], + sensitiveValuesIncluded: false, + }) + }) + + test('只有查看权限时管理与控制动作不可用', async ({ page }, testInfo) => { + const failures = collectPageFailures(page) + await installRemoteMocks(page, ['ai.remote.view']) + await page.goto('/agents/remote-control') + + await expect(page.getByText('只读权限', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '新增终端', exact: true })).toHaveCount(0) + const row = tableRowByText(page, '电气实训区在线终端') + await expect(row.getByRole('button', { name: '编辑', exact: true })).toHaveCount(0) + await row.getByRole('button', { name: '详情', exact: true }).click() + + const drawer = page.locator('.remote-detail-drawer:visible') + await drawer.getByRole('tab', { name: '实时控制', exact: true }).click() + for (const action of ['唤醒运行页', '暂停播报', '继续播报', '结束会话', '重载运行页', '应用音量']) { + await expect(drawer.getByRole('button', { name: action, exact: true })).toBeDisabled() + } + await drawer.getByRole('tab', { name: '接入与安全', exact: true }).click() + await expect(drawer.getByRole('button', { name: '重新生成激活链接', exact: true })).toHaveCount(0) + await expect(drawer.getByRole('button', { name: '停用终端', exact: true })).toHaveCount(0) + await expect(drawer.getByRole('button', { name: '撤销终端', exact: true })).toHaveCount(0) + await captureScreenshot(page, testInfo, 'remote-v2-05-view-only-permission-isolation') + + expect(failures.consoleErrors, '只读权限页面不应产生 console.error').toEqual([]) + expect(failures.pageErrors, '只读权限页面不应产生 pageerror').toEqual([]) + }) + + test('最终回执丢失后新 deliveryId 重投只重放回执而不重复执行页面动作', async ({ page }, testInfo) => { + const failures = collectPageFailures(page) + const reports: Array<{ deliveryId: string; status: string; httpStatus: number; errorCode: string }> = [] + let pullCount = 0 + let sessionCreates = 0 + let terminalConfigRequests = 0 + let activationRequests = 0 + let activationRequestValid = true + let legacyPublicConfigRequests = 0 + let legacyPublicSessionRequests = 0 + let terminalConfigHeadersValid = true + let terminalSessionHeadersValid = true + let terminalChatRequests = 0 + let terminalEndRequests = 0 + let terminalChatHeadersValid = true + let terminalEndHeadersValid = true + let ordinaryPublicPhase = false + let ordinaryConfigRequests = 0 + let ordinarySessionRequests = 0 + let ordinaryChatRequests = 0 + let ordinaryRequestsOmitDeviceHeaders = true + let unknownRedeliverySent = false + let knownDeliveryReady = false + let knownDeliveryAttempts = 0 + let stopDeliveryReady = false + let stopDelivered = false + let firstDeliverySucceededFailures = 0 + let releaseResponses = 0 + let leaderRuntimeInstanceId = '' + let standbyInteractionBlocked = false + let originMismatchCredentialPreserved = false + let trustedHostReconnectSucceeded = false + + await page.route('**/api/auth/v1/**', async (route) => { + const pathname = new URL(route.request().url()).pathname + if (pathname.endsWith('/system-config/public')) return fulfill(route, { systemName: '虚拟教员系统', shortName: '数字人平台' }) + return fulfill(route, {}) + }) + await page.route('**/api/v1/**', async (route) => { + const pathname = new URL(route.request().url()).pathname + if (pathname.endsWith('/avatars')) return fulfill(route, { items: [], total: 0, page: 1, pageSize: 200, pages: 0 }) + return fulfill(route, {}) + }) + await page.route('**/open/v1/**', async (route) => { + const request = route.request() + const pathname = new URL(request.url()).pathname + const requestHeaders = request.headers() + const hasManagedSessionHeaders = requestHeaders['x-terminal-id'] === 'RTC-RELIABILITY-01' + && requestHeaders['x-device-secret'] === 'controlled-device-secret' + && requestHeaders['x-terminal-page-origin'] === controlledAppOrigin + && Boolean(leaderRuntimeInstanceId) + && requestHeaders['x-runtime-instance-id'] === leaderRuntimeInstanceId + && !requestHeaders['x-terminal-secret'] + const omitsDeviceHeaders = !requestHeaders['x-terminal-id'] + && !requestHeaders['x-device-secret'] + && !requestHeaders['x-terminal-page-origin'] + && !requestHeaders['x-runtime-instance-id'] + && !requestHeaders['x-terminal-secret'] + if (pathname === `/open/v1/realtime/${publishedAgent.slug}` && request.method() === 'GET') { + if (ordinaryPublicPhase) { + ordinaryConfigRequests += 1 + ordinaryRequestsOmitDeviceHeaders = ordinaryRequestsOmitDeviceHeaders && omitsDeviceHeaders + return fulfill(route, { + ...publishedAgent, + welcomeMessage: '普通公开会话已就绪', + fallbackMessage: '服务暂不可用', + accessMode: 'internal', + brandVisible: true, + uiConfig: { + components: [ + { key: 'welcome', enabled: true, order: 1 }, + { key: 'history', enabled: true, order: 2 }, + { key: 'input', enabled: true, order: 3 }, + ], + suggestions: [], + }, + }) + } + legacyPublicConfigRequests += 1 + return fulfill(route, {}, 500) + } + if (pathname === `/open/v1/realtime/${publishedAgent.slug}/sessions` && request.method() === 'POST') { + if (ordinaryPublicPhase) { + ordinarySessionRequests += 1 + ordinaryRequestsOmitDeviceHeaders = ordinaryRequestsOmitDeviceHeaders && omitsDeviceHeaders + return fulfill(route, { + sessionId: 'ordinary-public-session', + sessionToken: 'ordinary-public-session-token', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, 201) + } + legacyPublicSessionRequests += 1 + return fulfill(route, {}, 500) + } + if (pathname === `/open/v1/realtime/${publishedAgent.slug}/chat` && request.method() === 'POST') { + if (ordinaryPublicPhase) { + ordinaryChatRequests += 1 + ordinaryRequestsOmitDeviceHeaders = ordinaryRequestsOmitDeviceHeaders + && omitsDeviceHeaders + && requestHeaders.authorization === 'Bearer ordinary-public-session-token' + return fulfill(route, { + reply: '普通公开会话未携带远程终端设备凭据。', + audioUrl: null, + citations: [], + provider: 'local', + degraded: false, + degradationReason: null, + blocked: false, + sensitiveCategory: null, + requestId: 'ordinary-public-chat', + }) + } + terminalChatRequests += 1 + terminalChatHeadersValid = terminalChatHeadersValid + && hasManagedSessionHeaders + && requestHeaders.authorization === 'Bearer controlled-session-token' + return fulfill(route, { + reply: '受控终端会话设备授权头校验通过。', + audioUrl: null, + citations: [], + provider: 'local', + degraded: false, + degradationReason: null, + blocked: false, + sensitiveCategory: null, + requestId: 'controlled-terminal-chat', + }) + } + if (pathname === `/open/v1/realtime/${publishedAgent.slug}/end` && request.method() === 'POST') { + terminalEndRequests += 1 + terminalEndHeadersValid = terminalEndHeadersValid + && hasManagedSessionHeaders + && requestHeaders.authorization === 'Bearer controlled-session-token' + return fulfill(route, { sessionId: 'controlled-runtime-session', ended: true }) + } + return fulfill(route, {}) + }) + await page.route('**/open/v2/**', async (route) => { + const request = route.request() + const pathname = new URL(request.url()).pathname + const headers = request.headers() + const baseDeviceHeadersValid = headers['x-terminal-id'] === 'RTC-RELIABILITY-01' + && headers['x-device-secret'] === 'controlled-device-secret' + && headers['x-terminal-page-origin'] === controlledAppOrigin + && !headers['x-terminal-secret'] + const configHeadersValid = baseDeviceHeadersValid && !headers['x-runtime-instance-id'] + const ownerSessionHeadersValid = baseDeviceHeadersValid + && Boolean(leaderRuntimeInstanceId) + && headers['x-runtime-instance-id'] === leaderRuntimeInstanceId + if (pathname === '/open/v2/terminals/activate' && request.method() === 'POST') { + activationRequests += 1 + const payload = request.postDataJSON() as JsonRecord + activationRequestValid = activationRequestValid + && payload.activationCode === activationCode + && payload.terminalCode === 'RTC-RELIABILITY-01' + && payload.pageOrigin === controlledAppOrigin + && !headers['x-terminal-id'] + && !headers['x-device-secret'] + && !headers['x-runtime-instance-id'] + && !headers['x-terminal-secret'] + return fulfill(route, { + terminalId: 'RTC-RELIABILITY-01', + terminalCode: 'RTC-RELIABILITY-01', + deviceSecret: 'controlled-device-secret', + deviceId: 'controlled-device-id', + pageOrigin: controlledAppOrigin, + }, 200, { 'Cache-Control': 'no-store' }) + } + if (pathname === `/open/v2/terminals/agents/${publishedAgent.slug}/config` && request.method() === 'GET') { + terminalConfigRequests += 1 + terminalConfigHeadersValid = terminalConfigHeadersValid && configHeadersValid + return fulfill(route, { + terminal: { id: 'rt-reliability', displayMode: 'WIDGET' }, + agent: { + ...publishedAgent, + welcomeMessage: '受控终端已就绪', + fallbackMessage: '服务暂不可用', + accessMode: 'private', + brandVisible: true, + interactionModes: ['text', 'voice'], + uiConfig: { + components: [ + { key: 'welcome', enabled: true, order: 1 }, + { key: 'history', enabled: true, order: 2 }, + { key: 'suggestions', enabled: true, order: 3 }, + { key: 'input', enabled: true, order: 4 }, + ], + suggestions: ['如何完成设备点检?'], + }, + }, + }, 200, { 'Cache-Control': 'no-store' }) + } + if (pathname === `/open/v2/terminals/agents/${publishedAgent.slug}/sessions` && request.method() === 'POST') { + sessionCreates += 1 + terminalSessionHeadersValid = terminalSessionHeadersValid && ownerSessionHeadersValid + return fulfill(route, { + sessionId: 'controlled-runtime-session', + sessionToken: 'controlled-session-token', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + websocketTicket: 'controlled-websocket-ticket', + websocketUrl: `/open/v1/realtime/${publishedAgent.slug}/stream?ticket=controlled-websocket-ticket`, + configVersion: publishedAgent.dataVersion, + }, 201, { 'Cache-Control': 'no-store' }) + } + if (pathname.endsWith('/heartbeat')) { + const runtimeInstanceId = headers['x-runtime-instance-id'] || '' + if (!leaderRuntimeInstanceId) leaderRuntimeInstanceId = runtimeInstanceId + if (runtimeInstanceId !== leaderRuntimeInstanceId) { + return fulfill(route, {}, 423, { 'Cache-Control': 'no-store' }) + } + return fulfill(route, { + terminalId: 'rt-reliability', + terminalCode: 'RTC-RELIABILITY-01', + online: true, + serverTime: new Date().toISOString(), + nextHeartbeatSeconds: 15, + runtimeLeaseSeconds: 45, + runtimeLeaseExpiresAt: new Date(Date.now() + 45_000).toISOString(), + desiredConfig: { volume: 60, allowInterrupt: true }, + agent: agentBinding, + displayMode: 'WIDGET', + }, 200, { 'Cache-Control': 'no-store' }) + } + if (pathname.endsWith('/commands/pull')) { + pullCount += 1 + const isUnknownRedelivery = !unknownRedeliverySent + if (isUnknownRedelivery) unknownRedeliverySent = true + const isKnownDelivery = !isUnknownRedelivery && knownDeliveryReady && knownDeliveryAttempts < 2 + if (isKnownDelivery) knownDeliveryAttempts += 1 + const isStopDelivery = !isUnknownRedelivery && !isKnownDelivery && stopDeliveryReady && !stopDelivered + const deliveryId = isUnknownRedelivery + ? 'delivery-unknown-redelivery' + : isKnownDelivery + ? knownDeliveryAttempts === 1 ? 'delivery-first' : 'delivery-redelivered' + : isStopDelivery ? 'delivery-stop' : '' + if (isStopDelivery) stopDelivered = true + return fulfill(route, { + items: deliveryId ? [{ + id: isUnknownRedelivery ? 'command-unknown-redelivery' : isStopDelivery ? 'command-stop' : 'command-same-id', + commandId: isUnknownRedelivery ? 'command-unknown-redelivery' : isStopDelivery ? 'command-stop' : 'command-same-id', + deliveryId, + type: isStopDelivery ? 'STOP' : 'WAKE', + payload: { + detail: isUnknownRedelivery + ? '无缓存重投不得执行业务动作' + : isStopDelivery ? '结束受控终端会话' : '可靠性回归唤醒', + }, + deliveryAttempt: isUnknownRedelivery ? 2 : isKnownDelivery ? knownDeliveryAttempts : 1, + redelivery: isUnknownRedelivery || deliveryId === 'delivery-redelivered', + boundAgentId: publishedAgent.id, + boundAgentSlug: publishedAgent.slug, + boundAgentDataVersion: publishedAgent.dataVersion, + boundAgent: agentBinding, + agent: agentBinding, + }] : [], + pollAfterMs: deliveryId ? 0 : 1_000, + }, 200, { 'Cache-Control': 'no-store' }) + } + if (pathname.endsWith('/reports')) { + const payload = request.postDataJSON() as JsonRecord + const deliveryId = String(payload.deliveryId || '') + const status = String(payload.status || '') + const errorCode = String(payload.errorCode || '') + if (deliveryId === 'delivery-first' && status === 'SUCCEEDED' && firstDeliverySucceededFailures < 3) { + firstDeliverySucceededFailures += 1 + reports.push({ deliveryId, status, httpStatus: 503, errorCode }) + return fulfill(route, {}, 503) + } + reports.push({ deliveryId, status, httpStatus: 200, errorCode }) + return fulfill(route, { idempotent: false, status }, 200, { 'Cache-Control': 'no-store' }) + } + if (pathname.endsWith('/runtime/release')) { + if (headers['x-runtime-instance-id'] !== leaderRuntimeInstanceId) { + return fulfill(route, {}, 423, { 'Cache-Control': 'no-store' }) + } + releaseResponses += 1 + leaderRuntimeInstanceId = '' + return fulfill(route, { released: true, idempotent: false, releasedAt: new Date().toISOString() }, 200, { 'Cache-Control': 'no-store' }) + } + return fulfill(route, {}, 404) + }) + + const hostPath = '/ape2e/controlled-remote-widget-reliability-host' + const mismatchHostPath = '/ape2e/controlled-remote-widget-origin-mismatch-host' + let includeControlledActivation = true + const mismatchHostOrigin = (() => { + const url = new URL(controlledAppOrigin) + url.hostname = 'origin-mismatch.invalid' + return url.origin + })() + await page.route(`**${hostPath}`, async (route) => { + if (new URL(route.request().url()).pathname !== hostPath) return route.continue() + const activationFragment = includeControlledActivation + ? `#terminal-activation=${encodeURIComponent(activationCode)}` + : '' + await route.fulfill({ + status: 200, + contentType: 'text/html; charset=utf-8', + headers: { 'Cache-Control': 'no-store' }, + body: `受控远程终端宿主`, + }) + }) + await page.route(`**${mismatchHostPath}`, async (route) => { + if (new URL(route.request().url()).pathname !== mismatchHostPath) return route.continue() + await route.fulfill({ + status: 200, + contentType: 'text/html; charset=utf-8', + headers: { 'Cache-Control': 'no-store' }, + body: `非法宿主`, + }) + }) + + const runtimeTransport = observeRuntimeTransport(page, 'RTC-RELIABILITY-01', controlledAppOrigin) + const activationResponse = page.waitForResponse((response) => response.request().method() === 'POST' + && new URL(response.url()).pathname === '/open/v2/terminals/activate' + && response.status() === 200, { timeout: 20_000 }) + await page.goto(hostPath) + await activationResponse + includeControlledActivation = false + expect(activationRequests).toBe(1) + expect(activationRequestValid).toBe(true) + const widget = page.frameLocator('#terminal-widget') + await expect(widget.locator('.embed-widget')).not.toHaveClass(/is-expanded/) + await expect.poll(() => reports.filter((item) => item.deliveryId === 'delivery-unknown-redelivery' + && item.status === 'FAILED' && item.httpStatus === 200).length, { + message: '没有本地终态缓存的重投必须直接失败收敛', + timeout: 20_000, + }).toBe(1) + const unknownReports = reports.filter((item) => item.deliveryId === 'delivery-unknown-redelivery') + expect(unknownReports.map((item) => item.status)).toEqual(['FAILED']) + expect(unknownReports[0]?.errorCode).toBe('RUNTIME_OUTCOME_UNKNOWN') + expect(sessionCreates, '未知结果重投不得执行 WAKE 业务动作').toBe(0) + knownDeliveryReady = true + await expect.poll(() => reports.filter((item) => item.deliveryId === 'delivery-redelivered' + && item.status === 'SUCCEEDED' && item.httpStatus === 200).length, { + timeout: 20_000, + }).toBe(1) + await expect(widget.locator('.embed-widget')).toHaveClass(/is-expanded/, { timeout: 20_000 }) + + expect(sessionCreates, 'WAKE 页面业务动作必须只执行一次').toBe(1) + expect(terminalConfigRequests, '受管终端必须通过设备凭据读取一次智能体配置').toBe(1) + expect(terminalConfigHeadersValid, '终端配置请求必须携带设备头和页面 Origin').toBe(true) + expect(terminalSessionHeadersValid, '终端会话请求必须携带设备头和页面 Origin').toBe(true) + expect(legacyPublicConfigRequests, '带 terminal 参数时不得调用旧公开配置接口').toBe(0) + expect(legacyPublicSessionRequests, '带 terminal 参数时不得调用旧公开会话接口').toBe(0) + expect(firstDeliverySucceededFailures).toBe(3) + expect(reports.filter((item) => item.deliveryId === 'delivery-first').map((item) => item.status)).toEqual([ + 'ACK', 'EXECUTING', 'SUCCEEDED', 'SUCCEEDED', 'SUCCEEDED', + ]) + expect(reports.filter((item) => item.deliveryId === 'delivery-redelivered').map((item) => item.status)).toEqual([ + 'SUCCEEDED', + ]) + expect(runtimeTransport.heartbeatSuccesses(), 'pull 前必须先有一次成功 heartbeat claim').toBeGreaterThan(0) + expect(runtimeTransport.pullStartedAfterHeartbeat(), '首次 heartbeat 成功前不得启动 pull').toBe(true) + expect(runtimeTransport.headersValid(), 'heartbeat/pull/report 必须携带同页运行实例头、三项设备头且无旧头').toBe(true) + expect(runtimeTransport.runtimeInstanceOmittedFromUrls(), '运行实例标识不得进入 URL').toBe(true) + expect(runtimeTransport.runtimeInstanceIds()).toHaveLength(1) + expect(await runtimeInstancesAbsentFromBrowserState(page, runtimeTransport.runtimeInstanceIds()), '运行实例标识不得持久化到 URL/storage').toBe(true) + + const sessionsBeforeStandby = sessionCreates + const chatsBeforeStandby = terminalChatRequests + const heartbeatBeforeStandby = runtimeTransport.heartbeatRequests() + const pullsBeforeStandby = runtimeTransport.pullRequests() + await page.evaluate(({ slug }) => { + const iframe = document.createElement('iframe') + iframe.id = 'terminal-widget-standby' + iframe.title = '远程数字人终端备用页' + iframe.src = `/embed/${encodeURIComponent(slug)}?terminal=RTC-RELIABILITY-01` + iframe.allow = 'microphone; autoplay' + iframe.referrerPolicy = 'origin' + iframe.style.cssText = 'width:420px;height:680px;border:0' + document.body.appendChild(iframe) + }, { slug: publishedAgent.slug }) + const standbyWidget = page.frameLocator('#terminal-widget-standby') + await expect(standbyWidget.locator('.embed-widget')).toBeVisible({ timeout: 20_000 }) + await standbyWidget.locator('.embed-launcher').click() + await expect(standbyWidget.locator('.embed-widget')).toHaveClass(/is-expanded/) + await expect(standbyWidget.locator('.terminal-runtime-notice')).toContainText('另一页面运行', { timeout: 20_000 }) + await expect(standbyWidget.getByRole('textbox', { name: '维修问题' })).toBeDisabled() + await expect(standbyWidget.getByRole('button', { name: '语音提问', exact: true })).toBeDisabled() + await expect(standbyWidget.getByRole('button', { name: '发送问题', exact: true })).toBeDisabled() + await expect(standbyWidget.getByText('如何完成设备点检?', { exact: true })).toHaveCount(0) + await standbyWidget.locator('form.embed-input').evaluate((form) => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + }) + await page.waitForTimeout(500) + expect(sessionCreates, 'STANDBY 页面不得签发 scoped session').toBe(sessionsBeforeStandby) + expect(terminalChatRequests, 'STANDBY 页面不得发送 scoped chat').toBe(chatsBeforeStandby) + expect(runtimeTransport.heartbeatRequests(), '浏览器非 leader 不得竞争 heartbeat').toBe(heartbeatBeforeStandby) + expect(runtimeTransport.pullRequests(), '浏览器非 leader 不得启动 pull').toBe(pullsBeforeStandby) + standbyInteractionBlocked = true + await page.locator('#terminal-widget-standby').evaluate((element) => element.remove()) + + await widget.getByRole('textbox', { name: '维修问题' }).fill('验证受控终端会话请求头') + await widget.getByRole('button', { name: '发送问题', exact: true }).click() + await expect(widget.getByText('受控终端会话设备授权头校验通过。', { exact: true })).toBeVisible() + expect(terminalChatRequests, 'REMOTE_TERMINAL chat 必须实际发出一次').toBe(1) + expect(terminalChatHeadersValid, 'REMOTE_TERMINAL chat 必须同时携带会话令牌、三项设备头且无旧头').toBe(true) + + stopDeliveryReady = true + await expect.poll(() => terminalEndRequests, { + message: 'STOP 必须结束真实受控会话并调用 end', + timeout: 20_000, + }).toBe(1) + await expect.poll(() => reports.filter((item) => item.deliveryId === 'delivery-stop' + && item.status === 'SUCCEEDED' && item.httpStatus === 200).length, { + message: 'STOP 必须形成成功终态回执', + timeout: 20_000, + }).toBe(1) + expect(terminalEndHeadersValid, 'REMOTE_TERMINAL end 必须同时携带会话令牌、三项设备头且无旧头').toBe(true) + await expect(widget.locator('.terminal-runtime-notice.is-error')).toHaveCount(0) + await captureScreenshot(page, testInfo, 'remote-v2-06-redelivery-cached-outcome-single-action') + + const configRequestsBeforeMismatch = terminalConfigRequests + const heartbeatRequestsBeforeMismatch = runtimeTransport.heartbeatRequests() + const activationRequestsBeforeMismatch = activationRequests + await page.goto(new URL(mismatchHostPath, mismatchHostOrigin).toString()) + const mismatchWidget = page.frameLocator('#terminal-widget-mismatch') + await expect(mismatchWidget.locator('.embed-widget')).toBeVisible({ timeout: 20_000 }) + await expect(mismatchWidget.locator('.terminal-runtime-notice.is-error')).toContainText('嵌入来源与终端激活来源不一致', { timeout: 20_000 }) + expect(terminalConfigRequests, '非法宿主来源必须在读取 scoped config 前失败关闭').toBe(configRequestsBeforeMismatch) + expect(runtimeTransport.heartbeatRequests(), '非法宿主来源不得参与 runtime lease').toBe(heartbeatRequestsBeforeMismatch) + const mismatchFrame = page.frames().find((frame) => { + try { + const url = new URL(frame.url()) + return url.origin === controlledAppOrigin && url.pathname === `/embed/${publishedAgent.slug}` + } catch { + return false + } + }) + expect(mismatchFrame, '非法宿主页面仍应加载应用 iframe 以呈现拒绝原因').toBeTruthy() + originMismatchCredentialPreserved = await mismatchFrame!.evaluate(() => ( + Boolean(window.localStorage.getItem('ai-person:remote-terminal-runtime:v2:RTC-RELIABILITY-01')) + )) + expect(originMismatchCredentialPreserved, '来源不匹配只拒绝本次连接,不得删除合法宿主激活的设备凭据').toBe(true) + + const heartbeatSuccessesBeforeTrustedReturn = runtimeTransport.heartbeatSuccesses() + await page.goto(hostPath) + await expect.poll(runtimeTransport.heartbeatSuccesses, { + message: '返回合法宿主后必须复用原设备凭据重新取得 runtime lease', + timeout: 20_000, + }).toBeGreaterThan(heartbeatSuccessesBeforeTrustedReturn) + await expect.poll(() => terminalConfigRequests, { + message: '返回合法宿主后必须重新读取 scoped config', + timeout: 20_000, + }).toBeGreaterThan(configRequestsBeforeMismatch) + const trustedWidget = page.frameLocator('#terminal-widget') + await expect(trustedWidget.locator('.embed-widget')).toBeVisible() + await expect(trustedWidget.locator('.terminal-runtime-notice.is-error')).toHaveCount(0) + trustedHostReconnectSucceeded = true + expect(activationRequests, '来源切换过程不得重复兑换一次性激活码').toBe(activationRequestsBeforeMismatch) + + const releasesBeforeLeavingHost = runtimeTransport.releaseRequests() + const releaseResponsesBeforeLeavingHost = releaseResponses + ordinaryPublicPhase = true + await page.goto(`/live/${publishedAgent.slug}`) + await expect.poll(runtimeTransport.releaseRequests, { + message: '离开受管 WIDGET 时必须 best-effort 释放服务端运行租约', + timeout: 15_000, + }).toBeGreaterThan(releasesBeforeLeavingHost) + await expect.poll(() => releaseResponses, { + message: '受控 release mock 必须实际处理卸载请求', + timeout: 15_000, + }).toBeGreaterThan(releaseResponsesBeforeLeavingHost) + expect(runtimeTransport.releaseBodyEmpty(), 'release 必须是无 body 的 keepalive POST').toBe(true) + expect(runtimeTransport.headersValid(), 'release 必须复用同页运行实例和三项设备头且无旧头').toBe(true) + await expect(page.locator('.live-agent-page')).toBeVisible() + await page.getByRole('textbox', { name: '维修问题' }).fill('验证普通公开会话请求头隔离') + await page.getByRole('button', { name: '发送问题', exact: true }).click() + await expect(page.getByText('普通公开会话未携带远程终端设备凭据。', { exact: true })).toBeVisible() + expect(ordinaryConfigRequests, '普通运行页必须读取公开配置').toBe(1) + expect(ordinarySessionRequests, '普通运行页必须创建公开会话').toBe(1) + expect(ordinaryChatRequests, '普通运行页必须发送公开 chat').toBe(1) + expect(ordinaryRequestsOmitDeviceHeaders, '无 terminal 参数的公开请求不得携带任何设备授权头').toBe(true) + + await attachJson(testInfo, 'remote-v2-redelivery-evidence', { + commandId: 'command-same-id', + deliveryIds: ['delivery-first', 'delivery-redelivered', 'delivery-stop'], + actionExecutionCount: sessionCreates, + terminalScopedAccess: { + configRequests: terminalConfigRequests, + sessionRequests: sessionCreates, + configDeviceHeadersValid: terminalConfigHeadersValid, + sessionDeviceHeadersValid: terminalSessionHeadersValid, + legacyPublicConfigRequests, + legacyPublicSessionRequests, + }, + terminalSessionFollowups: { + chatRequests: terminalChatRequests, + endRequests: terminalEndRequests, + chatDeviceHeadersValid: terminalChatHeadersValid, + endDeviceHeadersValid: terminalEndHeadersValid, + }, + ordinaryPublicIsolation: { + configRequests: ordinaryConfigRequests, + sessionRequests: ordinarySessionRequests, + chatRequests: ordinaryChatRequests, + deviceHeadersOmitted: ordinaryRequestsOmitDeviceHeaders, + }, + firstFinalReportNetworkFailures: firstDeliverySucceededFailures, + redeliveryReportSequence: reports.map((item) => ({ ...item })), + unknownRedelivery: { + failedWithoutBusinessAction: true, + errorCode: 'RUNTIME_OUTCOME_UNKNOWN', + businessActionCountBeforeKnownDelivery: 0, + }, + standbyInteraction: { + blocked: standbyInteractionBlocked, + scopedSessionRequests: 0, + scopedChatRequests: 0, + runtimeTransportRequests: 0, + }, + trustedOriginIsolation: { + activationRequests, + activationRequestValid, + mismatchConnectionRejectedBeforeConfig: true, + credentialPreserved: originMismatchCredentialPreserved, + trustedHostReconnectSucceeded, + }, + runtimeLeaseContract: { + heartbeatRequests: runtimeTransport.heartbeatRequests(), + pullRequests: runtimeTransport.pullRequests(), + reportRequests: runtimeTransport.reportRequests(), + releaseRequests: runtimeTransport.releaseRequests(), + releaseResponses, + firstHeartbeatBeforePull: runtimeTransport.pullStartedAfterHeartbeat(), + samePageInstanceHeadersValid: runtimeTransport.headersValid(), + instanceInUrlOrStorage: false, + releaseBodyEmpty: runtimeTransport.releaseBodyEmpty(), + }, + secretIncluded: false, + }) + const unexpectedConsoleErrors = failures.consoleErrors.filter((message) => !/503|Failed to load resource/i.test(message)) + expect(unexpectedConsoleErrors, '除受控 503 外,重投可靠性回归不应产生 console.error').toEqual([]) + expect(failures.pageErrors, '重投可靠性回归不应产生 pageerror').toEqual([]) + }) +})