選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

1890 行
98 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import {
  4. request as playwrightRequest,
  5. type APIRequestContext,
  6. type Browser,
  7. type BrowserContext,
  8. type Page,
  9. type Response,
  10. type Route,
  11. } from '@playwright/test'
  12. import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
  13. import {
  14. authHeaders,
  15. envelopeData,
  16. fillFormItem,
  17. formItem,
  18. loginAsAdmin,
  19. tableRowByText,
  20. visibleDialog,
  21. } from './helpers'
  22. import {
  23. observeRuntimeTransport,
  24. runtimeInstancesAbsentFromBrowserState,
  25. type RuntimeTransportMonitor,
  26. } from './remote-terminal-runtime-contract'
  27. type JsonRecord = Record<string, unknown>
  28. type Headers = Record<string, string>
  29. interface ResponseLike {
  30. status(): number
  31. json(): Promise<unknown>
  32. headers(): Record<string, string>
  33. }
  34. interface TerminalRef {
  35. id: string
  36. code: string
  37. name: string
  38. dataVersion: number
  39. }
  40. interface PublishedAgent {
  41. id: string
  42. dataVersion: number
  43. name: string
  44. slug: string
  45. accessMode: string
  46. voiceCapabilityId: string
  47. }
  48. interface LifecycleEvidence {
  49. phase: string
  50. result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED'
  51. terminalId?: string
  52. commandId?: string
  53. status?: string
  54. detail?: string
  55. }
  56. interface RuntimeHandle {
  57. context: BrowserContext
  58. page: Page
  59. publicPath: string
  60. transport: RuntimeTransportMonitor
  61. activationRequests: () => number
  62. heartbeatRequests: () => number
  63. terminalConfigRequests: () => number
  64. terminalSessionRequests: () => number
  65. legacyPublicConfigRequests: () => number
  66. legacyPublicSessionRequests: () => number
  67. terminalConfigHeadersValid: () => boolean
  68. terminalSessionHeadersValid: () => boolean
  69. terminalFollowupRequests: () => Record<'chat' | 'asr' | 'feedback' | 'end', number>
  70. terminalFollowupHeadersValid: () => boolean
  71. }
  72. interface TerminalScopedAccessEvidence {
  73. configRequests: number
  74. sessionRequests: number
  75. legacyPublicConfigRequests: number
  76. legacyPublicSessionRequests: number
  77. configDeviceHeadersValid: boolean
  78. sessionDeviceHeadersValid: boolean
  79. followupRequests: Record<'chat' | 'asr' | 'feedback' | 'end', number>
  80. followupDeviceHeadersValid: boolean
  81. }
  82. interface DirectRuntimeResponse {
  83. status: number
  84. code: number
  85. data: JsonRecord
  86. }
  87. interface DirectSessionFenceEvidence {
  88. ownerSessionCreated: boolean
  89. ownerSessionNoStore: boolean
  90. websocketOpened: boolean
  91. ownerReleaseStatus: number
  92. contenderClaimStatus: number
  93. oldOwnerHttpStatus: number
  94. oldOwnerHttpCode: number
  95. oldOwnerWebSocketCloseCode: number
  96. contenderSessionCreated: boolean
  97. contenderSessionEnded: boolean
  98. staleOwnerReleaseStatus: number
  99. staleOwnerReleaseCode: number
  100. }
  101. const baseURL = (process.env.BASE_URL || 'http://127.0.0.1:8003').replace(/\/$/, '')
  102. const artifactRoot = path.resolve('artifacts', 'runs', runId)
  103. const terminalCapabilities = ['WAKE', 'PAUSE', 'RESUME', 'STOP', 'VOLUME', 'RESTART']
  104. const immutableCommandIds: string[] = []
  105. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {}
  106. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
  107. const textValue = (value: unknown) => value == null ? '' : String(value)
  108. const stringList = (value: unknown) => Array.isArray(value) ? value.map(textValue).filter(Boolean) : []
  109. const numberValue = (value: unknown) => {
  110. const parsed = Number(value)
  111. return Number.isFinite(parsed) ? parsed : 0
  112. }
  113. function pageItems(value: unknown) {
  114. const source = asRecord(value)
  115. return asRecords(source.items ?? source.records ?? source.rows)
  116. }
  117. function responseStatus(response: ResponseLike, expected: number | number[], label: string) {
  118. const statuses = Array.isArray(expected) ? expected : [expected]
  119. expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
  120. }
  121. async function responseData<T = JsonRecord>(response: ResponseLike, expected: number | number[], label: string): Promise<T> {
  122. responseStatus(response, expected, label)
  123. const body = await response.json().catch(() => {
  124. throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`)
  125. })
  126. return envelopeData<T>(body)
  127. }
  128. function commandStatus(command: JsonRecord) {
  129. return textValue(command.statusCode ?? command.status).toUpperCase()
  130. }
  131. function terminalConnection(terminal: JsonRecord) {
  132. return textValue(terminal.connectionStatus ?? terminal.onlineStatus).toUpperCase()
  133. }
  134. function terminalLifecycle(terminal: JsonRecord) {
  135. return textValue(terminal.lifecycleStatus ?? terminal.status).toUpperCase()
  136. }
  137. function terminalSession(terminal: JsonRecord) {
  138. const reported = asRecord(terminal.reportedState)
  139. return textValue(terminal.sessionStatus ?? reported.sessionStatus ?? asRecord(terminal.session).status).toUpperCase()
  140. }
  141. function terminalPlayback(terminal: JsonRecord) {
  142. const reported = asRecord(terminal.reportedState)
  143. return textValue(terminal.playbackStatus ?? reported.playbackStatus).toUpperCase()
  144. }
  145. function terminalActualVolume(terminal: JsonRecord) {
  146. const reported = asRecord(terminal.reportedState)
  147. return numberValue(terminal.actualVolume ?? reported.volume ?? reported.actualVolume)
  148. }
  149. function activationParts(value: unknown) {
  150. const source = asRecord(value)
  151. const terminal = asRecord(source.terminal ?? source)
  152. const activation = asRecord(source.activation ?? source.credential ?? source)
  153. return {
  154. terminal,
  155. activationCode: textValue(activation.activationCode ?? activation.code),
  156. expiresAt: textValue(activation.expiresAt),
  157. shownOnce: Boolean(activation.shownOnce),
  158. }
  159. }
  160. function recursiveKeys(value: unknown, output: string[] = []): string[] {
  161. if (!value || typeof value !== 'object') return output
  162. if (Array.isArray(value)) {
  163. for (const item of value) recursiveKeys(item, output)
  164. return output
  165. }
  166. for (const [key, item] of Object.entries(value as JsonRecord)) {
  167. output.push(key)
  168. recursiveKeys(item, output)
  169. }
  170. return output
  171. }
  172. function terminalRuntimeVersion(value: JsonRecord) {
  173. const runtime = asRecord(value.runtime)
  174. return textValue(value.runtimeVersion ?? runtime.version ?? runtime.runtimeVersion)
  175. }
  176. async function getTerminal(api: APIRequestContext, headers: Headers, id: string) {
  177. const response = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(id)}`, { headers })
  178. return responseData<JsonRecord>(response, 200, '读取远程终端详情')
  179. }
  180. async function waitTerminal(
  181. api: APIRequestContext,
  182. headers: Headers,
  183. id: string,
  184. predicate: (terminal: JsonRecord) => boolean,
  185. label: string,
  186. timeout = 70_000,
  187. ) {
  188. let latest: JsonRecord = {}
  189. await expect.poll(async () => {
  190. latest = await getTerminal(api, headers, id)
  191. return predicate(latest)
  192. }, { message: label, timeout, intervals: [500, 1_000, 2_000, 5_000] }).toBe(true)
  193. return latest
  194. }
  195. async function getCommand(api: APIRequestContext, headers: Headers, id: string) {
  196. const response = await api.get(`/api/v1/remote-commands/${encodeURIComponent(id)}`, { headers })
  197. return responseData<JsonRecord>(response, 200, '读取远控命令详情')
  198. }
  199. async function waitCommand(
  200. api: APIRequestContext,
  201. headers: Headers,
  202. id: string,
  203. expected: string | string[],
  204. timeout = 120_000,
  205. ) {
  206. const statuses = (Array.isArray(expected) ? expected : [expected]).map((item) => item.toUpperCase())
  207. let latest: JsonRecord = {}
  208. await expect.poll(async () => {
  209. latest = await getCommand(api, headers, id)
  210. return statuses.includes(commandStatus(latest))
  211. }, {
  212. message: `命令 ${id} 应收敛到 ${statuses.join(' / ')}`,
  213. timeout,
  214. intervals: [500, 1_000, 2_000, 5_000],
  215. }).toBe(true)
  216. return latest
  217. }
  218. async function findPublishedAgent(api: APIRequestContext, headers: Headers): Promise<PublishedAgent> {
  219. const response = await api.get('/api/v1/realtime-agents', {
  220. headers,
  221. params: { page: '1', pageSize: '100', status: 'published' },
  222. })
  223. const data = await responseData<JsonRecord>(response, 200, '查询已发布智能体')
  224. const currentTime = Date.now()
  225. const found = pageItems(data).find((item) => {
  226. const status = textValue(item.status).toLowerCase()
  227. const expiresAt = textValue(item.expiresAt)
  228. const expiry = expiresAt ? new Date(expiresAt).getTime() : Number.POSITIVE_INFINITY
  229. const interactionModes = stringList(item.interactionModes).map((mode) => mode.toLowerCase())
  230. return status === 'published' && textValue(item.id) && textValue(item.slug)
  231. && interactionModes.includes('text') && interactionModes.includes('voice')
  232. && textValue(item.ttsModel) && textValue(item.voiceCapabilityId)
  233. && (!Number.isFinite(expiry) || expiry > currentTime)
  234. })
  235. if (!found) throw new Error('真实环境没有可绑定且未过期的已发布智能体')
  236. return {
  237. id: textValue(found.id),
  238. dataVersion: numberValue(found.dataVersion),
  239. name: textValue(found.name),
  240. slug: textValue(found.slug),
  241. accessMode: textValue(found.accessMode),
  242. voiceCapabilityId: textValue(found.voiceCapabilityId),
  243. }
  244. }
  245. async function createRemoteControlSpeech(api: APIRequestContext, headers: Headers, voiceCapabilityId: string) {
  246. const text = Array.from(
  247. { length: 14 },
  248. () => '远程控制媒体链路验收正在进行,请保持设备在线,并依次核对暂停、继续和结束播报操作。',
  249. ).join('')
  250. const response = await api.post('/api/v1/tts', {
  251. headers,
  252. data: {
  253. text,
  254. voiceCapabilityId,
  255. speed: 0.7,
  256. },
  257. timeout: 180_000,
  258. })
  259. const speech = await responseData<JsonRecord>(response, 200, '生成远控媒体控制验收音频')
  260. const audioUrl = textValue(speech.audioUrl)
  261. if (!audioUrl) throw new Error('真实 TTS 未返回可播放音频地址')
  262. expect(textValue(speech.mimeType)).toBe('audio/wav')
  263. expect(numberValue(speech.duration)).toBeGreaterThanOrEqual(45)
  264. expect(textValue(speech.voiceCapabilityId)).toBe(voiceCapabilityId)
  265. const media = await api.get(audioUrl, { timeout: 30_000 })
  266. responseStatus(media, 200, '读取真实 TTS 音频')
  267. expect(media.headers()['content-type'] || '').toContain('audio')
  268. const audio = await media.body()
  269. expect(audio.byteLength).toBeGreaterThan(44)
  270. expect(audio.subarray(0, 4).toString('ascii')).toBe('RIFF')
  271. return {
  272. id: textValue(speech.id),
  273. audioUrl,
  274. duration: numberValue(speech.duration),
  275. voiceCapabilityId: textValue(speech.voiceCapabilityId),
  276. mediaBytes: audio.byteLength,
  277. }
  278. }
  279. async function chooseAgent(page: Page, dialog: ReturnType<Page['locator']>, agent: PublishedAgent) {
  280. await formItem(dialog, '绑定智能体').locator('.el-select').click()
  281. const dropdown = page.locator('.el-select-dropdown:visible').last()
  282. await expect(dropdown).toBeVisible()
  283. await dropdown.locator('.el-select-dropdown__item').filter({ hasText: agent.slug }).click()
  284. }
  285. async function setSliderValue(slider: ReturnType<Page['locator']>, target: number) {
  286. const control = slider.getByRole('slider')
  287. await expect(control).toBeVisible()
  288. await control.focus()
  289. let current = Number(await control.getAttribute('aria-valuenow'))
  290. if (!Number.isFinite(current)) throw new Error('音量滑块缺少 aria-valuenow')
  291. for (let attempt = 0; current !== target && attempt <= 100; attempt += 1) {
  292. await control.press(target > current ? 'ArrowRight' : 'ArrowLeft')
  293. current = Number(await control.getAttribute('aria-valuenow'))
  294. }
  295. await expect(control).toHaveAttribute('aria-valuenow', String(target))
  296. }
  297. async function scrubActivationDialog(page: Page) {
  298. const dialog = page.locator('.activation-dialog:visible')
  299. await expect(dialog).toBeVisible()
  300. const code = dialog.locator('.activation-link-row code')
  301. if (await code.count()) {
  302. await code.evaluate((element) => {
  303. element.textContent = '[一次性激活链接已由测试进程安全接收并遮蔽]'
  304. element.setAttribute('data-secret-scrubbed', 'true')
  305. })
  306. }
  307. return dialog
  308. }
  309. async function injectActivation(page: Page, code: string) {
  310. const activationResponse = page.waitForResponse((response) => response.request().method() === 'POST'
  311. && new URL(response.url()).pathname.endsWith('/open/v2/terminals/activate'), { timeout: 40_000 })
  312. await page.evaluate((secret) => {
  313. const fragment = new URLSearchParams({ 'terminal-activation': secret }).toString()
  314. window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}#${fragment}`)
  315. }, code)
  316. await page.reload({ waitUntil: 'domcontentloaded' })
  317. const response = await activationResponse
  318. await expect.poll(() => new URL(page.url()).hash, {
  319. message: '运行页必须在兑换前清除 URL fragment 中的一次性激活码',
  320. timeout: 12_000,
  321. }).toBe('')
  322. return response
  323. }
  324. /**
  325. * 在已激活浏览器上下文内直接验证服务端租约 fencing。设备密钥只在浏览器
  326. * evaluate 内从既有凭据读取并用于 fetch,不返回 Node 测试进程或附件。
  327. */
  328. async function directRuntimeRequest(
  329. page: Page,
  330. terminalCode: string,
  331. runtimeInstanceId: string,
  332. pathname: string,
  333. data?: JsonRecord,
  334. ): Promise<DirectRuntimeResponse> {
  335. return page.evaluate(async ({ code, instanceId, path, payload }) => {
  336. const key = `ai-person:remote-terminal-runtime:v2:${encodeURIComponent(code.trim().toUpperCase())}`
  337. const stored = JSON.parse(window.localStorage.getItem(key) || 'null') as {
  338. terminalId?: string
  339. secret?: string
  340. pageOrigin?: string
  341. } | null
  342. if (!stored?.terminalId || !stored.secret || !stored.pageOrigin) {
  343. throw new Error('浏览器内不存在可用的远程终端设备凭据')
  344. }
  345. const headers: Record<string, string> = {
  346. Accept: 'application/json',
  347. 'X-Terminal-Id': stored.terminalId,
  348. 'X-Device-Secret': stored.secret,
  349. 'X-Terminal-Page-Origin': stored.pageOrigin,
  350. 'X-Runtime-Instance-Id': instanceId,
  351. }
  352. if (payload !== undefined) headers['Content-Type'] = 'application/json'
  353. const response = await window.fetch(path, {
  354. method: 'POST',
  355. headers,
  356. body: payload === undefined ? undefined : JSON.stringify(payload),
  357. cache: 'no-store',
  358. })
  359. const body = await response.json().catch(() => ({})) as Record<string, unknown>
  360. return {
  361. status: response.status,
  362. code: Number(body.code || 0),
  363. data: body.data && typeof body.data === 'object' ? body.data as Record<string, unknown> : {},
  364. }
  365. }, { code: terminalCode, instanceId: runtimeInstanceId, path: pathname, payload: data })
  366. }
  367. /**
  368. * 设备密钥、会话令牌与 websocket ticket 始终留在浏览器 evaluate 闭包内;Node 侧只接收
  369. * HTTP 状态、错误码和布尔后验,避免任何授权材料进入 Playwright 附件或失败日志。
  370. */
  371. async function directRuntimeSessionFence(
  372. page: Page,
  373. terminalCode: string,
  374. runtimeA: string,
  375. runtimeB: string,
  376. agentSlug: string,
  377. heartbeatState: JsonRecord,
  378. ): Promise<DirectSessionFenceEvidence> {
  379. return page.evaluate(async ({ code, ownerInstance, contenderInstance, slug, state }) => {
  380. const key = `ai-person:remote-terminal-runtime:v2:${encodeURIComponent(code.trim().toUpperCase())}`
  381. const stored = JSON.parse(window.localStorage.getItem(key) || 'null') as {
  382. terminalId?: string
  383. secret?: string
  384. pageOrigin?: string
  385. } | null
  386. if (!stored?.terminalId || !stored.secret || !stored.pageOrigin) {
  387. throw new Error('浏览器内不存在可用于会话租约验证的设备凭据')
  388. }
  389. const headersFor = (instanceId: string, token = '') => {
  390. const headers: Record<string, string> = {
  391. Accept: 'application/json',
  392. 'Content-Type': 'application/json',
  393. 'X-Terminal-Id': stored.terminalId!,
  394. 'X-Device-Secret': stored.secret!,
  395. 'X-Terminal-Page-Origin': stored.pageOrigin!,
  396. 'X-Runtime-Instance-Id': instanceId,
  397. }
  398. if (token) headers.Authorization = `Bearer ${token}`
  399. return headers
  400. }
  401. const post = async (pathname: string, instanceId: string, payload: Record<string, unknown> | undefined, token = '') => {
  402. const response = await window.fetch(pathname, {
  403. method: 'POST',
  404. headers: headersFor(instanceId, token),
  405. body: payload === undefined ? undefined : JSON.stringify(payload),
  406. cache: 'no-store',
  407. })
  408. const body = await response.json().catch(() => ({})) as Record<string, unknown>
  409. const data = body.data && typeof body.data === 'object'
  410. ? body.data as Record<string, unknown>
  411. : {}
  412. return {
  413. status: response.status,
  414. code: Number(body.code || 0),
  415. data,
  416. noStore: (response.headers.get('cache-control') || '').includes('no-store'),
  417. }
  418. }
  419. const sessionPath = `/open/v2/terminals/agents/${encodeURIComponent(slug)}/sessions`
  420. const ownerSession = await post(sessionPath, ownerInstance, {})
  421. const ownerToken = String(ownerSession.data.sessionToken || '')
  422. const rawSocketUrl = String(ownerSession.data.websocketUrl || '')
  423. if (ownerSession.status !== 201 || !ownerToken || !rawSocketUrl) {
  424. throw new Error(`owner scoped session 创建失败:HTTP ${ownerSession.status}`)
  425. }
  426. const socketUrl = new URL(rawSocketUrl, window.location.href)
  427. if (socketUrl.protocol === 'http:') socketUrl.protocol = 'ws:'
  428. if (socketUrl.protocol === 'https:') socketUrl.protocol = 'wss:'
  429. const socket = new WebSocket(socketUrl.toString())
  430. await new Promise<void>((resolve, reject) => {
  431. const timer = window.setTimeout(() => reject(new Error('owner websocket 建连超时')), 15_000)
  432. socket.addEventListener('open', () => {
  433. window.clearTimeout(timer)
  434. resolve()
  435. }, { once: true })
  436. socket.addEventListener('error', () => {
  437. window.clearTimeout(timer)
  438. reject(new Error('owner websocket 建连失败'))
  439. }, { once: true })
  440. })
  441. const socketClosed = new Promise<number>((resolve, reject) => {
  442. const timer = window.setTimeout(() => reject(new Error('旧 owner websocket 未在接管后关闭')), 15_000)
  443. socket.addEventListener('close', (event) => {
  444. window.clearTimeout(timer)
  445. resolve(event.code)
  446. }, { once: true })
  447. })
  448. const ownerRelease = await post('/open/v2/terminals/runtime/release', ownerInstance, undefined)
  449. const contenderClaim = await post('/open/v2/terminals/heartbeat', contenderInstance, state)
  450. const oldOwnerChat = await post(
  451. `/open/v1/realtime/${encodeURIComponent(slug)}/chat`,
  452. ownerInstance,
  453. { message: '旧 owner 会话必须被服务端拒绝' },
  454. ownerToken,
  455. )
  456. const oldOwnerWebSocketCloseCode = await socketClosed
  457. const contenderSession = await post(sessionPath, contenderInstance, {})
  458. const contenderToken = String(contenderSession.data.sessionToken || '')
  459. if (contenderSession.status !== 201 || !contenderToken) {
  460. throw new Error(`contender scoped session 创建失败:HTTP ${contenderSession.status}`)
  461. }
  462. const contenderEnd = await post(
  463. `/open/v1/realtime/${encodeURIComponent(slug)}/end`,
  464. contenderInstance,
  465. {},
  466. contenderToken,
  467. )
  468. const staleOwnerRelease = await post('/open/v2/terminals/runtime/release', ownerInstance, undefined)
  469. if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close()
  470. return {
  471. ownerSessionCreated: ownerSession.status === 201,
  472. ownerSessionNoStore: ownerSession.noStore,
  473. websocketOpened: true,
  474. ownerReleaseStatus: ownerRelease.status,
  475. contenderClaimStatus: contenderClaim.status,
  476. oldOwnerHttpStatus: oldOwnerChat.status,
  477. oldOwnerHttpCode: oldOwnerChat.code,
  478. oldOwnerWebSocketCloseCode,
  479. contenderSessionCreated: contenderSession.status === 201,
  480. contenderSessionEnded: contenderEnd.status === 200,
  481. staleOwnerReleaseStatus: staleOwnerRelease.status,
  482. staleOwnerReleaseCode: staleOwnerRelease.code,
  483. }
  484. }, {
  485. code: terminalCode,
  486. ownerInstance: runtimeA,
  487. contenderInstance: runtimeB,
  488. slug: agentSlug,
  489. state: heartbeatState,
  490. })
  491. }
  492. function directRuntimeState(publicPath: string, actualVolume = 43): JsonRecord {
  493. return {
  494. runtimeVersion: 'ape2e/remote-v2-runtime-lease',
  495. displayMode: 'STANDALONE',
  496. capabilities: terminalCapabilities,
  497. actualVolume,
  498. allowInterrupt: true,
  499. visibility: 'VISIBLE',
  500. sessionStatus: 'IDLE',
  501. playbackStatus: 'IDLE',
  502. pageUrl: `${baseURL}${publicPath}`,
  503. pageOrigin: new URL(baseURL).origin,
  504. platform: 'ape2e',
  505. }
  506. }
  507. async function activateRuntime(
  508. browser: Browser,
  509. agent: PublishedAgent,
  510. terminal: TerminalRef,
  511. code: string,
  512. ): Promise<RuntimeHandle> {
  513. const context = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
  514. const page = await context.newPage()
  515. const publicPath = `/live/${encodeURIComponent(agent.slug)}?terminal=${encodeURIComponent(terminal.code)}`
  516. const terminalConfigPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/config`
  517. const terminalSessionPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/sessions`
  518. const legacyPublicConfigPath = `/open/v1/realtime/${encodeURIComponent(agent.slug)}`
  519. const legacyPublicSessionPath = `${legacyPublicConfigPath}/sessions`
  520. const pageOrigin = new URL(baseURL).origin
  521. const transport = observeRuntimeTransport(page, terminal.code, pageOrigin)
  522. let activationCount = 0
  523. let terminalConfigCount = 0
  524. let terminalSessionCount = 0
  525. let legacyPublicConfigCount = 0
  526. let legacyPublicSessionCount = 0
  527. let terminalConfigHeadersAreValid = true
  528. let terminalSessionHeadersAreValid = true
  529. const terminalFollowupCounts = { chat: 0, asr: 0, feedback: 0, end: 0 }
  530. let terminalFollowupHeadersAreValid = true
  531. page.on('request', (request) => {
  532. const pathname = new URL(request.url()).pathname
  533. const method = request.method()
  534. if (pathname.endsWith('/open/v2/terminals/activate')) activationCount += 1
  535. if (pathname === terminalConfigPath && method === 'GET') {
  536. terminalConfigCount += 1
  537. const headers = request.headers()
  538. terminalConfigHeadersAreValid = terminalConfigHeadersAreValid
  539. && headers['x-terminal-id'] === terminal.code
  540. && Boolean(headers['x-device-secret'])
  541. && headers['x-terminal-page-origin'] === pageOrigin
  542. && !headers['x-runtime-instance-id']
  543. && !headers['x-terminal-secret']
  544. }
  545. const followupMatch = pathname.match(new RegExp(`^${legacyPublicConfigPath}/(chat|asr|feedback|end)$`))
  546. if (followupMatch && method === 'POST') {
  547. const endpoint = followupMatch[1] as keyof typeof terminalFollowupCounts
  548. terminalFollowupCounts[endpoint] += 1
  549. const headers = request.headers()
  550. terminalFollowupHeadersAreValid = terminalFollowupHeadersAreValid
  551. && headers['x-terminal-id'] === terminal.code
  552. && Boolean(headers['x-device-secret'])
  553. && headers['x-terminal-page-origin'] === pageOrigin
  554. && headers['x-runtime-instance-id'] === transport.lastRuntimeInstanceId()
  555. && /^Bearer\s+\S+$/i.test(headers.authorization || '')
  556. && !headers['x-terminal-secret']
  557. }
  558. if (pathname === terminalSessionPath && method === 'POST') {
  559. terminalSessionCount += 1
  560. const headers = request.headers()
  561. terminalSessionHeadersAreValid = terminalSessionHeadersAreValid
  562. && headers['x-terminal-id'] === terminal.code
  563. && Boolean(headers['x-device-secret'])
  564. && headers['x-terminal-page-origin'] === pageOrigin
  565. && headers['x-runtime-instance-id'] === transport.lastRuntimeInstanceId()
  566. && !headers['x-terminal-secret']
  567. }
  568. if (pathname === legacyPublicConfigPath && method === 'GET') legacyPublicConfigCount += 1
  569. if (pathname === legacyPublicSessionPath && method === 'POST') legacyPublicSessionCount += 1
  570. })
  571. await page.goto(publicPath)
  572. await expect(page.locator('.live-agent-page')).toBeVisible()
  573. const activated = await injectActivation(page, code)
  574. responseStatus(activated, 200, '公开运行页兑换一次性激活码')
  575. expect(activated.headers()['cache-control'] || '').toContain('no-store')
  576. await expect(page.locator('.terminal-runtime-notice')).toContainText('终端激活成功,已开始连接远程控制服务')
  577. await expect.poll(transport.heartbeatSuccesses, { timeout: 25_000 }).toBeGreaterThan(0)
  578. return {
  579. context,
  580. page,
  581. publicPath,
  582. transport,
  583. activationRequests: () => activationCount,
  584. heartbeatRequests: transport.heartbeatRequests,
  585. terminalConfigRequests: () => terminalConfigCount,
  586. terminalSessionRequests: () => terminalSessionCount,
  587. legacyPublicConfigRequests: () => legacyPublicConfigCount,
  588. legacyPublicSessionRequests: () => legacyPublicSessionCount,
  589. terminalConfigHeadersValid: () => terminalConfigHeadersAreValid,
  590. terminalSessionHeadersValid: () => terminalSessionHeadersAreValid,
  591. terminalFollowupRequests: () => ({ ...terminalFollowupCounts }),
  592. terminalFollowupHeadersValid: () => terminalFollowupHeadersAreValid,
  593. }
  594. }
  595. function terminalScopedAccess(runtime: RuntimeHandle): TerminalScopedAccessEvidence {
  596. return {
  597. configRequests: runtime.terminalConfigRequests(),
  598. sessionRequests: runtime.terminalSessionRequests(),
  599. legacyPublicConfigRequests: runtime.legacyPublicConfigRequests(),
  600. legacyPublicSessionRequests: runtime.legacyPublicSessionRequests(),
  601. configDeviceHeadersValid: runtime.terminalConfigHeadersValid(),
  602. sessionDeviceHeadersValid: runtime.terminalSessionHeadersValid(),
  603. followupRequests: runtime.terminalFollowupRequests(),
  604. followupDeviceHeadersValid: runtime.terminalFollowupHeadersValid(),
  605. }
  606. }
  607. async function verifyConsumedActivation(
  608. browser: Browser,
  609. agent: PublishedAgent,
  610. terminal: TerminalRef,
  611. consumedCode: string,
  612. ) {
  613. const context = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
  614. const page = await context.newPage()
  615. try {
  616. const publicPath = `/live/${encodeURIComponent(agent.slug)}?terminal=${encodeURIComponent(terminal.code)}`
  617. await page.goto(publicPath)
  618. await expect(page.locator('.live-agent-page')).toBeVisible()
  619. const replay = await injectActivation(page, consumedCode)
  620. responseStatus(replay, [400, 401, 403, 409, 410, 422], '已消费激活码重放必须被拒绝')
  621. await expect(page.locator('.terminal-runtime-notice.is-error')).toBeVisible()
  622. } finally {
  623. await context.close()
  624. }
  625. }
  626. async function searchTerminal(page: Page, name: string) {
  627. await page.goto('/agents/remote-control')
  628. await expect(page.getByRole('heading', { name: '远程控制', exact: true })).toBeVisible()
  629. const keyword = page.getByRole('textbox', { name: '搜索终端' })
  630. await keyword.fill(name)
  631. await page.getByRole('button', { name: '搜索', exact: true }).click()
  632. const row = tableRowByText(page, name)
  633. await expect(row).toBeVisible()
  634. return row
  635. }
  636. async function openTerminalDrawer(page: Page, name: string) {
  637. const existing = page.locator('.remote-detail-drawer:visible')
  638. if (await existing.count()) {
  639. await existing.getByRole('button', { name: '关闭', exact: true }).click()
  640. await expect(existing).toBeHidden()
  641. }
  642. const row = await searchTerminal(page, name)
  643. await row.getByRole('button', { name: '详情', exact: true }).click()
  644. const drawer = page.locator('.remote-detail-drawer:visible')
  645. await expect(drawer).toBeVisible()
  646. return drawer
  647. }
  648. async function parseCommandResponse(response: ResponseLike, label: string) {
  649. const command = await responseData<JsonRecord>(response, [200, 202], label)
  650. const id = textValue(command.id ?? command.commandId)
  651. if (!id) throw new Error(`${label}未返回命令编号`)
  652. if (!immutableCommandIds.includes(id)) immutableCommandIds.push(id)
  653. return { id, command }
  654. }
  655. async function sendUiCommand(
  656. page: Page,
  657. drawer: ReturnType<Page['locator']>,
  658. buttonName: string,
  659. expectedType: string,
  660. confirmName?: string,
  661. ) {
  662. const responsePromise = page.waitForResponse((response) => {
  663. if (response.request().method() !== 'POST') return false
  664. if (!/\/api\/v1\/remote-terminals\/[^/]+\/commands$/.test(new URL(response.url()).pathname)) return false
  665. const payload = asRecord(response.request().postDataJSON())
  666. return textValue(payload.type).toUpperCase() === expectedType.toUpperCase()
  667. }, { timeout: 20_000 })
  668. await drawer.getByRole('button', { name: buttonName, exact: true }).click()
  669. if (confirmName) {
  670. const box = page.locator('.el-message-box:visible')
  671. await expect(box).toBeVisible()
  672. await box.getByRole('button', { name: confirmName, exact: true }).click()
  673. }
  674. return parseCommandResponse(await responsePromise, `页面下发${buttonName}`)
  675. }
  676. async function postCommand(
  677. api: APIRequestContext,
  678. headers: Headers,
  679. terminalId: string,
  680. payload: JsonRecord,
  681. idempotencyKey: string,
  682. ) {
  683. return api.post(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/commands`, {
  684. headers: { ...headers, 'Idempotency-Key': idempotencyKey },
  685. data: payload,
  686. })
  687. }
  688. function safeTextFiles(directory: string) {
  689. if (!fs.existsSync(directory)) return []
  690. const files: string[] = []
  691. const visit = (current: string) => {
  692. for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
  693. const filename = path.join(current, entry.name)
  694. if (entry.isDirectory()) visit(filename)
  695. else if (/\.(?:json|txt|md|html|xml|log)$/i.test(entry.name)) files.push(filename)
  696. }
  697. }
  698. visit(directory)
  699. return files
  700. }
  701. function assertArtifactsExclude(values: string[]) {
  702. const secrets = values.filter(Boolean)
  703. if (!secrets.length) return
  704. for (const filename of safeTextFiles(artifactRoot)) {
  705. const content = fs.readFileSync(filename, 'utf8')
  706. for (const secret of secrets) {
  707. if (content.includes(secret)) throw new Error(`测试附件疑似包含一次性凭据:${path.relative(artifactRoot, filename)}`)
  708. }
  709. }
  710. }
  711. async function cleanupTerminal(
  712. api: APIRequestContext,
  713. headers: Headers,
  714. terminal: TerminalRef | null,
  715. evidence: LifecycleEvidence[],
  716. cleanupErrors: string[],
  717. ) {
  718. if (!terminal) return
  719. try {
  720. const detailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers })
  721. if (detailResponse.status() === 404) return
  722. const current = await responseData<JsonRecord>(detailResponse, 200, '失败兜底读取终端')
  723. const commandListResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}/commands`, {
  724. headers,
  725. params: { page: '1', pageSize: '100' },
  726. })
  727. if (commandListResponse.status() === 200) {
  728. const commandList = await responseData<JsonRecord>(commandListResponse, 200, '失败兜底读取活动命令')
  729. for (const command of pageItems(commandList).filter((item) => commandStatus(item) === 'QUEUED')) {
  730. await api.post(`/api/v1/remote-commands/${encodeURIComponent(textValue(command.id))}/cancel`, {
  731. headers,
  732. data: { dataVersion: numberValue(command.dataVersion) },
  733. }).catch(() => undefined)
  734. }
  735. }
  736. let dataVersion = numberValue(current.dataVersion)
  737. if (terminalLifecycle(current) !== 'SUSPENDED') {
  738. const disabled = await api.put(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}/status`, {
  739. headers,
  740. data: { enabled: false, dataVersion },
  741. })
  742. if (disabled.status() === 200) {
  743. const value = await responseData<JsonRecord>(disabled, 200, '失败兜底停用终端')
  744. dataVersion = numberValue(value.dataVersion) || dataVersion
  745. }
  746. }
  747. const removed = await api.delete(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, {
  748. headers,
  749. params: { dataVersion: String(dataVersion) },
  750. })
  751. responseStatus(removed, [200, 204, 404], '失败兜底撤销终端')
  752. const absent = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers })
  753. responseStatus(absent, 404, '失败兜底清理后终端必须不存在')
  754. evidence.push({ phase: '失败兜底精确清理', result: 'CLEANED', terminalId: terminal.id })
  755. } catch (cause) {
  756. const message = cause instanceof Error ? cause.message : String(cause)
  757. cleanupErrors.push(message)
  758. evidence.push({ phase: '失败兜底精确清理', result: 'CLEANUP_FAILED', terminalId: terminal.id, detail: message })
  759. }
  760. }
  761. test.describe('远程终端 V2 真实双浏览器生命周期', () => {
  762. test('创建绑定、一次性激活、真实页面动作、回执幂等、重连撤销与零活动残留', async ({ page, browser }, testInfo) => {
  763. test.setTimeout(22 * 60_000)
  764. const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
  765. const lifecycleStartedAt = new Date(Date.now() - 2_000).toISOString()
  766. const terminalName = `${runPrefix}-远控终端-${suffix}`.slice(0, 110)
  767. const terminalCode = `RTC-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').toUpperCase().slice(0, 64)
  768. const evidence: LifecycleEvidence[] = []
  769. const cleanupErrors: string[] = []
  770. const activationValues: string[] = []
  771. const commandIds: Record<string, string> = {}
  772. let terminal: TerminalRef | null = null
  773. let terminalDeleted = false
  774. let primaryRuntime: RuntimeHandle | null = null
  775. let replacementRuntime: RuntimeHandle | null = null
  776. let api: APIRequestContext | null = null
  777. let headers: Headers = {}
  778. let activePhase = '初始化'
  779. let boundAgentEvidence: Pick<PublishedAgent, 'id' | 'slug' | 'accessMode' | 'voiceCapabilityId'> | null = null
  780. let mediaEvidence: { speechId: string; duration: number; mediaBytes: number } | null = null
  781. let terminalScopedAccessEvidence: TerminalScopedAccessEvidence | null = null
  782. let terminalChatDeviceHeadersValid: boolean | null = null
  783. let runtimeLeaseEvidence: JsonRecord | null = null
  784. try {
  785. activePhase = '使用账号文件进程内登录并选择已发布智能体'
  786. api = await playwrightRequest.newContext({ baseURL })
  787. headers = await authHeaders(api)
  788. const agent = await findPublishedAgent(api, headers)
  789. boundAgentEvidence = {
  790. id: agent.id,
  791. slug: agent.slug,
  792. accessMode: agent.accessMode,
  793. voiceCapabilityId: agent.voiceCapabilityId,
  794. }
  795. await loginAsAdmin(page)
  796. activePhase = '通过管理页面创建独立页面终端并安全接收一次性激活码'
  797. await page.goto('/agents/remote-control')
  798. await page.getByRole('button', { name: '新增终端', exact: true }).click()
  799. const editor = visibleDialog(page)
  800. await fillFormItem(editor, '终端名称', terminalName)
  801. await fillFormItem(editor, '终端编码', terminalCode)
  802. await fillFormItem(editor, '部署位置', `E2E 本地模拟终端 ${suffix}`)
  803. await chooseAgent(page, editor, agent)
  804. await expect(formItem(editor, '页面模式').getByText('独立页面', { exact: true })).toBeVisible()
  805. const createResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST'
  806. && new URL(response.url()).pathname === '/api/v1/remote-terminals', { timeout: 30_000 })
  807. await editor.getByRole('button', { name: '创建并生成激活链接', exact: true }).click()
  808. const createResponse = await createResponsePromise
  809. const created = activationParts(await responseData(createResponse, 201, '页面创建远程终端'))
  810. expect(createResponse.headers()['cache-control'] || '').toContain('no-store')
  811. expect(created.shownOnce).toBe(true)
  812. expect(new Date(created.expiresAt).getTime()).toBeGreaterThan(Date.now())
  813. if (!created.activationCode || created.activationCode.length < 12) throw new Error('创建响应未返回有效的一次性激活码')
  814. activationValues.push(created.activationCode)
  815. terminal = {
  816. id: textValue(created.terminal.id),
  817. code: textValue(created.terminal.code) || terminalCode,
  818. name: textValue(created.terminal.name) || terminalName,
  819. dataVersion: numberValue(created.terminal.dataVersion),
  820. }
  821. if (!terminal.id) throw new Error('创建响应未返回终端编号')
  822. const activationDialog = await scrubActivationDialog(page)
  823. await expect(activationDialog).toContainText(`接入终端 · ${terminalName}`)
  824. await expect(activationDialog).toContainText('仅显示一次')
  825. await captureScreenshot(page, testInfo, 'remote-v2-real-01-created-activation-masked', [activationDialog.locator('code')])
  826. await activationDialog.getByRole('button', { name: '完成', exact: true }).click()
  827. const initialDetail = await getTerminal(api, headers, terminal.id)
  828. const keys = recursiveKeys(initialDetail).map((key) => key.toLowerCase())
  829. expect(keys).not.toContain('activationcode')
  830. expect(keys).not.toContain('devicesecret')
  831. expect(keys).not.toContain('secret')
  832. expect(JSON.stringify(initialDetail)).not.toContain(created.activationCode)
  833. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'PENDING_ACTIVATION' })
  834. activePhase = '第二浏览器上下文真实激活 embed 页面并验证激活码不可重放'
  835. primaryRuntime = await activateRuntime(browser, agent, terminal, created.activationCode)
  836. await expect.poll(primaryRuntime.terminalConfigRequests, {
  837. message: '带 terminal 参数的运行页必须通过设备凭据读取绑定智能体配置',
  838. timeout: 20_000,
  839. }).toBeGreaterThan(0)
  840. expect(primaryRuntime.terminalConfigHeadersValid(), '终端配置请求必须携带设备头和激活时页面 Origin').toBe(true)
  841. expect(primaryRuntime.legacyPublicConfigRequests(), '受管终端不得回退到旧公开配置接口').toBe(0)
  842. expect(primaryRuntime.legacyPublicSessionRequests(), '激活阶段不得调用旧公开会话接口').toBe(0)
  843. expect(primaryRuntime.transport.headersValid(), '运行租约端点必须携带三项设备头和同页 runtime instance,且不得发送旧头').toBe(true)
  844. expect(primaryRuntime.transport.pullStartedAfterHeartbeat(), '运行页首次 heartbeat 成功前不得启动 pull').toBe(true)
  845. expect(primaryRuntime.transport.runtimeInstanceOmittedFromUrls(), 'runtime instance 不得进入请求 URL').toBe(true)
  846. expect(await runtimeInstancesAbsentFromBrowserState(
  847. primaryRuntime.page,
  848. primaryRuntime.transport.runtimeInstanceIds(),
  849. ), 'runtime instance 只允许存在页面内存,不得写入 URL 或 storage').toBe(true)
  850. await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-02-runtime-activated-fragment-cleared')
  851. await verifyConsumedActivation(browser, agent, terminal, created.activationCode)
  852. const online = await waitTerminal(api, headers, terminal.id, (value) => {
  853. const capabilities = stringList(value.capabilities).map((item) => item.toUpperCase())
  854. return terminalConnection(value) === 'ONLINE' && value.online === true
  855. && terminalCapabilities.every((item) => capabilities.includes(item))
  856. },
  857. '激活后的真实心跳必须上报在线和六项能力')
  858. expect(terminalRuntimeVersion(online)).toContain('remote-v2')
  859. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'ONLINE' })
  860. let drawer = await openTerminalDrawer(page, terminalName)
  861. await expect(drawer).toContainText('在线')
  862. await expect(drawer).toContainText('ai-person-web/remote-v2')
  863. await captureScreenshot(page, testInfo, 'remote-v2-real-03-manager-online-heartbeat-capabilities')
  864. activePhase = '后台 WAKE 下发到真实运行页 DOM 并完成三段回执'
  865. await drawer.getByRole('tab', { name: '实时控制', exact: true }).click()
  866. const wake = await sendUiCommand(page, drawer, '唤醒运行页', 'WAKE')
  867. commandIds.wake = wake.id
  868. const wakeDone = await waitCommand(api, headers, wake.id, 'SUCCEEDED')
  869. expect(asRecord(wakeDone.result).sessionStatus).toBe('ACTIVE')
  870. await expect.poll(primaryRuntime.terminalSessionRequests, {
  871. message: 'WAKE 必须通过设备凭据签发绑定智能体会话',
  872. timeout: 20_000,
  873. }).toBeGreaterThan(0)
  874. expect(primaryRuntime.terminalSessionHeadersValid(), '终端会话请求必须携带设备头和激活时页面 Origin').toBe(true)
  875. expect(primaryRuntime.legacyPublicSessionRequests(), '受管终端不得回退到旧公开会话接口').toBe(0)
  876. terminalScopedAccessEvidence = terminalScopedAccess(primaryRuntime)
  877. await expect(primaryRuntime.page.locator('.live-agent-page')).toBeVisible()
  878. await expect.poll(() => primaryRuntime!.page.evaluate(() => document.hasFocus()), {
  879. message: 'STANDALONE WAKE 必须真实聚焦运行页窗口', timeout: 12_000,
  880. }).toBe(true)
  881. await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'ACTIVE', 'WAKE 后心跳必须上报活动会话')
  882. await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-04-wake-focused-real-live-dom')
  883. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: wake.id, status: 'SUCCEEDED' })
  884. activePhase = '后台 VOLUME 修改真实 HTMLAudio/Viewer 输出并由心跳回报'
  885. const volumeSlider = drawer.locator('.volume-command .el-slider')
  886. await setSliderValue(volumeSlider, 37)
  887. const volume = await sendUiCommand(page, drawer, '应用音量', 'VOLUME')
  888. commandIds.volume = volume.id
  889. const volumeDone = await waitCommand(api, headers, volume.id, 'SUCCEEDED')
  890. expect(numberValue(asRecord(volumeDone.result).volume)).toBe(37)
  891. await waitTerminal(api, headers, terminal.id, (value) => terminalActualVolume(value) === 37, 'VOLUME 后心跳必须回报实际音量 37%')
  892. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: volume.id, status: 'SUCCEEDED' })
  893. activePhase = '真实服务端 TTS 音频进入运行页后执行 PAUSE / RESUME / STOP DOM 动作'
  894. const speech = await createRemoteControlSpeech(api, headers, agent.voiceCapabilityId)
  895. mediaEvidence = { speechId: speech.id, duration: speech.duration, mediaBytes: speech.mediaBytes }
  896. let controlledChatRequestCount = 0
  897. const controlledChatHandler = async (route: Route) => {
  898. controlledChatRequestCount += 1
  899. const requestHeaders = route.request().headers()
  900. terminalChatDeviceHeadersValid = requestHeaders['x-terminal-id'] === terminal!.code
  901. && Boolean(requestHeaders['x-device-secret'])
  902. && requestHeaders['x-terminal-page-origin'] === new URL(baseURL).origin
  903. && requestHeaders['x-runtime-instance-id'] === primaryRuntime!.transport.lastRuntimeInstanceId()
  904. && !requestHeaders['x-terminal-secret']
  905. await route.fulfill({
  906. status: 200,
  907. contentType: 'application/json; charset=utf-8',
  908. body: JSON.stringify({
  909. code: 0,
  910. message: 'success',
  911. data: {
  912. reply: '远程控制媒体链路验收音频已就绪。',
  913. audioUrl: speech.audioUrl,
  914. citations: [],
  915. provider: 'local',
  916. degraded: false,
  917. degradationReason: null,
  918. blocked: false,
  919. sensitiveCategory: null,
  920. requestId: `remote-media-${suffix}`,
  921. },
  922. }),
  923. })
  924. }
  925. await primaryRuntime.page.route(`**/open/v1/realtime/${agent.slug}/chat`, controlledChatHandler)
  926. const question = '开始远程媒体控制链路验收。'
  927. await primaryRuntime.page.getByRole('textbox', { name: '维修问题' }).fill(question)
  928. await primaryRuntime.page.getByRole('button', { name: '发送问题', exact: true }).click()
  929. await expect(primaryRuntime.page.locator('.live-speech-toggle.is-speaking')).toBeVisible({ timeout: 180_000 })
  930. expect(controlledChatRequestCount).toBe(1)
  931. expect(terminalChatDeviceHeadersValid, 'REMOTE_TERMINAL chat 必须同时携带设备头、页面 Origin 且不发送旧头').toBe(true)
  932. expect(primaryRuntime.terminalFollowupRequests().chat, 'REMOTE_TERMINAL chat 必须经过受管会话请求封装').toBe(1)
  933. expect(primaryRuntime.terminalFollowupHeadersValid(), 'REMOTE_TERMINAL chat 必须携带 Bearer、三项设备头与当前 runtime instance').toBe(true)
  934. await primaryRuntime.page.unroute(`**/open/v1/realtime/${agent.slug}/chat`, controlledChatHandler)
  935. await waitTerminal(api, headers, terminal.id, (value) => terminalPlayback(value) === 'PLAYING', '真实 TTS 播放必须由心跳上报 PLAYING', 45_000)
  936. await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-05-real-tts-playing')
  937. evidence.push({
  938. phase: '真实 TTS 媒体前置',
  939. result: 'PASS',
  940. terminalId: terminal.id,
  941. status: `${speech.voiceCapabilityId}/${speech.duration.toFixed(1)}s/${speech.id}`,
  942. })
  943. await expect(drawer.getByRole('button', { name: '暂停播报', exact: true })).toBeEnabled({ timeout: 15_000 })
  944. const pause = await sendUiCommand(page, drawer, '暂停播报', 'PAUSE')
  945. commandIds.pause = pause.id
  946. await waitCommand(api, headers, pause.id, 'SUCCEEDED')
  947. await expect(primaryRuntime.page.locator('.live-speech-toggle[aria-label="继续播报"]')).toBeVisible()
  948. await waitTerminal(api, headers, terminal.id, (value) => terminalPlayback(value) === 'PAUSED', 'PAUSE 后心跳必须回报 PAUSED', 45_000)
  949. await captureScreenshot(page, testInfo, 'remote-v2-real-06-manager-pause-succeeded')
  950. await expect(drawer.getByRole('button', { name: '继续播报', exact: true })).toBeEnabled({ timeout: 15_000 })
  951. const resume = await sendUiCommand(page, drawer, '继续播报', 'RESUME')
  952. commandIds.resume = resume.id
  953. await waitCommand(api, headers, resume.id, 'SUCCEEDED')
  954. await expect(primaryRuntime.page.locator('.live-speech-toggle.is-speaking[aria-label="暂停播报"]')).toBeVisible()
  955. await waitTerminal(api, headers, terminal.id, (value) => terminalPlayback(value) === 'PLAYING', 'RESUME 后心跳必须回报 PLAYING', 45_000)
  956. await expect(drawer.getByRole('button', { name: '结束会话', exact: true })).toBeEnabled({ timeout: 15_000 })
  957. const stop = await sendUiCommand(page, drawer, '结束会话', 'STOP', '确认结束会话')
  958. commandIds.stop = stop.id
  959. await waitCommand(api, headers, stop.id, 'SUCCEEDED')
  960. await expect.poll(() => primaryRuntime!.terminalFollowupRequests().end, {
  961. message: 'STOP 必须先调用受管会话 end',
  962. timeout: 20_000,
  963. }).toBeGreaterThan(0)
  964. expect(primaryRuntime.terminalFollowupHeadersValid(), 'REMOTE_TERMINAL end 必须携带 Bearer、三项设备头与当前 runtime instance').toBe(true)
  965. await expect(primaryRuntime.page.locator('.live-speech-toggle.is-speaking')).toHaveCount(0)
  966. await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE'
  967. && terminalPlayback(value) === 'IDLE', 'STOP 后心跳必须回报空闲会话与空闲播放', 45_000)
  968. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: stop.id, status: 'SUCCEEDED' })
  969. activePhase = '命令幂等键同载荷重放、不同载荷冲突和空闲态预检拒绝'
  970. const idempotencyKey = `remote-v2-${suffix}-idempotent`
  971. const identicalPayload = { type: 'VOLUME', detail: '幂等设置音量', volume: 43 }
  972. const firstIdempotentResponse = await postCommand(api, headers, terminal.id, identicalPayload, idempotencyKey)
  973. const firstIdempotent = await parseCommandResponse(firstIdempotentResponse, '首次幂等命令')
  974. const replayResponse = await postCommand(api, headers, terminal.id, identicalPayload, idempotencyKey)
  975. const replay = await parseCommandResponse(replayResponse, '相同幂等键同载荷重放')
  976. expect(replay.id).toBe(firstIdempotent.id)
  977. expect(replay.command.idempotent).toBe(true)
  978. const conflictResponse = await postCommand(api, headers, terminal.id, { ...identicalPayload, volume: 44 }, idempotencyKey)
  979. responseStatus(conflictResponse, 409, '相同幂等键不同载荷必须冲突')
  980. commandIds.idempotent = firstIdempotent.id
  981. await waitCommand(api, headers, firstIdempotent.id, 'SUCCEEDED')
  982. const rejectedPause = await postCommand(api, headers, terminal.id, { type: 'PAUSE', detail: '空闲态预检拒绝验证' }, `remote-v2-${suffix}-failed`)
  983. responseStatus(rejectedPause, 409, '空闲态 PAUSE 必须在入队前拒绝')
  984. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'IDLE_PAUSE_REJECTED' })
  985. activePhase = '同源双页面仅单一 leader 执行,刷新时 release 后快速交棒且不重复激活'
  986. const activationCountBeforeReload = primaryRuntime.activationRequests()
  987. const primaryInstanceBeforeReload = primaryRuntime.transport.lastRuntimeInstanceId()
  988. const primaryReleaseBeforeReload = primaryRuntime.transport.releaseRequests()
  989. const primaryReportsBeforeHandoff = primaryRuntime.transport.reportRequests()
  990. const standbyPage = await primaryRuntime.context.newPage()
  991. const standbyTransport = observeRuntimeTransport(standbyPage, terminal.code, new URL(baseURL).origin)
  992. await standbyPage.goto(primaryRuntime.publicPath)
  993. await expect(standbyPage.locator('.live-agent-page')).toBeVisible()
  994. await expect(standbyPage.locator('.terminal-runtime-notice')).toContainText('另一页面运行', { timeout: 20_000 })
  995. await standbyPage.waitForTimeout(1_200)
  996. expect(standbyTransport.heartbeatRequests(), '同源 standby 页面不得竞争服务端心跳租约').toBe(0)
  997. expect(standbyTransport.pullRequests(), '同源 standby 页面不得拉取命令').toBe(0)
  998. expect(standbyTransport.reportRequests(), '同源 standby 页面不得执行或回报命令').toBe(0)
  999. const standbyHeartbeat = standbyPage.waitForResponse((response) => response.request().method() === 'POST'
  1000. && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat'
  1001. && response.status() === 200, { timeout: 35_000 })
  1002. await primaryRuntime.page.reload({ waitUntil: 'domcontentloaded' })
  1003. responseStatus(await standbyHeartbeat, 200, '原 leader 刷新释放租约后 standby 页面快速接管')
  1004. await expect.poll(primaryRuntime.transport.releaseRequests, {
  1005. message: '页面刷新必须 best-effort 调用 runtime release',
  1006. timeout: 12_000,
  1007. }).toBeGreaterThan(primaryReleaseBeforeReload)
  1008. // unload keepalive 的 response 事件可能在旧 document 销毁后不再被 Playwright 派发;
  1009. // 上面的新 instance heartbeat=200 严格证明服务端已完成 release,而非等待 45 秒租约过期。
  1010. expect(primaryRuntime.transport.releaseBodyEmpty(), 'runtime release 必须使用无 body 请求').toBe(true)
  1011. await expect(primaryRuntime.page.locator('.terminal-runtime-notice')).toContainText('另一页面运行', { timeout: 20_000 })
  1012. expect(primaryRuntime.activationRequests(), '刷新不得重复兑换一次性激活码').toBe(activationCountBeforeReload)
  1013. expect(standbyTransport.headersValid(), '接管页面四类运行请求必须复用同一 runtime instance 与三项设备头').toBe(true)
  1014. expect(standbyTransport.pullStartedAfterHeartbeat(), '接管页面必须 heartbeat 成功后才 pull').toBe(true)
  1015. const standbyInstance = standbyTransport.lastRuntimeInstanceId()
  1016. expect(standbyInstance).not.toBe(primaryInstanceBeforeReload)
  1017. expect(await runtimeInstancesAbsentFromBrowserState(standbyPage, [standbyInstance])).toBe(true)
  1018. const standbyReportsBeforeCommand = standbyTransport.reportRequests()
  1019. const handoffCommandResponse = await postCommand(
  1020. api,
  1021. headers,
  1022. terminal.id,
  1023. { type: 'VOLUME', detail: '双页面 leader 单次执行验证', volume: 46 },
  1024. `remote-v2-${suffix}-leader-handoff`,
  1025. )
  1026. const handoffCommand = await parseCommandResponse(handoffCommandResponse, '刷新交棒后下发单次执行命令')
  1027. commandIds.handoff = handoffCommand.id
  1028. const handoffDone = await waitCommand(api, headers, handoffCommand.id, 'SUCCEEDED', 60_000)
  1029. expect(numberValue(asRecord(handoffDone.result).volume)).toBe(46)
  1030. expect(standbyTransport.reportRequests()).toBeGreaterThan(standbyReportsBeforeCommand)
  1031. expect(primaryRuntime.transport.reportRequests(), 'standby 页面不得重复回报接管页面已执行的命令').toBe(primaryReportsBeforeHandoff)
  1032. await waitTerminal(api, headers, terminal.id, (value) => terminalActualVolume(value) === 46,
  1033. '交棒后的唯一 leader 必须回报 46% 音量', 45_000)
  1034. const standbyReleaseBeforeClose = standbyTransport.releaseRequests()
  1035. const primaryHeartbeatAfterHandoff = primaryRuntime.page.waitForResponse((response) => response.request().method() === 'POST'
  1036. && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat'
  1037. && response.status() === 200, { timeout: 35_000 })
  1038. await standbyPage.close()
  1039. responseStatus(await primaryHeartbeatAfterHandoff, 200, '接管页面关闭释放后刷新页重新成为 leader')
  1040. await expect.poll(standbyTransport.releaseRequests, {
  1041. message: 'leader 页面关闭必须 best-effort 释放 runtime lease',
  1042. timeout: 12_000,
  1043. }).toBeGreaterThan(standbyReleaseBeforeClose)
  1044. // primaryHeartbeatAfterHandoff=200 严格证明关闭页的 release 已在服务端生效。
  1045. expect(standbyTransport.releaseBodyEmpty()).toBe(true)
  1046. const primaryInstanceAfterReload = primaryRuntime.transport.lastRuntimeInstanceId()
  1047. expect(primaryInstanceAfterReload).not.toBe(primaryInstanceBeforeReload)
  1048. expect(primaryInstanceAfterReload).not.toBe(standbyInstance)
  1049. expect(primaryRuntime.transport.headersValid()).toBe(true)
  1050. expect(primaryRuntime.transport.pullStartedAfterHeartbeat()).toBe(true)
  1051. expect(await runtimeInstancesAbsentFromBrowserState(
  1052. primaryRuntime.page,
  1053. primaryRuntime.transport.runtimeInstanceIds(),
  1054. )).toBe(true)
  1055. terminalScopedAccessEvidence = terminalScopedAccess(primaryRuntime)
  1056. await expect(primaryRuntime.page.locator('.terminal-runtime-notice.is-error')).toHaveCount(0)
  1057. await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-07-refresh-leader-handoff')
  1058. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: handoffCommand.id, status: 'LEADER_HANDOFF_SINGLE_EXECUTION' })
  1059. activePhase = 'RESTART 先结束受管会话再回报终态并刷新,刷新后仍可创建唯一新会话'
  1060. const sessionsBeforeRestartWake = primaryRuntime.terminalSessionRequests()
  1061. const restartWakeResponse = await postCommand(
  1062. api,
  1063. headers,
  1064. terminal.id,
  1065. { type: 'WAKE', detail: 'RESTART 前创建受管会话' },
  1066. `remote-v2-${suffix}-restart-wake`,
  1067. )
  1068. const restartWake = await parseCommandResponse(restartWakeResponse, 'RESTART 前唤醒运行页')
  1069. commandIds.restartWake = restartWake.id
  1070. await waitCommand(api, headers, restartWake.id, 'SUCCEEDED', 60_000)
  1071. await expect.poll(primaryRuntime.terminalSessionRequests, {
  1072. message: 'RESTART 前 WAKE 必须创建受管会话',
  1073. timeout: 20_000,
  1074. }).toBeGreaterThan(sessionsBeforeRestartWake)
  1075. const activeBeforeRestart = await waitTerminal(
  1076. api,
  1077. headers,
  1078. terminal.id,
  1079. (value) => terminalSession(value) === 'ACTIVE',
  1080. 'RESTART 前终端必须存在活动会话',
  1081. 45_000,
  1082. )
  1083. const sessionIdBeforeRestart = textValue(activeBeforeRestart.sessionId ?? asRecord(activeBeforeRestart.session).id)
  1084. const restartOrder: string[] = []
  1085. const restartResponseObserver = (response: Response) => {
  1086. const pathname = new URL(response.url()).pathname
  1087. if (pathname.endsWith(`/open/v1/realtime/${encodeURIComponent(agent.slug)}/end`) && response.status() === 200) {
  1088. restartOrder.push('end-response')
  1089. return
  1090. }
  1091. const reportMatch = pathname.match(/^\/open\/v2\/terminals\/commands\/([^/]+)\/reports$/)
  1092. if (!reportMatch || response.status() !== 200) return
  1093. const payload = asRecord(response.request().postDataJSON())
  1094. if (textValue(payload.status).toUpperCase() === 'SUCCEEDED') {
  1095. restartOrder.push(`final-report:${decodeURIComponent(reportMatch[1] || '')}`)
  1096. }
  1097. }
  1098. primaryRuntime.page.on('response', restartResponseObserver)
  1099. const heartbeatAfterRestart = primaryRuntime.page.waitForResponse((response) => response.request().method() === 'POST'
  1100. && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat'
  1101. && response.status() === 200, { timeout: 45_000 })
  1102. const restartResponse = await postCommand(
  1103. api,
  1104. headers,
  1105. terminal.id,
  1106. { type: 'RESTART', detail: '验证先结束会话再终态回执与刷新' },
  1107. `remote-v2-${suffix}-restart`,
  1108. )
  1109. const restart = await parseCommandResponse(restartResponse, '下发 RESTART 命令')
  1110. commandIds.restart = restart.id
  1111. await waitCommand(api, headers, restart.id, 'SUCCEEDED', 60_000)
  1112. responseStatus(await heartbeatAfterRestart, 200, 'RESTART 后新页面运行实例重新取得租约')
  1113. await expect.poll(() => primaryRuntime!.terminalFollowupRequests().end, {
  1114. message: 'RESTART 必须调用受管会话 end',
  1115. timeout: 20_000,
  1116. }).toBeGreaterThan(0)
  1117. const endIndex = restartOrder.indexOf('end-response')
  1118. const finalReportIndex = restartOrder.indexOf(`final-report:${restart.id}`)
  1119. expect(endIndex, 'RESTART 必须观察到会话 end 成功响应').toBeGreaterThanOrEqual(0)
  1120. expect(finalReportIndex, 'RESTART 必须观察到 SUCCEEDED 终态回执').toBeGreaterThan(endIndex)
  1121. primaryRuntime.page.off('response', restartResponseObserver)
  1122. await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE',
  1123. 'RESTART 刷新后不得残留 ACTIVE 会话', 45_000)
  1124. const sessionsBeforePostRestartWake = primaryRuntime.terminalSessionRequests()
  1125. const postRestartWakeResponse = await postCommand(
  1126. api,
  1127. headers,
  1128. terminal.id,
  1129. { type: 'WAKE', detail: 'RESTART 后验证新会话可用' },
  1130. `remote-v2-${suffix}-post-restart-wake`,
  1131. )
  1132. const postRestartWake = await parseCommandResponse(postRestartWakeResponse, 'RESTART 后重新唤醒运行页')
  1133. commandIds.postRestartWake = postRestartWake.id
  1134. await waitCommand(api, headers, postRestartWake.id, 'SUCCEEDED', 60_000)
  1135. await expect.poll(primaryRuntime.terminalSessionRequests, {
  1136. message: 'RESTART 后 WAKE 必须创建新的受管会话',
  1137. timeout: 20_000,
  1138. }).toBeGreaterThan(sessionsBeforePostRestartWake)
  1139. const activeAfterRestart = await waitTerminal(api, headers, terminal.id,
  1140. (value) => terminalSession(value) === 'ACTIVE', 'RESTART 后新会话必须可用', 45_000)
  1141. const sessionIdAfterRestart = textValue(activeAfterRestart.sessionId ?? asRecord(activeAfterRestart.session).id)
  1142. if (sessionIdBeforeRestart && sessionIdAfterRestart) expect(sessionIdAfterRestart).not.toBe(sessionIdBeforeRestart)
  1143. const postRestartStopResponse = await postCommand(
  1144. api,
  1145. headers,
  1146. terminal.id,
  1147. { type: 'STOP', detail: 'RESTART 回归完成后结束新会话' },
  1148. `remote-v2-${suffix}-post-restart-stop`,
  1149. )
  1150. const postRestartStop = await parseCommandResponse(postRestartStopResponse, '结束 RESTART 后的新会话')
  1151. commandIds.postRestartStop = postRestartStop.id
  1152. await waitCommand(api, headers, postRestartStop.id, 'SUCCEEDED', 60_000)
  1153. await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE',
  1154. 'RESTART 后新会话结束必须回到 IDLE', 45_000)
  1155. expect(primaryRuntime.terminalFollowupHeadersValid(), 'RESTART/STOP 的 end 必须使用当前 runtime owner 的完整授权头').toBe(true)
  1156. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: restart.id, status: 'END_BEFORE_FINAL_REPORT_AND_RELOAD' })
  1157. activePhase = '真实 API 双实例租约 fencing、owner release 接管与旧 owner 回执拒绝'
  1158. const runtimeHarness = await primaryRuntime.context.newPage()
  1159. await runtimeHarness.goto('/')
  1160. const primaryReleaseBeforeClose = primaryRuntime.transport.releaseRequests()
  1161. await primaryRuntime.page.close()
  1162. await expect.poll(primaryRuntime.transport.releaseRequests, {
  1163. message: '最后一个 leader 页面关闭必须发出 runtime release',
  1164. timeout: 12_000,
  1165. }).toBeGreaterThan(primaryReleaseBeforeClose)
  1166. expect(primaryRuntime.transport.releaseBodyEmpty()).toBe(true)
  1167. const runtimeA = `runtime-ape2e-a-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 128)
  1168. const runtimeB = `runtime-ape2e-b-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 128)
  1169. const directTerminalCode = terminal.code
  1170. const directState = directRuntimeState(primaryRuntime.publicPath, 46)
  1171. let claimA: DirectRuntimeResponse = { status: 0, code: 0, data: {} }
  1172. await expect.poll(async () => {
  1173. claimA = await directRuntimeRequest(runtimeHarness, directTerminalCode, runtimeA, '/open/v2/terminals/heartbeat', directState)
  1174. return claimA.status
  1175. }, { message: '页面 release 后直接运行实例 A 必须快速取得租约', timeout: 15_000 }).toBe(200)
  1176. expect(numberValue(claimA.data.runtimeLeaseSeconds)).toBeGreaterThan(0)
  1177. expect(textValue(claimA.data.runtimeLeaseExpiresAt)).not.toBe('')
  1178. const renewedA = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeA, '/open/v2/terminals/heartbeat', directState)
  1179. expect(renewedA.status).toBe(200)
  1180. expect(new Date(textValue(renewedA.data.runtimeLeaseExpiresAt)).getTime())
  1181. .toBeGreaterThanOrEqual(new Date(textValue(claimA.data.runtimeLeaseExpiresAt)).getTime())
  1182. const blockedB = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeB, '/open/v2/terminals/heartbeat', directState)
  1183. expect(blockedB.status).toBe(423)
  1184. expect(blockedB.code).toBe(42301)
  1185. const sessionFence = await directRuntimeSessionFence(
  1186. runtimeHarness,
  1187. terminal.code,
  1188. runtimeA,
  1189. runtimeB,
  1190. agent.slug,
  1191. directState,
  1192. )
  1193. expect(sessionFence.ownerSessionCreated).toBe(true)
  1194. expect(sessionFence.ownerSessionNoStore).toBe(true)
  1195. expect(sessionFence.websocketOpened).toBe(true)
  1196. expect(sessionFence.ownerReleaseStatus).toBe(200)
  1197. expect(sessionFence.contenderClaimStatus).toBe(200)
  1198. expect([401, 423]).toContain(sessionFence.oldOwnerHttpStatus)
  1199. expect([40100, 42301]).toContain(sessionFence.oldOwnerHttpCode)
  1200. expect(sessionFence.oldOwnerWebSocketCloseCode).toBe(4401)
  1201. expect(sessionFence.contenderSessionCreated).toBe(true)
  1202. expect(sessionFence.contenderSessionEnded).toBe(true)
  1203. expect(sessionFence.staleOwnerReleaseStatus).toBe(423)
  1204. expect(sessionFence.staleOwnerReleaseCode).toBe(42301)
  1205. const fencedCommandResponse = await postCommand(
  1206. api,
  1207. headers,
  1208. terminal.id,
  1209. { type: 'VOLUME', detail: '旧 runtime owner fencing 验证', volume: 47 },
  1210. `remote-v2-${suffix}-runtime-fence`,
  1211. )
  1212. const fencedCommand = await parseCommandResponse(fencedCommandResponse, '为双实例 fencing 创建命令')
  1213. commandIds.fenced = fencedCommand.id
  1214. const pulledByB = await directRuntimeRequest(
  1215. runtimeHarness,
  1216. terminal.code,
  1217. runtimeB,
  1218. '/open/v2/terminals/commands/pull',
  1219. { limit: 1, waitSeconds: 0 },
  1220. )
  1221. expect(pulledByB.status).toBe(200)
  1222. const fencedDelivery = asRecord(asRecords(pulledByB.data.items)[0])
  1223. expect(textValue(fencedDelivery.id)).toBe(fencedCommand.id)
  1224. const staleReportA = await directRuntimeRequest(
  1225. runtimeHarness,
  1226. terminal.code,
  1227. runtimeA,
  1228. `/open/v2/terminals/commands/${encodeURIComponent(fencedCommand.id)}/reports`,
  1229. {
  1230. reportId: `stale-a-${suffix}`,
  1231. deliveryId: textValue(fencedDelivery.deliveryId),
  1232. status: 'SUCCEEDED',
  1233. result: { message: '旧 owner 不应被接受' },
  1234. },
  1235. )
  1236. expect(staleReportA.status).toBe(423)
  1237. expect(staleReportA.code).toBe(42301)
  1238. const staleReleaseA = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeA, '/open/v2/terminals/runtime/release')
  1239. expect(staleReleaseA.status).toBe(423)
  1240. expect(staleReleaseA.code).toBe(42301)
  1241. const settledByB = await directRuntimeRequest(
  1242. runtimeHarness,
  1243. terminal.code,
  1244. runtimeB,
  1245. `/open/v2/terminals/commands/${encodeURIComponent(fencedCommand.id)}/reports`,
  1246. {
  1247. reportId: `cleanup-b-${suffix}`,
  1248. deliveryId: textValue(fencedDelivery.deliveryId),
  1249. status: 'FAILED',
  1250. errorCode: 'APE2E_RUNTIME_FENCE_CLEANUP',
  1251. errorMessage: '租约 fencing 验证完成后的测试收敛',
  1252. },
  1253. )
  1254. expect(settledByB.status).toBe(200)
  1255. await waitCommand(api, headers, fencedCommand.id, 'FAILED', 30_000)
  1256. activePhase = '运行实例释放前补充验证 QUEUED 取消与 90 秒超时收敛'
  1257. const cancellableResponse = await postCommand(api, headers, terminal.id, { type: 'RESTART', detail: '排队取消验证' }, `remote-v2-${suffix}-cancel`)
  1258. const cancellable = await parseCommandResponse(cancellableResponse, '创建待取消命令')
  1259. commandIds.cancelled = cancellable.id
  1260. const cancelResponse = await api.post(`/api/v1/remote-commands/${encodeURIComponent(cancellable.id)}/cancel`, {
  1261. headers,
  1262. data: { dataVersion: numberValue(cancellable.command.dataVersion) },
  1263. })
  1264. const cancelled = await responseData<JsonRecord>(cancelResponse, 200, '取消 QUEUED 命令')
  1265. expect(commandStatus(cancelled)).toBe('CANCELLED')
  1266. const expiringResponse = await postCommand(api, headers, terminal.id, { type: 'WAKE', detail: '真实九十秒过期验证' }, `remote-v2-${suffix}-timeout`)
  1267. const expiring = await parseCommandResponse(expiringResponse, '创建待超时命令')
  1268. commandIds.timedOut = expiring.id
  1269. const releasedB = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeB, '/open/v2/terminals/runtime/release')
  1270. expect(releasedB.status).toBe(200)
  1271. runtimeLeaseEvidence = {
  1272. ownerClaimAndRenew: claimA.status === 200 && renewedA.status === 200,
  1273. contenderBlocked: blockedB.status === 423 && blockedB.code === 42301,
  1274. ownerReleased: sessionFence.ownerReleaseStatus === 200,
  1275. contenderTookOver: sessionFence.contenderClaimStatus === 200,
  1276. ownerSessionInterrupted: [401, 423].includes(sessionFence.oldOwnerHttpStatus)
  1277. && sessionFence.oldOwnerWebSocketCloseCode === 4401,
  1278. contenderSessionUsable: sessionFence.contenderSessionCreated && sessionFence.contenderSessionEnded,
  1279. staleOwnerReportBlocked: staleReportA.status === 423 && staleReportA.code === 42301,
  1280. staleOwnerReleaseBlocked: staleReleaseA.status === 423 && staleReleaseA.code === 42301,
  1281. newOwnerSettledCommand: settledByB.status === 200,
  1282. newOwnerReleased: releasedB.status === 200,
  1283. }
  1284. await runtimeHarness.close()
  1285. evidence.push({ phase: '真实 API 双实例租约 fencing、owner release 接管与旧 owner 回执拒绝', result: 'PASS', terminalId: terminal.id, commandId: fencedCommand.id, status: 'LEASE_FENCING_AND_RELEASE_PASS' })
  1286. await waitCommand(api, headers, expiring.id, 'TIMED_OUT', 110_000)
  1287. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'CANCELLED/TIMED_OUT' })
  1288. const resumedPage = await primaryRuntime.context.newPage()
  1289. primaryRuntime.page = resumedPage
  1290. primaryRuntime.transport = observeRuntimeTransport(resumedPage, terminal.code, new URL(baseURL).origin)
  1291. const resumedHeartbeatPromise = resumedPage.waitForResponse((response) => response.request().method() === 'POST'
  1292. && new URL(response.url()).pathname.endsWith('/open/v2/terminals/heartbeat'),
  1293. { timeout: 30_000 })
  1294. await resumedPage.goto(primaryRuntime.publicPath)
  1295. responseStatus(await resumedHeartbeatPromise, 200, '关闭页面后同一设备上下文持久重连')
  1296. expect(primaryRuntime.transport.headersValid()).toBe(true)
  1297. expect(primaryRuntime.transport.pullStartedAfterHeartbeat()).toBe(true)
  1298. await waitTerminal(api, headers, terminal.id, (value) => terminalConnection(value) === 'ONLINE', '重新打开运行页后恢复在线')
  1299. activePhase = '页面重新激活使旧设备凭据失效,新浏览器接管运行通道'
  1300. drawer = await openTerminalDrawer(page, terminalName)
  1301. await drawer.getByRole('tab', { name: '接入与安全', exact: true }).click()
  1302. const regenerationResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST'
  1303. && new URL(response.url()).pathname.endsWith(`/api/v1/remote-terminals/${encodeURIComponent(terminal!.id)}/activation`),
  1304. { timeout: 30_000 })
  1305. await drawer.getByRole('button', { name: '重新生成激活链接', exact: true }).click()
  1306. const confirmation = page.locator('.el-message-box:visible')
  1307. await confirmation.getByRole('button', { name: '确认重新生成', exact: true }).click()
  1308. const regenerationResponse = await regenerationResponsePromise
  1309. const regenerated = activationParts(await responseData(regenerationResponse, 200, '页面重新生成激活链接'))
  1310. expect(regenerationResponse.headers()['cache-control'] || '').toContain('no-store')
  1311. expect(regenerated.shownOnce).toBe(true)
  1312. if (!regenerated.activationCode || regenerated.activationCode === created.activationCode) {
  1313. throw new Error('重新激活没有生成独立的一次性激活码')
  1314. }
  1315. activationValues.push(regenerated.activationCode)
  1316. terminal.dataVersion = numberValue(regenerated.terminal.dataVersion) || terminal.dataVersion
  1317. const regeneratedDialog = await scrubActivationDialog(page)
  1318. await expect(resumedPage.locator('.terminal-runtime-notice.is-error')).toContainText('终端凭据已失效', { timeout: 40_000 })
  1319. replacementRuntime = await activateRuntime(browser, agent, terminal, regenerated.activationCode)
  1320. await regeneratedDialog.getByRole('button', { name: '完成', exact: true }).click()
  1321. await waitTerminal(api, headers, terminal.id, (value) => terminalConnection(value) === 'ONLINE', '新设备激活后必须重新在线')
  1322. await captureScreenshot(page, testInfo, 'remote-v2-real-08-reactivated-new-device-online')
  1323. await captureScreenshot(resumedPage, testInfo, 'remote-v2-real-09-old-device-credential-revoked')
  1324. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'OLD_REVOKED/NEW_ONLINE' })
  1325. activePhase = '停止真实运行页超过离线阈值后后台呈现离线'
  1326. await primaryRuntime.context.close()
  1327. primaryRuntime = null
  1328. await replacementRuntime.context.close()
  1329. replacementRuntime = null
  1330. const runtimesClosedAt = Date.now()
  1331. await waitTerminal(api, headers, terminal.id, (value) => terminalConnection(value) === 'OFFLINE'
  1332. && value.online === false
  1333. && Date.now() - runtimesClosedAt >= 110_000,
  1334. '关闭运行页并超过服务端离线窗口后必须真实离线', 140_000)
  1335. drawer = await openTerminalDrawer(page, terminalName)
  1336. await expect(drawer).toContainText('离线')
  1337. await captureScreenshot(page, testInfo, 'remote-v2-real-10-runtime-stopped-real-offline')
  1338. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'OFFLINE' })
  1339. activePhase = '通过页面停用并撤销终端,活动资源清零而命令事件与审计留痕保留'
  1340. await drawer.getByRole('tab', { name: '接入与安全', exact: true }).click()
  1341. const statusResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT'
  1342. && new URL(response.url()).pathname.endsWith(`/api/v1/remote-terminals/${encodeURIComponent(terminal!.id)}/status`),
  1343. { timeout: 30_000 })
  1344. await drawer.getByRole('button', { name: '停用终端', exact: true }).click()
  1345. const stopConfirmation = page.locator('.el-message-box:visible')
  1346. await stopConfirmation.getByRole('button', { name: '确认停用', exact: true }).click()
  1347. const statusResponse = await statusResponsePromise
  1348. const suspended = await responseData<JsonRecord>(statusResponse, 200, '页面停用终端')
  1349. expect(terminalLifecycle(suspended)).toBe('SUSPENDED')
  1350. terminal.dataVersion = numberValue(suspended.dataVersion) || terminal.dataVersion
  1351. await captureScreenshot(page, testInfo, 'remote-v2-real-11-terminal-suspended')
  1352. const deleteResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE'
  1353. && new URL(response.url()).pathname.endsWith(`/api/v1/remote-terminals/${encodeURIComponent(terminal!.id)}`),
  1354. { timeout: 30_000 })
  1355. await drawer.getByRole('button', { name: '撤销终端', exact: true }).click()
  1356. const deleteConfirmation = page.locator('.el-message-box:visible')
  1357. await deleteConfirmation.getByRole('button', { name: '确认撤销', exact: true }).click()
  1358. const deleteResponse = await deleteResponsePromise
  1359. responseStatus(deleteResponse, [200, 204], '页面撤销终端')
  1360. terminalDeleted = true
  1361. const absentDetail = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers })
  1362. responseStatus(absentDetail, 404, '撤销后终端详情必须不可访问')
  1363. const absentListResponse = await api.get('/api/v1/remote-terminals', {
  1364. headers,
  1365. params: { page: '1', pageSize: '20', keyword: terminalName },
  1366. })
  1367. const absentList = await responseData<JsonRecord>(absentListResponse, 200, '撤销后按精确名称检索终端')
  1368. expect(pageItems(absentList)).toHaveLength(0)
  1369. expect(numberValue(absentList.total)).toBe(0)
  1370. const immutableProbeId = commandIds.wake || Object.values(commandIds)[0]
  1371. if (!immutableProbeId) throw new Error('真实生命周期没有产生可核验的命令审计编号')
  1372. const retainedCommandResponse = await api.get(`/api/v1/remote-commands/${encodeURIComponent(immutableProbeId)}`, { headers })
  1373. responseStatus(retainedCommandResponse, 200, '撤销终端后命令审计历史必须保留')
  1374. const retainedEventsResponse = await api.get(`/api/v1/remote-commands/${encodeURIComponent(immutableProbeId)}/events`, { headers })
  1375. const retainedEvents = await responseData<JsonRecord | JsonRecord[]>(retainedEventsResponse, 200, '撤销终端后命令事件账本必须保留')
  1376. const eventItems = Array.isArray(retainedEvents) ? retainedEvents : pageItems(retainedEvents)
  1377. expect(eventItems.length).toBeGreaterThanOrEqual(3)
  1378. const retainedStatuses = eventItems.map((item) => textValue(item.toStatus ?? item.status).toUpperCase())
  1379. for (const status of ['ACKNOWLEDGED', 'EXECUTING', 'SUCCEEDED']) {
  1380. expect(retainedStatuses, `命令事件账本应包含 ${status}`).toContain(status)
  1381. }
  1382. const auditResponse = await api.get('/api/auth/v1/audit-logs', {
  1383. headers,
  1384. params: { page: '1', size: '100', keyword: terminalName, start: lifecycleStartedAt },
  1385. })
  1386. const auditData = await responseData<JsonRecord>(auditResponse, 200, '查询远控系统审计')
  1387. const terminalAudits = pageItems(auditData)
  1388. const terminalAuditCodes = terminalAudits.map((item) => textValue(item.actionCode))
  1389. for (const actionCode of ['ai.remote.create', 'ai.remote.activation', 'ai.remote.status', 'ai.remote.delete']) {
  1390. expect(terminalAuditCodes, `审计日志应包含 ${actionCode}`).toContain(actionCode)
  1391. }
  1392. const commandAuditResponse = await api.get('/api/auth/v1/audit-logs', {
  1393. headers,
  1394. params: { page: '1', size: '100', action: 'ai.remote.command', keyword: immutableProbeId, start: lifecycleStartedAt },
  1395. })
  1396. const commandAuditData = await responseData<JsonRecord>(commandAuditResponse, 200, '查询远控命令系统审计')
  1397. const commandAudits = pageItems(commandAuditData)
  1398. expect(commandAudits.some((item) => textValue(item.actionCode) === 'ai.remote.command'
  1399. && textValue(item.targetId) === immutableProbeId)).toBe(true)
  1400. const serializedAudit = JSON.stringify({ terminalAudits, commandAudits })
  1401. for (const secret of activationValues) expect(serializedAudit).not.toContain(secret)
  1402. expect(serializedAudit).not.toContain(idempotencyKey)
  1403. evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'DELETED_WITH_IMMUTABLE_AUDIT' })
  1404. await page.goto('/agents/remote-control')
  1405. await page.getByRole('textbox', { name: '搜索终端' }).fill(terminalName)
  1406. await page.getByRole('button', { name: '搜索', exact: true }).click()
  1407. await expect(page.getByText('没有符合条件的远程终端', { exact: true })).toBeVisible()
  1408. await captureScreenshot(page, testInfo, 'remote-v2-real-12-deleted-zero-active-residual')
  1409. } catch (cause) {
  1410. evidence.push({
  1411. phase: activePhase,
  1412. result: 'FAIL',
  1413. terminalId: terminal?.id,
  1414. detail: cause instanceof Error ? cause.message.slice(0, 500) : '未知失败',
  1415. })
  1416. throw cause
  1417. } finally {
  1418. await page.locator('.activation-dialog code').evaluateAll((elements) => {
  1419. for (const element of elements) element.textContent = '[已遮蔽]'
  1420. }).catch(() => undefined)
  1421. if (primaryRuntime) await primaryRuntime.context.close().catch(() => undefined)
  1422. if (replacementRuntime) await replacementRuntime.context.close().catch(() => undefined)
  1423. if (api && !terminalDeleted) await cleanupTerminal(api, headers, terminal, evidence, cleanupErrors)
  1424. await attachJson(testInfo, 'remote-terminal-v2-real-lifecycle-evidence', {
  1425. runId,
  1426. terminalId: terminal?.id || null,
  1427. boundPublishedAgent: boundAgentEvidence,
  1428. mediaIsolation: {
  1429. chatTransport: 'CONTROLLED',
  1430. tts: 'REAL',
  1431. media: 'REAL',
  1432. remoteControl: 'REAL',
  1433. speech: mediaEvidence,
  1434. terminalChatDeviceHeadersValid,
  1435. signedAudioUrlAttached: false,
  1436. },
  1437. terminalScopedAccess: terminalScopedAccessEvidence,
  1438. runtimeLease: runtimeLeaseEvidence,
  1439. commandIds,
  1440. evidence,
  1441. expectedImmutableHistory: {
  1442. commandAndEventRowsRetainedAfterTerminalDeletion: terminalDeleted,
  1443. systemAuditRetainedAfterTerminalDeletion: terminalDeleted,
  1444. immutableCommandIds,
  1445. },
  1446. activeTerminalResidualCount: terminalDeleted || cleanupErrors.length === 0 ? 0 : null,
  1447. cleanupComplete: cleanupErrors.length === 0,
  1448. cleanupErrors,
  1449. security: {
  1450. accountPasswordOrBearerTokenAttached: false,
  1451. activationCodeAttached: false,
  1452. deviceSecretReadByTestProcess: false,
  1453. traceAndVideoDisabledByPlaywrightConfig: true,
  1454. runtimeFragmentClearedBeforeScreenshots: true,
  1455. },
  1456. })
  1457. assertArtifactsExclude(activationValues)
  1458. await api?.dispose()
  1459. }
  1460. expect(cleanupErrors, '活动远程终端必须精确清理为零').toEqual([])
  1461. })
  1462. test('短链路 WIDGET 在受控宿主 iframe 激活、执行命令并在宿主刷新后复用凭据', async ({ page, browser }, testInfo) => {
  1463. test.setTimeout(8 * 60_000)
  1464. const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
  1465. const terminalName = `${runPrefix}-WIDGET-终端-${suffix}`.slice(0, 110)
  1466. const terminalCode = `RTC-WIDGET-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').toUpperCase().slice(0, 64)
  1467. const baseOrigin = new URL(baseURL).origin
  1468. const hostPath = `/ape2e/remote-widget-host-${encodeURIComponent(suffix)}`
  1469. const activationValues: string[] = []
  1470. const evidence: LifecycleEvidence[] = []
  1471. const cleanupErrors: string[] = []
  1472. let terminal: TerminalRef | null = null
  1473. let runtimeContext: BrowserContext | null = null
  1474. let api: APIRequestContext | null = null
  1475. let headers: Headers = {}
  1476. let activationRequests = 0
  1477. let runtimeTransport: RuntimeTransportMonitor | null = null
  1478. let terminalConfigRequests = 0
  1479. let terminalSessionRequests = 0
  1480. let legacyPublicConfigRequests = 0
  1481. let legacyPublicSessionRequests = 0
  1482. let terminalConfigHeadersValid = true
  1483. let terminalSessionHeadersValid = true
  1484. const terminalFollowupRequests = { chat: 0, asr: 0, feedback: 0, end: 0 }
  1485. let terminalFollowupHeadersValid = true
  1486. let widgetCommandId = ''
  1487. let widgetCommandStatus = ''
  1488. let hostRefreshReusedCredential = false
  1489. let releasedSessionBeforeReconnect = false
  1490. let createdSessionAfterReconnect = false
  1491. let widgetFailure = ''
  1492. try {
  1493. api = await playwrightRequest.newContext({ baseURL })
  1494. headers = await authHeaders(api)
  1495. const agent = await findPublishedAgent(api, headers)
  1496. await loginAsAdmin(page)
  1497. const createResponse = await api.post('/api/v1/remote-terminals', {
  1498. headers,
  1499. data: {
  1500. name: terminalName,
  1501. code: terminalCode,
  1502. location: `E2E WIDGET 宿主 ${suffix}`,
  1503. boundAgentId: agent.id,
  1504. displayMode: 'WIDGET',
  1505. allowedOrigins: [baseOrigin],
  1506. defaultVolume: 60,
  1507. allowInterrupt: true,
  1508. },
  1509. })
  1510. const created = activationParts(await responseData(createResponse, 201, '创建真实 WIDGET 终端'))
  1511. expect(createResponse.headers()['cache-control'] || '').toContain('no-store')
  1512. if (!created.activationCode || created.activationCode.length < 12) throw new Error('WIDGET 创建响应未返回有效的一次性激活码')
  1513. activationValues.push(created.activationCode)
  1514. terminal = {
  1515. id: textValue(created.terminal.id),
  1516. code: textValue(created.terminal.code) || terminalCode,
  1517. name: textValue(created.terminal.name) || terminalName,
  1518. dataVersion: numberValue(created.terminal.dataVersion),
  1519. }
  1520. expect(stringList(created.terminal.allowedOrigins)).toEqual([baseOrigin])
  1521. expect(textValue(created.terminal.displayMode)).toBe('WIDGET')
  1522. runtimeContext = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
  1523. const hostPage = await runtimeContext.newPage()
  1524. runtimeTransport = observeRuntimeTransport(hostPage, terminal.code, baseOrigin)
  1525. const cleanEmbedPath = `/embed/${encodeURIComponent(agent.slug)}?terminal=${encodeURIComponent(terminal.code)}`
  1526. const terminalConfigPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/config`
  1527. const terminalSessionPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/sessions`
  1528. const legacyPublicConfigPath = `/open/v1/realtime/${encodeURIComponent(agent.slug)}`
  1529. const legacyPublicSessionPath = `${legacyPublicConfigPath}/sessions`
  1530. let includeActivation = true
  1531. runtimeContext.on('request', (request) => {
  1532. const pathname = new URL(request.url()).pathname
  1533. const method = request.method()
  1534. if (pathname === '/open/v2/terminals/activate') activationRequests += 1
  1535. if (pathname === terminalConfigPath && method === 'GET') {
  1536. terminalConfigRequests += 1
  1537. const requestHeaders = request.headers()
  1538. terminalConfigHeadersValid = terminalConfigHeadersValid
  1539. && requestHeaders['x-terminal-id'] === terminal!.code
  1540. && Boolean(requestHeaders['x-device-secret'])
  1541. && requestHeaders['x-terminal-page-origin'] === baseOrigin
  1542. && !requestHeaders['x-runtime-instance-id']
  1543. && !requestHeaders['x-terminal-secret']
  1544. }
  1545. if (pathname === terminalSessionPath && method === 'POST') {
  1546. terminalSessionRequests += 1
  1547. const requestHeaders = request.headers()
  1548. terminalSessionHeadersValid = terminalSessionHeadersValid
  1549. && requestHeaders['x-terminal-id'] === terminal!.code
  1550. && Boolean(requestHeaders['x-device-secret'])
  1551. && requestHeaders['x-terminal-page-origin'] === baseOrigin
  1552. && requestHeaders['x-runtime-instance-id'] === runtimeTransport!.lastRuntimeInstanceId()
  1553. && !requestHeaders['x-terminal-secret']
  1554. }
  1555. const followupMatch = pathname.match(new RegExp(`^${legacyPublicConfigPath}/(chat|asr|feedback|end)$`))
  1556. if (followupMatch && method === 'POST') {
  1557. const endpoint = followupMatch[1] as keyof typeof terminalFollowupRequests
  1558. terminalFollowupRequests[endpoint] += 1
  1559. const requestHeaders = request.headers()
  1560. terminalFollowupHeadersValid = terminalFollowupHeadersValid
  1561. && requestHeaders['x-terminal-id'] === terminal!.code
  1562. && Boolean(requestHeaders['x-device-secret'])
  1563. && requestHeaders['x-terminal-page-origin'] === baseOrigin
  1564. && requestHeaders['x-runtime-instance-id'] === runtimeTransport!.lastRuntimeInstanceId()
  1565. && /^Bearer\s+\S+$/i.test(requestHeaders.authorization || '')
  1566. && !requestHeaders['x-terminal-secret']
  1567. }
  1568. if (pathname === legacyPublicConfigPath && method === 'GET') legacyPublicConfigRequests += 1
  1569. if (pathname === legacyPublicSessionPath && method === 'POST') legacyPublicSessionRequests += 1
  1570. })
  1571. await hostPage.route(`**${hostPath}`, async (route) => {
  1572. if (new URL(route.request().url()).pathname !== hostPath) return route.continue()
  1573. const fragment = includeActivation
  1574. ? `#${new URLSearchParams({ 'terminal-activation': created.activationCode }).toString()}`
  1575. : ''
  1576. const iframeSource = `${cleanEmbedPath}${fragment}`
  1577. .replaceAll('&', '&amp;')
  1578. .replaceAll('"', '&quot;')
  1579. await route.fulfill({
  1580. status: 200,
  1581. contentType: 'text/html; charset=utf-8',
  1582. headers: { 'Cache-Control': 'no-store' },
  1583. body: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>APE2E WIDGET 宿主</title><style>html,body{margin:0;min-height:100%;background:#eef5f3}main{min-height:760px;padding:24px}iframe{position:fixed;right:24px;bottom:24px;width:420px;height:680px;border:0}</style></head><body><main><h1>受控内网业务宿主</h1><p>宿主 Origin:${baseOrigin}</p><iframe id="terminal-widget" title="远程数字人终端" src="${iframeSource}" allow="microphone; autoplay" referrerpolicy="origin"></iframe></main></body></html>`,
  1584. })
  1585. })
  1586. const activationResponsePromise = hostPage.waitForResponse((response) => response.request().method() === 'POST'
  1587. && new URL(response.url()).pathname === '/open/v2/terminals/activate', { timeout: 40_000 })
  1588. await hostPage.goto(hostPath)
  1589. const activationResponse = await activationResponsePromise
  1590. responseStatus(activationResponse, 200, '宿主 iframe 兑换一次性激活码')
  1591. expect(activationResponse.headers()['cache-control'] || '').toContain('no-store')
  1592. includeActivation = false
  1593. const widget = hostPage.frameLocator('#terminal-widget')
  1594. await expect(widget.locator('.embed-widget')).toBeVisible({ timeout: 30_000 })
  1595. await expect.poll(() => {
  1596. const frame = hostPage.frames().find((item) => {
  1597. try { return new URL(item.url()).pathname === `/embed/${encodeURIComponent(agent.slug)}` } catch { return false }
  1598. })
  1599. return frame ? new URL(frame.url()).hash : '#waiting'
  1600. }, { message: 'iframe 必须在激活请求前清除 URL 中的一次性激活码', timeout: 15_000 }).toBe('')
  1601. await expect.poll(runtimeTransport.heartbeatSuccesses, { timeout: 30_000 }).toBeGreaterThan(0)
  1602. await expect.poll(() => terminalConfigRequests, {
  1603. message: 'WIDGET 必须使用设备凭据读取绑定智能体配置',
  1604. timeout: 20_000,
  1605. }).toBeGreaterThan(0)
  1606. expect(activationRequests).toBe(1)
  1607. expect(runtimeTransport.headersValid(), '运行端点必须携带设备头、runtime instance、可信宿主 Origin 且不再发送旧头').toBe(true)
  1608. expect(runtimeTransport.pullStartedAfterHeartbeat(), 'WIDGET 必须首次 heartbeat 成功后才启动 pull').toBe(true)
  1609. expect(runtimeTransport.runtimeInstanceOmittedFromUrls()).toBe(true)
  1610. expect(await runtimeInstancesAbsentFromBrowserState(hostPage, runtimeTransport.runtimeInstanceIds())).toBe(true)
  1611. expect(terminalConfigHeadersValid, 'WIDGET 配置请求必须携带设备头和可信宿主 Origin').toBe(true)
  1612. expect(legacyPublicConfigRequests, 'WIDGET 受管终端不得回退到旧公开配置接口').toBe(0)
  1613. expect(legacyPublicSessionRequests, 'WIDGET 激活阶段不得调用旧公开会话接口').toBe(0)
  1614. await waitTerminal(api, headers, terminal.id, (value) => value.online === true
  1615. && terminalConnection(value) === 'ONLINE'
  1616. && stringList(value.capabilities).includes('VOLUME'), 'WIDGET 激活后必须心跳在线并上报 VOLUME 能力')
  1617. await captureScreenshot(hostPage, testInfo, 'remote-v2-widget-01-iframe-activated-no-secret')
  1618. evidence.push({ phase: 'WIDGET iframe 激活与心跳', result: 'PASS', terminalId: terminal.id, status: 'ONLINE' })
  1619. const sessionRequestsBeforeWake = terminalSessionRequests
  1620. const wakeResponse = await postCommand(
  1621. api,
  1622. headers,
  1623. terminal.id,
  1624. { type: 'WAKE', detail: 'WIDGET 首次受管会话签发验证' },
  1625. `remote-v2-widget-${suffix}-wake`,
  1626. )
  1627. const wake = await parseCommandResponse(wakeResponse, '向 WIDGET 下发 WAKE 命令')
  1628. await waitCommand(api, headers, wake.id, 'SUCCEEDED', 60_000)
  1629. await expect.poll(() => terminalSessionRequests, {
  1630. message: 'WIDGET WAKE 必须通过当前 runtime owner 签发 scoped session',
  1631. timeout: 20_000,
  1632. }).toBeGreaterThan(sessionRequestsBeforeWake)
  1633. expect(terminalSessionHeadersValid, 'WIDGET scoped session 必须携带三项设备头和当前 runtime instance').toBe(true)
  1634. expect(legacyPublicSessionRequests, 'WIDGET WAKE 不得回退旧公开 session 路径').toBe(0)
  1635. await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'ACTIVE',
  1636. 'WIDGET WAKE 后必须进入 ACTIVE 会话', 45_000)
  1637. evidence.push({ phase: 'WIDGET runtime-owner scoped session', result: 'PASS', terminalId: terminal.id, commandId: wake.id, status: 'ACTIVE' })
  1638. const reportsBeforeCommand = runtimeTransport.reportRequests()
  1639. const commandResponse = await postCommand(
  1640. api,
  1641. headers,
  1642. terminal.id,
  1643. { type: 'VOLUME', detail: 'WIDGET 短链路设置音量', volume: 41 },
  1644. `remote-v2-widget-${suffix}-volume`,
  1645. )
  1646. const command = await parseCommandResponse(commandResponse, '向 WIDGET 下发 VOLUME 命令')
  1647. const completed = await waitCommand(api, headers, command.id, 'SUCCEEDED', 60_000)
  1648. widgetCommandId = command.id
  1649. widgetCommandStatus = commandStatus(completed)
  1650. expect(numberValue(asRecord(completed.result).volume)).toBe(41)
  1651. expect(runtimeTransport.reportRequests()).toBeGreaterThan(reportsBeforeCommand)
  1652. expect(runtimeTransport.headersValid()).toBe(true)
  1653. await waitTerminal(api, headers, terminal.id, (value) => terminalActualVolume(value) === 41,
  1654. 'WIDGET 命令执行后心跳必须回报 41% 音量', 45_000)
  1655. evidence.push({ phase: 'WIDGET 真实命令与终态回执', result: 'PASS', terminalId: terminal.id, commandId: command.id, status: 'SUCCEEDED' })
  1656. const activationCountBeforeRefresh = activationRequests
  1657. const terminalConfigCountBeforeRefresh = terminalConfigRequests
  1658. const releaseCountBeforeRefresh = runtimeTransport.releaseRequests()
  1659. const instanceBeforeRefresh = runtimeTransport.lastRuntimeInstanceId()
  1660. const refreshedHeartbeat = hostPage.waitForResponse((response) => response.request().method() === 'POST'
  1661. && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat'
  1662. && response.status() === 200, { timeout: 35_000 })
  1663. await hostPage.reload()
  1664. responseStatus(await refreshedHeartbeat, 200, '宿主刷新后 iframe 复用本地设备凭据心跳')
  1665. await expect(hostPage.frameLocator('#terminal-widget').locator('.embed-widget')).toBeVisible({ timeout: 30_000 })
  1666. expect(activationRequests, '宿主刷新不得重复兑换一次性激活码').toBe(activationCountBeforeRefresh)
  1667. await expect.poll(runtimeTransport.releaseRequests, {
  1668. message: '宿主刷新必须先由旧 iframe runtime best-effort 释放租约',
  1669. timeout: 12_000,
  1670. }).toBeGreaterThan(releaseCountBeforeRefresh)
  1671. // refreshedHeartbeat=200 且发生在旧租约窗口内,证明服务端接受了刷新前 release。
  1672. expect(runtimeTransport.releaseBodyEmpty()).toBe(true)
  1673. expect(runtimeTransport.lastRuntimeInstanceId()).not.toBe(instanceBeforeRefresh)
  1674. expect(runtimeTransport.headersValid()).toBe(true)
  1675. expect(runtimeTransport.pullStartedAfterHeartbeat()).toBe(true)
  1676. expect(await runtimeInstancesAbsentFromBrowserState(hostPage, runtimeTransport.runtimeInstanceIds())).toBe(true)
  1677. await expect.poll(() => terminalConfigRequests, {
  1678. message: '宿主刷新后必须复用设备凭据重新读取绑定智能体配置',
  1679. timeout: 20_000,
  1680. }).toBeGreaterThan(terminalConfigCountBeforeRefresh)
  1681. expect(terminalConfigHeadersValid, '宿主刷新后的配置请求仍必须携带设备头和原宿主 Origin').toBe(true)
  1682. expect(legacyPublicConfigRequests, '宿主刷新不得回退到旧公开配置接口').toBe(0)
  1683. expect(legacyPublicSessionRequests, 'WIDGET 短链路不得调用旧公开会话接口').toBe(0)
  1684. const persistedWithoutReadingSecret = await hostPage.frames()
  1685. .find((item) => {
  1686. try { return new URL(item.url()).pathname === `/embed/${encodeURIComponent(agent.slug)}` } catch { return false }
  1687. })
  1688. ?.evaluate((code) => Boolean(localStorage.getItem(`ai-person:remote-terminal-runtime:v2:${encodeURIComponent(code.trim().toUpperCase())}`)), terminal.code)
  1689. expect(persistedWithoutReadingSecret, '宿主刷新前设备凭据必须已持久化,测试不读取其内容').toBe(true)
  1690. hostRefreshReusedCredential = true
  1691. await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE',
  1692. 'WIDGET 旧 runtime release 后服务端必须中断旧 ACTIVE 会话', 45_000)
  1693. releasedSessionBeforeReconnect = true
  1694. const sessionsBeforeReconnectWake = terminalSessionRequests
  1695. const reconnectWakeResponse = await postCommand(
  1696. api,
  1697. headers,
  1698. terminal.id,
  1699. { type: 'WAKE', detail: 'WIDGET 刷新接管后新会话验证' },
  1700. `remote-v2-widget-${suffix}-wake-after-refresh`,
  1701. )
  1702. const reconnectWake = await parseCommandResponse(reconnectWakeResponse, 'WIDGET 刷新后重新 WAKE')
  1703. await waitCommand(api, headers, reconnectWake.id, 'SUCCEEDED', 60_000)
  1704. await expect.poll(() => terminalSessionRequests, {
  1705. message: 'WIDGET 新 runtime owner 必须能签发新 scoped session',
  1706. timeout: 20_000,
  1707. }).toBeGreaterThan(sessionsBeforeReconnectWake)
  1708. await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'ACTIVE',
  1709. 'WIDGET 刷新接管后新会话必须 ACTIVE', 45_000)
  1710. createdSessionAfterReconnect = true
  1711. const endRequestsBeforeStop = terminalFollowupRequests.end
  1712. const reconnectStopResponse = await postCommand(
  1713. api,
  1714. headers,
  1715. terminal.id,
  1716. { type: 'STOP', detail: 'WIDGET 刷新接管会话收敛' },
  1717. `remote-v2-widget-${suffix}-stop-after-refresh`,
  1718. )
  1719. const reconnectStop = await parseCommandResponse(reconnectStopResponse, '结束 WIDGET 刷新后的 scoped session')
  1720. await waitCommand(api, headers, reconnectStop.id, 'SUCCEEDED', 60_000)
  1721. await expect.poll(() => terminalFollowupRequests.end, {
  1722. message: 'WIDGET STOP 必须调用受管会话 end',
  1723. timeout: 20_000,
  1724. }).toBeGreaterThan(endRequestsBeforeStop)
  1725. expect(terminalFollowupHeadersValid, 'WIDGET end 必须携带 Bearer、三项设备头与当前 runtime instance').toBe(true)
  1726. await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE',
  1727. 'WIDGET STOP 后不得残留 ACTIVE 会话', 45_000)
  1728. await captureScreenshot(hostPage, testInfo, 'remote-v2-widget-02-host-refreshed-credential-reused')
  1729. evidence.push({ phase: 'WIDGET 宿主刷新持久重连', result: 'PASS', terminalId: terminal.id, status: 'RECONNECTED_WITHOUT_ACTIVATION' })
  1730. } catch (cause) {
  1731. widgetFailure = cause instanceof Error ? cause.message.slice(0, 500) : '未知失败'
  1732. evidence.push({ phase: 'WIDGET 真实短链路', result: 'FAIL', terminalId: terminal?.id, detail: widgetFailure })
  1733. throw cause
  1734. } finally {
  1735. await runtimeContext?.close().catch(() => undefined)
  1736. if (api) await cleanupTerminal(api, headers, terminal, evidence, cleanupErrors)
  1737. await attachJson(testInfo, 'remote-terminal-v2-widget-short-lifecycle-evidence', {
  1738. runId,
  1739. terminalId: terminal?.id || null,
  1740. allowedOrigin: baseOrigin,
  1741. displayMode: 'WIDGET',
  1742. activationRequestCount: activationRequests,
  1743. runtimeTransport: {
  1744. heartbeatObserved: (runtimeTransport?.heartbeatSuccesses() || 0) > 0,
  1745. pullObserved: (runtimeTransport?.pullRequests() || 0) > 0,
  1746. reportObserved: (runtimeTransport?.reportRequests() || 0) > 0,
  1747. releaseObserved: (runtimeTransport?.releaseRequests() || 0) > 0,
  1748. finalCredentialHeadersObserved: runtimeTransport?.headersValid() ?? false,
  1749. pullStartedAfterHeartbeat: runtimeTransport?.pullStartedAfterHeartbeat() ?? false,
  1750. releaseBodyEmpty: runtimeTransport?.releaseBodyEmpty() ?? false,
  1751. runtimeInstanceIdsAttached: false,
  1752. },
  1753. terminalScopedAccess: {
  1754. configRequests: terminalConfigRequests,
  1755. sessionRequests: terminalSessionRequests,
  1756. configDeviceHeadersValid: terminalConfigHeadersValid,
  1757. sessionDeviceHeadersValid: terminalSessionHeadersValid,
  1758. followupRequests: terminalFollowupRequests,
  1759. followupDeviceHeadersValid: terminalFollowupHeadersValid,
  1760. legacyPublicConfigRequests,
  1761. legacyPublicSessionRequests,
  1762. },
  1763. command: widgetCommandId ? { id: widgetCommandId, type: 'VOLUME', status: widgetCommandStatus } : null,
  1764. hostRefreshReusedCredential,
  1765. releasedSessionBeforeReconnect,
  1766. createdSessionAfterReconnect,
  1767. evidence,
  1768. failure: widgetFailure || null,
  1769. cleanupComplete: cleanupErrors.length === 0,
  1770. cleanupErrors,
  1771. security: {
  1772. activationCodeAttached: false,
  1773. deviceSecretReadByTestProcess: false,
  1774. screenshotsCapturedAfterActivationFragmentCleared: true,
  1775. },
  1776. })
  1777. assertArtifactsExclude(activationValues)
  1778. await api?.dispose()
  1779. }
  1780. expect(cleanupErrors, 'WIDGET 远程终端必须精确清理为零').toEqual([])
  1781. })
  1782. })