|
- import fs from 'node:fs'
- import path from 'node:path'
-
- import {
- request as playwrightRequest,
- type APIRequestContext,
- type Browser,
- type BrowserContext,
- type Page,
- type Response,
- type Route,
- } from '@playwright/test'
-
- import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
- import {
- authHeaders,
- envelopeData,
- fillFormItem,
- formItem,
- loginAsAdmin,
- tableRowByText,
- visibleDialog,
- } from './helpers'
- import {
- observeRuntimeTransport,
- runtimeInstancesAbsentFromBrowserState,
- type RuntimeTransportMonitor,
- } from './remote-terminal-runtime-contract'
-
- type JsonRecord = Record<string, unknown>
- type Headers = Record<string, string>
-
- interface ResponseLike {
- status(): number
- json(): Promise<unknown>
- headers(): Record<string, string>
- }
-
- interface TerminalRef {
- id: string
- code: string
- name: string
- dataVersion: number
- }
-
- interface PublishedAgent {
- id: string
- dataVersion: number
- name: string
- slug: string
- accessMode: string
- voiceCapabilityId: string
- }
-
- interface LifecycleEvidence {
- phase: string
- result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED'
- terminalId?: string
- commandId?: string
- status?: string
- detail?: string
- }
-
- interface RuntimeHandle {
- context: BrowserContext
- page: Page
- publicPath: string
- transport: RuntimeTransportMonitor
- activationRequests: () => number
- heartbeatRequests: () => number
- terminalConfigRequests: () => number
- terminalSessionRequests: () => number
- legacyPublicConfigRequests: () => number
- legacyPublicSessionRequests: () => number
- terminalConfigHeadersValid: () => boolean
- terminalSessionHeadersValid: () => boolean
- terminalFollowupRequests: () => Record<'chat' | 'asr' | 'feedback' | 'end', number>
- terminalFollowupHeadersValid: () => boolean
- }
-
- interface TerminalScopedAccessEvidence {
- configRequests: number
- sessionRequests: number
- legacyPublicConfigRequests: number
- legacyPublicSessionRequests: number
- configDeviceHeadersValid: boolean
- sessionDeviceHeadersValid: boolean
- followupRequests: Record<'chat' | 'asr' | 'feedback' | 'end', number>
- followupDeviceHeadersValid: boolean
- }
-
- interface DirectRuntimeResponse {
- status: number
- code: number
- data: JsonRecord
- }
-
- interface DirectSessionFenceEvidence {
- ownerSessionCreated: boolean
- ownerSessionNoStore: boolean
- websocketOpened: boolean
- ownerReleaseStatus: number
- contenderClaimStatus: number
- oldOwnerHttpStatus: number
- oldOwnerHttpCode: number
- oldOwnerWebSocketCloseCode: number
- contenderSessionCreated: boolean
- contenderSessionEnded: boolean
- staleOwnerReleaseStatus: number
- staleOwnerReleaseCode: number
- }
-
- const baseURL = (process.env.BASE_URL || 'http://127.0.0.1:8003').replace(/\/$/, '')
- const artifactRoot = path.resolve('artifacts', 'runs', runId)
- const terminalCapabilities = ['WAKE', 'PAUSE', 'RESUME', 'STOP', 'VOLUME', 'RESTART']
- const immutableCommandIds: string[] = []
-
- const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {}
- const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
- const textValue = (value: unknown) => value == null ? '' : String(value)
- const stringList = (value: unknown) => Array.isArray(value) ? value.map(textValue).filter(Boolean) : []
- const numberValue = (value: unknown) => {
- const parsed = Number(value)
- return Number.isFinite(parsed) ? parsed : 0
- }
-
- function pageItems(value: unknown) {
- const source = asRecord(value)
- return asRecords(source.items ?? source.records ?? source.rows)
- }
-
- function responseStatus(response: ResponseLike, expected: number | number[], label: string) {
- const statuses = Array.isArray(expected) ? expected : [expected]
- expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
- }
-
- async function responseData<T = JsonRecord>(response: ResponseLike, expected: number | number[], label: string): Promise<T> {
- responseStatus(response, expected, label)
- const body = await response.json().catch(() => {
- throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`)
- })
- return envelopeData<T>(body)
- }
-
- function commandStatus(command: JsonRecord) {
- return textValue(command.statusCode ?? command.status).toUpperCase()
- }
-
- function terminalConnection(terminal: JsonRecord) {
- return textValue(terminal.connectionStatus ?? terminal.onlineStatus).toUpperCase()
- }
-
- function terminalLifecycle(terminal: JsonRecord) {
- return textValue(terminal.lifecycleStatus ?? terminal.status).toUpperCase()
- }
-
- function terminalSession(terminal: JsonRecord) {
- const reported = asRecord(terminal.reportedState)
- return textValue(terminal.sessionStatus ?? reported.sessionStatus ?? asRecord(terminal.session).status).toUpperCase()
- }
-
- function terminalPlayback(terminal: JsonRecord) {
- const reported = asRecord(terminal.reportedState)
- return textValue(terminal.playbackStatus ?? reported.playbackStatus).toUpperCase()
- }
-
- function terminalActualVolume(terminal: JsonRecord) {
- const reported = asRecord(terminal.reportedState)
- return numberValue(terminal.actualVolume ?? reported.volume ?? reported.actualVolume)
- }
-
- function activationParts(value: unknown) {
- const source = asRecord(value)
- const terminal = asRecord(source.terminal ?? source)
- const activation = asRecord(source.activation ?? source.credential ?? source)
- return {
- terminal,
- activationCode: textValue(activation.activationCode ?? activation.code),
- expiresAt: textValue(activation.expiresAt),
- shownOnce: Boolean(activation.shownOnce),
- }
- }
-
- function recursiveKeys(value: unknown, output: string[] = []): string[] {
- if (!value || typeof value !== 'object') return output
- if (Array.isArray(value)) {
- for (const item of value) recursiveKeys(item, output)
- return output
- }
- for (const [key, item] of Object.entries(value as JsonRecord)) {
- output.push(key)
- recursiveKeys(item, output)
- }
- return output
- }
-
- function terminalRuntimeVersion(value: JsonRecord) {
- const runtime = asRecord(value.runtime)
- return textValue(value.runtimeVersion ?? runtime.version ?? runtime.runtimeVersion)
- }
-
- async function getTerminal(api: APIRequestContext, headers: Headers, id: string) {
- const response = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(id)}`, { headers })
- return responseData<JsonRecord>(response, 200, '读取远程终端详情')
- }
-
- async function waitTerminal(
- api: APIRequestContext,
- headers: Headers,
- id: string,
- predicate: (terminal: JsonRecord) => boolean,
- label: string,
- timeout = 70_000,
- ) {
- let latest: JsonRecord = {}
- await expect.poll(async () => {
- latest = await getTerminal(api, headers, id)
- return predicate(latest)
- }, { message: label, timeout, intervals: [500, 1_000, 2_000, 5_000] }).toBe(true)
- return latest
- }
-
- async function getCommand(api: APIRequestContext, headers: Headers, id: string) {
- const response = await api.get(`/api/v1/remote-commands/${encodeURIComponent(id)}`, { headers })
- return responseData<JsonRecord>(response, 200, '读取远控命令详情')
- }
-
- async function waitCommand(
- api: APIRequestContext,
- headers: Headers,
- id: string,
- expected: string | string[],
- timeout = 120_000,
- ) {
- const statuses = (Array.isArray(expected) ? expected : [expected]).map((item) => item.toUpperCase())
- let latest: JsonRecord = {}
- await expect.poll(async () => {
- latest = await getCommand(api, headers, id)
- return statuses.includes(commandStatus(latest))
- }, {
- message: `命令 ${id} 应收敛到 ${statuses.join(' / ')}`,
- timeout,
- intervals: [500, 1_000, 2_000, 5_000],
- }).toBe(true)
- return latest
- }
-
- async function findPublishedAgent(api: APIRequestContext, headers: Headers): Promise<PublishedAgent> {
- const response = await api.get('/api/v1/realtime-agents', {
- headers,
- params: { page: '1', pageSize: '100', status: 'published' },
- })
- const data = await responseData<JsonRecord>(response, 200, '查询已发布智能体')
- const currentTime = Date.now()
- const found = pageItems(data).find((item) => {
- const status = textValue(item.status).toLowerCase()
- const expiresAt = textValue(item.expiresAt)
- const expiry = expiresAt ? new Date(expiresAt).getTime() : Number.POSITIVE_INFINITY
- const interactionModes = stringList(item.interactionModes).map((mode) => mode.toLowerCase())
- return status === 'published' && textValue(item.id) && textValue(item.slug)
- && interactionModes.includes('text') && interactionModes.includes('voice')
- && textValue(item.ttsModel) && textValue(item.voiceCapabilityId)
- && (!Number.isFinite(expiry) || expiry > currentTime)
- })
- if (!found) throw new Error('真实环境没有可绑定且未过期的已发布智能体')
- return {
- id: textValue(found.id),
- dataVersion: numberValue(found.dataVersion),
- name: textValue(found.name),
- slug: textValue(found.slug),
- accessMode: textValue(found.accessMode),
- voiceCapabilityId: textValue(found.voiceCapabilityId),
- }
- }
-
- async function createRemoteControlSpeech(api: APIRequestContext, headers: Headers, voiceCapabilityId: string) {
- const text = Array.from(
- { length: 14 },
- () => '远程控制媒体链路验收正在进行,请保持设备在线,并依次核对暂停、继续和结束播报操作。',
- ).join('')
- const response = await api.post('/api/v1/tts', {
- headers,
- data: {
- text,
- voiceCapabilityId,
- speed: 0.7,
- },
- timeout: 180_000,
- })
- const speech = await responseData<JsonRecord>(response, 200, '生成远控媒体控制验收音频')
- const audioUrl = textValue(speech.audioUrl)
- if (!audioUrl) throw new Error('真实 TTS 未返回可播放音频地址')
- expect(textValue(speech.mimeType)).toBe('audio/wav')
- expect(numberValue(speech.duration)).toBeGreaterThanOrEqual(45)
- expect(textValue(speech.voiceCapabilityId)).toBe(voiceCapabilityId)
- const media = await api.get(audioUrl, { timeout: 30_000 })
- responseStatus(media, 200, '读取真实 TTS 音频')
- expect(media.headers()['content-type'] || '').toContain('audio')
- const audio = await media.body()
- expect(audio.byteLength).toBeGreaterThan(44)
- expect(audio.subarray(0, 4).toString('ascii')).toBe('RIFF')
- return {
- id: textValue(speech.id),
- audioUrl,
- duration: numberValue(speech.duration),
- voiceCapabilityId: textValue(speech.voiceCapabilityId),
- mediaBytes: audio.byteLength,
- }
- }
-
- async function chooseAgent(page: Page, dialog: ReturnType<Page['locator']>, agent: PublishedAgent) {
- await formItem(dialog, '绑定智能体').locator('.el-select').click()
- const dropdown = page.locator('.el-select-dropdown:visible').last()
- await expect(dropdown).toBeVisible()
- await dropdown.locator('.el-select-dropdown__item').filter({ hasText: agent.slug }).click()
- }
-
- async function setSliderValue(slider: ReturnType<Page['locator']>, target: number) {
- const control = slider.getByRole('slider')
- await expect(control).toBeVisible()
- await control.focus()
- let current = Number(await control.getAttribute('aria-valuenow'))
- if (!Number.isFinite(current)) throw new Error('音量滑块缺少 aria-valuenow')
- for (let attempt = 0; current !== target && attempt <= 100; attempt += 1) {
- await control.press(target > current ? 'ArrowRight' : 'ArrowLeft')
- current = Number(await control.getAttribute('aria-valuenow'))
- }
- await expect(control).toHaveAttribute('aria-valuenow', String(target))
- }
-
- async function scrubActivationDialog(page: Page) {
- const dialog = page.locator('.activation-dialog:visible')
- await expect(dialog).toBeVisible()
- const code = dialog.locator('.activation-link-row code')
- if (await code.count()) {
- await code.evaluate((element) => {
- element.textContent = '[一次性激活链接已由测试进程安全接收并遮蔽]'
- element.setAttribute('data-secret-scrubbed', 'true')
- })
- }
- return dialog
- }
-
- async function injectActivation(page: Page, code: string) {
- const activationResponse = page.waitForResponse((response) => response.request().method() === 'POST'
- && new URL(response.url()).pathname.endsWith('/open/v2/terminals/activate'), { timeout: 40_000 })
- await page.evaluate((secret) => {
- const fragment = new URLSearchParams({ 'terminal-activation': secret }).toString()
- window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}#${fragment}`)
- }, code)
- await page.reload({ waitUntil: 'domcontentloaded' })
- const response = await activationResponse
- await expect.poll(() => new URL(page.url()).hash, {
- message: '运行页必须在兑换前清除 URL fragment 中的一次性激活码',
- timeout: 12_000,
- }).toBe('')
- return response
- }
-
- /**
- * 在已激活浏览器上下文内直接验证服务端租约 fencing。设备密钥只在浏览器
- * evaluate 内从既有凭据读取并用于 fetch,不返回 Node 测试进程或附件。
- */
- async function directRuntimeRequest(
- page: Page,
- terminalCode: string,
- runtimeInstanceId: string,
- pathname: string,
- data?: JsonRecord,
- ): Promise<DirectRuntimeResponse> {
- return page.evaluate(async ({ code, instanceId, path, payload }) => {
- const key = `ai-person:remote-terminal-runtime:v2:${encodeURIComponent(code.trim().toUpperCase())}`
- const stored = JSON.parse(window.localStorage.getItem(key) || 'null') as {
- terminalId?: string
- secret?: string
- pageOrigin?: string
- } | null
- if (!stored?.terminalId || !stored.secret || !stored.pageOrigin) {
- throw new Error('浏览器内不存在可用的远程终端设备凭据')
- }
- const headers: Record<string, string> = {
- Accept: 'application/json',
- 'X-Terminal-Id': stored.terminalId,
- 'X-Device-Secret': stored.secret,
- 'X-Terminal-Page-Origin': stored.pageOrigin,
- 'X-Runtime-Instance-Id': instanceId,
- }
- if (payload !== undefined) headers['Content-Type'] = 'application/json'
- const response = await window.fetch(path, {
- method: 'POST',
- headers,
- body: payload === undefined ? undefined : JSON.stringify(payload),
- cache: 'no-store',
- })
- const body = await response.json().catch(() => ({})) as Record<string, unknown>
- return {
- status: response.status,
- code: Number(body.code || 0),
- data: body.data && typeof body.data === 'object' ? body.data as Record<string, unknown> : {},
- }
- }, { code: terminalCode, instanceId: runtimeInstanceId, path: pathname, payload: data })
- }
-
- /**
- * 设备密钥、会话令牌与 websocket ticket 始终留在浏览器 evaluate 闭包内;Node 侧只接收
- * HTTP 状态、错误码和布尔后验,避免任何授权材料进入 Playwright 附件或失败日志。
- */
- async function directRuntimeSessionFence(
- page: Page,
- terminalCode: string,
- runtimeA: string,
- runtimeB: string,
- agentSlug: string,
- heartbeatState: JsonRecord,
- ): Promise<DirectSessionFenceEvidence> {
- return page.evaluate(async ({ code, ownerInstance, contenderInstance, slug, state }) => {
- const key = `ai-person:remote-terminal-runtime:v2:${encodeURIComponent(code.trim().toUpperCase())}`
- const stored = JSON.parse(window.localStorage.getItem(key) || 'null') as {
- terminalId?: string
- secret?: string
- pageOrigin?: string
- } | null
- if (!stored?.terminalId || !stored.secret || !stored.pageOrigin) {
- throw new Error('浏览器内不存在可用于会话租约验证的设备凭据')
- }
-
- const headersFor = (instanceId: string, token = '') => {
- const headers: Record<string, string> = {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-Terminal-Id': stored.terminalId!,
- 'X-Device-Secret': stored.secret!,
- 'X-Terminal-Page-Origin': stored.pageOrigin!,
- 'X-Runtime-Instance-Id': instanceId,
- }
- if (token) headers.Authorization = `Bearer ${token}`
- return headers
- }
- const post = async (pathname: string, instanceId: string, payload: Record<string, unknown> | undefined, token = '') => {
- const response = await window.fetch(pathname, {
- method: 'POST',
- headers: headersFor(instanceId, token),
- body: payload === undefined ? undefined : JSON.stringify(payload),
- cache: 'no-store',
- })
- const body = await response.json().catch(() => ({})) as Record<string, unknown>
- const data = body.data && typeof body.data === 'object'
- ? body.data as Record<string, unknown>
- : {}
- return {
- status: response.status,
- code: Number(body.code || 0),
- data,
- noStore: (response.headers.get('cache-control') || '').includes('no-store'),
- }
- }
-
- const sessionPath = `/open/v2/terminals/agents/${encodeURIComponent(slug)}/sessions`
- const ownerSession = await post(sessionPath, ownerInstance, {})
- const ownerToken = String(ownerSession.data.sessionToken || '')
- const rawSocketUrl = String(ownerSession.data.websocketUrl || '')
- if (ownerSession.status !== 201 || !ownerToken || !rawSocketUrl) {
- throw new Error(`owner scoped session 创建失败:HTTP ${ownerSession.status}`)
- }
- const socketUrl = new URL(rawSocketUrl, window.location.href)
- if (socketUrl.protocol === 'http:') socketUrl.protocol = 'ws:'
- if (socketUrl.protocol === 'https:') socketUrl.protocol = 'wss:'
- const socket = new WebSocket(socketUrl.toString())
- await new Promise<void>((resolve, reject) => {
- const timer = window.setTimeout(() => reject(new Error('owner websocket 建连超时')), 15_000)
- socket.addEventListener('open', () => {
- window.clearTimeout(timer)
- resolve()
- }, { once: true })
- socket.addEventListener('error', () => {
- window.clearTimeout(timer)
- reject(new Error('owner websocket 建连失败'))
- }, { once: true })
- })
- const socketClosed = new Promise<number>((resolve, reject) => {
- const timer = window.setTimeout(() => reject(new Error('旧 owner websocket 未在接管后关闭')), 15_000)
- socket.addEventListener('close', (event) => {
- window.clearTimeout(timer)
- resolve(event.code)
- }, { once: true })
- })
-
- const ownerRelease = await post('/open/v2/terminals/runtime/release', ownerInstance, undefined)
- const contenderClaim = await post('/open/v2/terminals/heartbeat', contenderInstance, state)
- const oldOwnerChat = await post(
- `/open/v1/realtime/${encodeURIComponent(slug)}/chat`,
- ownerInstance,
- { message: '旧 owner 会话必须被服务端拒绝' },
- ownerToken,
- )
- socket.send(JSON.stringify({ type: 'ping' }))
- const oldOwnerWebSocketCloseCode = await socketClosed
-
- const contenderSession = await post(sessionPath, contenderInstance, {})
- const contenderToken = String(contenderSession.data.sessionToken || '')
- if (contenderSession.status !== 201 || !contenderToken) {
- throw new Error(`contender scoped session 创建失败:HTTP ${contenderSession.status}`)
- }
- const contenderEnd = await post(
- `/open/v1/realtime/${encodeURIComponent(slug)}/end`,
- contenderInstance,
- {},
- contenderToken,
- )
- const staleOwnerRelease = await post('/open/v2/terminals/runtime/release', ownerInstance, undefined)
- if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close()
-
- return {
- ownerSessionCreated: ownerSession.status === 201,
- ownerSessionNoStore: ownerSession.noStore,
- websocketOpened: true,
- ownerReleaseStatus: ownerRelease.status,
- contenderClaimStatus: contenderClaim.status,
- oldOwnerHttpStatus: oldOwnerChat.status,
- oldOwnerHttpCode: oldOwnerChat.code,
- oldOwnerWebSocketCloseCode,
- contenderSessionCreated: contenderSession.status === 201,
- contenderSessionEnded: contenderEnd.status === 200,
- staleOwnerReleaseStatus: staleOwnerRelease.status,
- staleOwnerReleaseCode: staleOwnerRelease.code,
- }
- }, {
- code: terminalCode,
- ownerInstance: runtimeA,
- contenderInstance: runtimeB,
- slug: agentSlug,
- state: heartbeatState,
- })
- }
-
- function directRuntimeState(publicPath: string, actualVolume = 43): JsonRecord {
- return {
- runtimeVersion: 'ape2e/remote-v2-runtime-lease',
- displayMode: 'STANDALONE',
- capabilities: terminalCapabilities,
- actualVolume,
- allowInterrupt: true,
- visibility: 'VISIBLE',
- sessionStatus: 'IDLE',
- playbackStatus: 'IDLE',
- pageUrl: `${baseURL}${publicPath}`,
- pageOrigin: new URL(baseURL).origin,
- platform: 'ape2e',
- }
- }
-
- async function activateRuntime(
- browser: Browser,
- agent: PublishedAgent,
- terminal: TerminalRef,
- code: string,
- ): Promise<RuntimeHandle> {
- const context = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
- const page = await context.newPage()
- const publicPath = `/live/${encodeURIComponent(agent.slug)}?terminal=${encodeURIComponent(terminal.code)}`
- const terminalConfigPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/config`
- const terminalSessionPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/sessions`
- const legacyPublicConfigPath = `/open/v1/realtime/${encodeURIComponent(agent.slug)}`
- const legacyPublicSessionPath = `${legacyPublicConfigPath}/sessions`
- const pageOrigin = new URL(baseURL).origin
- const transport = observeRuntimeTransport(page, terminal.code, pageOrigin)
- let activationCount = 0
- let terminalConfigCount = 0
- let terminalSessionCount = 0
- let legacyPublicConfigCount = 0
- let legacyPublicSessionCount = 0
- let terminalConfigHeadersAreValid = true
- let terminalSessionHeadersAreValid = true
- const terminalFollowupCounts = { chat: 0, asr: 0, feedback: 0, end: 0 }
- let terminalFollowupHeadersAreValid = true
- page.on('request', (request) => {
- const pathname = new URL(request.url()).pathname
- const method = request.method()
- if (pathname.endsWith('/open/v2/terminals/activate')) activationCount += 1
- if (pathname === terminalConfigPath && method === 'GET') {
- terminalConfigCount += 1
- const headers = request.headers()
- terminalConfigHeadersAreValid = terminalConfigHeadersAreValid
- && headers['x-terminal-id'] === terminal.code
- && Boolean(headers['x-device-secret'])
- && headers['x-terminal-page-origin'] === pageOrigin
- && !headers['x-runtime-instance-id']
- && !headers['x-terminal-secret']
- }
- const followupMatch = pathname.match(new RegExp(`^${legacyPublicConfigPath}/(chat|asr|feedback|end)$`))
- if (followupMatch && method === 'POST') {
- const endpoint = followupMatch[1] as keyof typeof terminalFollowupCounts
- terminalFollowupCounts[endpoint] += 1
- const headers = request.headers()
- terminalFollowupHeadersAreValid = terminalFollowupHeadersAreValid
- && headers['x-terminal-id'] === terminal.code
- && Boolean(headers['x-device-secret'])
- && headers['x-terminal-page-origin'] === pageOrigin
- && headers['x-runtime-instance-id'] === transport.lastRuntimeInstanceId()
- && /^Bearer\s+\S+$/i.test(headers.authorization || '')
- && !headers['x-terminal-secret']
- }
- if (pathname === terminalSessionPath && method === 'POST') {
- terminalSessionCount += 1
- const headers = request.headers()
- terminalSessionHeadersAreValid = terminalSessionHeadersAreValid
- && headers['x-terminal-id'] === terminal.code
- && Boolean(headers['x-device-secret'])
- && headers['x-terminal-page-origin'] === pageOrigin
- && headers['x-runtime-instance-id'] === transport.lastRuntimeInstanceId()
- && !headers['x-terminal-secret']
- }
- if (pathname === legacyPublicConfigPath && method === 'GET') legacyPublicConfigCount += 1
- if (pathname === legacyPublicSessionPath && method === 'POST') legacyPublicSessionCount += 1
- })
- await page.goto(publicPath)
- await expect(page.locator('.live-agent-page')).toBeVisible()
- const activated = await injectActivation(page, code)
- responseStatus(activated, 200, '公开运行页兑换一次性激活码')
- expect(activated.headers()['cache-control'] || '').toContain('no-store')
- await expect(page.locator('.terminal-runtime-notice')).toContainText('终端激活成功,已开始连接远程控制服务')
- await expect.poll(transport.heartbeatSuccesses, { timeout: 25_000 }).toBeGreaterThan(0)
- return {
- context,
- page,
- publicPath,
- transport,
- activationRequests: () => activationCount,
- heartbeatRequests: transport.heartbeatRequests,
- terminalConfigRequests: () => terminalConfigCount,
- terminalSessionRequests: () => terminalSessionCount,
- legacyPublicConfigRequests: () => legacyPublicConfigCount,
- legacyPublicSessionRequests: () => legacyPublicSessionCount,
- terminalConfigHeadersValid: () => terminalConfigHeadersAreValid,
- terminalSessionHeadersValid: () => terminalSessionHeadersAreValid,
- terminalFollowupRequests: () => ({ ...terminalFollowupCounts }),
- terminalFollowupHeadersValid: () => terminalFollowupHeadersAreValid,
- }
- }
-
- function terminalScopedAccess(runtime: RuntimeHandle): TerminalScopedAccessEvidence {
- return {
- configRequests: runtime.terminalConfigRequests(),
- sessionRequests: runtime.terminalSessionRequests(),
- legacyPublicConfigRequests: runtime.legacyPublicConfigRequests(),
- legacyPublicSessionRequests: runtime.legacyPublicSessionRequests(),
- configDeviceHeadersValid: runtime.terminalConfigHeadersValid(),
- sessionDeviceHeadersValid: runtime.terminalSessionHeadersValid(),
- followupRequests: runtime.terminalFollowupRequests(),
- followupDeviceHeadersValid: runtime.terminalFollowupHeadersValid(),
- }
- }
-
- async function verifyConsumedActivation(
- browser: Browser,
- agent: PublishedAgent,
- terminal: TerminalRef,
- consumedCode: string,
- ) {
- const context = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
- const page = await context.newPage()
- try {
- const publicPath = `/live/${encodeURIComponent(agent.slug)}?terminal=${encodeURIComponent(terminal.code)}`
- await page.goto(publicPath)
- await expect(page.locator('.live-agent-page')).toBeVisible()
- const replay = await injectActivation(page, consumedCode)
- responseStatus(replay, [400, 401, 403, 409, 410, 422], '已消费激活码重放必须被拒绝')
- await expect(page.locator('.terminal-runtime-notice.is-error')).toBeVisible()
- } finally {
- await context.close()
- }
- }
-
- async function searchTerminal(page: Page, name: string) {
- await page.goto('/agents/remote-control')
- await expect(page.getByRole('heading', { name: '远程控制', exact: true })).toBeVisible()
- const keyword = page.getByRole('textbox', { name: '搜索终端' })
- await keyword.fill(name)
- await page.getByRole('button', { name: '搜索', exact: true }).click()
- const row = tableRowByText(page, name)
- await expect(row).toBeVisible()
- return row
- }
-
- async function openTerminalDrawer(page: Page, name: string) {
- const existing = page.locator('.remote-detail-drawer:visible')
- if (await existing.count()) {
- await existing.getByRole('button', { name: '关闭', exact: true }).click()
- await expect(existing).toBeHidden()
- }
- const row = await searchTerminal(page, name)
- await row.getByRole('button', { name: '详情', exact: true }).click()
- const drawer = page.locator('.remote-detail-drawer:visible')
- await expect(drawer).toBeVisible()
- return drawer
- }
-
- async function parseCommandResponse(response: ResponseLike, label: string) {
- const command = await responseData<JsonRecord>(response, [200, 202], label)
- const id = textValue(command.id ?? command.commandId)
- if (!id) throw new Error(`${label}未返回命令编号`)
- if (!immutableCommandIds.includes(id)) immutableCommandIds.push(id)
- return { id, command }
- }
-
- async function sendUiCommand(
- page: Page,
- drawer: ReturnType<Page['locator']>,
- buttonName: string,
- expectedType: string,
- confirmName?: string,
- ) {
- const responsePromise = page.waitForResponse((response) => {
- if (response.request().method() !== 'POST') return false
- if (!/\/api\/v1\/remote-terminals\/[^/]+\/commands$/.test(new URL(response.url()).pathname)) return false
- const payload = asRecord(response.request().postDataJSON())
- return textValue(payload.type).toUpperCase() === expectedType.toUpperCase()
- }, { timeout: 20_000 })
- await drawer.getByRole('button', { name: buttonName, exact: true }).click()
- if (confirmName) {
- const box = page.locator('.el-message-box:visible')
- await expect(box).toBeVisible()
- await box.getByRole('button', { name: confirmName, exact: true }).click()
- }
- return parseCommandResponse(await responsePromise, `页面下发${buttonName}`)
- }
-
- async function postCommand(
- api: APIRequestContext,
- headers: Headers,
- terminalId: string,
- payload: JsonRecord,
- idempotencyKey: string,
- ) {
- return api.post(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/commands`, {
- headers: { ...headers, 'Idempotency-Key': idempotencyKey },
- data: payload,
- })
- }
-
- function safeTextFiles(directory: string) {
- if (!fs.existsSync(directory)) return []
- const files: string[] = []
- const visit = (current: string) => {
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
- const filename = path.join(current, entry.name)
- if (entry.isDirectory()) visit(filename)
- else if (/\.(?:json|txt|md|html|xml|log)$/i.test(entry.name)) files.push(filename)
- }
- }
- visit(directory)
- return files
- }
-
- function assertArtifactsExclude(values: string[]) {
- const secrets = values.filter(Boolean)
- if (!secrets.length) return
- for (const filename of safeTextFiles(artifactRoot)) {
- const content = fs.readFileSync(filename, 'utf8')
- for (const secret of secrets) {
- if (content.includes(secret)) throw new Error(`测试附件疑似包含一次性凭据:${path.relative(artifactRoot, filename)}`)
- }
- }
- }
-
- async function cleanupTerminal(
- api: APIRequestContext,
- headers: Headers,
- terminal: TerminalRef | null,
- evidence: LifecycleEvidence[],
- cleanupErrors: string[],
- ) {
- if (!terminal) return
- try {
- const detailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers })
- if (detailResponse.status() === 404) return
- const current = await responseData<JsonRecord>(detailResponse, 200, '失败兜底读取终端')
- const commandListResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}/commands`, {
- headers,
- params: { page: '1', pageSize: '100' },
- })
- if (commandListResponse.status() === 200) {
- const commandList = await responseData<JsonRecord>(commandListResponse, 200, '失败兜底读取活动命令')
- for (const command of pageItems(commandList).filter((item) => commandStatus(item) === 'QUEUED')) {
- await api.post(`/api/v1/remote-commands/${encodeURIComponent(textValue(command.id))}/cancel`, {
- headers,
- data: { dataVersion: numberValue(command.dataVersion) },
- }).catch(() => undefined)
- }
- }
- let dataVersion = numberValue(current.dataVersion)
- if (terminalLifecycle(current) !== 'SUSPENDED') {
- const disabled = await api.put(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}/status`, {
- headers,
- data: { enabled: false, dataVersion },
- })
- if (disabled.status() === 200) {
- const value = await responseData<JsonRecord>(disabled, 200, '失败兜底停用终端')
- dataVersion = numberValue(value.dataVersion) || dataVersion
- }
- }
- const removed = await api.delete(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, {
- headers,
- params: { dataVersion: String(dataVersion) },
- })
- responseStatus(removed, [200, 204, 404], '失败兜底撤销终端')
- const absent = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers })
- responseStatus(absent, 404, '失败兜底清理后终端必须不存在')
- evidence.push({ phase: '失败兜底精确清理', result: 'CLEANED', terminalId: terminal.id })
- } catch (cause) {
- const message = cause instanceof Error ? cause.message : String(cause)
- cleanupErrors.push(message)
- evidence.push({ phase: '失败兜底精确清理', result: 'CLEANUP_FAILED', terminalId: terminal.id, detail: message })
- }
- }
-
- test.describe('远程终端 V2 真实双浏览器生命周期', () => {
- test('创建绑定、一次性激活、真实页面动作、回执幂等、重连撤销与零活动残留', async ({ page, browser }, testInfo) => {
- test.setTimeout(22 * 60_000)
-
- const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
- const lifecycleStartedAt = new Date(Date.now() - 2_000).toISOString()
- const terminalName = `${runPrefix}-远控终端-${suffix}`.slice(0, 110)
- const terminalCode = `RTC-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').toUpperCase().slice(0, 64)
- const evidence: LifecycleEvidence[] = []
- const cleanupErrors: string[] = []
- const activationValues: string[] = []
- const commandIds: Record<string, string> = {}
- let terminal: TerminalRef | null = null
- let terminalDeleted = false
- let primaryRuntime: RuntimeHandle | null = null
- let replacementRuntime: RuntimeHandle | null = null
- let api: APIRequestContext | null = null
- let headers: Headers = {}
- let activePhase = '初始化'
- let boundAgentEvidence: Pick<PublishedAgent, 'id' | 'slug' | 'accessMode' | 'voiceCapabilityId'> | null = null
- let mediaEvidence: { speechId: string; duration: number; mediaBytes: number } | null = null
- let terminalScopedAccessEvidence: TerminalScopedAccessEvidence | null = null
- let terminalChatDeviceHeadersValid: boolean | null = null
- let runtimeLeaseEvidence: JsonRecord | null = null
-
- try {
- activePhase = '使用账号文件进程内登录并选择已发布智能体'
- api = await playwrightRequest.newContext({ baseURL })
- headers = await authHeaders(api)
- const agent = await findPublishedAgent(api, headers)
- boundAgentEvidence = {
- id: agent.id,
- slug: agent.slug,
- accessMode: agent.accessMode,
- voiceCapabilityId: agent.voiceCapabilityId,
- }
- await loginAsAdmin(page)
-
- activePhase = '通过管理页面创建独立页面终端并安全接收一次性激活码'
- await page.goto('/agents/remote-control')
- await page.getByRole('button', { name: '新增终端', exact: true }).click()
- const editor = visibleDialog(page)
- await fillFormItem(editor, '终端名称', terminalName)
- await fillFormItem(editor, '终端编码', terminalCode)
- await fillFormItem(editor, '部署位置', `E2E 本地模拟终端 ${suffix}`)
- await chooseAgent(page, editor, agent)
- await expect(formItem(editor, '页面模式').getByText('独立页面', { exact: true })).toBeVisible()
-
- const createResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST'
- && new URL(response.url()).pathname === '/api/v1/remote-terminals', { timeout: 30_000 })
- await editor.getByRole('button', { name: '创建并生成激活链接', exact: true }).click()
- const createResponse = await createResponsePromise
- const created = activationParts(await responseData(createResponse, 201, '页面创建远程终端'))
- expect(createResponse.headers()['cache-control'] || '').toContain('no-store')
- expect(created.shownOnce).toBe(true)
- expect(new Date(created.expiresAt).getTime()).toBeGreaterThan(Date.now())
- if (!created.activationCode || created.activationCode.length < 12) throw new Error('创建响应未返回有效的一次性激活码')
- activationValues.push(created.activationCode)
- terminal = {
- id: textValue(created.terminal.id),
- code: textValue(created.terminal.code) || terminalCode,
- name: textValue(created.terminal.name) || terminalName,
- dataVersion: numberValue(created.terminal.dataVersion),
- }
- if (!terminal.id) throw new Error('创建响应未返回终端编号')
- const activationDialog = await scrubActivationDialog(page)
- await expect(activationDialog).toContainText(`接入终端 · ${terminalName}`)
- await expect(activationDialog).toContainText('仅显示一次')
- await captureScreenshot(page, testInfo, 'remote-v2-real-01-created-activation-masked', [activationDialog.locator('code')])
- await activationDialog.getByRole('button', { name: '完成', exact: true }).click()
-
- const initialDetail = await getTerminal(api, headers, terminal.id)
- const keys = recursiveKeys(initialDetail).map((key) => key.toLowerCase())
- expect(keys).not.toContain('activationcode')
- expect(keys).not.toContain('devicesecret')
- expect(keys).not.toContain('secret')
- expect(JSON.stringify(initialDetail)).not.toContain(created.activationCode)
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'PENDING_ACTIVATION' })
-
- activePhase = '第二浏览器上下文真实激活 embed 页面并验证激活码不可重放'
- primaryRuntime = await activateRuntime(browser, agent, terminal, created.activationCode)
- await expect.poll(primaryRuntime.terminalConfigRequests, {
- message: '带 terminal 参数的运行页必须通过设备凭据读取绑定智能体配置',
- timeout: 20_000,
- }).toBeGreaterThan(0)
- expect(primaryRuntime.terminalConfigHeadersValid(), '终端配置请求必须携带设备头和激活时页面 Origin').toBe(true)
- expect(primaryRuntime.legacyPublicConfigRequests(), '受管终端不得回退到旧公开配置接口').toBe(0)
- expect(primaryRuntime.legacyPublicSessionRequests(), '激活阶段不得调用旧公开会话接口').toBe(0)
- expect(primaryRuntime.transport.headersValid(), '运行租约端点必须携带三项设备头和同页 runtime instance,且不得发送旧头').toBe(true)
- expect(primaryRuntime.transport.pullStartedAfterHeartbeat(), '运行页首次 heartbeat 成功前不得启动 pull').toBe(true)
- expect(primaryRuntime.transport.runtimeInstanceOmittedFromUrls(), 'runtime instance 不得进入请求 URL').toBe(true)
- expect(await runtimeInstancesAbsentFromBrowserState(
- primaryRuntime.page,
- primaryRuntime.transport.runtimeInstanceIds(),
- ), 'runtime instance 只允许存在页面内存,不得写入 URL 或 storage').toBe(true)
- await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-02-runtime-activated-fragment-cleared')
- await verifyConsumedActivation(browser, agent, terminal, created.activationCode)
- const online = await waitTerminal(api, headers, terminal.id, (value) => {
- const capabilities = stringList(value.capabilities).map((item) => item.toUpperCase())
- return terminalConnection(value) === 'ONLINE' && value.online === true
- && terminalCapabilities.every((item) => capabilities.includes(item))
- },
- '激活后的真实心跳必须上报在线和六项能力')
- expect(terminalRuntimeVersion(online)).toContain('remote-v2')
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'ONLINE' })
-
- let drawer = await openTerminalDrawer(page, terminalName)
- await expect(drawer).toContainText('在线')
- await expect(drawer).toContainText('ai-person-web/remote-v2')
- await captureScreenshot(page, testInfo, 'remote-v2-real-03-manager-online-heartbeat-capabilities')
-
- activePhase = '后台 WAKE 下发到真实运行页 DOM 并完成三段回执'
- await drawer.getByRole('tab', { name: '实时控制', exact: true }).click()
- const wake = await sendUiCommand(page, drawer, '唤醒运行页', 'WAKE')
- commandIds.wake = wake.id
- const wakeDone = await waitCommand(api, headers, wake.id, 'SUCCEEDED')
- expect(asRecord(wakeDone.result).sessionStatus).toBe('ACTIVE')
- await expect.poll(primaryRuntime.terminalSessionRequests, {
- message: 'WAKE 必须通过设备凭据签发绑定智能体会话',
- timeout: 20_000,
- }).toBeGreaterThan(0)
- expect(primaryRuntime.terminalSessionHeadersValid(), '终端会话请求必须携带设备头和激活时页面 Origin').toBe(true)
- expect(primaryRuntime.legacyPublicSessionRequests(), '受管终端不得回退到旧公开会话接口').toBe(0)
- terminalScopedAccessEvidence = terminalScopedAccess(primaryRuntime)
- await expect(primaryRuntime.page.locator('.live-agent-page')).toBeVisible()
- await expect.poll(() => primaryRuntime!.page.evaluate(() => document.hasFocus()), {
- message: 'STANDALONE WAKE 必须真实聚焦运行页窗口', timeout: 12_000,
- }).toBe(true)
- await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'ACTIVE', 'WAKE 后心跳必须上报活动会话')
- await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-04-wake-focused-real-live-dom')
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: wake.id, status: 'SUCCEEDED' })
-
- activePhase = '后台 VOLUME 修改真实 HTMLAudio/Viewer 输出并由心跳回报'
- const volumeSlider = drawer.locator('.volume-command .el-slider')
- await setSliderValue(volumeSlider, 37)
- const volume = await sendUiCommand(page, drawer, '应用音量', 'VOLUME')
- commandIds.volume = volume.id
- const volumeDone = await waitCommand(api, headers, volume.id, 'SUCCEEDED')
- expect(numberValue(asRecord(volumeDone.result).volume)).toBe(37)
- await waitTerminal(api, headers, terminal.id, (value) => terminalActualVolume(value) === 37, 'VOLUME 后心跳必须回报实际音量 37%')
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: volume.id, status: 'SUCCEEDED' })
-
- activePhase = '真实服务端 TTS 音频进入运行页后执行 PAUSE / RESUME / STOP DOM 动作'
- const speech = await createRemoteControlSpeech(api, headers, agent.voiceCapabilityId)
- mediaEvidence = { speechId: speech.id, duration: speech.duration, mediaBytes: speech.mediaBytes }
- let controlledChatRequestCount = 0
- const controlledChatHandler = async (route: Route) => {
- controlledChatRequestCount += 1
- const requestHeaders = route.request().headers()
- terminalChatDeviceHeadersValid = requestHeaders['x-terminal-id'] === terminal!.code
- && Boolean(requestHeaders['x-device-secret'])
- && requestHeaders['x-terminal-page-origin'] === new URL(baseURL).origin
- && requestHeaders['x-runtime-instance-id'] === primaryRuntime!.transport.lastRuntimeInstanceId()
- && !requestHeaders['x-terminal-secret']
- await route.fulfill({
- status: 200,
- contentType: 'application/json; charset=utf-8',
- body: JSON.stringify({
- code: 0,
- message: 'success',
- data: {
- reply: '远程控制媒体链路验收音频已就绪。',
- audioUrl: speech.audioUrl,
- citations: [],
- provider: 'local',
- degraded: false,
- degradationReason: null,
- blocked: false,
- sensitiveCategory: null,
- requestId: `remote-media-${suffix}`,
- },
- }),
- })
- }
- await primaryRuntime.page.route(`**/open/v1/realtime/${agent.slug}/chat`, controlledChatHandler)
- const question = '开始远程媒体控制链路验收。'
- await primaryRuntime.page.getByRole('textbox', { name: '维修问题' }).fill(question)
- await primaryRuntime.page.getByRole('button', { name: '发送问题', exact: true }).click()
- await expect(primaryRuntime.page.locator('.live-speech-toggle.is-speaking')).toBeVisible({ timeout: 180_000 })
- expect(controlledChatRequestCount).toBe(1)
- expect(terminalChatDeviceHeadersValid, 'REMOTE_TERMINAL chat 必须同时携带设备头、页面 Origin 且不发送旧头').toBe(true)
- expect(primaryRuntime.terminalFollowupRequests().chat, 'REMOTE_TERMINAL chat 必须经过受管会话请求封装').toBe(1)
- expect(primaryRuntime.terminalFollowupHeadersValid(), 'REMOTE_TERMINAL chat 必须携带 Bearer、三项设备头与当前 runtime instance').toBe(true)
- await primaryRuntime.page.unroute(`**/open/v1/realtime/${agent.slug}/chat`, controlledChatHandler)
- await waitTerminal(api, headers, terminal.id, (value) => terminalPlayback(value) === 'PLAYING', '真实 TTS 播放必须由心跳上报 PLAYING', 45_000)
- await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-05-real-tts-playing')
- evidence.push({
- phase: '真实 TTS 媒体前置',
- result: 'PASS',
- terminalId: terminal.id,
- status: `${speech.voiceCapabilityId}/${speech.duration.toFixed(1)}s/${speech.id}`,
- })
-
- await expect(drawer.getByRole('button', { name: '暂停播报', exact: true })).toBeEnabled({ timeout: 15_000 })
- const pause = await sendUiCommand(page, drawer, '暂停播报', 'PAUSE')
- commandIds.pause = pause.id
- await waitCommand(api, headers, pause.id, 'SUCCEEDED')
- await expect(primaryRuntime.page.locator('.live-speech-toggle[aria-label="继续播报"]')).toBeVisible()
- await waitTerminal(api, headers, terminal.id, (value) => terminalPlayback(value) === 'PAUSED', 'PAUSE 后心跳必须回报 PAUSED', 45_000)
- await captureScreenshot(page, testInfo, 'remote-v2-real-06-manager-pause-succeeded')
-
- await expect(drawer.getByRole('button', { name: '继续播报', exact: true })).toBeEnabled({ timeout: 15_000 })
- const resume = await sendUiCommand(page, drawer, '继续播报', 'RESUME')
- commandIds.resume = resume.id
- await waitCommand(api, headers, resume.id, 'SUCCEEDED')
- await expect(primaryRuntime.page.locator('.live-speech-toggle.is-speaking[aria-label="暂停播报"]')).toBeVisible()
- await waitTerminal(api, headers, terminal.id, (value) => terminalPlayback(value) === 'PLAYING', 'RESUME 后心跳必须回报 PLAYING', 45_000)
-
- await expect(drawer.getByRole('button', { name: '结束会话', exact: true })).toBeEnabled({ timeout: 15_000 })
- const stop = await sendUiCommand(page, drawer, '结束会话', 'STOP', '确认结束会话')
- commandIds.stop = stop.id
- await waitCommand(api, headers, stop.id, 'SUCCEEDED')
- await expect.poll(() => primaryRuntime!.terminalFollowupRequests().end, {
- message: 'STOP 必须先调用受管会话 end',
- timeout: 20_000,
- }).toBeGreaterThan(0)
- expect(primaryRuntime.terminalFollowupHeadersValid(), 'REMOTE_TERMINAL end 必须携带 Bearer、三项设备头与当前 runtime instance').toBe(true)
- await expect(primaryRuntime.page.locator('.live-speech-toggle.is-speaking')).toHaveCount(0)
- await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE'
- && terminalPlayback(value) === 'IDLE', 'STOP 后心跳必须回报空闲会话与空闲播放', 45_000)
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: stop.id, status: 'SUCCEEDED' })
-
- activePhase = '命令幂等键同载荷重放、不同载荷冲突和空闲态预检拒绝'
- const idempotencyKey = `remote-v2-${suffix}-idempotent`
- const identicalPayload = { type: 'VOLUME', detail: '幂等设置音量', volume: 43 }
- const firstIdempotentResponse = await postCommand(api, headers, terminal.id, identicalPayload, idempotencyKey)
- const firstIdempotent = await parseCommandResponse(firstIdempotentResponse, '首次幂等命令')
- const replayResponse = await postCommand(api, headers, terminal.id, identicalPayload, idempotencyKey)
- const replay = await parseCommandResponse(replayResponse, '相同幂等键同载荷重放')
- expect(replay.id).toBe(firstIdempotent.id)
- expect(replay.command.idempotent).toBe(true)
- const conflictResponse = await postCommand(api, headers, terminal.id, { ...identicalPayload, volume: 44 }, idempotencyKey)
- responseStatus(conflictResponse, 409, '相同幂等键不同载荷必须冲突')
- commandIds.idempotent = firstIdempotent.id
- await waitCommand(api, headers, firstIdempotent.id, 'SUCCEEDED')
-
- const rejectedPause = await postCommand(api, headers, terminal.id, { type: 'PAUSE', detail: '空闲态预检拒绝验证' }, `remote-v2-${suffix}-failed`)
- responseStatus(rejectedPause, 409, '空闲态 PAUSE 必须在入队前拒绝')
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'IDLE_PAUSE_REJECTED' })
-
- activePhase = '同源双页面仅单一 leader 执行,刷新时 release 后快速交棒且不重复激活'
- const activationCountBeforeReload = primaryRuntime.activationRequests()
- const primaryInstanceBeforeReload = primaryRuntime.transport.lastRuntimeInstanceId()
- const primaryReleaseBeforeReload = primaryRuntime.transport.releaseRequests()
- const primaryReportsBeforeHandoff = primaryRuntime.transport.reportRequests()
- const standbyPage = await primaryRuntime.context.newPage()
- const standbyTransport = observeRuntimeTransport(standbyPage, terminal.code, new URL(baseURL).origin)
- await standbyPage.goto(primaryRuntime.publicPath)
- await expect(standbyPage.locator('.live-agent-page')).toBeVisible()
- await expect(standbyPage.locator('.terminal-runtime-notice')).toContainText('另一页面运行', { timeout: 20_000 })
- await standbyPage.waitForTimeout(1_200)
- expect(standbyTransport.heartbeatRequests(), '同源 standby 页面不得竞争服务端心跳租约').toBe(0)
- expect(standbyTransport.pullRequests(), '同源 standby 页面不得拉取命令').toBe(0)
- expect(standbyTransport.reportRequests(), '同源 standby 页面不得执行或回报命令').toBe(0)
-
- const standbyHeartbeat = standbyPage.waitForResponse((response) => response.request().method() === 'POST'
- && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat'
- && response.status() === 200, { timeout: 35_000 })
- await primaryRuntime.page.reload({ waitUntil: 'domcontentloaded' })
- responseStatus(await standbyHeartbeat, 200, '原 leader 刷新释放租约后 standby 页面快速接管')
- await expect.poll(primaryRuntime.transport.releaseRequests, {
- message: '页面刷新必须 best-effort 调用 runtime release',
- timeout: 12_000,
- }).toBeGreaterThan(primaryReleaseBeforeReload)
- // unload keepalive 的 response 事件可能在旧 document 销毁后不再被 Playwright 派发;
- // 上面的新 instance heartbeat=200 严格证明服务端已完成 release,而非等待 45 秒租约过期。
- expect(primaryRuntime.transport.releaseBodyEmpty(), 'runtime release 必须使用无 body 请求').toBe(true)
- await expect(primaryRuntime.page.locator('.terminal-runtime-notice')).toContainText('另一页面运行', { timeout: 20_000 })
- expect(primaryRuntime.activationRequests(), '刷新不得重复兑换一次性激活码').toBe(activationCountBeforeReload)
- expect(standbyTransport.headersValid(), '接管页面四类运行请求必须复用同一 runtime instance 与三项设备头').toBe(true)
- expect(standbyTransport.pullStartedAfterHeartbeat(), '接管页面必须 heartbeat 成功后才 pull').toBe(true)
- const standbyInstance = standbyTransport.lastRuntimeInstanceId()
- expect(standbyInstance).not.toBe(primaryInstanceBeforeReload)
- expect(await runtimeInstancesAbsentFromBrowserState(standbyPage, [standbyInstance])).toBe(true)
-
- const standbyReportsBeforeCommand = standbyTransport.reportRequests()
- const handoffCommandResponse = await postCommand(
- api,
- headers,
- terminal.id,
- { type: 'VOLUME', detail: '双页面 leader 单次执行验证', volume: 46 },
- `remote-v2-${suffix}-leader-handoff`,
- )
- const handoffCommand = await parseCommandResponse(handoffCommandResponse, '刷新交棒后下发单次执行命令')
- commandIds.handoff = handoffCommand.id
- const handoffDone = await waitCommand(api, headers, handoffCommand.id, 'SUCCEEDED', 60_000)
- expect(numberValue(asRecord(handoffDone.result).volume)).toBe(46)
- expect(standbyTransport.reportRequests()).toBeGreaterThan(standbyReportsBeforeCommand)
- expect(primaryRuntime.transport.reportRequests(), 'standby 页面不得重复回报接管页面已执行的命令').toBe(primaryReportsBeforeHandoff)
- await waitTerminal(api, headers, terminal.id, (value) => terminalActualVolume(value) === 46,
- '交棒后的唯一 leader 必须回报 46% 音量', 45_000)
-
- const standbyReleaseBeforeClose = standbyTransport.releaseRequests()
- const primaryHeartbeatAfterHandoff = primaryRuntime.page.waitForResponse((response) => response.request().method() === 'POST'
- && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat'
- && response.status() === 200, { timeout: 35_000 })
- await standbyPage.close()
- responseStatus(await primaryHeartbeatAfterHandoff, 200, '接管页面关闭释放后刷新页重新成为 leader')
- await expect.poll(standbyTransport.releaseRequests, {
- message: 'leader 页面关闭必须 best-effort 释放 runtime lease',
- timeout: 12_000,
- }).toBeGreaterThan(standbyReleaseBeforeClose)
- // primaryHeartbeatAfterHandoff=200 严格证明关闭页的 release 已在服务端生效。
- expect(standbyTransport.releaseBodyEmpty()).toBe(true)
- const primaryInstanceAfterReload = primaryRuntime.transport.lastRuntimeInstanceId()
- expect(primaryInstanceAfterReload).not.toBe(primaryInstanceBeforeReload)
- expect(primaryInstanceAfterReload).not.toBe(standbyInstance)
- expect(primaryRuntime.transport.headersValid()).toBe(true)
- expect(primaryRuntime.transport.pullStartedAfterHeartbeat()).toBe(true)
- expect(await runtimeInstancesAbsentFromBrowserState(
- primaryRuntime.page,
- primaryRuntime.transport.runtimeInstanceIds(),
- )).toBe(true)
- terminalScopedAccessEvidence = terminalScopedAccess(primaryRuntime)
- await expect(primaryRuntime.page.locator('.terminal-runtime-notice.is-error')).toHaveCount(0)
- await captureScreenshot(primaryRuntime.page, testInfo, 'remote-v2-real-07-refresh-leader-handoff')
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: handoffCommand.id, status: 'LEADER_HANDOFF_SINGLE_EXECUTION' })
-
- activePhase = 'RESTART 先结束受管会话再回报终态并刷新,刷新后仍可创建唯一新会话'
- const sessionsBeforeRestartWake = primaryRuntime.terminalSessionRequests()
- const restartWakeResponse = await postCommand(
- api,
- headers,
- terminal.id,
- { type: 'WAKE', detail: 'RESTART 前创建受管会话' },
- `remote-v2-${suffix}-restart-wake`,
- )
- const restartWake = await parseCommandResponse(restartWakeResponse, 'RESTART 前唤醒运行页')
- commandIds.restartWake = restartWake.id
- await waitCommand(api, headers, restartWake.id, 'SUCCEEDED', 60_000)
- await expect.poll(primaryRuntime.terminalSessionRequests, {
- message: 'RESTART 前 WAKE 必须创建受管会话',
- timeout: 20_000,
- }).toBeGreaterThan(sessionsBeforeRestartWake)
- const activeBeforeRestart = await waitTerminal(
- api,
- headers,
- terminal.id,
- (value) => terminalSession(value) === 'ACTIVE',
- 'RESTART 前终端必须存在活动会话',
- 45_000,
- )
- const sessionIdBeforeRestart = textValue(activeBeforeRestart.sessionId ?? asRecord(activeBeforeRestart.session).id)
- const restartOrder: string[] = []
- const restartResponseObserver = (response: Response) => {
- const pathname = new URL(response.url()).pathname
- if (pathname.endsWith(`/open/v1/realtime/${encodeURIComponent(agent.slug)}/end`) && response.status() === 200) {
- restartOrder.push('end-response')
- return
- }
- const reportMatch = pathname.match(/^\/open\/v2\/terminals\/commands\/([^/]+)\/reports$/)
- if (!reportMatch || response.status() !== 200) return
- const payload = asRecord(response.request().postDataJSON())
- if (textValue(payload.status).toUpperCase() === 'SUCCEEDED') {
- restartOrder.push(`final-report:${decodeURIComponent(reportMatch[1] || '')}`)
- }
- }
- primaryRuntime.page.on('response', restartResponseObserver)
- const releasesBeforeRestart = primaryRuntime.transport.releaseRequests()
- const heartbeatAfterRestart = primaryRuntime.page.waitForResponse((response) => response.request().method() === 'POST'
- && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat'
- && response.status() === 200, { timeout: 45_000 })
- const restartResponse = await postCommand(
- api,
- headers,
- terminal.id,
- { type: 'RESTART', detail: '验证先结束会话再终态回执与刷新' },
- `remote-v2-${suffix}-restart`,
- )
- const restart = await parseCommandResponse(restartResponse, '下发 RESTART 命令')
- commandIds.restart = restart.id
- await waitCommand(api, headers, restart.id, 'SUCCEEDED', 60_000)
- await expect.poll(primaryRuntime.transport.releaseRequests, {
- message: 'RESTART 刷新前必须立即释放旧 runtime lease,不能等待租约自然过期',
- timeout: 10_000,
- }).toBeGreaterThan(releasesBeforeRestart)
- responseStatus(await heartbeatAfterRestart, 200, 'RESTART 后新页面运行实例重新取得租约')
- await expect.poll(() => primaryRuntime!.terminalFollowupRequests().end, {
- message: 'RESTART 必须调用受管会话 end',
- timeout: 20_000,
- }).toBeGreaterThan(0)
- const endIndex = restartOrder.indexOf('end-response')
- const finalReportIndex = restartOrder.indexOf(`final-report:${restart.id}`)
- expect(endIndex, 'RESTART 必须观察到会话 end 成功响应').toBeGreaterThanOrEqual(0)
- expect(finalReportIndex, 'RESTART 必须观察到 SUCCEEDED 终态回执').toBeGreaterThan(endIndex)
- primaryRuntime.page.off('response', restartResponseObserver)
- await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE',
- 'RESTART 刷新后不得残留 ACTIVE 会话', 45_000)
-
- const sessionsBeforePostRestartWake = primaryRuntime.terminalSessionRequests()
- const postRestartWakeResponse = await postCommand(
- api,
- headers,
- terminal.id,
- { type: 'WAKE', detail: 'RESTART 后验证新会话可用' },
- `remote-v2-${suffix}-post-restart-wake`,
- )
- const postRestartWake = await parseCommandResponse(postRestartWakeResponse, 'RESTART 后重新唤醒运行页')
- commandIds.postRestartWake = postRestartWake.id
- await waitCommand(api, headers, postRestartWake.id, 'SUCCEEDED', 60_000)
- await expect.poll(primaryRuntime.terminalSessionRequests, {
- message: 'RESTART 后 WAKE 必须创建新的受管会话',
- timeout: 20_000,
- }).toBeGreaterThan(sessionsBeforePostRestartWake)
- const activeAfterRestart = await waitTerminal(api, headers, terminal.id,
- (value) => terminalSession(value) === 'ACTIVE', 'RESTART 后新会话必须可用', 45_000)
- const sessionIdAfterRestart = textValue(activeAfterRestart.sessionId ?? asRecord(activeAfterRestart.session).id)
- if (sessionIdBeforeRestart && sessionIdAfterRestart) expect(sessionIdAfterRestart).not.toBe(sessionIdBeforeRestart)
- const postRestartStopResponse = await postCommand(
- api,
- headers,
- terminal.id,
- { type: 'STOP', detail: 'RESTART 回归完成后结束新会话' },
- `remote-v2-${suffix}-post-restart-stop`,
- )
- const postRestartStop = await parseCommandResponse(postRestartStopResponse, '结束 RESTART 后的新会话')
- commandIds.postRestartStop = postRestartStop.id
- await waitCommand(api, headers, postRestartStop.id, 'SUCCEEDED', 60_000)
- await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE',
- 'RESTART 后新会话结束必须回到 IDLE', 45_000)
- expect(primaryRuntime.terminalFollowupHeadersValid(), 'RESTART/STOP 的 end 必须使用当前 runtime owner 的完整授权头').toBe(true)
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, commandId: restart.id, status: 'END_BEFORE_FINAL_REPORT_AND_RELOAD' })
-
- activePhase = '真实 API 双实例租约 fencing、owner release 接管与旧 owner 回执拒绝'
- const runtimeHarness = await primaryRuntime.context.newPage()
- await runtimeHarness.goto('/')
- const primaryReleaseBeforeClose = primaryRuntime.transport.releaseRequests()
- await primaryRuntime.page.close()
- await expect.poll(primaryRuntime.transport.releaseRequests, {
- message: '最后一个 leader 页面关闭必须发出 runtime release',
- timeout: 12_000,
- }).toBeGreaterThan(primaryReleaseBeforeClose)
- expect(primaryRuntime.transport.releaseBodyEmpty()).toBe(true)
-
- const runtimeA = `runtime-ape2e-a-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 128)
- const runtimeB = `runtime-ape2e-b-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 128)
- const directTerminalCode = terminal.code
- const directState = directRuntimeState(primaryRuntime.publicPath, 46)
- let claimA: DirectRuntimeResponse = { status: 0, code: 0, data: {} }
- await expect.poll(async () => {
- claimA = await directRuntimeRequest(runtimeHarness, directTerminalCode, runtimeA, '/open/v2/terminals/heartbeat', directState)
- return claimA.status
- }, { message: '页面 release 后直接运行实例 A 必须快速取得租约', timeout: 15_000 }).toBe(200)
- expect(numberValue(claimA.data.runtimeLeaseSeconds)).toBeGreaterThan(0)
- expect(textValue(claimA.data.runtimeLeaseExpiresAt)).not.toBe('')
- const renewedA = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeA, '/open/v2/terminals/heartbeat', directState)
- expect(renewedA.status).toBe(200)
- expect(new Date(textValue(renewedA.data.runtimeLeaseExpiresAt)).getTime())
- .toBeGreaterThanOrEqual(new Date(textValue(claimA.data.runtimeLeaseExpiresAt)).getTime())
- const blockedB = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeB, '/open/v2/terminals/heartbeat', directState)
- expect(blockedB.status).toBe(423)
- expect(blockedB.code).toBe(42301)
- const sessionFence = await directRuntimeSessionFence(
- runtimeHarness,
- terminal.code,
- runtimeA,
- runtimeB,
- agent.slug,
- directState,
- )
- expect(sessionFence.ownerSessionCreated).toBe(true)
- expect(sessionFence.ownerSessionNoStore).toBe(true)
- expect(sessionFence.websocketOpened).toBe(true)
- expect(sessionFence.ownerReleaseStatus).toBe(200)
- expect(sessionFence.contenderClaimStatus).toBe(200)
- expect([401, 423]).toContain(sessionFence.oldOwnerHttpStatus)
- expect([40100, 42301]).toContain(sessionFence.oldOwnerHttpCode)
- expect(sessionFence.oldOwnerWebSocketCloseCode).toBe(4401)
- expect(sessionFence.contenderSessionCreated).toBe(true)
- expect(sessionFence.contenderSessionEnded).toBe(true)
- expect(sessionFence.staleOwnerReleaseStatus).toBe(423)
- expect(sessionFence.staleOwnerReleaseCode).toBe(42301)
-
- const fencedCommandResponse = await postCommand(
- api,
- headers,
- terminal.id,
- { type: 'VOLUME', detail: '旧 runtime owner fencing 验证', volume: 47 },
- `remote-v2-${suffix}-runtime-fence`,
- )
- const fencedCommand = await parseCommandResponse(fencedCommandResponse, '为双实例 fencing 创建命令')
- commandIds.fenced = fencedCommand.id
- const pulledByB = await directRuntimeRequest(
- runtimeHarness,
- terminal.code,
- runtimeB,
- '/open/v2/terminals/commands/pull',
- { limit: 1, waitSeconds: 0 },
- )
- expect(pulledByB.status).toBe(200)
- const fencedDelivery = asRecord(asRecords(pulledByB.data.items)[0])
- expect(textValue(fencedDelivery.id)).toBe(fencedCommand.id)
- const staleReportA = await directRuntimeRequest(
- runtimeHarness,
- terminal.code,
- runtimeA,
- `/open/v2/terminals/commands/${encodeURIComponent(fencedCommand.id)}/reports`,
- {
- reportId: `stale-a-${suffix}`,
- deliveryId: textValue(fencedDelivery.deliveryId),
- status: 'SUCCEEDED',
- result: { message: '旧 owner 不应被接受' },
- },
- )
- expect(staleReportA.status).toBe(423)
- expect(staleReportA.code).toBe(42301)
- const staleReleaseA = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeA, '/open/v2/terminals/runtime/release')
- expect(staleReleaseA.status).toBe(423)
- expect(staleReleaseA.code).toBe(42301)
- const settledByB = await directRuntimeRequest(
- runtimeHarness,
- terminal.code,
- runtimeB,
- `/open/v2/terminals/commands/${encodeURIComponent(fencedCommand.id)}/reports`,
- {
- reportId: `cleanup-b-${suffix}`,
- deliveryId: textValue(fencedDelivery.deliveryId),
- status: 'FAILED',
- errorCode: 'APE2E_RUNTIME_FENCE_CLEANUP',
- errorMessage: '租约 fencing 验证完成后的测试收敛',
- },
- )
- expect(settledByB.status).toBe(200)
- await waitCommand(api, headers, fencedCommand.id, 'FAILED', 30_000)
-
- activePhase = '运行实例释放前补充验证 QUEUED 取消与 90 秒超时收敛'
- const cancellableResponse = await postCommand(api, headers, terminal.id, { type: 'RESTART', detail: '排队取消验证' }, `remote-v2-${suffix}-cancel`)
- const cancellable = await parseCommandResponse(cancellableResponse, '创建待取消命令')
- commandIds.cancelled = cancellable.id
- const cancelResponse = await api.post(`/api/v1/remote-commands/${encodeURIComponent(cancellable.id)}/cancel`, {
- headers,
- data: { dataVersion: numberValue(cancellable.command.dataVersion) },
- })
- const cancelled = await responseData<JsonRecord>(cancelResponse, 200, '取消 QUEUED 命令')
- expect(commandStatus(cancelled)).toBe('CANCELLED')
-
- const expiringResponse = await postCommand(api, headers, terminal.id, { type: 'WAKE', detail: '真实九十秒过期验证' }, `remote-v2-${suffix}-timeout`)
- const expiring = await parseCommandResponse(expiringResponse, '创建待超时命令')
- commandIds.timedOut = expiring.id
- const releasedB = await directRuntimeRequest(runtimeHarness, terminal.code, runtimeB, '/open/v2/terminals/runtime/release')
- expect(releasedB.status).toBe(200)
- runtimeLeaseEvidence = {
- ownerClaimAndRenew: claimA.status === 200 && renewedA.status === 200,
- contenderBlocked: blockedB.status === 423 && blockedB.code === 42301,
- ownerReleased: sessionFence.ownerReleaseStatus === 200,
- contenderTookOver: sessionFence.contenderClaimStatus === 200,
- ownerSessionInterrupted: [401, 423].includes(sessionFence.oldOwnerHttpStatus)
- && sessionFence.oldOwnerWebSocketCloseCode === 4401,
- contenderSessionUsable: sessionFence.contenderSessionCreated && sessionFence.contenderSessionEnded,
- staleOwnerReportBlocked: staleReportA.status === 423 && staleReportA.code === 42301,
- staleOwnerReleaseBlocked: staleReleaseA.status === 423 && staleReleaseA.code === 42301,
- newOwnerSettledCommand: settledByB.status === 200,
- newOwnerReleased: releasedB.status === 200,
- }
- await runtimeHarness.close()
- evidence.push({ phase: '真实 API 双实例租约 fencing、owner release 接管与旧 owner 回执拒绝', result: 'PASS', terminalId: terminal.id, commandId: fencedCommand.id, status: 'LEASE_FENCING_AND_RELEASE_PASS' })
- await waitCommand(api, headers, expiring.id, 'TIMED_OUT', 110_000)
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'CANCELLED/TIMED_OUT' })
-
- const resumedPage = await primaryRuntime.context.newPage()
- primaryRuntime.page = resumedPage
- primaryRuntime.transport = observeRuntimeTransport(resumedPage, terminal.code, new URL(baseURL).origin)
- const resumedHeartbeatPromise = resumedPage.waitForResponse((response) => response.request().method() === 'POST'
- && new URL(response.url()).pathname.endsWith('/open/v2/terminals/heartbeat'),
- { timeout: 30_000 })
- await resumedPage.goto(primaryRuntime.publicPath)
- responseStatus(await resumedHeartbeatPromise, 200, '关闭页面后同一设备上下文持久重连')
- expect(primaryRuntime.transport.headersValid()).toBe(true)
- expect(primaryRuntime.transport.pullStartedAfterHeartbeat()).toBe(true)
- await waitTerminal(api, headers, terminal.id, (value) => terminalConnection(value) === 'ONLINE', '重新打开运行页后恢复在线')
-
- activePhase = '页面重新激活使旧设备凭据失效,新浏览器接管运行通道'
- drawer = await openTerminalDrawer(page, terminalName)
- await drawer.getByRole('tab', { name: '接入与安全', exact: true }).click()
- const regenerationResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST'
- && new URL(response.url()).pathname.endsWith(`/api/v1/remote-terminals/${encodeURIComponent(terminal!.id)}/activation`),
- { timeout: 30_000 })
- await drawer.getByRole('button', { name: '重新生成激活链接', exact: true }).click()
- const confirmation = page.locator('.el-message-box:visible')
- await confirmation.getByRole('button', { name: '确认重新生成', exact: true }).click()
- const regenerationResponse = await regenerationResponsePromise
- const regenerated = activationParts(await responseData(regenerationResponse, 200, '页面重新生成激活链接'))
- expect(regenerationResponse.headers()['cache-control'] || '').toContain('no-store')
- expect(regenerated.shownOnce).toBe(true)
- if (!regenerated.activationCode || regenerated.activationCode === created.activationCode) {
- throw new Error('重新激活没有生成独立的一次性激活码')
- }
- activationValues.push(regenerated.activationCode)
- terminal.dataVersion = numberValue(regenerated.terminal.dataVersion) || terminal.dataVersion
- const regeneratedDialog = await scrubActivationDialog(page)
- await expect(resumedPage.locator('.terminal-runtime-notice.is-error')).toContainText('终端凭据已失效', { timeout: 40_000 })
- replacementRuntime = await activateRuntime(browser, agent, terminal, regenerated.activationCode)
- await regeneratedDialog.getByRole('button', { name: '完成', exact: true }).click()
- await waitTerminal(api, headers, terminal.id, (value) => terminalConnection(value) === 'ONLINE', '新设备激活后必须重新在线')
- await captureScreenshot(page, testInfo, 'remote-v2-real-08-reactivated-new-device-online')
- await captureScreenshot(resumedPage, testInfo, 'remote-v2-real-09-old-device-credential-revoked')
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'OLD_REVOKED/NEW_ONLINE' })
-
- activePhase = '停止真实运行页超过离线阈值后后台呈现离线'
- await primaryRuntime.context.close()
- primaryRuntime = null
- await replacementRuntime.context.close()
- replacementRuntime = null
- const runtimesClosedAt = Date.now()
- await waitTerminal(api, headers, terminal.id, (value) => terminalConnection(value) === 'OFFLINE'
- && value.online === false
- && Date.now() - runtimesClosedAt >= 110_000,
- '关闭运行页并超过服务端离线窗口后必须真实离线', 140_000)
- drawer = await openTerminalDrawer(page, terminalName)
- await expect(drawer).toContainText('离线')
- await captureScreenshot(page, testInfo, 'remote-v2-real-10-runtime-stopped-real-offline')
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'OFFLINE' })
-
- activePhase = '通过页面停用并撤销终端,活动资源清零而命令事件与审计留痕保留'
- await drawer.getByRole('tab', { name: '接入与安全', exact: true }).click()
- const statusResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT'
- && new URL(response.url()).pathname.endsWith(`/api/v1/remote-terminals/${encodeURIComponent(terminal!.id)}/status`),
- { timeout: 30_000 })
- await drawer.getByRole('button', { name: '停用终端', exact: true }).click()
- const stopConfirmation = page.locator('.el-message-box:visible')
- await stopConfirmation.getByRole('button', { name: '确认停用', exact: true }).click()
- const statusResponse = await statusResponsePromise
- const suspended = await responseData<JsonRecord>(statusResponse, 200, '页面停用终端')
- expect(terminalLifecycle(suspended)).toBe('SUSPENDED')
- terminal.dataVersion = numberValue(suspended.dataVersion) || terminal.dataVersion
- await captureScreenshot(page, testInfo, 'remote-v2-real-11-terminal-suspended')
-
- const deleteResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE'
- && new URL(response.url()).pathname.endsWith(`/api/v1/remote-terminals/${encodeURIComponent(terminal!.id)}`),
- { timeout: 30_000 })
- await drawer.getByRole('button', { name: '撤销终端', exact: true }).click()
- const deleteConfirmation = page.locator('.el-message-box:visible')
- await deleteConfirmation.getByRole('button', { name: '确认撤销', exact: true }).click()
- const deleteResponse = await deleteResponsePromise
- responseStatus(deleteResponse, [200, 204], '页面撤销终端')
- terminalDeleted = true
-
- const absentDetail = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers })
- responseStatus(absentDetail, 404, '撤销后终端详情必须不可访问')
- const absentListResponse = await api.get('/api/v1/remote-terminals', {
- headers,
- params: { page: '1', pageSize: '20', keyword: terminalName },
- })
- const absentList = await responseData<JsonRecord>(absentListResponse, 200, '撤销后按精确名称检索终端')
- expect(pageItems(absentList)).toHaveLength(0)
- expect(numberValue(absentList.total)).toBe(0)
-
- const immutableProbeId = commandIds.wake || Object.values(commandIds)[0]
- if (!immutableProbeId) throw new Error('真实生命周期没有产生可核验的命令审计编号')
- const retainedCommandResponse = await api.get(`/api/v1/remote-commands/${encodeURIComponent(immutableProbeId)}`, { headers })
- responseStatus(retainedCommandResponse, 200, '撤销终端后命令审计历史必须保留')
- const retainedEventsResponse = await api.get(`/api/v1/remote-commands/${encodeURIComponent(immutableProbeId)}/events`, { headers })
- const retainedEvents = await responseData<JsonRecord | JsonRecord[]>(retainedEventsResponse, 200, '撤销终端后命令事件账本必须保留')
- const eventItems = Array.isArray(retainedEvents) ? retainedEvents : pageItems(retainedEvents)
- expect(eventItems.length).toBeGreaterThanOrEqual(3)
- const retainedStatuses = eventItems.map((item) => textValue(item.toStatus ?? item.status).toUpperCase())
- for (const status of ['ACKNOWLEDGED', 'EXECUTING', 'SUCCEEDED']) {
- expect(retainedStatuses, `命令事件账本应包含 ${status}`).toContain(status)
- }
-
- const auditResponse = await api.get('/api/auth/v1/audit-logs', {
- headers,
- params: { page: '1', size: '100', keyword: terminalName, start: lifecycleStartedAt },
- })
- const auditData = await responseData<JsonRecord>(auditResponse, 200, '查询远控系统审计')
- const terminalAudits = pageItems(auditData)
- const terminalAuditCodes = terminalAudits.map((item) => textValue(item.actionCode))
- for (const actionCode of ['ai.remote.create', 'ai.remote.activation', 'ai.remote.status', 'ai.remote.delete']) {
- expect(terminalAuditCodes, `审计日志应包含 ${actionCode}`).toContain(actionCode)
- }
- const commandAuditResponse = await api.get('/api/auth/v1/audit-logs', {
- headers,
- params: { page: '1', size: '100', action: 'ai.remote.command', keyword: immutableProbeId, start: lifecycleStartedAt },
- })
- const commandAuditData = await responseData<JsonRecord>(commandAuditResponse, 200, '查询远控命令系统审计')
- const commandAudits = pageItems(commandAuditData)
- expect(commandAudits.some((item) => textValue(item.actionCode) === 'ai.remote.command'
- && textValue(item.targetId) === immutableProbeId)).toBe(true)
- const serializedAudit = JSON.stringify({ terminalAudits, commandAudits })
- for (const secret of activationValues) expect(serializedAudit).not.toContain(secret)
- expect(serializedAudit).not.toContain(idempotencyKey)
- evidence.push({ phase: activePhase, result: 'PASS', terminalId: terminal.id, status: 'DELETED_WITH_IMMUTABLE_AUDIT' })
-
- await page.goto('/agents/remote-control')
- await page.getByRole('textbox', { name: '搜索终端' }).fill(terminalName)
- await page.getByRole('button', { name: '搜索', exact: true }).click()
- await expect(page.getByText('没有符合条件的远程终端', { exact: true })).toBeVisible()
- await captureScreenshot(page, testInfo, 'remote-v2-real-12-deleted-zero-active-residual')
- } catch (cause) {
- evidence.push({
- phase: activePhase,
- result: 'FAIL',
- terminalId: terminal?.id,
- detail: cause instanceof Error ? cause.message.slice(0, 500) : '未知失败',
- })
- throw cause
- } finally {
- await page.locator('.activation-dialog code').evaluateAll((elements) => {
- for (const element of elements) element.textContent = '[已遮蔽]'
- }).catch(() => undefined)
- if (primaryRuntime) await primaryRuntime.context.close().catch(() => undefined)
- if (replacementRuntime) await replacementRuntime.context.close().catch(() => undefined)
- if (api && !terminalDeleted) await cleanupTerminal(api, headers, terminal, evidence, cleanupErrors)
-
- await attachJson(testInfo, 'remote-terminal-v2-real-lifecycle-evidence', {
- runId,
- terminalId: terminal?.id || null,
- boundPublishedAgent: boundAgentEvidence,
- mediaIsolation: {
- chatTransport: 'CONTROLLED',
- tts: 'REAL',
- media: 'REAL',
- remoteControl: 'REAL',
- speech: mediaEvidence,
- terminalChatDeviceHeadersValid,
- signedAudioUrlAttached: false,
- },
- terminalScopedAccess: terminalScopedAccessEvidence,
- runtimeLease: runtimeLeaseEvidence,
- commandIds,
- evidence,
- expectedImmutableHistory: {
- commandAndEventRowsRetainedAfterTerminalDeletion: terminalDeleted,
- systemAuditRetainedAfterTerminalDeletion: terminalDeleted,
- immutableCommandIds,
- },
- activeTerminalResidualCount: terminalDeleted || cleanupErrors.length === 0 ? 0 : null,
- cleanupComplete: cleanupErrors.length === 0,
- cleanupErrors,
- security: {
- accountPasswordOrBearerTokenAttached: false,
- activationCodeAttached: false,
- deviceSecretReadByTestProcess: false,
- traceAndVideoDisabledByPlaywrightConfig: true,
- runtimeFragmentClearedBeforeScreenshots: true,
- },
- })
- assertArtifactsExclude(activationValues)
- await api?.dispose()
- }
-
- expect(cleanupErrors, '活动远程终端必须精确清理为零').toEqual([])
- })
-
- test('短链路 WIDGET 在受控宿主 iframe 激活、执行命令并在宿主刷新后复用凭据', async ({ page, browser }, testInfo) => {
- test.setTimeout(8 * 60_000)
-
- const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
- const terminalName = `${runPrefix}-WIDGET-终端-${suffix}`.slice(0, 110)
- const terminalCode = `RTC-WIDGET-${suffix}`.replace(/[^A-Za-z0-9._:-]/g, '-').toUpperCase().slice(0, 64)
- const baseOrigin = new URL(baseURL).origin
- const hostPath = `/ape2e/remote-widget-host-${encodeURIComponent(suffix)}`
- const activationValues: string[] = []
- const evidence: LifecycleEvidence[] = []
- const cleanupErrors: string[] = []
- let terminal: TerminalRef | null = null
- let runtimeContext: BrowserContext | null = null
- let api: APIRequestContext | null = null
- let headers: Headers = {}
- let activationRequests = 0
- let runtimeTransport: RuntimeTransportMonitor | null = null
- let terminalConfigRequests = 0
- let terminalSessionRequests = 0
- let legacyPublicConfigRequests = 0
- let legacyPublicSessionRequests = 0
- let terminalConfigHeadersValid = true
- let terminalSessionHeadersValid = true
- const terminalFollowupRequests = { chat: 0, asr: 0, feedback: 0, end: 0 }
- let terminalFollowupHeadersValid = true
- let widgetCommandId = ''
- let widgetCommandStatus = ''
- let hostRefreshReusedCredential = false
- let releasedSessionBeforeReconnect = false
- let createdSessionAfterReconnect = false
- let widgetFailure = ''
-
- try {
- api = await playwrightRequest.newContext({ baseURL })
- headers = await authHeaders(api)
- const agent = await findPublishedAgent(api, headers)
- await loginAsAdmin(page)
-
- const createResponse = await api.post('/api/v1/remote-terminals', {
- headers,
- data: {
- name: terminalName,
- code: terminalCode,
- location: `E2E WIDGET 宿主 ${suffix}`,
- boundAgentId: agent.id,
- displayMode: 'WIDGET',
- allowedOrigins: [baseOrigin],
- defaultVolume: 60,
- allowInterrupt: true,
- },
- })
- const created = activationParts(await responseData(createResponse, 201, '创建真实 WIDGET 终端'))
- expect(createResponse.headers()['cache-control'] || '').toContain('no-store')
- if (!created.activationCode || created.activationCode.length < 12) throw new Error('WIDGET 创建响应未返回有效的一次性激活码')
- activationValues.push(created.activationCode)
- terminal = {
- id: textValue(created.terminal.id),
- code: textValue(created.terminal.code) || terminalCode,
- name: textValue(created.terminal.name) || terminalName,
- dataVersion: numberValue(created.terminal.dataVersion),
- }
- expect(stringList(created.terminal.allowedOrigins)).toEqual([baseOrigin])
- expect(textValue(created.terminal.displayMode)).toBe('WIDGET')
-
- runtimeContext = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
- const hostPage = await runtimeContext.newPage()
- runtimeTransport = observeRuntimeTransport(hostPage, terminal.code, baseOrigin)
- const cleanEmbedPath = `/embed/${encodeURIComponent(agent.slug)}?terminal=${encodeURIComponent(terminal.code)}`
- const terminalConfigPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/config`
- const terminalSessionPath = `/open/v2/terminals/agents/${encodeURIComponent(agent.slug)}/sessions`
- const legacyPublicConfigPath = `/open/v1/realtime/${encodeURIComponent(agent.slug)}`
- const legacyPublicSessionPath = `${legacyPublicConfigPath}/sessions`
- let includeActivation = true
- runtimeContext.on('request', (request) => {
- const pathname = new URL(request.url()).pathname
- const method = request.method()
- if (pathname === '/open/v2/terminals/activate') activationRequests += 1
- if (pathname === terminalConfigPath && method === 'GET') {
- terminalConfigRequests += 1
- const requestHeaders = request.headers()
- terminalConfigHeadersValid = terminalConfigHeadersValid
- && requestHeaders['x-terminal-id'] === terminal!.code
- && Boolean(requestHeaders['x-device-secret'])
- && requestHeaders['x-terminal-page-origin'] === baseOrigin
- && !requestHeaders['x-runtime-instance-id']
- && !requestHeaders['x-terminal-secret']
- }
- if (pathname === terminalSessionPath && method === 'POST') {
- terminalSessionRequests += 1
- const requestHeaders = request.headers()
- terminalSessionHeadersValid = terminalSessionHeadersValid
- && requestHeaders['x-terminal-id'] === terminal!.code
- && Boolean(requestHeaders['x-device-secret'])
- && requestHeaders['x-terminal-page-origin'] === baseOrigin
- && requestHeaders['x-runtime-instance-id'] === runtimeTransport!.lastRuntimeInstanceId()
- && !requestHeaders['x-terminal-secret']
- }
- const followupMatch = pathname.match(new RegExp(`^${legacyPublicConfigPath}/(chat|asr|feedback|end)$`))
- if (followupMatch && method === 'POST') {
- const endpoint = followupMatch[1] as keyof typeof terminalFollowupRequests
- terminalFollowupRequests[endpoint] += 1
- const requestHeaders = request.headers()
- terminalFollowupHeadersValid = terminalFollowupHeadersValid
- && requestHeaders['x-terminal-id'] === terminal!.code
- && Boolean(requestHeaders['x-device-secret'])
- && requestHeaders['x-terminal-page-origin'] === baseOrigin
- && requestHeaders['x-runtime-instance-id'] === runtimeTransport!.lastRuntimeInstanceId()
- && /^Bearer\s+\S+$/i.test(requestHeaders.authorization || '')
- && !requestHeaders['x-terminal-secret']
- }
- if (pathname === legacyPublicConfigPath && method === 'GET') legacyPublicConfigRequests += 1
- if (pathname === legacyPublicSessionPath && method === 'POST') legacyPublicSessionRequests += 1
- })
-
- await hostPage.route(`**${hostPath}`, async (route) => {
- if (new URL(route.request().url()).pathname !== hostPath) return route.continue()
- const fragment = includeActivation
- ? `#${new URLSearchParams({ 'terminal-activation': created.activationCode }).toString()}`
- : ''
- const iframeSource = `${cleanEmbedPath}${fragment}`
- .replaceAll('&', '&')
- .replaceAll('"', '"')
- await route.fulfill({
- status: 200,
- contentType: 'text/html; charset=utf-8',
- headers: { 'Cache-Control': 'no-store' },
- body: `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>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>`,
- })
- })
-
- const activationResponsePromise = hostPage.waitForResponse((response) => response.request().method() === 'POST'
- && new URL(response.url()).pathname === '/open/v2/terminals/activate', { timeout: 40_000 })
- await hostPage.goto(hostPath)
- const activationResponse = await activationResponsePromise
- responseStatus(activationResponse, 200, '宿主 iframe 兑换一次性激活码')
- expect(activationResponse.headers()['cache-control'] || '').toContain('no-store')
- includeActivation = false
-
- const widget = hostPage.frameLocator('#terminal-widget')
- await expect(widget.locator('.embed-widget')).toBeVisible({ timeout: 30_000 })
- await expect.poll(() => {
- const frame = hostPage.frames().find((item) => {
- try { return new URL(item.url()).pathname === `/embed/${encodeURIComponent(agent.slug)}` } catch { return false }
- })
- return frame ? new URL(frame.url()).hash : '#waiting'
- }, { message: 'iframe 必须在激活请求前清除 URL 中的一次性激活码', timeout: 15_000 }).toBe('')
- await expect.poll(runtimeTransport.heartbeatSuccesses, { timeout: 30_000 }).toBeGreaterThan(0)
- await expect.poll(() => terminalConfigRequests, {
- message: 'WIDGET 必须使用设备凭据读取绑定智能体配置',
- timeout: 20_000,
- }).toBeGreaterThan(0)
- expect(activationRequests).toBe(1)
- expect(runtimeTransport.headersValid(), '运行端点必须携带设备头、runtime instance、可信宿主 Origin 且不再发送旧头').toBe(true)
- expect(runtimeTransport.pullStartedAfterHeartbeat(), 'WIDGET 必须首次 heartbeat 成功后才启动 pull').toBe(true)
- expect(runtimeTransport.runtimeInstanceOmittedFromUrls()).toBe(true)
- expect(await runtimeInstancesAbsentFromBrowserState(hostPage, runtimeTransport.runtimeInstanceIds())).toBe(true)
- expect(terminalConfigHeadersValid, 'WIDGET 配置请求必须携带设备头和可信宿主 Origin').toBe(true)
- expect(legacyPublicConfigRequests, 'WIDGET 受管终端不得回退到旧公开配置接口').toBe(0)
- expect(legacyPublicSessionRequests, 'WIDGET 激活阶段不得调用旧公开会话接口').toBe(0)
-
- await waitTerminal(api, headers, terminal.id, (value) => value.online === true
- && terminalConnection(value) === 'ONLINE'
- && stringList(value.capabilities).includes('VOLUME'), 'WIDGET 激活后必须心跳在线并上报 VOLUME 能力')
- await captureScreenshot(hostPage, testInfo, 'remote-v2-widget-01-iframe-activated-no-secret')
- evidence.push({ phase: 'WIDGET iframe 激活与心跳', result: 'PASS', terminalId: terminal.id, status: 'ONLINE' })
-
- const sessionRequestsBeforeWake = terminalSessionRequests
- const wakeResponse = await postCommand(
- api,
- headers,
- terminal.id,
- { type: 'WAKE', detail: 'WIDGET 首次受管会话签发验证' },
- `remote-v2-widget-${suffix}-wake`,
- )
- const wake = await parseCommandResponse(wakeResponse, '向 WIDGET 下发 WAKE 命令')
- await waitCommand(api, headers, wake.id, 'SUCCEEDED', 60_000)
- await expect.poll(() => terminalSessionRequests, {
- message: 'WIDGET WAKE 必须通过当前 runtime owner 签发 scoped session',
- timeout: 20_000,
- }).toBeGreaterThan(sessionRequestsBeforeWake)
- expect(terminalSessionHeadersValid, 'WIDGET scoped session 必须携带三项设备头和当前 runtime instance').toBe(true)
- expect(legacyPublicSessionRequests, 'WIDGET WAKE 不得回退旧公开 session 路径').toBe(0)
- await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'ACTIVE',
- 'WIDGET WAKE 后必须进入 ACTIVE 会话', 45_000)
- evidence.push({ phase: 'WIDGET runtime-owner scoped session', result: 'PASS', terminalId: terminal.id, commandId: wake.id, status: 'ACTIVE' })
-
- const reportsBeforeCommand = runtimeTransport.reportRequests()
- const commandResponse = await postCommand(
- api,
- headers,
- terminal.id,
- { type: 'VOLUME', detail: 'WIDGET 短链路设置音量', volume: 41 },
- `remote-v2-widget-${suffix}-volume`,
- )
- const command = await parseCommandResponse(commandResponse, '向 WIDGET 下发 VOLUME 命令')
- const completed = await waitCommand(api, headers, command.id, 'SUCCEEDED', 60_000)
- widgetCommandId = command.id
- widgetCommandStatus = commandStatus(completed)
- expect(numberValue(asRecord(completed.result).volume)).toBe(41)
- expect(runtimeTransport.reportRequests()).toBeGreaterThan(reportsBeforeCommand)
- expect(runtimeTransport.headersValid()).toBe(true)
- await waitTerminal(api, headers, terminal.id, (value) => terminalActualVolume(value) === 41,
- 'WIDGET 命令执行后心跳必须回报 41% 音量', 45_000)
- evidence.push({ phase: 'WIDGET 真实命令与终态回执', result: 'PASS', terminalId: terminal.id, commandId: command.id, status: 'SUCCEEDED' })
-
- const activationCountBeforeRefresh = activationRequests
- const terminalConfigCountBeforeRefresh = terminalConfigRequests
- const releaseCountBeforeRefresh = runtimeTransport.releaseRequests()
- const instanceBeforeRefresh = runtimeTransport.lastRuntimeInstanceId()
- const refreshedHeartbeat = hostPage.waitForResponse((response) => response.request().method() === 'POST'
- && new URL(response.url()).pathname === '/open/v2/terminals/heartbeat'
- && response.status() === 200, { timeout: 35_000 })
- await hostPage.reload()
- responseStatus(await refreshedHeartbeat, 200, '宿主刷新后 iframe 复用本地设备凭据心跳')
- await expect(hostPage.frameLocator('#terminal-widget').locator('.embed-widget')).toBeVisible({ timeout: 30_000 })
- expect(activationRequests, '宿主刷新不得重复兑换一次性激活码').toBe(activationCountBeforeRefresh)
- await expect.poll(runtimeTransport.releaseRequests, {
- message: '宿主刷新必须先由旧 iframe runtime best-effort 释放租约',
- timeout: 12_000,
- }).toBeGreaterThan(releaseCountBeforeRefresh)
- // refreshedHeartbeat=200 且发生在旧租约窗口内,证明服务端接受了刷新前 release。
- expect(runtimeTransport.releaseBodyEmpty()).toBe(true)
- expect(runtimeTransport.lastRuntimeInstanceId()).not.toBe(instanceBeforeRefresh)
- expect(runtimeTransport.headersValid()).toBe(true)
- expect(runtimeTransport.pullStartedAfterHeartbeat()).toBe(true)
- expect(await runtimeInstancesAbsentFromBrowserState(hostPage, runtimeTransport.runtimeInstanceIds())).toBe(true)
- await expect.poll(() => terminalConfigRequests, {
- message: '宿主刷新后必须复用设备凭据重新读取绑定智能体配置',
- timeout: 20_000,
- }).toBeGreaterThan(terminalConfigCountBeforeRefresh)
- expect(terminalConfigHeadersValid, '宿主刷新后的配置请求仍必须携带设备头和原宿主 Origin').toBe(true)
- expect(legacyPublicConfigRequests, '宿主刷新不得回退到旧公开配置接口').toBe(0)
- expect(legacyPublicSessionRequests, 'WIDGET 短链路不得调用旧公开会话接口').toBe(0)
- const persistedWithoutReadingSecret = await hostPage.frames()
- .find((item) => {
- try { return new URL(item.url()).pathname === `/embed/${encodeURIComponent(agent.slug)}` } catch { return false }
- })
- ?.evaluate((code) => Boolean(localStorage.getItem(`ai-person:remote-terminal-runtime:v2:${encodeURIComponent(code.trim().toUpperCase())}`)), terminal.code)
- expect(persistedWithoutReadingSecret, '宿主刷新前设备凭据必须已持久化,测试不读取其内容').toBe(true)
- hostRefreshReusedCredential = true
- await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE',
- 'WIDGET 旧 runtime release 后服务端必须中断旧 ACTIVE 会话', 45_000)
- releasedSessionBeforeReconnect = true
-
- const sessionsBeforeReconnectWake = terminalSessionRequests
- const reconnectWakeResponse = await postCommand(
- api,
- headers,
- terminal.id,
- { type: 'WAKE', detail: 'WIDGET 刷新接管后新会话验证' },
- `remote-v2-widget-${suffix}-wake-after-refresh`,
- )
- const reconnectWake = await parseCommandResponse(reconnectWakeResponse, 'WIDGET 刷新后重新 WAKE')
- await waitCommand(api, headers, reconnectWake.id, 'SUCCEEDED', 60_000)
- await expect.poll(() => terminalSessionRequests, {
- message: 'WIDGET 新 runtime owner 必须能签发新 scoped session',
- timeout: 20_000,
- }).toBeGreaterThan(sessionsBeforeReconnectWake)
- await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'ACTIVE',
- 'WIDGET 刷新接管后新会话必须 ACTIVE', 45_000)
- createdSessionAfterReconnect = true
-
- const endRequestsBeforeStop = terminalFollowupRequests.end
- const reconnectStopResponse = await postCommand(
- api,
- headers,
- terminal.id,
- { type: 'STOP', detail: 'WIDGET 刷新接管会话收敛' },
- `remote-v2-widget-${suffix}-stop-after-refresh`,
- )
- const reconnectStop = await parseCommandResponse(reconnectStopResponse, '结束 WIDGET 刷新后的 scoped session')
- await waitCommand(api, headers, reconnectStop.id, 'SUCCEEDED', 60_000)
- await expect.poll(() => terminalFollowupRequests.end, {
- message: 'WIDGET STOP 必须调用受管会话 end',
- timeout: 20_000,
- }).toBeGreaterThan(endRequestsBeforeStop)
- expect(terminalFollowupHeadersValid, 'WIDGET end 必须携带 Bearer、三项设备头与当前 runtime instance').toBe(true)
- await waitTerminal(api, headers, terminal.id, (value) => terminalSession(value) === 'IDLE',
- 'WIDGET STOP 后不得残留 ACTIVE 会话', 45_000)
- await captureScreenshot(hostPage, testInfo, 'remote-v2-widget-02-host-refreshed-credential-reused')
- evidence.push({ phase: 'WIDGET 宿主刷新持久重连', result: 'PASS', terminalId: terminal.id, status: 'RECONNECTED_WITHOUT_ACTIVATION' })
- } catch (cause) {
- widgetFailure = cause instanceof Error ? cause.message.slice(0, 500) : '未知失败'
- evidence.push({ phase: 'WIDGET 真实短链路', result: 'FAIL', terminalId: terminal?.id, detail: widgetFailure })
- throw cause
- } finally {
- await runtimeContext?.close().catch(() => undefined)
- if (api) await cleanupTerminal(api, headers, terminal, evidence, cleanupErrors)
- await attachJson(testInfo, 'remote-terminal-v2-widget-short-lifecycle-evidence', {
- runId,
- terminalId: terminal?.id || null,
- allowedOrigin: baseOrigin,
- displayMode: 'WIDGET',
- activationRequestCount: activationRequests,
- runtimeTransport: {
- heartbeatObserved: (runtimeTransport?.heartbeatSuccesses() || 0) > 0,
- pullObserved: (runtimeTransport?.pullRequests() || 0) > 0,
- reportObserved: (runtimeTransport?.reportRequests() || 0) > 0,
- releaseObserved: (runtimeTransport?.releaseRequests() || 0) > 0,
- finalCredentialHeadersObserved: runtimeTransport?.headersValid() ?? false,
- pullStartedAfterHeartbeat: runtimeTransport?.pullStartedAfterHeartbeat() ?? false,
- releaseBodyEmpty: runtimeTransport?.releaseBodyEmpty() ?? false,
- runtimeInstanceIdsAttached: false,
- },
- terminalScopedAccess: {
- configRequests: terminalConfigRequests,
- sessionRequests: terminalSessionRequests,
- configDeviceHeadersValid: terminalConfigHeadersValid,
- sessionDeviceHeadersValid: terminalSessionHeadersValid,
- followupRequests: terminalFollowupRequests,
- followupDeviceHeadersValid: terminalFollowupHeadersValid,
- legacyPublicConfigRequests,
- legacyPublicSessionRequests,
- },
- command: widgetCommandId ? { id: widgetCommandId, type: 'VOLUME', status: widgetCommandStatus } : null,
- hostRefreshReusedCredential,
- releasedSessionBeforeReconnect,
- createdSessionAfterReconnect,
- evidence,
- failure: widgetFailure || null,
- cleanupComplete: cleanupErrors.length === 0,
- cleanupErrors,
- security: {
- activationCodeAttached: false,
- deviceSecretReadByTestProcess: false,
- screenshotsCapturedAfterActivationFragmentCleared: true,
- },
- })
- assertArtifactsExclude(activationValues)
- await api?.dispose()
- }
-
- expect(cleanupErrors, 'WIDGET 远程终端必须精确清理为零').toEqual([])
- })
- })
|