/** * 票 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()