| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237 |
- /**
- * 票 26 诊断探针:主场景静置期间的画面抽搐来源定位(不联网后端,走 dev server 真数据源)。
- *
- * 反馈回路(先建再判,不猜):
- * ① 页内 rAF 帧间隔连续记录 + PerformanceObserver 长任务;
- * ② 每 250ms 采一次 `__cockpitMap` 的相机 hpr、雾状态、交接状态、区域状态、引擎态;
- * ③ 对帧间隔按 5s 分桶出 p50/p95/max,对相机 hpr 做振荡检测(相邻差分变号频率 + 振幅);
- * ④ 抽搐若与某状态机翻转同频 → 锁定来源;帧间隔有周期尖峰 → 渲染/加载抖动。
- *
- * 运行(dev server 需已在 :8000):
- * node .scratch/cockpit-real-data-wayfinding/verify/artifacts/t26/probe-jitter.mjs
- */
- import { cdpLaunch } from '../../../../cockpit-shell-panel-layout/verify/cdp-driver.mjs'
- const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
- const TARGET_URL = process.env.T26_URL || 'http://127.0.0.1:8000/#/cockpit'
- const WAIT_MS = Number(process.env.T26_WAIT_MS || 75000)
- const POLL_MS = 250
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
- function stats(values) {
- if (!values.length) return null
- const sorted = [...values].sort((a, b) => a - b)
- const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]
- const mean = sorted.reduce((s, v) => s + v, 0) / sorted.length
- return { n: sorted.length, p50: at(0.5), p95: at(0.95), max: sorted[sorted.length - 1], mean: Number(mean.toFixed(3)) }
- }
- /** 振荡检测:差分序列里符号翻转的比例 + 逐次摆幅。静止时翻转≈0,抽搐时显著>0。 */
- function oscillation(values) {
- if (values.length < 4) return null
- const deltas = []
- for (let i = 1; i < values.length; i += 1) deltas.push(values[i] - values[i - 1])
- const signs = deltas.map((d) => (d > 1e-9 ? 1 : d < -1e-9 ? -1 : 0)).filter((s) => s !== 0)
- if (signs.length < 3) return { flips: 0, ratio: 0, amplitude: 0, samples: values.length }
- let flips = 0
- for (let i = 1; i < signs.length; i += 1) if (signs[i] !== signs[i - 1]) flips += 1
- const amplitude = Math.max(...values) - Math.min(...values)
- return { flips, ratio: Number((flips / (signs.length - 1)).toFixed(3)), amplitude, samples: values.length }
- }
- const browser = await cdpLaunch({ executablePath: CHROME, width: 1280, height: 800 })
- console.log(`Chrome: ${await browser.version()}`)
- const page = await browser.newPage()
- const pageErrors = []
- page.on('pageerror', (e) => pageErrors.push(String(e && e.message ? e.message : e)))
- await page.goto(TARGET_URL, { timeout: 60000 })
- await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
- // 装采样器(帧间隔 + 长任务 + WS 入站帧 + REST 请求完成时刻)。
- await page.evaluate(() => {
- window.__t26 = { frames: [], longTasks: [], wsFrames: [], requests: [] }
- let last = performance.now()
- const tick = (now) => {
- window.__t26.frames.push({ t: now, dt: now - last })
- last = now
- requestAnimationFrame(tick)
- }
- requestAnimationFrame(tick)
- try {
- new PerformanceObserver((list) => {
- for (const entry of list.getEntries()) window.__t26.longTasks.push({ start: entry.startTime, dur: entry.duration })
- }).observe({ entryTypes: ['longtask'] })
- } catch { /* 无 longtask 支持则跳过 */ }
- // WS 入站帧时刻(外壳的 runtimeSocket 用 addEventListener('message') 收帧)。
- const NativeWS = window.WebSocket
- window.WebSocket = class extends NativeWS {
- addEventListener(type, listener, ...rest) {
- if (type === 'message') {
- const wrapped = (event) => {
- try {
- const data = typeof event.data === 'string' ? event.data.slice(0, 100) : ''
- window.__t26.wsFrames.push({ t: performance.now(), head: data })
- } catch { /* 记录失败不影响收帧 */ }
- return listener.apply(this, arguments)
- }
- return super.addEventListener(type, wrapped, ...rest)
- }
- return super.addEventListener(type, listener, ...rest)
- }
- }
- // REST 完成时刻(axios 浏览器适配器走 XHR,另兜 fetch)。
- const nativeFetch = window.fetch.bind(window)
- window.fetch = async (...args) => {
- const url = String((args[0] && args[0].url) || args[0])
- try {
- const resp = await nativeFetch(...args)
- window.__t26.requests.push({ t: performance.now(), url: url.split('?')[0].slice(-70) })
- return resp
- } catch (error) {
- window.__t26.requests.push({ t: performance.now(), url: `ERR ${url.slice(-50)}` })
- throw error
- }
- }
- const XHR = XMLHttpRequest.prototype
- const nativeOpen = XHR.open
- XHR.open = function (method, url, ...rest) {
- this.__t26url = String(url).split('?')[0].slice(-70)
- return nativeOpen.call(this, method, url, ...rest)
- }
- const nativeSend = XHR.send
- XHR.send = function (...args) {
- const el = this
- const url = el.__t26url || ''
- el.addEventListener('loadend', () => window.__t26.requests.push({ t: performance.now(), url }))
- return nativeSend.apply(el, args)
- }
- })
- // 真实用户路径:点「点击进入系统」。
- if (await page.$('[data-cockpit-intro]')) {
- await page.click('[data-cockpit-intro]')
- await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
- }
- await page.waitForFunction(
- () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
- { timeout: 40000 },
- ).catch(() => {})
- await sleep(3000)
- // 静置轮询采样(不做任何交互——票面现象是「等待一段时间后」自发出现)。
- const series = []
- const poll = async () => {
- const snap = await page.evaluate(() => {
- const dev = window.__cockpitMap
- if (!dev) return null
- const cam = dev.getCameraState ? dev.getCameraState() : null
- const fog = dev.getFogState ? dev.getFogState() : null
- const hand = dev.getHandoverState ? dev.getHandoverState() : null
- const area = dev.getAreaState ? dev.getAreaState() : null
- const host = document.querySelector('.cockpit-shell__map .map-canvas')
- return {
- t: performance.now(),
- engine: (host && host.dataset.mapEngine) || '',
- cam: cam && cam.hpr ? { h: cam.hpr.heading, p: cam.hpr.pitch, r: cam.hpr.range } : null,
- fog: fog ? { state: fog.state, strength: fog.strength, supported: fog.supported, degraded: fog.degradedReason || '' } : null,
- hand: hand ? { phase: hand.phase, alpha: hand.alpha, blocked: hand.blockedBy || '' } : null,
- area: area || null,
- }
- })
- if (snap) series.push(snap)
- }
- console.log(`静置采样 ${(WAIT_MS / 1000).toFixed(0)}s(每 ${POLL_MS}ms 一拍,无任何交互)…`)
- const deadline = Date.now() + WAIT_MS
- while (Date.now() < deadline) {
- await poll()
- await sleep(POLL_MS)
- }
- // ── 判定 ──
- const report = { waitedMs: WAIT_MS, samples: series.length, pageErrors: pageErrors.slice(0, 5) }
- const perWindow = await page.evaluate(() => {
- const frames = window.__t26.frames
- const start = frames.length ? frames[0].t : 0
- const out = []
- let bucket = []
- let bucketStart = start
- for (const frame of frames) {
- if (frame.t - bucketStart >= 5000) {
- out.push({ from: bucketStart - start, dts: bucket.map((f) => f.dt) })
- bucket = []
- bucketStart = frame.t
- }
- bucket.push(frame)
- }
- if (bucket.length) out.push({ from: bucketStart - start, dts: bucket.map((f) => f.dt) })
- return {
- windows: out.map((w) => ({ from: w.from, stats: null, _dts: w.dts })),
- longTasks: window.__t26.longTasks,
- wsFrames: window.__t26.wsFrames,
- requests: window.__t26.requests,
- }
- })
- report.fpsWindows = perWindow.windows.map((w) => ({ from: w.from, ...stats(w._dts) }))
- // 票 33:验收判据要逐条复算——补导出**全量**长任务与原始帧分桶(票 26 原探针只存 Top8 与统计)。
- report.frameBuckets = perWindow.windows.map((w) => ({ from: w.from, dts: w._dts }))
- report.longTasksAll = perWindow.longTasks
- report.longTaskCount = perWindow.longTasks.length
- report.longTasksTop = perWindow.longTasks.sort((a, b) => b.dur - a.dur).slice(0, 8)
- report.wsFrameCount = perWindow.wsFrames.length
- report.wsFrameHeads = perWindow.wsFrames.slice(0, 30)
- report.requestCount = perWindow.requests.length
- report.requests = perWindow.requests
- /** 相关:每个长任务(≥100ms)前后 400ms 内有没有 WS 帧 / REST 完成 / 状态机翻转。 */
- const t0 = series.length ? series[0].t : 0
- const near = (t, list, windowMs) => list.filter((item) => Math.abs(item.t - t) <= windowMs)
- report.longTaskCorrelation = perWindow.longTasks
- .filter((task) => task.dur >= 100)
- .map((task) => ({
- start: Number(task.start.toFixed(0)),
- dur: task.dur,
- ws: near(task.start, perWindow.wsFrames, 400).length,
- rest: near(task.start, perWindow.requests, 400).map((r) => r.url),
- }))
- .slice(0, 40)
- for (const key of ['h', 'p', 'r']) {
- const values = series.map((s) => (s.cam ? s.cam[key] : null)).filter((v) => v !== null)
- report[`cam_${key}`] = { ...oscillation(values), first: values[0], last: values[values.length - 1] }
- }
- const track = (pick, label) => {
- const seen = []
- for (const s of series) {
- const v = pick(s)
- const text = JSON.stringify(v)
- if (!seen.length || seen[seen.length - 1].text !== text) seen.push({ at: s.t, text })
- }
- report[label] = { transitions: seen.length - 1, timeline: seen.slice(0, 20) }
- }
- track((s) => s.fog && { state: s.fog.state, strength: s.fog.strength }, 'fogTrack')
- track((s) => s.hand && { phase: s.hand.phase, alpha: s.hand.alpha }, 'handoverTrack')
- track((s) => s.area, 'areaTrack')
- const outPath = new URL('./jitter-report.json', import.meta.url).pathname.replace(/^\//, '')
- const { writeFileSync } = await import('node:fs')
- writeFileSync(outPath.startsWith('/E') ? `E:${outPath.slice(2)}` : outPath, JSON.stringify(report, null, 2))
- console.log(`report -> ${outPath}`)
- console.log(JSON.stringify({
- samples: report.samples,
- fps: report.fpsWindows.map((w) => `${w.from / 1000}s p50=${w.p50} p95=${w.p95} max=${w.max}`),
- cam_r: report.cam_r,
- fogTransitions: report.fogTrack.transitions,
- handoverTransitions: report.handoverTrack.transitions,
- areaTransitions: report.areaTrack.transitions,
- longTasks: report.longTaskCount,
- wsFrames: report.wsFrameCount,
- requests: report.requestCount,
- longTaskCorrelation: report.longTaskCorrelation,
- }, null, 2))
- await browser.close()
|