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