Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

122 строки
5.1 KiB

  1. import type { Page, Request } from '@playwright/test'
  2. const RUNTIME_INSTANCE_ID = /^[A-Za-z0-9._:-]{16,128}$/
  3. type RuntimeEndpoint = 'heartbeat' | 'pull' | 'report' | 'release'
  4. export interface RuntimeTransportMonitor {
  5. heartbeatRequests: () => number
  6. heartbeatSuccesses: () => number
  7. pullRequests: () => number
  8. reportRequests: () => number
  9. releaseRequests: () => number
  10. releaseStatuses: () => number[]
  11. runtimeInstanceIds: () => string[]
  12. lastRuntimeInstanceId: () => string
  13. endpointInstances: () => Record<RuntimeEndpoint, string[]>
  14. headersValid: () => boolean
  15. pullStartedAfterHeartbeat: () => boolean
  16. runtimeInstanceOmittedFromUrls: () => boolean
  17. releaseBodyEmpty: () => boolean
  18. }
  19. function endpointFor(request: Request): RuntimeEndpoint | null {
  20. const pathname = new URL(request.url()).pathname
  21. if (request.method() !== 'POST') return null
  22. if (pathname === '/open/v2/terminals/heartbeat') return 'heartbeat'
  23. if (pathname === '/open/v2/terminals/commands/pull') return 'pull'
  24. if (/^\/open\/v2\/terminals\/commands\/[^/]+\/reports$/.test(pathname)) return 'report'
  25. if (pathname === '/open/v2/terminals/runtime/release') return 'release'
  26. return null
  27. }
  28. /**
  29. * 只记录运行租约契约的计数与非敏感 instanceId;设备密钥只检查“存在”,绝不读取值。
  30. * 每次主文档导航都允许生成新的 instanceId,但同一文档内四个运行端点必须完全一致。
  31. */
  32. export function observeRuntimeTransport(
  33. page: Page,
  34. terminalCode: string,
  35. trustedPageOrigin: string,
  36. ): RuntimeTransportMonitor {
  37. const counts: Record<RuntimeEndpoint, number> = { heartbeat: 0, pull: 0, report: 0, release: 0 }
  38. const instances: Record<RuntimeEndpoint, string[]> = { heartbeat: [], pull: [], report: [], release: [] }
  39. const successfulHeartbeatInstances = new Set<string>()
  40. const releaseResponseStatuses: number[] = []
  41. let activeDocumentInstance = ''
  42. let headerContractValid = true
  43. let pullGateValid = true
  44. let instanceUrlsValid = true
  45. let releasesHaveNoBody = true
  46. page.on('framenavigated', (frame) => {
  47. if (frame === page.mainFrame()) activeDocumentInstance = ''
  48. })
  49. page.on('request', (request) => {
  50. const endpoint = endpointFor(request)
  51. if (!endpoint) return
  52. counts[endpoint] += 1
  53. const headers = request.headers()
  54. const instanceId = headers['x-runtime-instance-id'] || ''
  55. const safeInstance = RUNTIME_INSTANCE_ID.test(instanceId)
  56. headerContractValid = headerContractValid
  57. && headers['x-terminal-id'] === terminalCode
  58. && Boolean(headers['x-device-secret'])
  59. && headers['x-terminal-page-origin'] === trustedPageOrigin
  60. && safeInstance
  61. && !headers['x-terminal-secret']
  62. if (safeInstance) {
  63. instances[endpoint].push(instanceId)
  64. if (!activeDocumentInstance) activeDocumentInstance = instanceId
  65. else headerContractValid = headerContractValid && activeDocumentInstance === instanceId
  66. instanceUrlsValid = instanceUrlsValid && !request.url().includes(instanceId)
  67. }
  68. if (endpoint === 'pull' && !successfulHeartbeatInstances.has(instanceId)) pullGateValid = false
  69. if (endpoint === 'release') releasesHaveNoBody = releasesHaveNoBody && !request.postData()
  70. })
  71. page.on('response', (response) => {
  72. const endpoint = endpointFor(response.request())
  73. if (!endpoint) return
  74. const instanceId = response.request().headers()['x-runtime-instance-id'] || ''
  75. if (endpoint === 'heartbeat' && response.status() === 200 && RUNTIME_INSTANCE_ID.test(instanceId)) {
  76. successfulHeartbeatInstances.add(instanceId)
  77. }
  78. if (endpoint === 'release') releaseResponseStatuses.push(response.status())
  79. })
  80. return {
  81. heartbeatRequests: () => counts.heartbeat,
  82. heartbeatSuccesses: () => successfulHeartbeatInstances.size,
  83. pullRequests: () => counts.pull,
  84. reportRequests: () => counts.report,
  85. releaseRequests: () => counts.release,
  86. releaseStatuses: () => [...releaseResponseStatuses],
  87. runtimeInstanceIds: () => [...new Set(Object.values(instances).flat())],
  88. lastRuntimeInstanceId: () => [...instances.heartbeat].at(-1) || '',
  89. endpointInstances: () => ({
  90. heartbeat: [...instances.heartbeat],
  91. pull: [...instances.pull],
  92. report: [...instances.report],
  93. release: [...instances.release],
  94. }),
  95. headersValid: () => headerContractValid,
  96. pullStartedAfterHeartbeat: () => pullGateValid,
  97. runtimeInstanceOmittedFromUrls: () => instanceUrlsValid,
  98. releaseBodyEmpty: () => releasesHaveNoBody,
  99. }
  100. }
  101. /** 返回布尔值而不是 storage 内容,避免设备凭据或租约 owner 进入测试进程与附件。 */
  102. export async function runtimeInstancesAbsentFromBrowserState(page: Page, instanceIds: string[]) {
  103. return page.evaluate((values) => {
  104. const locations = [window.location.href]
  105. for (const storage of [window.localStorage, window.sessionStorage]) {
  106. for (let index = 0; index < storage.length; index += 1) {
  107. const key = storage.key(index) || ''
  108. locations.push(key, storage.getItem(key) || '')
  109. }
  110. }
  111. return values.every((value) => value && locations.every((entry) => !entry.includes(value)))
  112. }, instanceIds)
  113. }