Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

975 строки
49 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 terminalConfigFailures = 0
  383. let activationRequests = 0
  384. let activationRequestValid = true
  385. let legacyPublicConfigRequests = 0
  386. let legacyPublicSessionRequests = 0
  387. let terminalConfigHeadersValid = true
  388. let terminalSessionHeadersValid = true
  389. let terminalChatRequests = 0
  390. let terminalEndRequests = 0
  391. let terminalChatHeadersValid = true
  392. let terminalEndHeadersValid = true
  393. let ordinaryPublicPhase = false
  394. let ordinaryConfigRequests = 0
  395. let ordinarySessionRequests = 0
  396. let ordinaryChatRequests = 0
  397. let ordinaryRequestsOmitDeviceHeaders = true
  398. let unknownRedeliverySent = false
  399. let knownDeliveryReady = false
  400. let knownDeliveryAttempts = 0
  401. let stopDeliveryReady = false
  402. let stopDelivered = false
  403. let firstDeliverySucceededFailures = 0
  404. let releaseResponses = 0
  405. let leaderRuntimeInstanceId = ''
  406. let standbyInteractionBlocked = false
  407. let originMismatchCredentialPreserved = false
  408. let trustedHostReconnectSucceeded = false
  409. await page.route('**/api/auth/v1/**', async (route) => {
  410. const pathname = new URL(route.request().url()).pathname
  411. if (pathname.endsWith('/system-config/public')) return fulfill(route, { systemName: '虚拟教员系统', shortName: '数字人平台' })
  412. return fulfill(route, {})
  413. })
  414. await page.route('**/api/v1/**', async (route) => {
  415. const pathname = new URL(route.request().url()).pathname
  416. if (pathname.endsWith('/avatars')) return fulfill(route, { items: [], total: 0, page: 1, pageSize: 200, pages: 0 })
  417. return fulfill(route, {})
  418. })
  419. await page.route('**/open/v1/**', async (route) => {
  420. const request = route.request()
  421. const pathname = new URL(request.url()).pathname
  422. const requestHeaders = request.headers()
  423. const hasManagedSessionHeaders = requestHeaders['x-terminal-id'] === 'RTC-RELIABILITY-01'
  424. && requestHeaders['x-device-secret'] === 'controlled-device-secret'
  425. && requestHeaders['x-terminal-page-origin'] === controlledAppOrigin
  426. && Boolean(leaderRuntimeInstanceId)
  427. && requestHeaders['x-runtime-instance-id'] === leaderRuntimeInstanceId
  428. && !requestHeaders['x-terminal-secret']
  429. const omitsDeviceHeaders = !requestHeaders['x-terminal-id']
  430. && !requestHeaders['x-device-secret']
  431. && !requestHeaders['x-terminal-page-origin']
  432. && !requestHeaders['x-runtime-instance-id']
  433. && !requestHeaders['x-terminal-secret']
  434. if (pathname === `/open/v1/realtime/${publishedAgent.slug}` && request.method() === 'GET') {
  435. if (ordinaryPublicPhase) {
  436. ordinaryConfigRequests += 1
  437. ordinaryRequestsOmitDeviceHeaders = ordinaryRequestsOmitDeviceHeaders && omitsDeviceHeaders
  438. return fulfill(route, {
  439. ...publishedAgent,
  440. welcomeMessage: '普通公开会话已就绪',
  441. fallbackMessage: '服务暂不可用',
  442. accessMode: 'internal',
  443. brandVisible: true,
  444. uiConfig: {
  445. components: [
  446. { key: 'welcome', enabled: true, order: 1 },
  447. { key: 'history', enabled: true, order: 2 },
  448. { key: 'input', enabled: true, order: 3 },
  449. ],
  450. suggestions: [],
  451. },
  452. })
  453. }
  454. legacyPublicConfigRequests += 1
  455. return fulfill(route, {}, 500)
  456. }
  457. if (pathname === `/open/v1/realtime/${publishedAgent.slug}/sessions` && request.method() === 'POST') {
  458. if (ordinaryPublicPhase) {
  459. ordinarySessionRequests += 1
  460. ordinaryRequestsOmitDeviceHeaders = ordinaryRequestsOmitDeviceHeaders && omitsDeviceHeaders
  461. return fulfill(route, {
  462. sessionId: 'ordinary-public-session',
  463. sessionToken: 'ordinary-public-session-token',
  464. expiresAt: new Date(Date.now() + 60_000).toISOString(),
  465. }, 201)
  466. }
  467. legacyPublicSessionRequests += 1
  468. return fulfill(route, {}, 500)
  469. }
  470. if (pathname === `/open/v1/realtime/${publishedAgent.slug}/chat` && request.method() === 'POST') {
  471. if (ordinaryPublicPhase) {
  472. ordinaryChatRequests += 1
  473. ordinaryRequestsOmitDeviceHeaders = ordinaryRequestsOmitDeviceHeaders
  474. && omitsDeviceHeaders
  475. && requestHeaders.authorization === 'Bearer ordinary-public-session-token'
  476. return fulfill(route, {
  477. reply: '普通公开会话未携带远程终端设备凭据。',
  478. audioUrl: null,
  479. citations: [],
  480. provider: 'local',
  481. degraded: false,
  482. degradationReason: null,
  483. blocked: false,
  484. sensitiveCategory: null,
  485. requestId: 'ordinary-public-chat',
  486. })
  487. }
  488. terminalChatRequests += 1
  489. terminalChatHeadersValid = terminalChatHeadersValid
  490. && hasManagedSessionHeaders
  491. && requestHeaders.authorization === 'Bearer controlled-session-token'
  492. return fulfill(route, {
  493. reply: '受控终端会话设备授权头校验通过。',
  494. audioUrl: null,
  495. citations: [],
  496. provider: 'local',
  497. degraded: false,
  498. degradationReason: null,
  499. blocked: false,
  500. sensitiveCategory: null,
  501. requestId: 'controlled-terminal-chat',
  502. })
  503. }
  504. if (pathname === `/open/v1/realtime/${publishedAgent.slug}/end` && request.method() === 'POST') {
  505. terminalEndRequests += 1
  506. terminalEndHeadersValid = terminalEndHeadersValid
  507. && hasManagedSessionHeaders
  508. && requestHeaders.authorization === 'Bearer controlled-session-token'
  509. return fulfill(route, { sessionId: 'controlled-runtime-session', ended: true })
  510. }
  511. return fulfill(route, {})
  512. })
  513. await page.route('**/open/v2/**', async (route) => {
  514. const request = route.request()
  515. const pathname = new URL(request.url()).pathname
  516. const headers = request.headers()
  517. const baseDeviceHeadersValid = headers['x-terminal-id'] === 'RTC-RELIABILITY-01'
  518. && headers['x-device-secret'] === 'controlled-device-secret'
  519. && headers['x-terminal-page-origin'] === controlledAppOrigin
  520. && !headers['x-terminal-secret']
  521. const configHeadersValid = baseDeviceHeadersValid && !headers['x-runtime-instance-id']
  522. const ownerSessionHeadersValid = baseDeviceHeadersValid
  523. && Boolean(leaderRuntimeInstanceId)
  524. && headers['x-runtime-instance-id'] === leaderRuntimeInstanceId
  525. if (pathname === '/open/v2/terminals/activate' && request.method() === 'POST') {
  526. activationRequests += 1
  527. const payload = request.postDataJSON() as JsonRecord
  528. activationRequestValid = activationRequestValid
  529. && payload.activationCode === activationCode
  530. && payload.terminalCode === 'RTC-RELIABILITY-01'
  531. && payload.pageOrigin === controlledAppOrigin
  532. && !headers['x-terminal-id']
  533. && !headers['x-device-secret']
  534. && !headers['x-runtime-instance-id']
  535. && !headers['x-terminal-secret']
  536. return fulfill(route, {
  537. terminalId: 'RTC-RELIABILITY-01',
  538. terminalCode: 'RTC-RELIABILITY-01',
  539. deviceSecret: 'controlled-device-secret',
  540. deviceId: 'controlled-device-id',
  541. pageOrigin: controlledAppOrigin,
  542. }, 200, { 'Cache-Control': 'no-store' })
  543. }
  544. if (pathname === `/open/v2/terminals/agents/${publishedAgent.slug}/config` && request.method() === 'GET') {
  545. terminalConfigRequests += 1
  546. terminalConfigHeadersValid = terminalConfigHeadersValid && configHeadersValid
  547. if (terminalConfigFailures === 0) {
  548. terminalConfigFailures += 1
  549. return fulfill(route, {}, 503, { 'Cache-Control': 'no-store' })
  550. }
  551. return fulfill(route, {
  552. terminal: { id: 'rt-reliability', displayMode: 'WIDGET' },
  553. agent: {
  554. ...publishedAgent,
  555. welcomeMessage: '受控终端已就绪',
  556. fallbackMessage: '服务暂不可用',
  557. accessMode: 'private',
  558. brandVisible: true,
  559. interactionModes: ['text', 'voice'],
  560. uiConfig: {
  561. components: [
  562. { key: 'welcome', enabled: true, order: 1 },
  563. { key: 'history', enabled: true, order: 2 },
  564. { key: 'suggestions', enabled: true, order: 3 },
  565. { key: 'input', enabled: true, order: 4 },
  566. ],
  567. suggestions: ['如何完成设备点检?'],
  568. },
  569. },
  570. }, 200, { 'Cache-Control': 'no-store' })
  571. }
  572. if (pathname === `/open/v2/terminals/agents/${publishedAgent.slug}/sessions` && request.method() === 'POST') {
  573. sessionCreates += 1
  574. terminalSessionHeadersValid = terminalSessionHeadersValid && ownerSessionHeadersValid
  575. return fulfill(route, {
  576. sessionId: 'controlled-runtime-session',
  577. sessionToken: 'controlled-session-token',
  578. expiresAt: new Date(Date.now() + 60_000).toISOString(),
  579. websocketTicket: 'controlled-websocket-ticket',
  580. websocketUrl: `/open/v1/realtime/${publishedAgent.slug}/stream?ticket=controlled-websocket-ticket`,
  581. configVersion: publishedAgent.dataVersion,
  582. }, 201, { 'Cache-Control': 'no-store' })
  583. }
  584. if (pathname.endsWith('/heartbeat')) {
  585. const runtimeInstanceId = headers['x-runtime-instance-id'] || ''
  586. if (!leaderRuntimeInstanceId) leaderRuntimeInstanceId = runtimeInstanceId
  587. if (runtimeInstanceId !== leaderRuntimeInstanceId) {
  588. return fulfill(route, {}, 423, { 'Cache-Control': 'no-store' })
  589. }
  590. return fulfill(route, {
  591. terminalId: 'rt-reliability',
  592. terminalCode: 'RTC-RELIABILITY-01',
  593. online: true,
  594. serverTime: new Date().toISOString(),
  595. nextHeartbeatSeconds: 15,
  596. runtimeLeaseSeconds: 45,
  597. runtimeLeaseExpiresAt: new Date(Date.now() + 45_000).toISOString(),
  598. desiredConfig: { volume: 60, allowInterrupt: true },
  599. agent: agentBinding,
  600. displayMode: 'WIDGET',
  601. }, 200, { 'Cache-Control': 'no-store' })
  602. }
  603. if (pathname.endsWith('/commands/pull')) {
  604. pullCount += 1
  605. const isUnknownRedelivery = !unknownRedeliverySent
  606. if (isUnknownRedelivery) unknownRedeliverySent = true
  607. const isKnownDelivery = !isUnknownRedelivery && knownDeliveryReady && knownDeliveryAttempts < 2
  608. if (isKnownDelivery) knownDeliveryAttempts += 1
  609. const isStopDelivery = !isUnknownRedelivery && !isKnownDelivery && stopDeliveryReady && !stopDelivered
  610. const deliveryId = isUnknownRedelivery
  611. ? 'delivery-unknown-redelivery'
  612. : isKnownDelivery
  613. ? knownDeliveryAttempts === 1 ? 'delivery-first' : 'delivery-redelivered'
  614. : isStopDelivery ? 'delivery-stop' : ''
  615. if (isStopDelivery) stopDelivered = true
  616. return fulfill(route, {
  617. items: deliveryId ? [{
  618. id: isUnknownRedelivery ? 'command-unknown-redelivery' : isStopDelivery ? 'command-stop' : 'command-same-id',
  619. commandId: isUnknownRedelivery ? 'command-unknown-redelivery' : isStopDelivery ? 'command-stop' : 'command-same-id',
  620. deliveryId,
  621. type: isStopDelivery ? 'STOP' : 'WAKE',
  622. payload: {
  623. detail: isUnknownRedelivery
  624. ? '无缓存重投不得执行业务动作'
  625. : isStopDelivery ? '结束受控终端会话' : '可靠性回归唤醒',
  626. },
  627. deliveryAttempt: isUnknownRedelivery ? 2 : isKnownDelivery ? knownDeliveryAttempts : 1,
  628. redelivery: isUnknownRedelivery || deliveryId === 'delivery-redelivered',
  629. boundAgentId: publishedAgent.id,
  630. boundAgentSlug: publishedAgent.slug,
  631. boundAgentDataVersion: publishedAgent.dataVersion,
  632. boundAgent: agentBinding,
  633. agent: agentBinding,
  634. }] : [],
  635. pollAfterMs: deliveryId ? 0 : 1_000,
  636. }, 200, { 'Cache-Control': 'no-store' })
  637. }
  638. if (pathname.endsWith('/reports')) {
  639. const payload = request.postDataJSON() as JsonRecord
  640. const deliveryId = String(payload.deliveryId || '')
  641. const status = String(payload.status || '')
  642. const errorCode = String(payload.errorCode || '')
  643. if (deliveryId === 'delivery-first' && status === 'SUCCEEDED' && firstDeliverySucceededFailures < 3) {
  644. firstDeliverySucceededFailures += 1
  645. reports.push({ deliveryId, status, httpStatus: 503, errorCode })
  646. return fulfill(route, {}, 503)
  647. }
  648. reports.push({ deliveryId, status, httpStatus: 200, errorCode })
  649. return fulfill(route, { idempotent: false, status }, 200, { 'Cache-Control': 'no-store' })
  650. }
  651. if (pathname.endsWith('/runtime/release')) {
  652. if (headers['x-runtime-instance-id'] !== leaderRuntimeInstanceId) {
  653. return fulfill(route, {}, 423, { 'Cache-Control': 'no-store' })
  654. }
  655. releaseResponses += 1
  656. leaderRuntimeInstanceId = ''
  657. return fulfill(route, { released: true, idempotent: false, releasedAt: new Date().toISOString() }, 200, { 'Cache-Control': 'no-store' })
  658. }
  659. return fulfill(route, {}, 404)
  660. })
  661. const hostPath = '/ape2e/controlled-remote-widget-reliability-host'
  662. const mismatchHostPath = '/ape2e/controlled-remote-widget-origin-mismatch-host'
  663. let includeControlledActivation = true
  664. const mismatchHostOrigin = (() => {
  665. const url = new URL(controlledAppOrigin)
  666. url.hostname = 'origin-mismatch.invalid'
  667. return url.origin
  668. })()
  669. await page.route(`**${hostPath}`, async (route) => {
  670. if (new URL(route.request().url()).pathname !== hostPath) return route.continue()
  671. const activationFragment = includeControlledActivation
  672. ? `#terminal-activation=${encodeURIComponent(activationCode)}`
  673. : ''
  674. await route.fulfill({
  675. status: 200,
  676. contentType: 'text/html; charset=utf-8',
  677. headers: { 'Cache-Control': 'no-store' },
  678. 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>`,
  679. })
  680. })
  681. await page.route(`**${mismatchHostPath}`, async (route) => {
  682. if (new URL(route.request().url()).pathname !== mismatchHostPath) return route.continue()
  683. await route.fulfill({
  684. status: 200,
  685. contentType: 'text/html; charset=utf-8',
  686. headers: { 'Cache-Control': 'no-store' },
  687. 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>`,
  688. })
  689. })
  690. const runtimeTransport = observeRuntimeTransport(page, 'RTC-RELIABILITY-01', controlledAppOrigin)
  691. const activationResponse = page.waitForResponse((response) => response.request().method() === 'POST'
  692. && new URL(response.url()).pathname === '/open/v2/terminals/activate'
  693. && response.status() === 200, { timeout: 20_000 })
  694. await page.goto(hostPath)
  695. await activationResponse
  696. includeControlledActivation = false
  697. expect(activationRequests).toBe(1)
  698. expect(activationRequestValid).toBe(true)
  699. const widget = page.frameLocator('#terminal-widget')
  700. await expect(widget.locator('.embed-widget')).not.toHaveClass(/is-expanded/)
  701. await expect.poll(() => reports.filter((item) => item.deliveryId === 'delivery-unknown-redelivery'
  702. && item.status === 'FAILED' && item.httpStatus === 200).length, {
  703. message: '没有本地终态缓存的重投必须直接失败收敛',
  704. timeout: 20_000,
  705. }).toBe(1)
  706. const unknownReports = reports.filter((item) => item.deliveryId === 'delivery-unknown-redelivery')
  707. expect(unknownReports.map((item) => item.status)).toEqual(['FAILED'])
  708. expect(unknownReports[0]?.errorCode).toBe('RUNTIME_OUTCOME_UNKNOWN')
  709. expect(sessionCreates, '未知结果重投不得执行 WAKE 业务动作').toBe(0)
  710. knownDeliveryReady = true
  711. await expect.poll(() => reports.filter((item) => item.deliveryId === 'delivery-redelivered'
  712. && item.status === 'SUCCEEDED' && item.httpStatus === 200).length, {
  713. timeout: 20_000,
  714. }).toBe(1)
  715. await expect(widget.locator('.embed-widget')).toHaveClass(/is-expanded/, { timeout: 20_000 })
  716. expect(sessionCreates, 'WAKE 页面业务动作必须只执行一次').toBe(1)
  717. expect(terminalConfigFailures, '受管配置首次临时失败必须被测试覆盖').toBe(1)
  718. expect(terminalConfigRequests, '受管终端配置首次 503 后必须自动重试成功').toBe(2)
  719. expect(terminalConfigHeadersValid, '终端配置请求必须携带设备头和页面 Origin').toBe(true)
  720. expect(terminalSessionHeadersValid, '终端会话请求必须携带设备头和页面 Origin').toBe(true)
  721. expect(legacyPublicConfigRequests, '带 terminal 参数时不得调用旧公开配置接口').toBe(0)
  722. expect(legacyPublicSessionRequests, '带 terminal 参数时不得调用旧公开会话接口').toBe(0)
  723. expect(firstDeliverySucceededFailures).toBe(3)
  724. expect(reports.filter((item) => item.deliveryId === 'delivery-first').map((item) => item.status)).toEqual([
  725. 'ACK', 'EXECUTING', 'SUCCEEDED', 'SUCCEEDED', 'SUCCEEDED',
  726. ])
  727. expect(reports.filter((item) => item.deliveryId === 'delivery-redelivered').map((item) => item.status)).toEqual([
  728. 'SUCCEEDED',
  729. ])
  730. expect(runtimeTransport.heartbeatSuccesses(), 'pull 前必须先有一次成功 heartbeat claim').toBeGreaterThan(0)
  731. expect(runtimeTransport.pullStartedAfterHeartbeat(), '首次 heartbeat 成功前不得启动 pull').toBe(true)
  732. expect(runtimeTransport.headersValid(), 'heartbeat/pull/report 必须携带同页运行实例头、三项设备头且无旧头').toBe(true)
  733. expect(runtimeTransport.runtimeInstanceOmittedFromUrls(), '运行实例标识不得进入 URL').toBe(true)
  734. expect(runtimeTransport.runtimeInstanceIds()).toHaveLength(1)
  735. expect(await runtimeInstancesAbsentFromBrowserState(page, runtimeTransport.runtimeInstanceIds()), '运行实例标识不得持久化到 URL/storage').toBe(true)
  736. const sessionsBeforeStandby = sessionCreates
  737. const chatsBeforeStandby = terminalChatRequests
  738. const heartbeatBeforeStandby = runtimeTransport.heartbeatRequests()
  739. const pullsBeforeStandby = runtimeTransport.pullRequests()
  740. await page.evaluate(({ slug }) => {
  741. const iframe = document.createElement('iframe')
  742. iframe.id = 'terminal-widget-standby'
  743. iframe.title = '远程数字人终端备用页'
  744. iframe.src = `/embed/${encodeURIComponent(slug)}?terminal=RTC-RELIABILITY-01`
  745. iframe.allow = 'microphone; autoplay'
  746. iframe.referrerPolicy = 'origin'
  747. iframe.style.cssText = 'width:420px;height:680px;border:0'
  748. document.body.appendChild(iframe)
  749. }, { slug: publishedAgent.slug })
  750. const standbyWidget = page.frameLocator('#terminal-widget-standby')
  751. await expect(standbyWidget.locator('.embed-widget')).toBeVisible({ timeout: 20_000 })
  752. await standbyWidget.locator('.embed-launcher').click()
  753. await expect(standbyWidget.locator('.embed-widget')).toHaveClass(/is-expanded/)
  754. await expect(standbyWidget.locator('.terminal-runtime-notice')).toContainText('另一页面运行', { timeout: 20_000 })
  755. await expect(standbyWidget.getByRole('textbox', { name: '维修问题' })).toBeDisabled()
  756. await expect(standbyWidget.getByRole('button', { name: '语音提问', exact: true })).toBeDisabled()
  757. await expect(standbyWidget.getByRole('button', { name: '发送问题', exact: true })).toBeDisabled()
  758. await expect(standbyWidget.getByText('如何完成设备点检?', { exact: true })).toHaveCount(0)
  759. await standbyWidget.locator('form.embed-input').evaluate((form) => {
  760. form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
  761. })
  762. await page.waitForTimeout(500)
  763. expect(sessionCreates, 'STANDBY 页面不得签发 scoped session').toBe(sessionsBeforeStandby)
  764. expect(terminalChatRequests, 'STANDBY 页面不得发送 scoped chat').toBe(chatsBeforeStandby)
  765. expect(runtimeTransport.heartbeatRequests(), '浏览器非 leader 不得竞争 heartbeat').toBe(heartbeatBeforeStandby)
  766. expect(
  767. [...new Set(runtimeTransport.endpointInstances().pull)],
  768. '浏览器非 leader 不得以第二个 runtime instance 启动 pull',
  769. ).toEqual([leaderRuntimeInstanceId])
  770. expect(runtimeTransport.pullRequests(), '主 leader 在校验期间应继续正常长轮询').toBeGreaterThanOrEqual(pullsBeforeStandby)
  771. standbyInteractionBlocked = true
  772. await page.locator('#terminal-widget-standby').evaluate((element) => element.remove())
  773. await widget.getByRole('textbox', { name: '维修问题' }).fill('验证受控终端会话请求头')
  774. await widget.getByRole('button', { name: '发送问题', exact: true }).click()
  775. await expect(widget.getByText('受控终端会话设备授权头校验通过。', { exact: true })).toBeVisible()
  776. expect(terminalChatRequests, 'REMOTE_TERMINAL chat 必须实际发出一次').toBe(1)
  777. expect(terminalChatHeadersValid, 'REMOTE_TERMINAL chat 必须同时携带会话令牌、三项设备头且无旧头').toBe(true)
  778. stopDeliveryReady = true
  779. await expect.poll(() => terminalEndRequests, {
  780. message: 'STOP 必须结束真实受控会话并调用 end',
  781. timeout: 20_000,
  782. }).toBe(1)
  783. await expect.poll(() => reports.filter((item) => item.deliveryId === 'delivery-stop'
  784. && item.status === 'SUCCEEDED' && item.httpStatus === 200).length, {
  785. message: 'STOP 必须形成成功终态回执',
  786. timeout: 20_000,
  787. }).toBe(1)
  788. expect(terminalEndHeadersValid, 'REMOTE_TERMINAL end 必须同时携带会话令牌、三项设备头且无旧头').toBe(true)
  789. await expect(widget.locator('.terminal-runtime-notice.is-error')).toHaveCount(0)
  790. await captureScreenshot(page, testInfo, 'remote-v2-06-redelivery-cached-outcome-single-action')
  791. const configRequestsBeforeMismatch = terminalConfigRequests
  792. const heartbeatRequestsBeforeMismatch = runtimeTransport.heartbeatRequests()
  793. const activationRequestsBeforeMismatch = activationRequests
  794. await page.addInitScript(({ appOrigin, hostOrigin }) => {
  795. if (window.location.origin !== appOrigin || !document.referrer.startsWith(hostOrigin)) return
  796. window.localStorage.setItem('ai-person:remote-terminal-runtime:v2:RTC-RELIABILITY-01', JSON.stringify({
  797. terminalId: 'RTC-RELIABILITY-01',
  798. terminalCode: 'RTC-RELIABILITY-01',
  799. secret: 'controlled-device-secret',
  800. deviceId: 'controlled-device-id',
  801. pageOrigin: appOrigin,
  802. }))
  803. }, { appOrigin: controlledAppOrigin, hostOrigin: mismatchHostOrigin })
  804. await page.goto(new URL(mismatchHostPath, mismatchHostOrigin).toString())
  805. const mismatchWidget = page.frameLocator('#terminal-widget-mismatch')
  806. await expect(mismatchWidget.locator('.embed-widget')).toBeVisible({ timeout: 20_000 })
  807. await expect(mismatchWidget.locator('.terminal-runtime-notice.is-error')).toContainText('嵌入来源与终端激活来源不一致', { timeout: 20_000 })
  808. expect(terminalConfigRequests, '非法宿主来源必须在读取 scoped config 前失败关闭').toBe(configRequestsBeforeMismatch)
  809. expect(runtimeTransport.heartbeatRequests(), '非法宿主来源不得参与 runtime lease').toBe(heartbeatRequestsBeforeMismatch)
  810. const mismatchFrame = page.frames().find((frame) => {
  811. try {
  812. const url = new URL(frame.url())
  813. return url.origin === controlledAppOrigin && url.pathname === `/embed/${publishedAgent.slug}`
  814. } catch {
  815. return false
  816. }
  817. })
  818. expect(mismatchFrame, '非法宿主页面仍应加载应用 iframe 以呈现拒绝原因').toBeTruthy()
  819. originMismatchCredentialPreserved = await mismatchFrame!.evaluate(() => (
  820. Boolean(window.localStorage.getItem('ai-person:remote-terminal-runtime:v2:RTC-RELIABILITY-01'))
  821. ))
  822. expect(originMismatchCredentialPreserved, '来源不匹配只拒绝本次连接,不得删除合法宿主激活的设备凭据').toBe(true)
  823. // 此处只验证 Origin fail-closed 与凭据保留;受控路由不模拟真实时间,
  824. // 手动推进到服务端租约已到期,正常卸载/release 由真实生命周期用例覆盖。
  825. leaderRuntimeInstanceId = ''
  826. const heartbeatAfterTrustedReturn = page.waitForResponse((response) => (
  827. response.request().method() === 'POST'
  828. && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat'
  829. && response.status() === 200
  830. ), { timeout: 20_000 })
  831. await page.goto(hostPath)
  832. await heartbeatAfterTrustedReturn
  833. await expect.poll(() => terminalConfigRequests, {
  834. message: '返回合法宿主后必须重新读取 scoped config',
  835. timeout: 20_000,
  836. }).toBeGreaterThan(configRequestsBeforeMismatch)
  837. const trustedWidget = page.frameLocator('#terminal-widget')
  838. await expect(trustedWidget.locator('.embed-widget')).toBeVisible()
  839. await expect(trustedWidget.locator('.terminal-runtime-notice.is-error')).toHaveCount(0)
  840. trustedHostReconnectSucceeded = true
  841. expect(activationRequests, '来源切换过程不得重复兑换一次性激活码').toBe(activationRequestsBeforeMismatch)
  842. const releasesBeforeLeavingHost = runtimeTransport.releaseRequests()
  843. ordinaryPublicPhase = true
  844. await page.goto(`/live/${publishedAgent.slug}`)
  845. await expect.poll(runtimeTransport.releaseRequests, {
  846. message: '离开受管 WIDGET 时必须 best-effort 释放服务端运行租约',
  847. timeout: 15_000,
  848. }).toBeGreaterThan(releasesBeforeLeavingHost)
  849. expect(runtimeTransport.releaseBodyEmpty(), 'release 必须是无 body 的 keepalive POST').toBe(true)
  850. expect(runtimeTransport.headersValid(), 'release 必须复用同页运行实例和三项设备头且无旧头').toBe(true)
  851. await expect(page.locator('.live-agent-page')).toBeVisible()
  852. await page.getByRole('textbox', { name: '维修问题' }).fill('验证普通公开会话请求头隔离')
  853. await page.getByRole('button', { name: '发送问题', exact: true }).click()
  854. await expect(page.getByText('普通公开会话未携带远程终端设备凭据。', { exact: true })).toBeVisible()
  855. expect(ordinaryConfigRequests, '普通运行页必须读取公开配置').toBe(1)
  856. expect(ordinarySessionRequests, '普通运行页必须创建公开会话').toBe(1)
  857. expect(ordinaryChatRequests, '普通运行页必须发送公开 chat').toBe(1)
  858. expect(ordinaryRequestsOmitDeviceHeaders, '无 terminal 参数的公开请求不得携带任何设备授权头').toBe(true)
  859. await attachJson(testInfo, 'remote-v2-redelivery-evidence', {
  860. commandId: 'command-same-id',
  861. deliveryIds: ['delivery-first', 'delivery-redelivered', 'delivery-stop'],
  862. actionExecutionCount: sessionCreates,
  863. terminalScopedAccess: {
  864. configRequests: terminalConfigRequests,
  865. sessionRequests: sessionCreates,
  866. configDeviceHeadersValid: terminalConfigHeadersValid,
  867. sessionDeviceHeadersValid: terminalSessionHeadersValid,
  868. legacyPublicConfigRequests,
  869. legacyPublicSessionRequests,
  870. },
  871. terminalSessionFollowups: {
  872. chatRequests: terminalChatRequests,
  873. endRequests: terminalEndRequests,
  874. chatDeviceHeadersValid: terminalChatHeadersValid,
  875. endDeviceHeadersValid: terminalEndHeadersValid,
  876. },
  877. ordinaryPublicIsolation: {
  878. configRequests: ordinaryConfigRequests,
  879. sessionRequests: ordinarySessionRequests,
  880. chatRequests: ordinaryChatRequests,
  881. deviceHeadersOmitted: ordinaryRequestsOmitDeviceHeaders,
  882. },
  883. firstFinalReportNetworkFailures: firstDeliverySucceededFailures,
  884. redeliveryReportSequence: reports.map((item) => ({ ...item })),
  885. unknownRedelivery: {
  886. failedWithoutBusinessAction: true,
  887. errorCode: 'RUNTIME_OUTCOME_UNKNOWN',
  888. businessActionCountBeforeKnownDelivery: 0,
  889. },
  890. standbyInteraction: {
  891. blocked: standbyInteractionBlocked,
  892. scopedSessionRequests: 0,
  893. scopedChatRequests: 0,
  894. runtimeTransportRequests: 0,
  895. },
  896. trustedOriginIsolation: {
  897. activationRequests,
  898. activationRequestValid,
  899. mismatchConnectionRejectedBeforeConfig: true,
  900. credentialPreserved: originMismatchCredentialPreserved,
  901. trustedHostReconnectSucceeded,
  902. },
  903. runtimeLeaseContract: {
  904. heartbeatRequests: runtimeTransport.heartbeatRequests(),
  905. pullRequests: runtimeTransport.pullRequests(),
  906. reportRequests: runtimeTransport.reportRequests(),
  907. releaseRequests: runtimeTransport.releaseRequests(),
  908. releaseResponses,
  909. firstHeartbeatBeforePull: runtimeTransport.pullStartedAfterHeartbeat(),
  910. samePageInstanceHeadersValid: runtimeTransport.headersValid(),
  911. instanceInUrlOrStorage: false,
  912. releaseBodyEmpty: runtimeTransport.releaseBodyEmpty(),
  913. },
  914. secretIncluded: false,
  915. })
  916. const unexpectedConsoleErrors = failures.consoleErrors.filter((message) => !/503|Failed to load resource/i.test(message))
  917. expect(unexpectedConsoleErrors, '除受控 503 外,重投可靠性回归不应产生 console.error').toEqual([])
  918. expect(failures.pageErrors, '重投可靠性回归不应产生 pageerror').toEqual([])
  919. })
  920. })