/** * 票 26 消融 B(缓存回放):15s 后 map-snapshot 全部回放缓存的同 URL 响应。 * * 与消融 A 的区别:refresh 仍然成功、分页照走、对象照在图上 —— 只是数据完全相同。 * 若 30s/60s 的周期性 stall 仍在 → 抽搐来自「refresh→整层重建」机制本身, * 与数据是否变化无关;这是对因果链最严格的检验。 * * 运行:node .scratch/cockpit-real-data-wayfinding/verify/artifacts/t26/probe-jitter-replay.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 || 70000) 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))] return { n: sorted.length, p50: at(0.5), p95: at(0.95), max: sorted[sorted.length - 1] } } const browser = await cdpLaunch({ executablePath: CHROME, width: 1280, height: 800 }) console.log(`Chrome: ${await browser.version()} [消融 B:map-snapshot 缓存回放(15s 后)]`) const page = await browser.newPage() await page.goto(TARGET_URL, { timeout: 60000 }) await page.waitForSelector('.cockpit-shell', { timeout: 20000 }) await page.evaluate(() => { window.__t26 = { frames: [], longTasks: [], replayed: 0, recorded: 0 } 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 { /* 忽略 */ } const isSnapshot = (url) => String(url).includes('map-snapshot') const armAt = performance.now() + 15000 const cache = new Map() // url -> { status, body } const nativeFetch = window.fetch.bind(window) window.fetch = async (...args) => { const url = String((args[0] && args[0].url) || args[0]) if (!isSnapshot(url)) return nativeFetch(...args) if (performance.now() < armAt) { const resp = await nativeFetch(...args) try { const clone = resp.clone() cache.set(url, { status: resp.status, body: await clone.json() }) window.__t26.recorded += 1 } catch { /* 非 JSON 不缓存 */ } return resp } const hit = cache.get(url) if (!hit) return nativeFetch(...args) // 未见过的 URL 照常取 window.__t26.replayed += 1 return { ok: hit.status >= 200 && hit.status < 300, status: hit.status, json: async () => hit.body, headers: { get: () => null } } } // XHR 路径同款(axios 可能走 XHR;real 模式下 cockpitApi 用的是 fetch 还是 XHR 未知,两条都拦)。 const XHR = XMLHttpRequest.prototype const nativeOpen = XHR.open XHR.open = function (method, url, ...rest) { this.__t26url = String(url) return nativeOpen.call(this, method, url, ...rest) } const nativeSend = XHR.send XHR.send = function (...args) { const el = this if (!isSnapshot(el.__t26url) || performance.now() < armAt) return nativeSend.apply(el, args) const hit = cache.get(el.__t26url) if (!hit) return nativeSend.apply(el, args) window.__t26.replayed += 1 const body = JSON.stringify(hit.body) Object.defineProperty(el, 'readyState', { configurable: true, get: () => 4 }) Object.defineProperty(el, 'status', { configurable: true, get: () => hit.status }) Object.defineProperty(el, 'responseText', { configurable: true, get: () => body }) Object.defineProperty(el, 'response', { configurable: true, get: () => body }) setTimeout(() => { el.dispatchEvent(new Event('readystatechange')) el.dispatchEvent(new Event('load')) el.dispatchEvent(new Event('loadend')) }, 5) return undefined } }) 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) console.log(`静置采样 ${(WAIT_MS / 1000).toFixed(0)}s…`) const deadline = Date.now() + WAIT_MS while (Date.now() < deadline) await sleep(POLL_MS) const result = 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, dts: w.dts })), longTasks: window.__t26.longTasks, replayed: window.__t26.replayed, recorded: window.__t26.recorded, } }) console.log(`录制 ${result.recorded} 页 / 回放 ${result.replayed} 页`) console.log('fps 窗口:') for (const w of result.windows) { const s = stats(w.dts) console.log(` ${(w.from / 1000).toFixed(1)}s p50=${s.p50} p95=${s.p95} max=${s.max}`) } const late = result.longTasks.filter((t) => t.start > 16000 && t.dur >= 100) console.log(`15s 后长任务(≥100ms):${late.length} 个 ${JSON.stringify(late.slice(0, 10))}`) await browser.close()