probe-jitter-replay.mjs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. /**
  2. * 票 26 消融 B(缓存回放):15s 后 map-snapshot 全部回放缓存的同 URL 响应。
  3. *
  4. * 与消融 A 的区别:refresh 仍然成功、分页照走、对象照在图上 —— 只是数据完全相同。
  5. * 若 30s/60s 的周期性 stall 仍在 → 抽搐来自「refresh→整层重建」机制本身,
  6. * 与数据是否变化无关;这是对因果链最严格的检验。
  7. *
  8. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/artifacts/t26/probe-jitter-replay.mjs
  9. */
  10. import { cdpLaunch } from '../../../../cockpit-shell-panel-layout/verify/cdp-driver.mjs'
  11. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  12. const TARGET_URL = process.env.T26_URL || 'http://127.0.0.1:8000/#/cockpit'
  13. const WAIT_MS = Number(process.env.T26_WAIT_MS || 70000)
  14. const POLL_MS = 250
  15. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  16. function stats(values) {
  17. if (!values.length) return null
  18. const sorted = [...values].sort((a, b) => a - b)
  19. const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]
  20. return { n: sorted.length, p50: at(0.5), p95: at(0.95), max: sorted[sorted.length - 1] }
  21. }
  22. const browser = await cdpLaunch({ executablePath: CHROME, width: 1280, height: 800 })
  23. console.log(`Chrome: ${await browser.version()} [消融 B:map-snapshot 缓存回放(15s 后)]`)
  24. const page = await browser.newPage()
  25. await page.goto(TARGET_URL, { timeout: 60000 })
  26. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  27. await page.evaluate(() => {
  28. window.__t26 = { frames: [], longTasks: [], replayed: 0, recorded: 0 }
  29. let last = performance.now()
  30. const tick = (now) => {
  31. window.__t26.frames.push({ t: now, dt: now - last })
  32. last = now
  33. requestAnimationFrame(tick)
  34. }
  35. requestAnimationFrame(tick)
  36. try {
  37. new PerformanceObserver((list) => {
  38. for (const entry of list.getEntries()) window.__t26.longTasks.push({ start: entry.startTime, dur: entry.duration })
  39. }).observe({ entryTypes: ['longtask'] })
  40. } catch { /* 忽略 */ }
  41. const isSnapshot = (url) => String(url).includes('map-snapshot')
  42. const armAt = performance.now() + 15000
  43. const cache = new Map() // url -> { status, body }
  44. const nativeFetch = window.fetch.bind(window)
  45. window.fetch = async (...args) => {
  46. const url = String((args[0] && args[0].url) || args[0])
  47. if (!isSnapshot(url)) return nativeFetch(...args)
  48. if (performance.now() < armAt) {
  49. const resp = await nativeFetch(...args)
  50. try {
  51. const clone = resp.clone()
  52. cache.set(url, { status: resp.status, body: await clone.json() })
  53. window.__t26.recorded += 1
  54. } catch { /* 非 JSON 不缓存 */ }
  55. return resp
  56. }
  57. const hit = cache.get(url)
  58. if (!hit) return nativeFetch(...args) // 未见过的 URL 照常取
  59. window.__t26.replayed += 1
  60. return { ok: hit.status >= 200 && hit.status < 300, status: hit.status, json: async () => hit.body, headers: { get: () => null } }
  61. }
  62. // XHR 路径同款(axios 可能走 XHR;real 模式下 cockpitApi 用的是 fetch 还是 XHR 未知,两条都拦)。
  63. const XHR = XMLHttpRequest.prototype
  64. const nativeOpen = XHR.open
  65. XHR.open = function (method, url, ...rest) {
  66. this.__t26url = String(url)
  67. return nativeOpen.call(this, method, url, ...rest)
  68. }
  69. const nativeSend = XHR.send
  70. XHR.send = function (...args) {
  71. const el = this
  72. if (!isSnapshot(el.__t26url) || performance.now() < armAt) return nativeSend.apply(el, args)
  73. const hit = cache.get(el.__t26url)
  74. if (!hit) return nativeSend.apply(el, args)
  75. window.__t26.replayed += 1
  76. const body = JSON.stringify(hit.body)
  77. Object.defineProperty(el, 'readyState', { configurable: true, get: () => 4 })
  78. Object.defineProperty(el, 'status', { configurable: true, get: () => hit.status })
  79. Object.defineProperty(el, 'responseText', { configurable: true, get: () => body })
  80. Object.defineProperty(el, 'response', { configurable: true, get: () => body })
  81. setTimeout(() => {
  82. el.dispatchEvent(new Event('readystatechange'))
  83. el.dispatchEvent(new Event('load'))
  84. el.dispatchEvent(new Event('loadend'))
  85. }, 5)
  86. return undefined
  87. }
  88. })
  89. if (await page.$('[data-cockpit-intro]')) {
  90. await page.click('[data-cockpit-intro]')
  91. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  92. }
  93. await page.waitForFunction(
  94. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  95. { timeout: 40000 },
  96. ).catch(() => {})
  97. await sleep(3000)
  98. console.log(`静置采样 ${(WAIT_MS / 1000).toFixed(0)}s…`)
  99. const deadline = Date.now() + WAIT_MS
  100. while (Date.now() < deadline) await sleep(POLL_MS)
  101. const result = await page.evaluate(() => {
  102. const frames = window.__t26.frames
  103. const start = frames.length ? frames[0].t : 0
  104. const out = []
  105. let bucket = []
  106. let bucketStart = start
  107. for (const frame of frames) {
  108. if (frame.t - bucketStart >= 5000) {
  109. out.push({ from: bucketStart - start, dts: bucket.map((f) => f.dt) })
  110. bucket = []
  111. bucketStart = frame.t
  112. }
  113. bucket.push(frame)
  114. }
  115. if (bucket.length) out.push({ from: bucketStart - start, dts: bucket.map((f) => f.dt) })
  116. return {
  117. windows: out.map((w) => ({ from: w.from, dts: w.dts })),
  118. longTasks: window.__t26.longTasks,
  119. replayed: window.__t26.replayed,
  120. recorded: window.__t26.recorded,
  121. }
  122. })
  123. console.log(`录制 ${result.recorded} 页 / 回放 ${result.replayed} 页`)
  124. console.log('fps 窗口:')
  125. for (const w of result.windows) {
  126. const s = stats(w.dts)
  127. console.log(` ${(w.from / 1000).toFixed(1)}s p50=${s.p50} p95=${s.p95} max=${s.max}`)
  128. }
  129. const late = result.longTasks.filter((t) => t.start > 16000 && t.dur >= 100)
  130. console.log(`15s 后长任务(≥100ms):${late.length} 个 ${JSON.stringify(late.slice(0, 10))}`)
  131. await browser.close()