probe-jitter.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. /**
  2. * 票 26 诊断探针:主场景静置期间的画面抽搐来源定位(不联网后端,走 dev server 真数据源)。
  3. *
  4. * 反馈回路(先建再判,不猜):
  5. * ① 页内 rAF 帧间隔连续记录 + PerformanceObserver 长任务;
  6. * ② 每 250ms 采一次 `__cockpitMap` 的相机 hpr、雾状态、交接状态、区域状态、引擎态;
  7. * ③ 对帧间隔按 5s 分桶出 p50/p95/max,对相机 hpr 做振荡检测(相邻差分变号频率 + 振幅);
  8. * ④ 抽搐若与某状态机翻转同频 → 锁定来源;帧间隔有周期尖峰 → 渲染/加载抖动。
  9. *
  10. * 运行(dev server 需已在 :8000):
  11. * node .scratch/cockpit-real-data-wayfinding/verify/artifacts/t26/probe-jitter.mjs
  12. */
  13. import { cdpLaunch } from '../../../../cockpit-shell-panel-layout/verify/cdp-driver.mjs'
  14. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  15. const TARGET_URL = process.env.T26_URL || 'http://127.0.0.1:8000/#/cockpit'
  16. const WAIT_MS = Number(process.env.T26_WAIT_MS || 75000)
  17. const POLL_MS = 250
  18. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  19. function stats(values) {
  20. if (!values.length) return null
  21. const sorted = [...values].sort((a, b) => a - b)
  22. const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]
  23. const mean = sorted.reduce((s, v) => s + v, 0) / sorted.length
  24. return { n: sorted.length, p50: at(0.5), p95: at(0.95), max: sorted[sorted.length - 1], mean: Number(mean.toFixed(3)) }
  25. }
  26. /** 振荡检测:差分序列里符号翻转的比例 + 逐次摆幅。静止时翻转≈0,抽搐时显著>0。 */
  27. function oscillation(values) {
  28. if (values.length < 4) return null
  29. const deltas = []
  30. for (let i = 1; i < values.length; i += 1) deltas.push(values[i] - values[i - 1])
  31. const signs = deltas.map((d) => (d > 1e-9 ? 1 : d < -1e-9 ? -1 : 0)).filter((s) => s !== 0)
  32. if (signs.length < 3) return { flips: 0, ratio: 0, amplitude: 0, samples: values.length }
  33. let flips = 0
  34. for (let i = 1; i < signs.length; i += 1) if (signs[i] !== signs[i - 1]) flips += 1
  35. const amplitude = Math.max(...values) - Math.min(...values)
  36. return { flips, ratio: Number((flips / (signs.length - 1)).toFixed(3)), amplitude, samples: values.length }
  37. }
  38. const browser = await cdpLaunch({ executablePath: CHROME, width: 1280, height: 800 })
  39. console.log(`Chrome: ${await browser.version()}`)
  40. const page = await browser.newPage()
  41. const pageErrors = []
  42. page.on('pageerror', (e) => pageErrors.push(String(e && e.message ? e.message : e)))
  43. await page.goto(TARGET_URL, { timeout: 60000 })
  44. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  45. // 装采样器(帧间隔 + 长任务 + WS 入站帧 + REST 请求完成时刻)。
  46. await page.evaluate(() => {
  47. window.__t26 = { frames: [], longTasks: [], wsFrames: [], requests: [] }
  48. let last = performance.now()
  49. const tick = (now) => {
  50. window.__t26.frames.push({ t: now, dt: now - last })
  51. last = now
  52. requestAnimationFrame(tick)
  53. }
  54. requestAnimationFrame(tick)
  55. try {
  56. new PerformanceObserver((list) => {
  57. for (const entry of list.getEntries()) window.__t26.longTasks.push({ start: entry.startTime, dur: entry.duration })
  58. }).observe({ entryTypes: ['longtask'] })
  59. } catch { /* 无 longtask 支持则跳过 */ }
  60. // WS 入站帧时刻(外壳的 runtimeSocket 用 addEventListener('message') 收帧)。
  61. const NativeWS = window.WebSocket
  62. window.WebSocket = class extends NativeWS {
  63. addEventListener(type, listener, ...rest) {
  64. if (type === 'message') {
  65. const wrapped = (event) => {
  66. try {
  67. const data = typeof event.data === 'string' ? event.data.slice(0, 100) : ''
  68. window.__t26.wsFrames.push({ t: performance.now(), head: data })
  69. } catch { /* 记录失败不影响收帧 */ }
  70. return listener.apply(this, arguments)
  71. }
  72. return super.addEventListener(type, wrapped, ...rest)
  73. }
  74. return super.addEventListener(type, listener, ...rest)
  75. }
  76. }
  77. // REST 完成时刻(axios 浏览器适配器走 XHR,另兜 fetch)。
  78. const nativeFetch = window.fetch.bind(window)
  79. window.fetch = async (...args) => {
  80. const url = String((args[0] && args[0].url) || args[0])
  81. try {
  82. const resp = await nativeFetch(...args)
  83. window.__t26.requests.push({ t: performance.now(), url: url.split('?')[0].slice(-70) })
  84. return resp
  85. } catch (error) {
  86. window.__t26.requests.push({ t: performance.now(), url: `ERR ${url.slice(-50)}` })
  87. throw error
  88. }
  89. }
  90. const XHR = XMLHttpRequest.prototype
  91. const nativeOpen = XHR.open
  92. XHR.open = function (method, url, ...rest) {
  93. this.__t26url = String(url).split('?')[0].slice(-70)
  94. return nativeOpen.call(this, method, url, ...rest)
  95. }
  96. const nativeSend = XHR.send
  97. XHR.send = function (...args) {
  98. const el = this
  99. const url = el.__t26url || ''
  100. el.addEventListener('loadend', () => window.__t26.requests.push({ t: performance.now(), url }))
  101. return nativeSend.apply(el, args)
  102. }
  103. })
  104. // 真实用户路径:点「点击进入系统」。
  105. if (await page.$('[data-cockpit-intro]')) {
  106. await page.click('[data-cockpit-intro]')
  107. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  108. }
  109. await page.waitForFunction(
  110. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  111. { timeout: 40000 },
  112. ).catch(() => {})
  113. await sleep(3000)
  114. // 静置轮询采样(不做任何交互——票面现象是「等待一段时间后」自发出现)。
  115. const series = []
  116. const poll = async () => {
  117. const snap = await page.evaluate(() => {
  118. const dev = window.__cockpitMap
  119. if (!dev) return null
  120. const cam = dev.getCameraState ? dev.getCameraState() : null
  121. const fog = dev.getFogState ? dev.getFogState() : null
  122. const hand = dev.getHandoverState ? dev.getHandoverState() : null
  123. const area = dev.getAreaState ? dev.getAreaState() : null
  124. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  125. return {
  126. t: performance.now(),
  127. engine: (host && host.dataset.mapEngine) || '',
  128. cam: cam && cam.hpr ? { h: cam.hpr.heading, p: cam.hpr.pitch, r: cam.hpr.range } : null,
  129. fog: fog ? { state: fog.state, strength: fog.strength, supported: fog.supported, degraded: fog.degradedReason || '' } : null,
  130. hand: hand ? { phase: hand.phase, alpha: hand.alpha, blocked: hand.blockedBy || '' } : null,
  131. area: area || null,
  132. }
  133. })
  134. if (snap) series.push(snap)
  135. }
  136. console.log(`静置采样 ${(WAIT_MS / 1000).toFixed(0)}s(每 ${POLL_MS}ms 一拍,无任何交互)…`)
  137. const deadline = Date.now() + WAIT_MS
  138. while (Date.now() < deadline) {
  139. await poll()
  140. await sleep(POLL_MS)
  141. }
  142. // ── 判定 ──
  143. const report = { waitedMs: WAIT_MS, samples: series.length, pageErrors: pageErrors.slice(0, 5) }
  144. const perWindow = await page.evaluate(() => {
  145. const frames = window.__t26.frames
  146. const start = frames.length ? frames[0].t : 0
  147. const out = []
  148. let bucket = []
  149. let bucketStart = start
  150. for (const frame of frames) {
  151. if (frame.t - bucketStart >= 5000) {
  152. out.push({ from: bucketStart - start, dts: bucket.map((f) => f.dt) })
  153. bucket = []
  154. bucketStart = frame.t
  155. }
  156. bucket.push(frame)
  157. }
  158. if (bucket.length) out.push({ from: bucketStart - start, dts: bucket.map((f) => f.dt) })
  159. return {
  160. windows: out.map((w) => ({ from: w.from, stats: null, _dts: w.dts })),
  161. longTasks: window.__t26.longTasks,
  162. wsFrames: window.__t26.wsFrames,
  163. requests: window.__t26.requests,
  164. }
  165. })
  166. report.fpsWindows = perWindow.windows.map((w) => ({ from: w.from, ...stats(w._dts) }))
  167. // 票 33:验收判据要逐条复算——补导出**全量**长任务与原始帧分桶(票 26 原探针只存 Top8 与统计)。
  168. report.frameBuckets = perWindow.windows.map((w) => ({ from: w.from, dts: w._dts }))
  169. report.longTasksAll = perWindow.longTasks
  170. report.longTaskCount = perWindow.longTasks.length
  171. report.longTasksTop = perWindow.longTasks.sort((a, b) => b.dur - a.dur).slice(0, 8)
  172. report.wsFrameCount = perWindow.wsFrames.length
  173. report.wsFrameHeads = perWindow.wsFrames.slice(0, 30)
  174. report.requestCount = perWindow.requests.length
  175. report.requests = perWindow.requests
  176. /** 相关:每个长任务(≥100ms)前后 400ms 内有没有 WS 帧 / REST 完成 / 状态机翻转。 */
  177. const t0 = series.length ? series[0].t : 0
  178. const near = (t, list, windowMs) => list.filter((item) => Math.abs(item.t - t) <= windowMs)
  179. report.longTaskCorrelation = perWindow.longTasks
  180. .filter((task) => task.dur >= 100)
  181. .map((task) => ({
  182. start: Number(task.start.toFixed(0)),
  183. dur: task.dur,
  184. ws: near(task.start, perWindow.wsFrames, 400).length,
  185. rest: near(task.start, perWindow.requests, 400).map((r) => r.url),
  186. }))
  187. .slice(0, 40)
  188. for (const key of ['h', 'p', 'r']) {
  189. const values = series.map((s) => (s.cam ? s.cam[key] : null)).filter((v) => v !== null)
  190. report[`cam_${key}`] = { ...oscillation(values), first: values[0], last: values[values.length - 1] }
  191. }
  192. const track = (pick, label) => {
  193. const seen = []
  194. for (const s of series) {
  195. const v = pick(s)
  196. const text = JSON.stringify(v)
  197. if (!seen.length || seen[seen.length - 1].text !== text) seen.push({ at: s.t, text })
  198. }
  199. report[label] = { transitions: seen.length - 1, timeline: seen.slice(0, 20) }
  200. }
  201. track((s) => s.fog && { state: s.fog.state, strength: s.fog.strength }, 'fogTrack')
  202. track((s) => s.hand && { phase: s.hand.phase, alpha: s.hand.alpha }, 'handoverTrack')
  203. track((s) => s.area, 'areaTrack')
  204. const outPath = new URL('./jitter-report.json', import.meta.url).pathname.replace(/^\//, '')
  205. const { writeFileSync } = await import('node:fs')
  206. writeFileSync(outPath.startsWith('/E') ? `E:${outPath.slice(2)}` : outPath, JSON.stringify(report, null, 2))
  207. console.log(`report -> ${outPath}`)
  208. console.log(JSON.stringify({
  209. samples: report.samples,
  210. fps: report.fpsWindows.map((w) => `${w.from / 1000}s p50=${w.p50} p95=${w.p95} max=${w.max}`),
  211. cam_r: report.cam_r,
  212. fogTransitions: report.fogTrack.transitions,
  213. handoverTransitions: report.handoverTrack.transitions,
  214. areaTransitions: report.areaTrack.transitions,
  215. longTasks: report.longTaskCount,
  216. wsFrames: report.wsFrameCount,
  217. requests: report.requestCount,
  218. longTaskCorrelation: report.longTaskCorrelation,
  219. }, null, 2))
  220. await browser.close()