t28-fill-container.mjs 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /**
  2. * T28 检查:外壳填满**所在容器**,而不是按 100vh 铺。
  3. *
  4. * 背景(票 28):外壳不能只认视口高度。真实启动路径是 `yarn dev`(`.fes.js` →
  5. * `layout.navigation: null`,页面无页头,外壳贴初始包含块=铺满视口);但 `/cockpit` 一旦被
  6. * 别的前端壳包住(例如将来 qiankun 主应用),容器的定位盒会比视口矮,`height: 100vh`
  7. * 就会把外壳顶出容器底部:底部被裁、滚动容器还多一条纵向滚动条。
  8. * 维护者已明确:**真实应用环境不使用 predev 启动服务**,所以这里不依赖 predev 服务。
  9. *
  10. * 做法:在当前真实页面里把 `#tvt-app` 临时改成「有定位 + top:54px + 高度 = 视口 - 54px +
  11. * 滚动容器」,复刻「宿主套壳」那条几何链路(预演站里实测的定位容器是
  12. * `section.fes-layout.is-fixed`:absolute、top 54px、高 896),再对比旧实现与交付实现。
  13. *
  14. * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/):
  15. * node .scratch/cockpit-shell-panel-layout/verify/t28-fill-container.mjs
  16. */
  17. import { join } from 'node:path'
  18. import { tmpdir } from 'node:os'
  19. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  20. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  21. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  22. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  23. const results = []
  24. function check(id, desc, pass, evidence) {
  25. results.push({ id, desc, pass })
  26. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`)
  27. }
  28. const puppeteer = (await import(PUPPETEER)).default
  29. const browser = await puppeteer.launch({
  30. executablePath: CHROME,
  31. headless: true,
  32. userDataDir: join(tmpdir(), 'cockpit-t28-profile'),
  33. defaultViewport: { width: 1680, height: 950 },
  34. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  35. })
  36. console.log(`Chrome: ${await browser.version()}`)
  37. try {
  38. const page = await browser.newPage()
  39. const pageErrors = []
  40. page.on('pageerror', (e) => pageErrors.push(String(e && e.message ? e.message : e)))
  41. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  42. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  43. await new Promise((r) => setTimeout(r, 800))
  44. const geom = () =>
  45. page.evaluate(() => {
  46. const round = (n) => Math.round(n)
  47. const box = (el) => {
  48. if (!el) return null
  49. const b = el.getBoundingClientRect()
  50. return { y: round(b.y), h: round(b.height), bottom: round(b.bottom), w: round(b.width) }
  51. }
  52. const shell = document.querySelector('.cockpit-shell')
  53. const style = shell ? getComputedStyle(shell) : null
  54. // 外壳的定位容器:有定位祖先就是它,否则是初始包含块(=视口)
  55. const cb = shell && shell.offsetParent && shell.offsetParent !== document.body ? shell.offsetParent : null
  56. // 链上有没有滚动条(含 html)
  57. const scrollables = []
  58. for (let el = shell; el; el = el.parentElement) {
  59. if (el.scrollHeight > el.clientHeight + 2) {
  60. scrollables.push(`${el.tagName.toLowerCase()}.${String(el.className || '').split(/\s+/)[0]}`)
  61. }
  62. }
  63. return {
  64. vh: window.innerHeight,
  65. scrollH: document.documentElement.scrollHeight,
  66. docScrollable: document.scrollingElement.scrollHeight > window.innerHeight + 2,
  67. scrollables,
  68. shell: box(shell),
  69. shellPosition: style ? style.position : '',
  70. containerBlock: cb ? { ...box(cb), cls: String(cb.className || cb.id) } : 'initial-containing-block',
  71. atmosphere: box(document.querySelector('.cockpit-shell__atmosphere')),
  72. main: box(document.querySelector('.cockpit-shell__main')),
  73. map: box(document.querySelector('.cockpit-shell__map')),
  74. }
  75. })
  76. // 把宿主改成「顶部有 headerPx 页头」:定位祖先 + 高的滚动容器(= predev 里 is-fixed 的角色)。
  77. const applyHostChrome = (headerPx) =>
  78. page.evaluate((px) => {
  79. const root = document.querySelector('#tvt-app')
  80. root.style.position = 'relative'
  81. root.style.top = `${px}px`
  82. root.style.height = `calc(100vh - ${px}px)`
  83. root.style.overflow = 'auto'
  84. }, headerPx)
  85. // 旧实现(票 28 之前):外壳是普通流里的 100vh 块。
  86. const applyOldShell = (on) =>
  87. page.evaluate((enable) => {
  88. const shell = document.querySelector('.cockpit-shell')
  89. shell.style.position = enable ? 'static' : ''
  90. shell.style.inset = enable ? 'auto' : ''
  91. shell.style.height = enable ? '100vh' : ''
  92. }, on)
  93. // ---------- A 真实宿主(本仓库默认布局:无页头) ----------
  94. const a = await geom()
  95. check(
  96. 'A-real-host',
  97. '真实宿主(无页头):外壳仍铺满视口,中央一张图铺满主体,页面无纵向滚动',
  98. !!a.shell &&
  99. Math.abs(a.shell.h - a.vh) <= 2 &&
  100. a.shell.y === 0 &&
  101. a.shell.bottom === a.vh &&
  102. a.scrollH <= a.vh + 2 &&
  103. !a.docScrollable &&
  104. a.scrollables.length === 0 &&
  105. a.map.h >= Math.round(a.main.h * 0.95),
  106. JSON.stringify(a),
  107. )
  108. // ---------- B1 带页头宿主 + 旧实现:复现 bug ----------
  109. await applyHostChrome(54)
  110. await applyOldShell(true)
  111. await new Promise((r) => setTimeout(r, 300))
  112. const b1 = await geom()
  113. check(
  114. 'B1-reproduce-old',
  115. '带 54px 页头的宿主 + 旧实现(height:100vh):外壳越过视口底 54px,宿主容器多出 54px 纵向滚动',
  116. !!b1.shell && b1.shell.bottom > b1.vh + 2 && b1.scrollables.length > 0,
  117. JSON.stringify({ shell: b1.shell, vh: b1.vh, scrollables: b1.scrollables }),
  118. )
  119. // ---------- B2 同一宿主 + 交付实现:修复 ----------
  120. await applyOldShell(false)
  121. await new Promise((r) => setTimeout(r, 400))
  122. const b2 = await geom()
  123. check(
  124. 'B2-fixed',
  125. '带 54px 页头的宿主 + 交付实现(absolute + inset:0):外壳高 = 容器高、底边对齐视口底、无纵向滚动',
  126. !!b2.shell &&
  127. typeof b2.containerBlock === 'object' &&
  128. Math.abs(b2.shell.h - b2.containerBlock.h) <= 2 &&
  129. Math.abs(b2.shell.bottom - b2.vh) <= 2 &&
  130. b2.main.bottom <= b2.vh &&
  131. b2.scrollables.length === 0 &&
  132. b2.map.h >= Math.round(b2.main.h * 0.95),
  133. JSON.stringify({
  134. shell: b2.shell,
  135. container: b2.containerBlock,
  136. vh: b2.vh,
  137. main: b2.main,
  138. scrollables: b2.scrollables,
  139. }),
  140. )
  141. // ---------- B3 氛围层:只铺自己的范围,不盖到宿主页头那一带 ----------
  142. check(
  143. 'B3-atmosphere-scoped',
  144. '氛围底不越界:随外壳自己的范围铺(y 与高都等于外壳),宿主页头那一带不归驾驶舱画',
  145. !!b2.atmosphere && !!b2.shell && b2.atmosphere.y === b2.shell.y && b2.atmosphere.h === b2.shell.h,
  146. JSON.stringify({ atmosphere: b2.atmosphere, shell: b2.shell }),
  147. )
  148. // ---------- B4 容器再变矮:外壳跟着缩(证明不是挂载时量一次) ----------
  149. await applyHostChrome(120)
  150. await new Promise((r) => setTimeout(r, 400))
  151. const b4 = await geom()
  152. check(
  153. 'B4-follows-container',
  154. '容器再变矮 66px:外壳与地图跟着缩,仍无纵向滚动(CSS 跟随,不是一次性测量)',
  155. !!b4.shell &&
  156. Math.abs(b4.shell.h - (b4.vh - 120)) <= 2 &&
  157. Math.abs(b4.shell.bottom - b4.vh) <= 2 &&
  158. b4.scrollables.length === 0,
  159. JSON.stringify({ shell: b4.shell, vh: b4.vh, scrollables: b4.scrollables }),
  160. )
  161. check('A11-no-page-errors', '走查期间无页面级报错', pageErrors.length === 0, JSON.stringify(pageErrors.slice(0, 3)))
  162. // ---------- C 真·视口 resize:外壳跟着新视口走(不是挂载时量一次) ----------
  163. await applyHostChrome(54)
  164. await page.setViewport({ width: 1280, height: 720 })
  165. await new Promise((r) => setTimeout(r, 600))
  166. const c = await geom()
  167. check(
  168. 'C-real-viewport-resize',
  169. '把视口从 1680x950 改成 1280x720(带 54px 页头):外壳跟着变矮、底边仍对齐视口底、无纵向滚动',
  170. !!c.shell &&
  171. c.vh === 720 &&
  172. Math.abs(c.shell.h - (720 - 54)) <= 2 &&
  173. Math.abs(c.shell.bottom - 720) <= 2 &&
  174. c.main.bottom <= 720 &&
  175. c.scrollables.length === 0,
  176. JSON.stringify({ shell: c.shell, vh: c.vh, scrollables: c.scrollables }),
  177. )
  178. } finally {
  179. await browser.close()
  180. }
  181. const failed = results.filter((r) => !r.pass)
  182. console.log(
  183. `\nT28:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`,
  184. )
  185. process.exit(failed.length ? 1 : 0)