Просмотр исходного кода

test: 重建远程终端 V2 生命周期回归

main
leiyun 3 недель назад
Родитель
Сommit
56ecec05cd
6 измененных файлов: 2990 добавлений и 650 удалений
  1. +22
    -4
      scripts/run-full.mjs
  2. +3
    -1
      tests/nonempty.ts
  3. +121
    -0
      tests/remote-terminal-runtime-contract.ts
  4. +0
    -645
      tests/remote-terminal-runtime.spec.ts
  5. +1889
    -0
      tests/remote-terminal-v2-lifecycle.spec.ts
  6. +955
    -0
      tests/remote-terminal-v2-ui.spec.ts

+ 22
- 4
scripts/run-full.mjs Просмотреть файл

@@ -57,10 +57,28 @@ function seedDurableDemoData() {


async function probeRuntimeApi(baseURL, timeoutMs = 3_000) { async function probeRuntimeApi(baseURL, timeoutMs = 3_000) {
try { 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', 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), signal: AbortSignal.timeout(timeoutMs),
}) })
// 未知终端 + 已配置 pepper 必须走恒定时间鉴权并返回 401。 // 未知终端 + 已配置 pepper 必须走恒定时间鉴权并返回 401。
@@ -279,7 +297,7 @@ if (typecheck.status === 0) {
throw new Error('APE2E_TEST_FILES 只能包含 tests/ 下以 .spec.ts 结尾的相对路径') throw new Error('APE2E_TEST_FILES 只能包含 tests/ 下以 .spec.ts 结尾的相对路径')
} }
const selectedFiles = env.APE2E_REMOTE_RUNTIME_ONLY === 'true' 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 : requestedFiles
const playwrightArgs = ['exec', 'playwright', 'test', ...selectedFiles, '--project=chromium'] const playwrightArgs = ['exec', 'playwright', 'test', ...selectedFiles, '--project=chromium']
tests = execute(playwrightArgs) tests = execute(playwrightArgs)


+ 3
- 1
tests/nonempty.ts Просмотреть файл

@@ -94,7 +94,9 @@ const routeAcceptance: Record<string, RouteAcceptance> = {
'agent-projects': { datasets: [{ label: '智能体组别', selector: tableRows, manifestEntityKey: 'agentProjects', markerScopeSelector: '.projects-panel' }] }, 'agent-projects': { datasets: [{ label: '智能体组别', selector: tableRows, manifestEntityKey: 'agentProjects', markerScopeSelector: '.projects-panel' }] },
'chat-history': { datasets: [{ label: '会话记录', selector: tableRows, manifestEntityKey: 'chatSessions', markerScopeSelector: '.history-panel' }] }, 'chat-history': { datasets: [{ label: '会话记录', selector: tableRows, manifestEntityKey: 'chatSessions', markerScopeSelector: '.history-panel' }] },
'wake-words': { datasets: [{ label: '唤醒词', selector: tableRows, manifestEntityKey: 'wakeWords', markerScopeSelector: '.wake-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' }] }, 'video-create': { datasets: [{ label: '视频制作可选数字形象', selector: '.studio-avatar-list > button' }] },
videos: { videos: {
datasets: [ datasets: [


+ 121
- 0
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<RuntimeEndpoint, string[]>
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<RuntimeEndpoint, number> = { heartbeat: 0, pull: 0, report: 0, release: 0 }
const instances: Record<RuntimeEndpoint, string[]> = { heartbeat: [], pull: [], report: [], release: [] }
const successfulHeartbeatInstances = new Set<string>()
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)
}

+ 0
- 645
tests/remote-terminal-runtime.spec.ts Просмотреть файл

@@ -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<string, unknown>
type Headers = Record<string, string>

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<unknown>
headers(): Record<string, string>
}

// 本地 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<T = JsonRecord>(
response: ResponseLike,
expected: number | number[],
label: string,
): Promise<T> {
assertStatus(response, expected, label)
let body: unknown
try {
body = await response.json()
} catch {
throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`)
}
return envelopeData<T>(body)
}

async function selectBoundAgent(page: Page, dialog: ReturnType<Page['locator']>, 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<Page['locator']>, 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<Page['locator']>, 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([])
})
})

+ 1889
- 0
tests/remote-terminal-v2-lifecycle.spec.ts
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 955
- 0
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<string, unknown>
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<string, string> = {}) {
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<Page['locator']>, 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: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>受控远程终端宿主</title></head><body><iframe id="terminal-widget" title="远程数字人终端" src="/embed/${publishedAgent.slug}?terminal=RTC-RELIABILITY-01${activationFragment}" allow="microphone; autoplay" referrerpolicy="origin" style="width:208px;height:92px;border:0"></iframe><script>const frame=document.getElementById('terminal-widget');window.addEventListener('message',function(event){const data=event.data||{};if(event.source!==frame.contentWindow||event.origin!==window.location.origin||data.source!=='virtual-instructor-widget'||data.type!=='resize')return;const width=Math.max(208,Math.min(720,Number(data.width)||208));const height=Math.max(92,Math.min(960,Number(data.height)||92));frame.style.width=width+'px';frame.style.height=height+'px';});</script></body></html>`,
})
})
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: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>非法宿主</title></head><body><iframe id="terminal-widget-mismatch" title="来源不匹配的远程数字人终端" src="${controlledAppOrigin}/embed/${publishedAgent.slug}?terminal=RTC-RELIABILITY-01" allow="microphone; autoplay" referrerpolicy="origin" style="width:420px;height:680px;border:0"></iframe></body></html>`,
})
})

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([])
})
})

Загрузка…
Отмена
Сохранить