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 type Headers = Record interface ResponseLike { status(): number json(): Promise headers(): Record } 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(response: ResponseLike, expected: number | number[], label: string): Promise { responseStatus(response, expected, label) const body = await response.json().catch(() => { throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`) }) return envelopeData(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(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(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 { const response = await api.get('/api/v1/realtime-agents', { headers, params: { page: '1', pageSize: '100', status: 'published' }, }) const data = await responseData(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(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, 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, 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 { 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 = { 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 return { status: response.status, code: Number(body.code || 0), data: body.data && typeof body.data === 'object' ? body.data as Record : {}, } }, { 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 { 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 = { 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 | 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 const data = body.data && typeof body.data === 'object' ? body.data as Record : {} 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((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((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, ) 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 { 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(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, 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(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(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(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 = {} 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 | 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 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) 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(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(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(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(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(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(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: `APE2E WIDGET 宿主

受控内网业务宿主

宿主 Origin:${baseOrigin}

`, }) }) 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([]) }) })