import type { Page, Request } from '@playwright/test' const RUNTIME_INSTANCE_ID = /^[A-Za-z0-9._:-]{16,128}$/ type RuntimeEndpoint = 'heartbeat' | 'pull' | 'report' | 'release' export interface RuntimeTransportMonitor { heartbeatRequests: () => number heartbeatSuccesses: () => number pullRequests: () => number reportRequests: () => number releaseRequests: () => number releaseStatuses: () => number[] runtimeInstanceIds: () => string[] lastRuntimeInstanceId: () => string endpointInstances: () => Record headersValid: () => boolean pullStartedAfterHeartbeat: () => boolean runtimeInstanceOmittedFromUrls: () => boolean releaseBodyEmpty: () => boolean } function endpointFor(request: Request): RuntimeEndpoint | null { const pathname = new URL(request.url()).pathname if (request.method() !== 'POST') return null if (pathname === '/open/v2/terminals/heartbeat') return 'heartbeat' if (pathname === '/open/v2/terminals/commands/pull') return 'pull' if (/^\/open\/v2\/terminals\/commands\/[^/]+\/reports$/.test(pathname)) return 'report' if (pathname === '/open/v2/terminals/runtime/release') return 'release' return null } /** * 只记录运行租约契约的计数与非敏感 instanceId;设备密钥只检查“存在”,绝不读取值。 * 每次主文档导航都允许生成新的 instanceId,但同一文档内四个运行端点必须完全一致。 */ export function observeRuntimeTransport( page: Page, terminalCode: string, trustedPageOrigin: string, ): RuntimeTransportMonitor { const counts: Record = { heartbeat: 0, pull: 0, report: 0, release: 0 } const instances: Record = { heartbeat: [], pull: [], report: [], release: [] } const successfulHeartbeatInstances = new Set() const releaseResponseStatuses: number[] = [] let activeDocumentInstance = '' let headerContractValid = true let pullGateValid = true let instanceUrlsValid = true let releasesHaveNoBody = true page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) activeDocumentInstance = '' }) page.on('request', (request) => { const endpoint = endpointFor(request) if (!endpoint) return counts[endpoint] += 1 const headers = request.headers() const instanceId = headers['x-runtime-instance-id'] || '' const safeInstance = RUNTIME_INSTANCE_ID.test(instanceId) headerContractValid = headerContractValid && headers['x-terminal-id'] === terminalCode && Boolean(headers['x-device-secret']) && headers['x-terminal-page-origin'] === trustedPageOrigin && safeInstance && !headers['x-terminal-secret'] if (safeInstance) { instances[endpoint].push(instanceId) if (!activeDocumentInstance) activeDocumentInstance = instanceId else headerContractValid = headerContractValid && activeDocumentInstance === instanceId instanceUrlsValid = instanceUrlsValid && !request.url().includes(instanceId) } if (endpoint === 'pull' && !successfulHeartbeatInstances.has(instanceId)) pullGateValid = false if (endpoint === 'release') releasesHaveNoBody = releasesHaveNoBody && !request.postData() }) page.on('response', (response) => { const endpoint = endpointFor(response.request()) if (!endpoint) return const instanceId = response.request().headers()['x-runtime-instance-id'] || '' if (endpoint === 'heartbeat' && response.status() === 200 && RUNTIME_INSTANCE_ID.test(instanceId)) { successfulHeartbeatInstances.add(instanceId) } if (endpoint === 'release') releaseResponseStatuses.push(response.status()) }) return { heartbeatRequests: () => counts.heartbeat, heartbeatSuccesses: () => successfulHeartbeatInstances.size, pullRequests: () => counts.pull, reportRequests: () => counts.report, releaseRequests: () => counts.release, releaseStatuses: () => [...releaseResponseStatuses], runtimeInstanceIds: () => [...new Set(Object.values(instances).flat())], lastRuntimeInstanceId: () => [...instances.heartbeat].at(-1) || '', endpointInstances: () => ({ heartbeat: [...instances.heartbeat], pull: [...instances.pull], report: [...instances.report], release: [...instances.release], }), headersValid: () => headerContractValid, pullStartedAfterHeartbeat: () => pullGateValid, runtimeInstanceOmittedFromUrls: () => instanceUrlsValid, releaseBodyEmpty: () => releasesHaveNoBody, } } /** 返回布尔值而不是 storage 内容,避免设备凭据或租约 owner 进入测试进程与附件。 */ export async function runtimeInstancesAbsentFromBrowserState(page: Page, instanceIds: string[]) { return page.evaluate((values) => { const locations = [window.location.href] for (const storage of [window.localStorage, window.sessionStorage]) { for (let index = 0; index < storage.length; index += 1) { const key = storage.key(index) || '' locations.push(key, storage.getItem(key) || '') } } return values.every((value) => value && locations.every((entry) => !entry.includes(value))) }, instanceIds) }