render_parity.mjs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. /**
  2. * 呈现对拍(P11)· 多页签版:把**重构前的页面**与**Vue 迁移后的组件**放在同一份数据上比一遍。
  3. *
  4. * 口径(实逮决定):
  5. * · `/detail/v2` 是浏览器里现渲的单页(HTML 只有壳 + 内联 app.js/charts.js + 语言包,无 window.__DATA__)
  6. * ⇒ 基线必须**让旧代码真跑一遍**:jsdom 加载现网页面,并在 **beforeParse** 装 fetch 桩转发到真实网关
  7. * (桩晚装一句,旧页面就 `fetch is not defined`,基线全空)。
  8. * · 页签由 hash 决定(`#tab=energy`)⇒ 基线按同一 hash 加载,两边渲染同页签。
  9. * · Vue 侧用**同一份数据**(fetch 桩记录下的 /api/fleet 响应)经 SSR 渲成 HTML。
  10. * · 比对:
  11. * ① 文本序列(`#root` 下所有文本节点,按 DOM 顺序、空白归一)—— 结构 + 文案一致;
  12. * ② 表格单元格文本(逐表、逐行);
  13. * ③ **图表数据**:旧页面内联 SVG 里的数字 vs Vue 侧 ECharts option 里 `__data__` 的数字
  14. * (视觉实现不同是用户令「引入 ECharts」的结果,**数据必须同源**)。
  15. *
  16. * 用法:node tools/render_parity.mjs [gateway] [tab1] [tab2] ...
  17. * 退出码:0 全一致 · 9 有不一致 · 2 基准页取不到。
  18. */
  19. import { fileURLToPath, pathToFileURL } from 'node:url'
  20. import { dirname, resolve } from 'node:path'
  21. import { JSDOM, VirtualConsole } from 'jsdom'
  22. const HERE = dirname(fileURLToPath(import.meta.url))
  23. const GATEWAY = process.argv[2] || 'http://127.0.0.1:28084'
  24. const TABS = process.argv.slice(3).length ? process.argv.slice(3) : ['overview', 'energy', 'component']
  25. /** 让 jsdom 里的相对地址 fetch 打到真实网关;顺手记录 `/api/fleet` 的原始响应。 */
  26. function installFetch(window, state) {
  27. window.fetch = async (input, init) => {
  28. const raw = typeof input === 'string' ? input : (input && input.url) || String(input)
  29. const url = raw.startsWith('http') ? raw : GATEWAY + (raw.startsWith('/') ? raw : '/' + raw)
  30. const resp = await fetch(url, init)
  31. const body = await resp.text()
  32. if (url.includes('/api/fleet')) {
  33. try { state.fleet = JSON.parse(body); state.fleetUrl = url } catch { /* 非 JSON */ }
  34. }
  35. return {
  36. ok: resp.ok, status: resp.status,
  37. headers: { get: (k) => resp.headers.get(k) },
  38. json: async () => JSON.parse(body), text: async () => body,
  39. }
  40. }
  41. }
  42. const norm = (s) => String(s).replace(/\s+/g, ' ').trim()
  43. /** 文本节点序列(跳过画布/SVG —— 图形由"图表数据"那一项比)。 */
  44. function textsIn(root) {
  45. const out = []
  46. const walk = (n) => {
  47. for (const c of n.childNodes) {
  48. if (c.nodeType === 3) {
  49. const s = norm(c.textContent)
  50. if (s) out.push(s)
  51. } else if (c.nodeType === 1) {
  52. const tag = c.tagName.toLowerCase()
  53. if (tag === 'canvas' || tag === 'svg' || tag === 'script' || tag === 'style') continue
  54. walk(c)
  55. }
  56. }
  57. }
  58. walk(root)
  59. return out
  60. }
  61. /** 表格:每张表的行×单元格文本。 */
  62. function tablesIn(root) {
  63. return Array.from(root.querySelectorAll('table')).map((tb) =>
  64. Array.from(tb.querySelectorAll('tr')).map((tr) =>
  65. Array.from(tr.querySelectorAll('th,td')).map((c) => norm(c.textContent))))
  66. }
  67. /** 旧页面图表里的数字(内联 SVG 文本里的数值)。 */
  68. function svgNumbers(root) {
  69. const out = []
  70. for (const t of Array.from(root.querySelectorAll('svg text'))) {
  71. for (const m of norm(t.textContent).matchAll(/-?\d[\d,]*(?:\.\d+)?/g)) out.push(m[0].replace(/,/g, ''))
  72. }
  73. return out
  74. }
  75. /** Vue 侧:ECharts option 的 `__data__` 里的数字(数据级对拍)。 */
  76. function chartNumbers(root) {
  77. const out = []
  78. for (const el of Array.from(root.querySelectorAll('[data-chart]'))) {
  79. let opt = null
  80. try { opt = JSON.parse(el.getAttribute('data-chart') || 'null') } catch { /* ignore */ }
  81. const d = opt && opt.__data__
  82. if (!d) continue
  83. const walk = (x) => {
  84. if (typeof x === 'number') { if (Number.isFinite(x)) out.push(String(Math.round(x))); return }
  85. if (Array.isArray(x)) { x.forEach(walk); return }
  86. if (x && typeof x === 'object') { Object.values(x).forEach(walk) }
  87. }
  88. walk(d)
  89. }
  90. return out
  91. }
  92. async function baseline(tab) {
  93. const html = await (await fetch(GATEWAY + '/detail/v2')).text()
  94. const state = {}
  95. const dom = new JSDOM(html, {
  96. url: GATEWAY + `/detail/v2#tab=${tab}`,
  97. runScripts: 'dangerously', pretendToBeVisual: true,
  98. virtualConsole: new VirtualConsole(),
  99. beforeParse: (window) => installFetch(window, state),
  100. })
  101. const doc = dom.window.document
  102. // ★实逮:`#root` 初始就带一个 `.panel`(出错提示占位)——若拿 `.panel` 当"渲染好了"的判据,
  103. // 会在 app.js 取数之前就跳出循环,基线全空。判据改用 render() 一定产出的 `.page-head`,
  104. // 并且要求 fetch 桩已经记下 `/api/fleet` 的响应。
  105. for (let i = 0; i < 120; i++) {
  106. const root = doc.getElementById('root')
  107. if (state.fleet && root && root.querySelector('.page-head')) break
  108. await new Promise((r) => setTimeout(r, 500))
  109. }
  110. return { doc, data: state.fleet, root: doc.getElementById('root') }
  111. }
  112. async function vueAnchors(data, tab) {
  113. const entry = pathToFileURL(resolve(HERE, '../target/ssr/entry-server.js')).href
  114. const html = await (await import(entry)).renderTab(data, tab)
  115. const dom = new JSDOM(`<div id="wrap">${html}</div>`)
  116. const wrap = dom.window.document.getElementById('wrap')
  117. // ★对齐口径:基线取的是 `#root`(页签内容本身,页签导航在 #root 之外的 #tabs)。
  118. // Vue 的 Workbench 把导航也渲进来了 ⇒ 这里先把导航摘掉再比;导航文案单独比(见 tabsLabel)。
  119. const nav = wrap.querySelector('.wb-tabs, #tabs, nav')
  120. const tabsLabel = nav ? Array.from(nav.querySelectorAll('button')).map((b) => norm(b.textContent.replace(/迁移中/, ''))) : []
  121. if (nav) nav.remove()
  122. return { wrap, tabsLabel }
  123. }
  124. /** 深比较(表格用):返回第一个差异的说明。 */
  125. function cmpDeep(a, b, label) {
  126. const A = JSON.stringify(a), B = JSON.stringify(b)
  127. if (A === B) return ''
  128. if (a.length !== b.length) return `${label}: 张数/行数不同 ${a.length} ≠ ${b.length}`
  129. for (let i = 0; i < a.length; i++) {
  130. if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) {
  131. const ra = a[i] || [], rb = b[i] || []
  132. if (ra.length !== rb.length) return `${label}: 第 ${i + 1} 张表行数 ${ra.length} ≠ ${rb.length}`
  133. for (let r = 0; r < ra.length; r++) {
  134. if (JSON.stringify(ra[r]) !== JSON.stringify(rb[r])) {
  135. return `${label}: 第 ${i + 1} 张表第 ${r + 1} 行不同 —— "${String(ra[r]).slice(0, 60)}" ≠ "${String(rb[r]).slice(0, 60)}"`
  136. }
  137. }
  138. }
  139. }
  140. return label + ': 有差异'
  141. }
  142. function cmpList(a, b, label, limit = 3) {
  143. if (a.length !== b.length) return `${label}: 条数 ${a.length} ≠ ${b.length}`
  144. for (let i = 0; i < a.length; i++) {
  145. if (a[i] !== b[i]) return `${label}[${i}]: "${String(a[i]).slice(0, 70)}" ≠ "${String(b[i]).slice(0, 70)}"`
  146. }
  147. return ''
  148. }
  149. function cmpSorted(a, b, label) {
  150. const A = [...a].sort(), B = [...b].sort()
  151. if (A.length !== B.length) return `${label}: 条数 ${A.length} ≠ ${B.length}`
  152. for (let i = 0; i < A.length; i++) if (A[i] !== B[i]) return `${label}: 值集合不同(首个差异 ${A[i]} ≠ ${B[i]})`
  153. return ''
  154. }
  155. let bad = 0
  156. for (const tab of TABS) {
  157. const base = await baseline(tab)
  158. if (!base.data) {
  159. console.log(`[X] ${tab}: 取不到 /api/fleet 数据(服务没起?)`)
  160. bad++
  161. continue
  162. }
  163. const vroot = await vueAnchors(base.data, tab)
  164. const baseTabs = Array.from(base.doc.querySelectorAll('#tabs button')).map((b) => norm(b.textContent))
  165. const checks = [
  166. cmpList(textsIn(base.root), textsIn(vroot.wrap), '文本序列'),
  167. cmpDeep(tablesIn(base.root), tablesIn(vroot.wrap), '表格'),
  168. cmpList(baseTabs, vroot.tabsLabel, '页签'),
  169. ]
  170. const svgN = svgNumbers(base.root)
  171. const echN = chartNumbers(vroot.wrap)
  172. // 图表:老页面没有 SVG(或该页签本就无图)时不比;两边都有才比数据集合
  173. if (svgN.length && echN.length) checks.push(cmpSorted(svgN, echN, '图表数据(数字集合)'))
  174. const fails = checks.filter(Boolean)
  175. const extra = `文本 ${textsIn(base.root).length} 项 · 表 ${tablesIn(base.root).length} 张 · 图数字 ${svgN.length}/${echN.length}`
  176. if (fails.length) {
  177. bad++
  178. console.log(`[X] ${tab} —— ${extra}`)
  179. for (const f of fails) console.log(' ' + f)
  180. } else {
  181. console.log(`[OK] ${tab} —— ${extra}(呈现一致)`)
  182. }
  183. }
  184. console.log(`\n结论: ${bad === 0 ? `页签 ${TABS.join('/')} 与重构前呈现一致` : `${bad} 个页签不一致(见上)`}`)
  185. process.exit(bad === 0 ? 0 : 9)