Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 
 

956 lignes
48 KiB

  1. import type { Page, Route } from '@playwright/test'
  2. import { attachJson, captureScreenshot, expect, test } from './fixtures'
  3. import { fillFormItem, formItem, tableRowByText, visibleDialog } from './helpers'
  4. import { observeRuntimeTransport, runtimeInstancesAbsentFromBrowserState } from './remote-terminal-runtime-contract'
  5. type JsonRecord = Record<string, unknown>
  6. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {}
  7. const now = new Date().toISOString()
  8. const activationCode = 'controlled-once-only-activation-value'
  9. const controlledAppOrigin = new URL(process.env.BASE_URL ?? 'http://127.0.0.1:8003').origin
  10. const publishedAgent = {
  11. id: 'remote-agent-published',
  12. dataVersion: 7,
  13. name: '设备点检数字教员',
  14. slug: 'remote-agent-published',
  15. description: '受控远程终端回归使用的已发布智能体',
  16. status: 'published',
  17. expiresAt: null,
  18. interactionModes: ['text'],
  19. uiConfig: { components: [{ key: 'input', enabled: true, order: 1 }], suggestions: [] },
  20. }
  21. const agentBinding = {
  22. id: publishedAgent.id,
  23. dataVersion: publishedAgent.dataVersion,
  24. name: publishedAgent.name,
  25. slug: publishedAgent.slug,
  26. status: publishedAgent.status,
  27. }
  28. function terminal(
  29. id: string,
  30. name: string,
  31. overrides: JsonRecord = {},
  32. ) {
  33. return {
  34. id,
  35. dataVersion: 1,
  36. name,
  37. code: id.toUpperCase(),
  38. location: '综合实训楼 B-02',
  39. displayMode: 'WIDGET',
  40. lifecycleStatus: 'ACTIVE',
  41. connectionStatus: 'ONLINE',
  42. enabled: true,
  43. online: true,
  44. boundAgentId: publishedAgent.id,
  45. boundAgent: agentBinding,
  46. expectedVolume: 60,
  47. actualVolume: 60,
  48. allowInterrupt: true,
  49. allowedOrigins: ['https://portal.example.test'],
  50. capabilities: ['WAKE', 'PAUSE', 'RESUME', 'STOP', 'VOLUME', 'RESTART'],
  51. runtimeVersion: 'ai-person-web/remote-v2',
  52. platform: 'Controlled Chromium',
  53. pageUrl: `https://portal.example.test/embed/${publishedAgent.slug}`,
  54. origin: 'https://portal.example.test',
  55. deviceId: `device-${id}`,
  56. sessionId: '',
  57. sessionStatus: 'IDLE',
  58. playbackStatus: 'IDLE',
  59. visibilityState: 'visible',
  60. lastHeartbeatAt: now,
  61. createdAt: now,
  62. updatedAt: now,
  63. ...overrides,
  64. }
  65. }
  66. const initialTerminals = [
  67. terminal('rt-online', '电气实训区在线终端'),
  68. terminal('rt-pending', '液压实训区待激活终端', {
  69. lifecycleStatus: 'PENDING_ACTIVATION', connectionStatus: 'NEVER_CONNECTED', online: false,
  70. actualVolume: null, capabilities: [], runtimeVersion: '', deviceId: '', lastHeartbeatAt: '',
  71. }),
  72. terminal('rt-offline', '装配实训区离线终端', {
  73. connectionStatus: 'OFFLINE', online: false, lastHeartbeatAt: '2026-08-23T08:00:00+08:00',
  74. }),
  75. terminal('rt-suspended', '仓储区停用终端', {
  76. lifecycleStatus: 'SUSPENDED', connectionStatus: 'OFFLINE', enabled: false, online: false,
  77. }),
  78. ]
  79. const envelope = (data: unknown, status = 200) => ({
  80. code: status >= 400 ? status * 100 : 0,
  81. message: status >= 400 ? '请求失败' : '成功',
  82. data,
  83. timestamp: Date.now(),
  84. requestId: 'remote-terminal-v2-controlled',
  85. })
  86. async function fulfill(route: Route, data: unknown, status = 200, headers: Record<string, string> = {}) {
  87. await route.fulfill({
  88. status,
  89. headers,
  90. contentType: 'application/json',
  91. body: JSON.stringify(envelope(data, status)),
  92. })
  93. }
  94. function profile(permissions: string[]) {
  95. const superAdmin = permissions.includes('*')
  96. return {
  97. user: {
  98. id: superAdmin ? 'remote-admin' : 'remote-viewer',
  99. username: superAdmin ? 'remote-admin' : 'remote-viewer',
  100. displayName: superAdmin ? '系统管理员' : '远控审阅员',
  101. departmentId: 'system',
  102. departmentName: '数字人平台',
  103. enabled: true,
  104. mustChangePassword: false,
  105. version: 1,
  106. },
  107. roles: [{
  108. id: superAdmin ? 'admin-role' : 'viewer-role',
  109. code: superAdmin ? 'ADMIN' : 'REMOTE_VIEWER',
  110. name: superAdmin ? '系统管理员' : '远控审阅员',
  111. shortName: superAdmin ? '系统' : '审阅',
  112. enabled: true,
  113. builtIn: false,
  114. isSuperAdmin: superAdmin,
  115. dataScope: 'ALL',
  116. }],
  117. activeRoleId: superAdmin ? 'admin-role' : 'viewer-role',
  118. permissions,
  119. authorizationMode: 'SINGLE_ACTIVE',
  120. loginTime: now,
  121. }
  122. }
  123. async function installRemoteMocks(page: Page, permissions: string[]) {
  124. const terminals = initialTerminals.map((item) => ({ ...item }))
  125. const commands: JsonRecord[] = []
  126. const submissions: JsonRecord[] = []
  127. await page.addInitScript(() => {
  128. window.sessionStorage.setItem('ai-person:web:access-token:v1', 'controlled-remote-v2-token')
  129. })
  130. await page.route('**/api/auth/v1/**', async (route) => {
  131. const pathname = new URL(route.request().url()).pathname
  132. if (pathname.endsWith('/auth/me')) return fulfill(route, profile(permissions))
  133. if (pathname.endsWith('/menus/navigation')) return fulfill(route, [])
  134. if (pathname.endsWith('/system-config/public')) return fulfill(route, { systemName: '虚拟教员系统', shortName: '数字人平台' })
  135. return fulfill(route, {})
  136. })
  137. await page.route('**/api/v1/**', async (route) => {
  138. const request = route.request()
  139. const url = new URL(request.url())
  140. const pathname = url.pathname
  141. const method = request.method()
  142. if (pathname.endsWith('/realtime-agents')) {
  143. return fulfill(route, { items: [publishedAgent], page: 1, pageSize: 100, total: 1, pages: 1 })
  144. }
  145. if (pathname === '/api/v1/remote-terminals' && method === 'GET') {
  146. const keyword = (url.searchParams.get('keyword') || '').toLowerCase()
  147. const filtered = terminals.filter((item) => !keyword
  148. || [item.name, item.code, item.location].join('\n').toLowerCase().includes(keyword))
  149. return fulfill(route, { items: filtered, page: 1, pageSize: 20, total: filtered.length, pages: 1 })
  150. }
  151. if (pathname === '/api/v1/remote-terminals' && method === 'POST') {
  152. const payload = request.postDataJSON() as JsonRecord
  153. submissions.push({ method, pathname, payload })
  154. const created = terminal('rt-created', String(payload.name || '受控新终端'), {
  155. dataVersion: 1,
  156. code: String(payload.code || 'RTC-CREATED'),
  157. location: String(payload.location || ''),
  158. displayMode: payload.displayMode,
  159. expectedVolume: payload.defaultVolume,
  160. actualVolume: null,
  161. allowInterrupt: payload.allowInterrupt,
  162. allowedOrigins: payload.allowedOrigins,
  163. lifecycleStatus: 'PENDING_ACTIVATION',
  164. connectionStatus: 'NEVER_CONNECTED',
  165. online: false,
  166. capabilities: [],
  167. runtimeVersion: '',
  168. deviceId: '',
  169. lastHeartbeatAt: '',
  170. })
  171. terminals.unshift(created)
  172. return fulfill(route, {
  173. terminal: created,
  174. activation: {
  175. activationCode,
  176. expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(),
  177. shownOnce: true,
  178. activationPath: `/embed/${publishedAgent.slug}`,
  179. },
  180. }, 201, { 'Cache-Control': 'no-store' })
  181. }
  182. const terminalMatch = pathname.match(/^\/api\/v1\/remote-terminals\/([^/]+)$/)
  183. if (terminalMatch && method === 'GET') {
  184. const found = terminals.find((item) => item.id === decodeURIComponent(terminalMatch[1]!))
  185. return found ? fulfill(route, found) : fulfill(route, {}, 404)
  186. }
  187. const commandListMatch = pathname.match(/^\/api\/v1\/remote-terminals\/([^/]+)\/commands$/)
  188. if (commandListMatch && method === 'POST') {
  189. const payload = request.postDataJSON() as JsonRecord
  190. const command = {
  191. id: `controlled-command-${commands.length + 1}`,
  192. terminalId: decodeURIComponent(commandListMatch[1]!),
  193. type: String(payload.type || 'wake').toUpperCase(),
  194. label: payload.type === 'wake' || payload.type === 'WAKE' ? '唤醒' : String(payload.type || ''),
  195. detail: String(payload.detail || ''),
  196. operator: '系统管理员',
  197. status: 'SUCCEEDED',
  198. statusLabel: '执行成功',
  199. deliveryAttempts: 1,
  200. volume: payload.volume ?? null,
  201. queuedAt: now,
  202. sentAt: now,
  203. acknowledgedAt: now,
  204. executingAt: now,
  205. completedAt: now,
  206. expiresAt: new Date(Date.now() + 90_000).toISOString(),
  207. durationMs: 42,
  208. errorCode: '',
  209. errorMessage: '',
  210. result: { expanded: true },
  211. }
  212. commands.unshift(command)
  213. submissions.push({
  214. method,
  215. pathname,
  216. payload,
  217. idempotencyHeaderPresent: Boolean(request.headers()['idempotency-key']),
  218. })
  219. return fulfill(route, command, 202)
  220. }
  221. if (commandListMatch && method === 'GET') {
  222. const terminalId = decodeURIComponent(commandListMatch[1]!)
  223. const items = commands.filter((item) => item.terminalId === terminalId)
  224. return fulfill(route, { items, page: 1, pageSize: 10, total: items.length, pages: items.length ? 1 : 0 })
  225. }
  226. const commandEventsMatch = pathname.match(/^\/api\/v1\/remote-commands\/([^/]+)\/events$/)
  227. if (commandEventsMatch && method === 'GET') {
  228. const commandId = decodeURIComponent(commandEventsMatch[1]!)
  229. return fulfill(route, { items: [
  230. { id: `${commandId}-queued`, status: 'QUEUED', statusLabel: '等待下发', occurredAt: now, message: '命令已排队' },
  231. { id: `${commandId}-ack`, status: 'ACKNOWLEDGED', statusLabel: '终端已确认', occurredAt: now, message: '运行页已确认' },
  232. { id: `${commandId}-done`, status: 'SUCCEEDED', statusLabel: '执行成功', occurredAt: now, message: '真实页面动作已完成' },
  233. ] })
  234. }
  235. const commandMatch = pathname.match(/^\/api\/v1\/remote-commands\/([^/]+)$/)
  236. if (commandMatch && method === 'GET') {
  237. const found = commands.find((item) => item.id === decodeURIComponent(commandMatch[1]!))
  238. return found ? fulfill(route, found) : fulfill(route, {}, 404)
  239. }
  240. return fulfill(route, {})
  241. })
  242. return { terminals, commands, submissions }
  243. }
  244. async function chooseSelect(page: Page, scope: ReturnType<Page['locator']>, label: string, option: string) {
  245. const item = formItem(scope, label)
  246. await item.locator('.el-select').click()
  247. const dropdown = page.locator('.el-select-dropdown:visible').last()
  248. await expect(dropdown).toBeVisible()
  249. await dropdown.locator('.el-select-dropdown__item').filter({ hasText: option }).click()
  250. }
  251. function collectPageFailures(page: Page) {
  252. const consoleErrors: string[] = []
  253. const pageErrors: string[] = []
  254. page.on('console', (message) => {
  255. if (message.type() === 'error') consoleErrors.push(message.text())
  256. })
  257. page.on('pageerror', (error) => pageErrors.push(error.message))
  258. return { consoleErrors, pageErrors }
  259. }
  260. test.describe('远程终端 V2 受控页面回归', () => {
  261. test('表格状态、创建激活、详情控制、命令事件形成完整 UI 闭环', async ({ page }, testInfo) => {
  262. const failures = collectPageFailures(page)
  263. const mock = await installRemoteMocks(page, ['*'])
  264. await page.goto('/agents/remote-control')
  265. await expect(page.getByRole('heading', { name: '远程控制', exact: true })).toBeVisible()
  266. const table = page.locator('.desktop-terminal-table .el-table')
  267. await expect(table).toBeVisible()
  268. for (const heading of ['终端', '连接与心跳', '绑定智能体', '当前会话', '音量', '操作']) {
  269. await expect(table.locator('.el-table__header')).toContainText(heading)
  270. }
  271. for (const label of ['待激活', '在线', '离线', '已停用']) await expect(table.getByText(label, { exact: true })).toBeVisible()
  272. await captureScreenshot(page, testInfo, 'remote-v2-01-table-four-real-statuses')
  273. await page.getByRole('button', { name: '新增终端', exact: true }).click()
  274. const editor = visibleDialog(page)
  275. await expect(editor).toContainText('新增远程终端')
  276. await fillFormItem(editor, '终端名称', '受控回归悬浮终端')
  277. await fillFormItem(editor, '终端编码', 'RTC-CONTROLLED-01')
  278. await fillFormItem(editor, '部署位置', '综合实训楼 C-01')
  279. await chooseSelect(page, editor, '绑定智能体', publishedAgent.name)
  280. await formItem(editor, '页面模式').getByText('悬浮图标', { exact: true }).click()
  281. await editor.getByRole('button', { name: '创建并生成激活链接', exact: true }).click()
  282. await expect(page.getByText(/至少填写一个允许嵌入来源/).last()).toBeVisible()
  283. await page.waitForTimeout(400)
  284. expect(mock.submissions, '悬浮模式空来源时不得发起创建请求').toHaveLength(0)
  285. await formItem(editor, /允许嵌入来源/).getByPlaceholder('https://portal.example.local').first().fill('https://portal.example.test')
  286. const createdResponse = page.waitForResponse((response) => response.request().method() === 'POST'
  287. && new URL(response.url()).pathname === '/api/v1/remote-terminals')
  288. await editor.getByRole('button', { name: '创建并生成激活链接', exact: true }).click()
  289. const response = await createdResponse
  290. expect(response.status()).toBe(201)
  291. expect(response.headers()['cache-control']).toContain('no-store')
  292. const activation = visibleDialog(page)
  293. await expect(activation).toContainText('接入终端 · 受控回归悬浮终端')
  294. await expect(activation).toContainText('仅显示一次')
  295. await expect(activation).toContainText('此 iframe 嵌入代码包含一次性激活码')
  296. await expect(activation.getByRole('button', { name: '复制 iframe 代码', exact: true })).toBeVisible()
  297. await expect(activation.getByRole('button', { name: '重新生成激活链接', exact: true })).toBeVisible()
  298. await captureScreenshot(page, testInfo, 'remote-v2-02-one-time-activation-masked', [
  299. activation.getByTestId('terminal-activation-embed-code'),
  300. ])
  301. await activation.getByRole('button', { name: '完成', exact: true }).click()
  302. expect(mock.submissions[0]?.payload).toMatchObject({
  303. name: '受控回归悬浮终端',
  304. code: 'RTC-CONTROLLED-01',
  305. location: '综合实训楼 C-01',
  306. boundAgentId: publishedAgent.id,
  307. displayMode: 'WIDGET',
  308. defaultVolume: 60,
  309. allowInterrupt: true,
  310. allowedOrigins: ['https://portal.example.test'],
  311. })
  312. const row = tableRowByText(page, '电气实训区在线终端')
  313. await row.getByRole('button', { name: '详情', exact: true }).click()
  314. const drawer = page.locator('.remote-detail-drawer:visible')
  315. await expect(drawer).toBeVisible()
  316. for (const tab of ['概览', '实时控制', '命令记录', '接入与安全']) {
  317. await expect(drawer.getByRole('tab', { name: tab, exact: true })).toBeVisible()
  318. }
  319. await expect(drawer).toContainText('ai-person-web/remote-v2')
  320. await captureScreenshot(page, testInfo, 'remote-v2-03-detail-overview')
  321. await drawer.getByRole('tab', { name: '实时控制', exact: true }).click()
  322. await expect(drawer.getByRole('button', { name: '唤醒运行页', exact: true })).toBeEnabled()
  323. await expect(drawer.getByRole('button', { name: '暂停播报', exact: true })).toBeDisabled()
  324. await expect(drawer.getByRole('button', { name: '继续播报', exact: true })).toBeDisabled()
  325. await expect(drawer.getByRole('button', { name: '结束会话', exact: true })).toBeDisabled()
  326. await expect(drawer.getByRole('button', { name: '重载运行页', exact: true })).toBeEnabled()
  327. await expect(drawer.getByRole('button', { name: '应用音量', exact: true })).toBeDisabled()
  328. await drawer.getByRole('button', { name: '唤醒运行页', exact: true }).click()
  329. await expect(page.getByText('唤醒执行成功', { exact: true })).toBeVisible()
  330. const commandSubmission = mock.submissions.at(-1) || {}
  331. expect(String(asRecord(commandSubmission.payload).type || '').toUpperCase()).toBe('WAKE')
  332. expect(commandSubmission.idempotencyHeaderPresent).toBe(true)
  333. await drawer.getByRole('tab', { name: '命令记录', exact: true }).click()
  334. const commandRow = drawer.locator('.el-table__row').filter({ hasText: '唤醒' }).first()
  335. await expect(commandRow).toContainText('执行成功')
  336. await commandRow.getByRole('button', { name: '事件', exact: true }).click()
  337. const events = visibleDialog(page)
  338. await expect(events).toContainText('命令事件 · 唤醒')
  339. await expect(events).toContainText('等待下发')
  340. await expect(events).toContainText('终端已确认')
  341. await expect(events).toContainText('执行成功')
  342. await captureScreenshot(page, testInfo, 'remote-v2-04-command-history-and-events')
  343. expect(failures.consoleErrors, '受控远控页面不应产生 console.error').toEqual([])
  344. expect(failures.pageErrors, '受控远控页面不应产生 pageerror').toEqual([])
  345. await attachJson(testInfo, 'remote-v2-controlled-contract', {
  346. statesCovered: ['PENDING_ACTIVATION', 'ONLINE', 'OFFLINE', 'SUSPENDED'],
  347. createPayloadVerified: true,
  348. oneTimeActivationMasked: true,
  349. commandIdempotencyHeaderVerified: true,
  350. eventChain: ['QUEUED', 'ACKNOWLEDGED', 'SUCCEEDED'],
  351. sensitiveValuesIncluded: false,
  352. })
  353. })
  354. test('只有查看权限时管理与控制动作不可用', async ({ page }, testInfo) => {
  355. const failures = collectPageFailures(page)
  356. await installRemoteMocks(page, ['ai.remote.view'])
  357. await page.goto('/agents/remote-control')
  358. await expect(page.getByText('只读权限', { exact: true })).toBeVisible()
  359. await expect(page.getByRole('button', { name: '新增终端', exact: true })).toHaveCount(0)
  360. const row = tableRowByText(page, '电气实训区在线终端')
  361. await expect(row.getByRole('button', { name: '编辑', exact: true })).toHaveCount(0)
  362. await row.getByRole('button', { name: '详情', exact: true }).click()
  363. const drawer = page.locator('.remote-detail-drawer:visible')
  364. await drawer.getByRole('tab', { name: '实时控制', exact: true }).click()
  365. for (const action of ['唤醒运行页', '暂停播报', '继续播报', '结束会话', '重载运行页', '应用音量']) {
  366. await expect(drawer.getByRole('button', { name: action, exact: true })).toBeDisabled()
  367. }
  368. await drawer.getByRole('tab', { name: '接入与安全', exact: true }).click()
  369. await expect(drawer.getByRole('button', { name: '重新生成激活链接', exact: true })).toHaveCount(0)
  370. await expect(drawer.getByRole('button', { name: '停用终端', exact: true })).toHaveCount(0)
  371. await expect(drawer.getByRole('button', { name: '撤销终端', exact: true })).toHaveCount(0)
  372. await captureScreenshot(page, testInfo, 'remote-v2-05-view-only-permission-isolation')
  373. expect(failures.consoleErrors, '只读权限页面不应产生 console.error').toEqual([])
  374. expect(failures.pageErrors, '只读权限页面不应产生 pageerror').toEqual([])
  375. })
  376. test('最终回执丢失后新 deliveryId 重投只重放回执而不重复执行页面动作', async ({ page }, testInfo) => {
  377. const failures = collectPageFailures(page)
  378. const reports: Array<{ deliveryId: string; status: string; httpStatus: number; errorCode: string }> = []
  379. let pullCount = 0
  380. let sessionCreates = 0
  381. let terminalConfigRequests = 0
  382. let activationRequests = 0
  383. let activationRequestValid = true
  384. let legacyPublicConfigRequests = 0
  385. let legacyPublicSessionRequests = 0
  386. let terminalConfigHeadersValid = true
  387. let terminalSessionHeadersValid = true
  388. let terminalChatRequests = 0
  389. let terminalEndRequests = 0
  390. let terminalChatHeadersValid = true
  391. let terminalEndHeadersValid = true
  392. let ordinaryPublicPhase = false
  393. let ordinaryConfigRequests = 0
  394. let ordinarySessionRequests = 0
  395. let ordinaryChatRequests = 0
  396. let ordinaryRequestsOmitDeviceHeaders = true
  397. let unknownRedeliverySent = false
  398. let knownDeliveryReady = false
  399. let knownDeliveryAttempts = 0
  400. let stopDeliveryReady = false
  401. let stopDelivered = false
  402. let firstDeliverySucceededFailures = 0
  403. let releaseResponses = 0
  404. let leaderRuntimeInstanceId = ''
  405. let standbyInteractionBlocked = false
  406. let originMismatchCredentialPreserved = false
  407. let trustedHostReconnectSucceeded = false
  408. await page.route('**/api/auth/v1/**', async (route) => {
  409. const pathname = new URL(route.request().url()).pathname
  410. if (pathname.endsWith('/system-config/public')) return fulfill(route, { systemName: '虚拟教员系统', shortName: '数字人平台' })
  411. return fulfill(route, {})
  412. })
  413. await page.route('**/api/v1/**', async (route) => {
  414. const pathname = new URL(route.request().url()).pathname
  415. if (pathname.endsWith('/avatars')) return fulfill(route, { items: [], total: 0, page: 1, pageSize: 200, pages: 0 })
  416. return fulfill(route, {})
  417. })
  418. await page.route('**/open/v1/**', async (route) => {
  419. const request = route.request()
  420. const pathname = new URL(request.url()).pathname
  421. const requestHeaders = request.headers()
  422. const hasManagedSessionHeaders = requestHeaders['x-terminal-id'] === 'RTC-RELIABILITY-01'
  423. && requestHeaders['x-device-secret'] === 'controlled-device-secret'
  424. && requestHeaders['x-terminal-page-origin'] === controlledAppOrigin
  425. && Boolean(leaderRuntimeInstanceId)
  426. && requestHeaders['x-runtime-instance-id'] === leaderRuntimeInstanceId
  427. && !requestHeaders['x-terminal-secret']
  428. const omitsDeviceHeaders = !requestHeaders['x-terminal-id']
  429. && !requestHeaders['x-device-secret']
  430. && !requestHeaders['x-terminal-page-origin']
  431. && !requestHeaders['x-runtime-instance-id']
  432. && !requestHeaders['x-terminal-secret']
  433. if (pathname === `/open/v1/realtime/${publishedAgent.slug}` && request.method() === 'GET') {
  434. if (ordinaryPublicPhase) {
  435. ordinaryConfigRequests += 1
  436. ordinaryRequestsOmitDeviceHeaders = ordinaryRequestsOmitDeviceHeaders && omitsDeviceHeaders
  437. return fulfill(route, {
  438. ...publishedAgent,
  439. welcomeMessage: '普通公开会话已就绪',
  440. fallbackMessage: '服务暂不可用',
  441. accessMode: 'internal',
  442. brandVisible: true,
  443. uiConfig: {
  444. components: [
  445. { key: 'welcome', enabled: true, order: 1 },
  446. { key: 'history', enabled: true, order: 2 },
  447. { key: 'input', enabled: true, order: 3 },
  448. ],
  449. suggestions: [],
  450. },
  451. })
  452. }
  453. legacyPublicConfigRequests += 1
  454. return fulfill(route, {}, 500)
  455. }
  456. if (pathname === `/open/v1/realtime/${publishedAgent.slug}/sessions` && request.method() === 'POST') {
  457. if (ordinaryPublicPhase) {
  458. ordinarySessionRequests += 1
  459. ordinaryRequestsOmitDeviceHeaders = ordinaryRequestsOmitDeviceHeaders && omitsDeviceHeaders
  460. return fulfill(route, {
  461. sessionId: 'ordinary-public-session',
  462. sessionToken: 'ordinary-public-session-token',
  463. expiresAt: new Date(Date.now() + 60_000).toISOString(),
  464. }, 201)
  465. }
  466. legacyPublicSessionRequests += 1
  467. return fulfill(route, {}, 500)
  468. }
  469. if (pathname === `/open/v1/realtime/${publishedAgent.slug}/chat` && request.method() === 'POST') {
  470. if (ordinaryPublicPhase) {
  471. ordinaryChatRequests += 1
  472. ordinaryRequestsOmitDeviceHeaders = ordinaryRequestsOmitDeviceHeaders
  473. && omitsDeviceHeaders
  474. && requestHeaders.authorization === 'Bearer ordinary-public-session-token'
  475. return fulfill(route, {
  476. reply: '普通公开会话未携带远程终端设备凭据。',
  477. audioUrl: null,
  478. citations: [],
  479. provider: 'local',
  480. degraded: false,
  481. degradationReason: null,
  482. blocked: false,
  483. sensitiveCategory: null,
  484. requestId: 'ordinary-public-chat',
  485. })
  486. }
  487. terminalChatRequests += 1
  488. terminalChatHeadersValid = terminalChatHeadersValid
  489. && hasManagedSessionHeaders
  490. && requestHeaders.authorization === 'Bearer controlled-session-token'
  491. return fulfill(route, {
  492. reply: '受控终端会话设备授权头校验通过。',
  493. audioUrl: null,
  494. citations: [],
  495. provider: 'local',
  496. degraded: false,
  497. degradationReason: null,
  498. blocked: false,
  499. sensitiveCategory: null,
  500. requestId: 'controlled-terminal-chat',
  501. })
  502. }
  503. if (pathname === `/open/v1/realtime/${publishedAgent.slug}/end` && request.method() === 'POST') {
  504. terminalEndRequests += 1
  505. terminalEndHeadersValid = terminalEndHeadersValid
  506. && hasManagedSessionHeaders
  507. && requestHeaders.authorization === 'Bearer controlled-session-token'
  508. return fulfill(route, { sessionId: 'controlled-runtime-session', ended: true })
  509. }
  510. return fulfill(route, {})
  511. })
  512. await page.route('**/open/v2/**', async (route) => {
  513. const request = route.request()
  514. const pathname = new URL(request.url()).pathname
  515. const headers = request.headers()
  516. const baseDeviceHeadersValid = headers['x-terminal-id'] === 'RTC-RELIABILITY-01'
  517. && headers['x-device-secret'] === 'controlled-device-secret'
  518. && headers['x-terminal-page-origin'] === controlledAppOrigin
  519. && !headers['x-terminal-secret']
  520. const configHeadersValid = baseDeviceHeadersValid && !headers['x-runtime-instance-id']
  521. const ownerSessionHeadersValid = baseDeviceHeadersValid
  522. && Boolean(leaderRuntimeInstanceId)
  523. && headers['x-runtime-instance-id'] === leaderRuntimeInstanceId
  524. if (pathname === '/open/v2/terminals/activate' && request.method() === 'POST') {
  525. activationRequests += 1
  526. const payload = request.postDataJSON() as JsonRecord
  527. activationRequestValid = activationRequestValid
  528. && payload.activationCode === activationCode
  529. && payload.terminalCode === 'RTC-RELIABILITY-01'
  530. && payload.pageOrigin === controlledAppOrigin
  531. && !headers['x-terminal-id']
  532. && !headers['x-device-secret']
  533. && !headers['x-runtime-instance-id']
  534. && !headers['x-terminal-secret']
  535. return fulfill(route, {
  536. terminalId: 'RTC-RELIABILITY-01',
  537. terminalCode: 'RTC-RELIABILITY-01',
  538. deviceSecret: 'controlled-device-secret',
  539. deviceId: 'controlled-device-id',
  540. pageOrigin: controlledAppOrigin,
  541. }, 200, { 'Cache-Control': 'no-store' })
  542. }
  543. if (pathname === `/open/v2/terminals/agents/${publishedAgent.slug}/config` && request.method() === 'GET') {
  544. terminalConfigRequests += 1
  545. terminalConfigHeadersValid = terminalConfigHeadersValid && configHeadersValid
  546. return fulfill(route, {
  547. terminal: { id: 'rt-reliability', displayMode: 'WIDGET' },
  548. agent: {
  549. ...publishedAgent,
  550. welcomeMessage: '受控终端已就绪',
  551. fallbackMessage: '服务暂不可用',
  552. accessMode: 'private',
  553. brandVisible: true,
  554. interactionModes: ['text', 'voice'],
  555. uiConfig: {
  556. components: [
  557. { key: 'welcome', enabled: true, order: 1 },
  558. { key: 'history', enabled: true, order: 2 },
  559. { key: 'suggestions', enabled: true, order: 3 },
  560. { key: 'input', enabled: true, order: 4 },
  561. ],
  562. suggestions: ['如何完成设备点检?'],
  563. },
  564. },
  565. }, 200, { 'Cache-Control': 'no-store' })
  566. }
  567. if (pathname === `/open/v2/terminals/agents/${publishedAgent.slug}/sessions` && request.method() === 'POST') {
  568. sessionCreates += 1
  569. terminalSessionHeadersValid = terminalSessionHeadersValid && ownerSessionHeadersValid
  570. return fulfill(route, {
  571. sessionId: 'controlled-runtime-session',
  572. sessionToken: 'controlled-session-token',
  573. expiresAt: new Date(Date.now() + 60_000).toISOString(),
  574. websocketTicket: 'controlled-websocket-ticket',
  575. websocketUrl: `/open/v1/realtime/${publishedAgent.slug}/stream?ticket=controlled-websocket-ticket`,
  576. configVersion: publishedAgent.dataVersion,
  577. }, 201, { 'Cache-Control': 'no-store' })
  578. }
  579. if (pathname.endsWith('/heartbeat')) {
  580. const runtimeInstanceId = headers['x-runtime-instance-id'] || ''
  581. if (!leaderRuntimeInstanceId) leaderRuntimeInstanceId = runtimeInstanceId
  582. if (runtimeInstanceId !== leaderRuntimeInstanceId) {
  583. return fulfill(route, {}, 423, { 'Cache-Control': 'no-store' })
  584. }
  585. return fulfill(route, {
  586. terminalId: 'rt-reliability',
  587. terminalCode: 'RTC-RELIABILITY-01',
  588. online: true,
  589. serverTime: new Date().toISOString(),
  590. nextHeartbeatSeconds: 15,
  591. runtimeLeaseSeconds: 45,
  592. runtimeLeaseExpiresAt: new Date(Date.now() + 45_000).toISOString(),
  593. desiredConfig: { volume: 60, allowInterrupt: true },
  594. agent: agentBinding,
  595. displayMode: 'WIDGET',
  596. }, 200, { 'Cache-Control': 'no-store' })
  597. }
  598. if (pathname.endsWith('/commands/pull')) {
  599. pullCount += 1
  600. const isUnknownRedelivery = !unknownRedeliverySent
  601. if (isUnknownRedelivery) unknownRedeliverySent = true
  602. const isKnownDelivery = !isUnknownRedelivery && knownDeliveryReady && knownDeliveryAttempts < 2
  603. if (isKnownDelivery) knownDeliveryAttempts += 1
  604. const isStopDelivery = !isUnknownRedelivery && !isKnownDelivery && stopDeliveryReady && !stopDelivered
  605. const deliveryId = isUnknownRedelivery
  606. ? 'delivery-unknown-redelivery'
  607. : isKnownDelivery
  608. ? knownDeliveryAttempts === 1 ? 'delivery-first' : 'delivery-redelivered'
  609. : isStopDelivery ? 'delivery-stop' : ''
  610. if (isStopDelivery) stopDelivered = true
  611. return fulfill(route, {
  612. items: deliveryId ? [{
  613. id: isUnknownRedelivery ? 'command-unknown-redelivery' : isStopDelivery ? 'command-stop' : 'command-same-id',
  614. commandId: isUnknownRedelivery ? 'command-unknown-redelivery' : isStopDelivery ? 'command-stop' : 'command-same-id',
  615. deliveryId,
  616. type: isStopDelivery ? 'STOP' : 'WAKE',
  617. payload: {
  618. detail: isUnknownRedelivery
  619. ? '无缓存重投不得执行业务动作'
  620. : isStopDelivery ? '结束受控终端会话' : '可靠性回归唤醒',
  621. },
  622. deliveryAttempt: isUnknownRedelivery ? 2 : isKnownDelivery ? knownDeliveryAttempts : 1,
  623. redelivery: isUnknownRedelivery || deliveryId === 'delivery-redelivered',
  624. boundAgentId: publishedAgent.id,
  625. boundAgentSlug: publishedAgent.slug,
  626. boundAgentDataVersion: publishedAgent.dataVersion,
  627. boundAgent: agentBinding,
  628. agent: agentBinding,
  629. }] : [],
  630. pollAfterMs: deliveryId ? 0 : 1_000,
  631. }, 200, { 'Cache-Control': 'no-store' })
  632. }
  633. if (pathname.endsWith('/reports')) {
  634. const payload = request.postDataJSON() as JsonRecord
  635. const deliveryId = String(payload.deliveryId || '')
  636. const status = String(payload.status || '')
  637. const errorCode = String(payload.errorCode || '')
  638. if (deliveryId === 'delivery-first' && status === 'SUCCEEDED' && firstDeliverySucceededFailures < 3) {
  639. firstDeliverySucceededFailures += 1
  640. reports.push({ deliveryId, status, httpStatus: 503, errorCode })
  641. return fulfill(route, {}, 503)
  642. }
  643. reports.push({ deliveryId, status, httpStatus: 200, errorCode })
  644. return fulfill(route, { idempotent: false, status }, 200, { 'Cache-Control': 'no-store' })
  645. }
  646. if (pathname.endsWith('/runtime/release')) {
  647. if (headers['x-runtime-instance-id'] !== leaderRuntimeInstanceId) {
  648. return fulfill(route, {}, 423, { 'Cache-Control': 'no-store' })
  649. }
  650. releaseResponses += 1
  651. leaderRuntimeInstanceId = ''
  652. return fulfill(route, { released: true, idempotent: false, releasedAt: new Date().toISOString() }, 200, { 'Cache-Control': 'no-store' })
  653. }
  654. return fulfill(route, {}, 404)
  655. })
  656. const hostPath = '/ape2e/controlled-remote-widget-reliability-host'
  657. const mismatchHostPath = '/ape2e/controlled-remote-widget-origin-mismatch-host'
  658. let includeControlledActivation = true
  659. const mismatchHostOrigin = (() => {
  660. const url = new URL(controlledAppOrigin)
  661. url.hostname = 'origin-mismatch.invalid'
  662. return url.origin
  663. })()
  664. await page.route(`**${hostPath}`, async (route) => {
  665. if (new URL(route.request().url()).pathname !== hostPath) return route.continue()
  666. const activationFragment = includeControlledActivation
  667. ? `#terminal-activation=${encodeURIComponent(activationCode)}`
  668. : ''
  669. await route.fulfill({
  670. status: 200,
  671. contentType: 'text/html; charset=utf-8',
  672. headers: { 'Cache-Control': 'no-store' },
  673. 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>`,
  674. })
  675. })
  676. await page.route(`**${mismatchHostPath}`, async (route) => {
  677. if (new URL(route.request().url()).pathname !== mismatchHostPath) return route.continue()
  678. await route.fulfill({
  679. status: 200,
  680. contentType: 'text/html; charset=utf-8',
  681. headers: { 'Cache-Control': 'no-store' },
  682. 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>`,
  683. })
  684. })
  685. const runtimeTransport = observeRuntimeTransport(page, 'RTC-RELIABILITY-01', controlledAppOrigin)
  686. const activationResponse = page.waitForResponse((response) => response.request().method() === 'POST'
  687. && new URL(response.url()).pathname === '/open/v2/terminals/activate'
  688. && response.status() === 200, { timeout: 20_000 })
  689. await page.goto(hostPath)
  690. await activationResponse
  691. includeControlledActivation = false
  692. expect(activationRequests).toBe(1)
  693. expect(activationRequestValid).toBe(true)
  694. const widget = page.frameLocator('#terminal-widget')
  695. await expect(widget.locator('.embed-widget')).not.toHaveClass(/is-expanded/)
  696. await expect.poll(() => reports.filter((item) => item.deliveryId === 'delivery-unknown-redelivery'
  697. && item.status === 'FAILED' && item.httpStatus === 200).length, {
  698. message: '没有本地终态缓存的重投必须直接失败收敛',
  699. timeout: 20_000,
  700. }).toBe(1)
  701. const unknownReports = reports.filter((item) => item.deliveryId === 'delivery-unknown-redelivery')
  702. expect(unknownReports.map((item) => item.status)).toEqual(['FAILED'])
  703. expect(unknownReports[0]?.errorCode).toBe('RUNTIME_OUTCOME_UNKNOWN')
  704. expect(sessionCreates, '未知结果重投不得执行 WAKE 业务动作').toBe(0)
  705. knownDeliveryReady = true
  706. await expect.poll(() => reports.filter((item) => item.deliveryId === 'delivery-redelivered'
  707. && item.status === 'SUCCEEDED' && item.httpStatus === 200).length, {
  708. timeout: 20_000,
  709. }).toBe(1)
  710. await expect(widget.locator('.embed-widget')).toHaveClass(/is-expanded/, { timeout: 20_000 })
  711. expect(sessionCreates, 'WAKE 页面业务动作必须只执行一次').toBe(1)
  712. expect(terminalConfigRequests, '受管终端必须通过设备凭据读取一次智能体配置').toBe(1)
  713. expect(terminalConfigHeadersValid, '终端配置请求必须携带设备头和页面 Origin').toBe(true)
  714. expect(terminalSessionHeadersValid, '终端会话请求必须携带设备头和页面 Origin').toBe(true)
  715. expect(legacyPublicConfigRequests, '带 terminal 参数时不得调用旧公开配置接口').toBe(0)
  716. expect(legacyPublicSessionRequests, '带 terminal 参数时不得调用旧公开会话接口').toBe(0)
  717. expect(firstDeliverySucceededFailures).toBe(3)
  718. expect(reports.filter((item) => item.deliveryId === 'delivery-first').map((item) => item.status)).toEqual([
  719. 'ACK', 'EXECUTING', 'SUCCEEDED', 'SUCCEEDED', 'SUCCEEDED',
  720. ])
  721. expect(reports.filter((item) => item.deliveryId === 'delivery-redelivered').map((item) => item.status)).toEqual([
  722. 'SUCCEEDED',
  723. ])
  724. expect(runtimeTransport.heartbeatSuccesses(), 'pull 前必须先有一次成功 heartbeat claim').toBeGreaterThan(0)
  725. expect(runtimeTransport.pullStartedAfterHeartbeat(), '首次 heartbeat 成功前不得启动 pull').toBe(true)
  726. expect(runtimeTransport.headersValid(), 'heartbeat/pull/report 必须携带同页运行实例头、三项设备头且无旧头').toBe(true)
  727. expect(runtimeTransport.runtimeInstanceOmittedFromUrls(), '运行实例标识不得进入 URL').toBe(true)
  728. expect(runtimeTransport.runtimeInstanceIds()).toHaveLength(1)
  729. expect(await runtimeInstancesAbsentFromBrowserState(page, runtimeTransport.runtimeInstanceIds()), '运行实例标识不得持久化到 URL/storage').toBe(true)
  730. const sessionsBeforeStandby = sessionCreates
  731. const chatsBeforeStandby = terminalChatRequests
  732. const heartbeatBeforeStandby = runtimeTransport.heartbeatRequests()
  733. const pullsBeforeStandby = runtimeTransport.pullRequests()
  734. await page.evaluate(({ slug }) => {
  735. const iframe = document.createElement('iframe')
  736. iframe.id = 'terminal-widget-standby'
  737. iframe.title = '远程数字人终端备用页'
  738. iframe.src = `/embed/${encodeURIComponent(slug)}?terminal=RTC-RELIABILITY-01`
  739. iframe.allow = 'microphone; autoplay'
  740. iframe.referrerPolicy = 'origin'
  741. iframe.style.cssText = 'width:420px;height:680px;border:0'
  742. document.body.appendChild(iframe)
  743. }, { slug: publishedAgent.slug })
  744. const standbyWidget = page.frameLocator('#terminal-widget-standby')
  745. await expect(standbyWidget.locator('.embed-widget')).toBeVisible({ timeout: 20_000 })
  746. await standbyWidget.locator('.embed-launcher').click()
  747. await expect(standbyWidget.locator('.embed-widget')).toHaveClass(/is-expanded/)
  748. await expect(standbyWidget.locator('.terminal-runtime-notice')).toContainText('另一页面运行', { timeout: 20_000 })
  749. await expect(standbyWidget.getByRole('textbox', { name: '维修问题' })).toBeDisabled()
  750. await expect(standbyWidget.getByRole('button', { name: '语音提问', exact: true })).toBeDisabled()
  751. await expect(standbyWidget.getByRole('button', { name: '发送问题', exact: true })).toBeDisabled()
  752. await expect(standbyWidget.getByText('如何完成设备点检?', { exact: true })).toHaveCount(0)
  753. await standbyWidget.locator('form.embed-input').evaluate((form) => {
  754. form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
  755. })
  756. await page.waitForTimeout(500)
  757. expect(sessionCreates, 'STANDBY 页面不得签发 scoped session').toBe(sessionsBeforeStandby)
  758. expect(terminalChatRequests, 'STANDBY 页面不得发送 scoped chat').toBe(chatsBeforeStandby)
  759. expect(runtimeTransport.heartbeatRequests(), '浏览器非 leader 不得竞争 heartbeat').toBe(heartbeatBeforeStandby)
  760. expect(runtimeTransport.pullRequests(), '浏览器非 leader 不得启动 pull').toBe(pullsBeforeStandby)
  761. standbyInteractionBlocked = true
  762. await page.locator('#terminal-widget-standby').evaluate((element) => element.remove())
  763. await widget.getByRole('textbox', { name: '维修问题' }).fill('验证受控终端会话请求头')
  764. await widget.getByRole('button', { name: '发送问题', exact: true }).click()
  765. await expect(widget.getByText('受控终端会话设备授权头校验通过。', { exact: true })).toBeVisible()
  766. expect(terminalChatRequests, 'REMOTE_TERMINAL chat 必须实际发出一次').toBe(1)
  767. expect(terminalChatHeadersValid, 'REMOTE_TERMINAL chat 必须同时携带会话令牌、三项设备头且无旧头').toBe(true)
  768. stopDeliveryReady = true
  769. await expect.poll(() => terminalEndRequests, {
  770. message: 'STOP 必须结束真实受控会话并调用 end',
  771. timeout: 20_000,
  772. }).toBe(1)
  773. await expect.poll(() => reports.filter((item) => item.deliveryId === 'delivery-stop'
  774. && item.status === 'SUCCEEDED' && item.httpStatus === 200).length, {
  775. message: 'STOP 必须形成成功终态回执',
  776. timeout: 20_000,
  777. }).toBe(1)
  778. expect(terminalEndHeadersValid, 'REMOTE_TERMINAL end 必须同时携带会话令牌、三项设备头且无旧头').toBe(true)
  779. await expect(widget.locator('.terminal-runtime-notice.is-error')).toHaveCount(0)
  780. await captureScreenshot(page, testInfo, 'remote-v2-06-redelivery-cached-outcome-single-action')
  781. const configRequestsBeforeMismatch = terminalConfigRequests
  782. const heartbeatRequestsBeforeMismatch = runtimeTransport.heartbeatRequests()
  783. const activationRequestsBeforeMismatch = activationRequests
  784. await page.goto(new URL(mismatchHostPath, mismatchHostOrigin).toString())
  785. const mismatchWidget = page.frameLocator('#terminal-widget-mismatch')
  786. await expect(mismatchWidget.locator('.embed-widget')).toBeVisible({ timeout: 20_000 })
  787. await expect(mismatchWidget.locator('.terminal-runtime-notice.is-error')).toContainText('嵌入来源与终端激活来源不一致', { timeout: 20_000 })
  788. expect(terminalConfigRequests, '非法宿主来源必须在读取 scoped config 前失败关闭').toBe(configRequestsBeforeMismatch)
  789. expect(runtimeTransport.heartbeatRequests(), '非法宿主来源不得参与 runtime lease').toBe(heartbeatRequestsBeforeMismatch)
  790. const mismatchFrame = page.frames().find((frame) => {
  791. try {
  792. const url = new URL(frame.url())
  793. return url.origin === controlledAppOrigin && url.pathname === `/embed/${publishedAgent.slug}`
  794. } catch {
  795. return false
  796. }
  797. })
  798. expect(mismatchFrame, '非法宿主页面仍应加载应用 iframe 以呈现拒绝原因').toBeTruthy()
  799. originMismatchCredentialPreserved = await mismatchFrame!.evaluate(() => (
  800. Boolean(window.localStorage.getItem('ai-person:remote-terminal-runtime:v2:RTC-RELIABILITY-01'))
  801. ))
  802. expect(originMismatchCredentialPreserved, '来源不匹配只拒绝本次连接,不得删除合法宿主激活的设备凭据').toBe(true)
  803. const heartbeatSuccessesBeforeTrustedReturn = runtimeTransport.heartbeatSuccesses()
  804. await page.goto(hostPath)
  805. await expect.poll(runtimeTransport.heartbeatSuccesses, {
  806. message: '返回合法宿主后必须复用原设备凭据重新取得 runtime lease',
  807. timeout: 20_000,
  808. }).toBeGreaterThan(heartbeatSuccessesBeforeTrustedReturn)
  809. await expect.poll(() => terminalConfigRequests, {
  810. message: '返回合法宿主后必须重新读取 scoped config',
  811. timeout: 20_000,
  812. }).toBeGreaterThan(configRequestsBeforeMismatch)
  813. const trustedWidget = page.frameLocator('#terminal-widget')
  814. await expect(trustedWidget.locator('.embed-widget')).toBeVisible()
  815. await expect(trustedWidget.locator('.terminal-runtime-notice.is-error')).toHaveCount(0)
  816. trustedHostReconnectSucceeded = true
  817. expect(activationRequests, '来源切换过程不得重复兑换一次性激活码').toBe(activationRequestsBeforeMismatch)
  818. const releasesBeforeLeavingHost = runtimeTransport.releaseRequests()
  819. const releaseResponsesBeforeLeavingHost = releaseResponses
  820. ordinaryPublicPhase = true
  821. await page.goto(`/live/${publishedAgent.slug}`)
  822. await expect.poll(runtimeTransport.releaseRequests, {
  823. message: '离开受管 WIDGET 时必须 best-effort 释放服务端运行租约',
  824. timeout: 15_000,
  825. }).toBeGreaterThan(releasesBeforeLeavingHost)
  826. await expect.poll(() => releaseResponses, {
  827. message: '受控 release mock 必须实际处理卸载请求',
  828. timeout: 15_000,
  829. }).toBeGreaterThan(releaseResponsesBeforeLeavingHost)
  830. expect(runtimeTransport.releaseBodyEmpty(), 'release 必须是无 body 的 keepalive POST').toBe(true)
  831. expect(runtimeTransport.headersValid(), 'release 必须复用同页运行实例和三项设备头且无旧头').toBe(true)
  832. await expect(page.locator('.live-agent-page')).toBeVisible()
  833. await page.getByRole('textbox', { name: '维修问题' }).fill('验证普通公开会话请求头隔离')
  834. await page.getByRole('button', { name: '发送问题', exact: true }).click()
  835. await expect(page.getByText('普通公开会话未携带远程终端设备凭据。', { exact: true })).toBeVisible()
  836. expect(ordinaryConfigRequests, '普通运行页必须读取公开配置').toBe(1)
  837. expect(ordinarySessionRequests, '普通运行页必须创建公开会话').toBe(1)
  838. expect(ordinaryChatRequests, '普通运行页必须发送公开 chat').toBe(1)
  839. expect(ordinaryRequestsOmitDeviceHeaders, '无 terminal 参数的公开请求不得携带任何设备授权头').toBe(true)
  840. await attachJson(testInfo, 'remote-v2-redelivery-evidence', {
  841. commandId: 'command-same-id',
  842. deliveryIds: ['delivery-first', 'delivery-redelivered', 'delivery-stop'],
  843. actionExecutionCount: sessionCreates,
  844. terminalScopedAccess: {
  845. configRequests: terminalConfigRequests,
  846. sessionRequests: sessionCreates,
  847. configDeviceHeadersValid: terminalConfigHeadersValid,
  848. sessionDeviceHeadersValid: terminalSessionHeadersValid,
  849. legacyPublicConfigRequests,
  850. legacyPublicSessionRequests,
  851. },
  852. terminalSessionFollowups: {
  853. chatRequests: terminalChatRequests,
  854. endRequests: terminalEndRequests,
  855. chatDeviceHeadersValid: terminalChatHeadersValid,
  856. endDeviceHeadersValid: terminalEndHeadersValid,
  857. },
  858. ordinaryPublicIsolation: {
  859. configRequests: ordinaryConfigRequests,
  860. sessionRequests: ordinarySessionRequests,
  861. chatRequests: ordinaryChatRequests,
  862. deviceHeadersOmitted: ordinaryRequestsOmitDeviceHeaders,
  863. },
  864. firstFinalReportNetworkFailures: firstDeliverySucceededFailures,
  865. redeliveryReportSequence: reports.map((item) => ({ ...item })),
  866. unknownRedelivery: {
  867. failedWithoutBusinessAction: true,
  868. errorCode: 'RUNTIME_OUTCOME_UNKNOWN',
  869. businessActionCountBeforeKnownDelivery: 0,
  870. },
  871. standbyInteraction: {
  872. blocked: standbyInteractionBlocked,
  873. scopedSessionRequests: 0,
  874. scopedChatRequests: 0,
  875. runtimeTransportRequests: 0,
  876. },
  877. trustedOriginIsolation: {
  878. activationRequests,
  879. activationRequestValid,
  880. mismatchConnectionRejectedBeforeConfig: true,
  881. credentialPreserved: originMismatchCredentialPreserved,
  882. trustedHostReconnectSucceeded,
  883. },
  884. runtimeLeaseContract: {
  885. heartbeatRequests: runtimeTransport.heartbeatRequests(),
  886. pullRequests: runtimeTransport.pullRequests(),
  887. reportRequests: runtimeTransport.reportRequests(),
  888. releaseRequests: runtimeTransport.releaseRequests(),
  889. releaseResponses,
  890. firstHeartbeatBeforePull: runtimeTransport.pullStartedAfterHeartbeat(),
  891. samePageInstanceHeadersValid: runtimeTransport.headersValid(),
  892. instanceInUrlOrStorage: false,
  893. releaseBodyEmpty: runtimeTransport.releaseBodyEmpty(),
  894. },
  895. secretIncluded: false,
  896. })
  897. const unexpectedConsoleErrors = failures.consoleErrors.filter((message) => !/503|Failed to load resource/i.test(message))
  898. expect(unexpectedConsoleErrors, '除受控 503 外,重投可靠性回归不应产生 console.error').toEqual([])
  899. expect(failures.pageErrors, '重投可靠性回归不应产生 pageerror').toEqual([])
  900. })
  901. })