No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 

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