t01-browser-walkthrough.mjs 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. /**
  2. * T01 浏览器交互走查(票 01 的唯一剩项)。
  3. *
  4. * 为什么不是 browser_* 工具:本机 dsh-browser 不可用 —— dsh-builtin-browser 的
  5. * Electron provider 需要一份可 spawn 的 Electron 二进制,而本机既没下载过
  6. * (node_modules/electron 下无 dist/ 与 path.txt),也装不成(GitHub / npmjs /
  7. * npmmirror 全部不可达,只有内网 GitLab 通)。改用已安装的 Chrome + puppeteer-core
  8. * 直接走查同一份页面,验证目标与票 01 的四点完全一致。
  9. *
  10. * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/):
  11. * node .scratch/cockpit-shell-panel-layout/verify/t01-browser-walkthrough.mjs
  12. *
  13. * ⚠ 用 `yarn dev`(= `fes dev` → .fes.js:layout.navigation = null,BaseLayout 走 v-else,
  14. * 只渲染 <FMain>,页面没有页头)。`yarn pre.dev` / `pre.dev.one` 是 TvT 预演站布局
  15. * (.fes.predev*.js:navigation='top' + isFixedHeader + 演示菜单,顶部压出 54px 固定页头)。
  16. * 维护者已明确:**真实应用环境不使用 predev 启动服务** —— 那里出来的红(A12)是环境差异,
  17. * 不是驾驶舱回归;A12 证据里的 pageHeaderH 就是用来分辨这一点的(见票 28)。
  18. * **复跑前先重启 dev server**:长跑实例可能仍给浏览器发旧模块,曾把 A15(Cesium 默认控件)
  19. * 误判为红一次;重启后同一份代码 20/20。
  20. */
  21. import { mkdirSync, writeFileSync } from 'node:fs'
  22. import { join } from 'node:path'
  23. import { tmpdir } from 'node:os'
  24. // 产物写到系统临时目录:Chrome 的 profile 会 churn 出成千上万个文件,而项目根的
  25. // `fes dev` 正在 watch 项目树 —— 往被 watch 的树里写文件会拖垮甚至打断 dev server
  26. // (实测把 .scratch 下的临时文件写进去会让 Vite watcher 抛 EBUSY 并整进程退出)。
  27. const OUT = join(tmpdir(), 'cockpit-t01-walkthrough')
  28. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  29. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  30. const CHROME =
  31. process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  32. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  33. // ADR 0003 的词典表:mode -> 左右两栏的 panelId 与中文标题,顺序即左栏上/下、右栏上/下。
  34. const ADR = {
  35. overview: {
  36. label: '总体概览',
  37. left: [
  38. ['situationSummary', '态势总览'],
  39. ['infrastructureSummary', '基础设施'],
  40. ],
  41. right: [
  42. ['monitoringSummary', '运行监测'],
  43. ['alertSettings', '预警设置'],
  44. ],
  45. },
  46. infrastructure: {
  47. label: '基础设施',
  48. left: [
  49. ['archive', '基础档案'],
  50. ['operation', '运行信息'],
  51. ],
  52. right: [
  53. ['management', '管理信息'],
  54. ['emergency', '应急保障'],
  55. ],
  56. },
  57. monitoring: {
  58. label: '运行监测',
  59. left: [
  60. ['monitoringOverview', '监测概览'],
  61. ['monitoringDistribution', '监测分布'],
  62. ],
  63. right: [
  64. ['operationOverview', '运行概况'],
  65. ['monitoringPointLedger', '监测点台账'],
  66. ],
  67. },
  68. warningResponse: {
  69. label: '预警处置',
  70. left: [
  71. ['currentEvents', '当前预警'],
  72. ['historyAnalysis', '历史分析'],
  73. ],
  74. right: [
  75. ['responseEfficiency', '预警效率'],
  76. ['eventAnalysis', '预警分析'],
  77. ],
  78. },
  79. }
  80. const MODE_ORDER = Object.keys(ADR)
  81. const results = []
  82. function check(id, desc, pass, evidence) {
  83. results.push({ id, desc, pass, evidence })
  84. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`)
  85. }
  86. const puppeteer = (await import(PUPPETEER)).default
  87. mkdirSync(OUT, { recursive: true })
  88. // 用受支持的 puppeteer.launch 路径。先前试过自己 spawn + puppeteer.connect(为了避开
  89. // 带管道 stdio 的 spawn EPERM),但连到既有 Chrome 后 puppeteer 的 page/frame 就绪时机
  90. // 完全不可控 —— mainFrame 始终不可用,goto 直接抛 "Requesting main frame too early!"。
  91. // launch 默认用管道 stdio,本机沙箱会拒绝,所以本脚本要以更宽的文件权限运行。
  92. const PROFILE_DIR = join(OUT, '.chrome-profile')
  93. mkdirSync(PROFILE_DIR, { recursive: true })
  94. const browser = await puppeteer.launch({
  95. executablePath: CHROME,
  96. headless: true,
  97. userDataDir: PROFILE_DIR,
  98. defaultViewport: { width: 1680, height: 950 },
  99. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  100. })
  101. console.log(`Chrome: ${await browser.version()}`)
  102. try {
  103. const page = await browser.newPage()
  104. // 页面自身的报错也算走查证据:编译不过就不会有外壳。
  105. const pageErrors = []
  106. page.on('pageerror', (e) => pageErrors.push(String(e && e.message ? e.message : e)))
  107. page.on('console', (m) => {
  108. if (m.type() === 'error') pageErrors.push(`console.error: ${m.text()}`)
  109. })
  110. // Fes 默认 hash 路由;票里写的也是 #/cockpit。失败则退回 history 路由形态。
  111. // 本机可能通过 .env.local 打开了运行时配置文件(票 04 的显式开关,指向票 21 的本地 GIS)。
  112. // 本条走查测的是**内置默认**(票 27:底图未配置就不伪造配准),所以显式关掉文件层,
  113. // 免得环境差异混进断言 —— 宿主给空串表示这个页面不要配置文件。
  114. // 票 30:同时关掉「点击进入系统」引导层(本走查测的是进入之后的外壳行为,不需要它挡在前面)。
  115. await page.evaluateOnNewDocument(() => {
  116. window.__COCKPIT_CONFIG_URL__ = ''
  117. window.__COCKPIT_CONFIG__ = { map: { intro: { enabled: false } } }
  118. })
  119. let entry = ENTRY_URL
  120. await page.goto(entry, { waitUntil: 'networkidle2', timeout: 60000 })
  121. let ok = await page
  122. .waitForSelector('.cockpit-shell', { timeout: 20000 })
  123. .then(() => true)
  124. .catch(() => false)
  125. if (!ok) {
  126. entry = `${BASE}/cockpit`
  127. await page.goto(entry, { waitUntil: 'networkidle2', timeout: 60000 })
  128. ok = await page
  129. .waitForSelector('.cockpit-shell', { timeout: 20000 })
  130. .then(() => true)
  131. .catch(() => false)
  132. }
  133. check('A1-entry', '/cockpit 可打开并渲染外壳', ok, `entry=${entry}`)
  134. if (!ok) throw new Error('cockpit shell never rendered')
  135. // 票 27:引擎是在地图服务里动态 import 的,先等它就绪;等不到就交给 A15 报失败,
  136. // 不在这里抛错中断整轮走查(引擎起不来也该看到其余断言的真实结果)。
  137. const engineReady = await page
  138. .waitForFunction(
  139. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
  140. { timeout: 60000 },
  141. )
  142. .then(() => true)
  143. .catch(() => false)
  144. console.log(`Cesium 引擎就绪:${engineReady}`)
  145. // A2 顶栏、左右两栏、中央一张图同时出现,且各只有一份。
  146. const structure = await page.evaluate(() => {
  147. const q = (s) => document.querySelector(s)
  148. const qa = (s) => Array.from(document.querySelectorAll(s))
  149. return {
  150. brand: q('.cockpit-shell__brand')?.textContent?.trim() || '',
  151. topbar: !!q('.cockpit-shell__topbar'),
  152. shells: qa('.cockpit-shell').length,
  153. left: qa('.cockpit-shell__column[aria-label="左栏"]').length,
  154. right: qa('.cockpit-shell__column[aria-label="右栏"]').length,
  155. maps: qa('.cockpit-shell__map').length,
  156. mapTitle: q('.map-canvas__title')?.textContent?.trim() || '',
  157. panels: qa('.cockpit-panel').length,
  158. }
  159. })
  160. check(
  161. 'A2-structure',
  162. '顶栏、左栏、右栏、中央地图占位同时出现且各一份',
  163. structure.topbar &&
  164. structure.shells === 1 &&
  165. structure.left === 1 &&
  166. structure.right === 1 &&
  167. structure.maps === 1 &&
  168. structure.mapTitle === '中央一张图占位',
  169. JSON.stringify(structure),
  170. )
  171. // A3 四个一级模式按钮,文案按 ADR 0003 顺序。
  172. const modeLabels = await page.$$eval('.cockpit-shell__mode', (els) =>
  173. els.map((e) => e.textContent.trim()),
  174. )
  175. check(
  176. 'A3-mode-buttons',
  177. '顶栏四个一级模式可切且文案正确',
  178. JSON.stringify(modeLabels) === JSON.stringify(MODE_ORDER.map((m) => ADR[m].label)),
  179. JSON.stringify(modeLabels),
  180. )
  181. // A4 默认高亮 overview。
  182. const initialActive = await page.evaluate(() => {
  183. const active = Array.from(document.querySelectorAll('.cockpit-shell__mode')).filter(
  184. (e) => e.getAttribute('aria-pressed') === 'true',
  185. )
  186. return {
  187. count: active.length,
  188. text: active[0]?.textContent?.trim() || '',
  189. hasClass: active[0]?.classList.contains('is-active') || false,
  190. meta: document.querySelector('.cockpit-shell__meta')?.textContent?.trim() || '',
  191. href: location.href,
  192. historyLength: history.length,
  193. }
  194. })
  195. check(
  196. 'A4-default-mode',
  197. '默认模式为 overview,高亮与 aria-pressed 同步',
  198. initialActive.count === 1 &&
  199. initialActive.text === '总体概览' &&
  200. initialActive.hasClass &&
  201. initialActive.meta.includes('overview'),
  202. JSON.stringify(initialActive),
  203. )
  204. // 打标记:模式切换与面板收放之后,外壳与地图宿主必须仍是同一实例(票 02 的核心契约)。
  205. const mapInstanceAtStart = await page.evaluate(() => {
  206. document.querySelector('.cockpit-shell').__t01 = 'shell-instance'
  207. document.querySelector('.cockpit-shell__map').__t01 = 'map-instance'
  208. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  209. if (host) host.__t01Map = 'map-host'
  210. return host ? host.dataset.mapInstance || '' : ''
  211. })
  212. const snapshot = () =>
  213. page.evaluate(() => {
  214. const cols = Array.from(document.querySelectorAll('.cockpit-shell__column'))
  215. const read = (col) =>
  216. Array.from(col.querySelectorAll('.cockpit-panel')).map((p) => ({
  217. title: p.querySelector('.cockpit-panel__title')?.textContent?.trim() || '',
  218. // 面板的稳定英文键从**外壳给的 `data-panel-id`** 读,不再从面板体文案反推。
  219. // 原因(票 17 暴露):原来读占位文案 `面板占位 · <panelId>`,
  220. // 而票 17 把基础设施四块换成了真实面板、占位组件不再渲染,
  221. // panelId 于是解析成空、A5 假红 —— 断言钉在了"面板体长什么样"上,
  222. // 而那本来是实现细节(占位或真实内容都该通过)。
  223. panelId: p.dataset.panelId || '',
  224. // 面板体占位文本(票 06 的组件):只在验占位契约时用,不参与 panelId 判定。
  225. body: p.querySelector('.cockpit-placeholder__text')?.textContent?.trim() || '',
  226. expanded: p.querySelector('.cockpit-panel__head')?.getAttribute('aria-expanded'),
  227. mark: p.querySelector('.cockpit-panel__mark')?.textContent?.trim() || '',
  228. bodyHidden: (() => {
  229. const b = p.querySelector('.cockpit-panel__body')
  230. return !b || b.style.display === 'none'
  231. })(),
  232. }))
  233. return {
  234. left: cols[0] ? read(cols[0]) : [],
  235. right: cols[1] ? read(cols[1]) : [],
  236. panels: document.querySelectorAll('.cockpit-panel').length,
  237. href: location.href,
  238. historyLength: history.length,
  239. active: Array.from(document.querySelectorAll('.cockpit-shell__mode'))
  240. .filter((e) => e.getAttribute('aria-pressed') === 'true')
  241. .map((e) => e.textContent.trim()),
  242. shellTag: document.querySelector('.cockpit-shell').__t01 || null,
  243. mapTag: document.querySelector('.cockpit-shell__map').__t01 || null,
  244. mapHostTag: document.querySelector('.cockpit-shell__map .map-canvas')?.__t01Map || null,
  245. mapInstanceId: document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapInstance || '',
  246. mapCount: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
  247. }
  248. })
  249. // A5 逐模式断言:4 块面板、左右各 2、panelId 与中文标题都按 ADR 0003。
  250. for (let i = 0; i < MODE_ORDER.length; i++) {
  251. const mode = MODE_ORDER[i]
  252. const spec = ADR[mode]
  253. if (i > 0) {
  254. const buttons = await page.$$('.cockpit-shell__mode')
  255. await buttons[i].click()
  256. await new Promise((r) => setTimeout(r, 350))
  257. }
  258. const s = await snapshot()
  259. const want = (arr) => arr.map(([id, label]) => ({ id, label }))
  260. // panelId 直接读外壳给的稳定属性(不再从面板体文案反推,见 snapshot() 里的说明)。
  261. const got = (arr) => arr.map((p) => ({ id: p.panelId, label: p.title }))
  262. const leftOk = JSON.stringify(got(s.left)) === JSON.stringify(want(spec.left))
  263. const rightOk = JSON.stringify(got(s.right)) === JSON.stringify(want(spec.right))
  264. check(
  265. `A5-${mode}`,
  266. `${spec.label}:左右各 2 块,panelId 与中文标题按 ADR 0003`,
  267. s.panels === 4 &&
  268. s.left.length === 2 &&
  269. s.right.length === 2 &&
  270. leftOk &&
  271. rightOk &&
  272. s.active.length === 1 &&
  273. s.active[0] === spec.label,
  274. `left=${JSON.stringify(got(s.left))} right=${JSON.stringify(got(s.right))}`,
  275. )
  276. await page.screenshot({ path: join(OUT, `mode-${i + 1}-${mode}.png`) })
  277. }
  278. // A6/A7 切完四个模式后:URL 与 history 未变(不新增路由),外壳与地图仍是原实例。
  279. const after = await snapshot()
  280. check(
  281. 'A6-no-route-change',
  282. '切换四个一级模式不新增路由(URL 与 history 长度不变)',
  283. after.href === initialActive.href && after.historyLength === initialActive.historyLength,
  284. `href=${after.href} history=${after.historyLength} (初始 ${initialActive.historyLength})`,
  285. )
  286. check(
  287. 'A7-persistent-nodes',
  288. '切换四个一级模式后外壳与地图宿主未被重建(DOM 实例标记与实例 id 均存活)',
  289. after.shellTag === 'shell-instance' &&
  290. after.mapTag === 'map-instance' &&
  291. after.mapHostTag === 'map-host' &&
  292. after.mapCount === 1 &&
  293. after.mapInstanceId !== '' &&
  294. after.mapInstanceId === mapInstanceAtStart,
  295. `shell=${after.shellTag} map=${after.mapTag} host=${after.mapHostTag} mapCount=${after.mapCount} instance=${after.mapInstanceId}(初始 ${mapInstanceAtStart})`,
  296. )
  297. // A8 点标题栏收起/展开。
  298. const beforeToggle = await page.evaluate(() => {
  299. const p = document.querySelector('.cockpit-panel')
  300. return {
  301. expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'),
  302. mark: p.querySelector('.cockpit-panel__mark').textContent.trim(),
  303. bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none',
  304. }
  305. })
  306. await page.click('.cockpit-panel .cockpit-panel__head')
  307. await new Promise((r) => setTimeout(r, 250))
  308. const collapsed = await page.evaluate(() => {
  309. const p = document.querySelector('.cockpit-panel')
  310. return {
  311. expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'),
  312. mark: p.querySelector('.cockpit-panel__mark').textContent.trim(),
  313. bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none',
  314. isCollapsedClass: p.classList.contains('is-collapsed'),
  315. }
  316. })
  317. await page.screenshot({ path: join(OUT, 'mode-4-collapsed.png') })
  318. await page.click('.cockpit-panel .cockpit-panel__head')
  319. await new Promise((r) => setTimeout(r, 250))
  320. const reExpanded = await page.evaluate(() => {
  321. const p = document.querySelector('.cockpit-panel')
  322. return {
  323. expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'),
  324. mark: p.querySelector('.cockpit-panel__mark').textContent.trim(),
  325. bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none',
  326. }
  327. })
  328. check(
  329. 'A8-toggle',
  330. '点面板标题栏可收起并再展开(aria-expanded / 符号 / 正文显隐同步)',
  331. beforeToggle.expanded === 'true' &&
  332. beforeToggle.mark === '−' &&
  333. collapsed.expanded === 'false' &&
  334. collapsed.mark === '+' &&
  335. collapsed.bodyHidden &&
  336. collapsed.isCollapsedClass &&
  337. reExpanded.expanded === 'true' &&
  338. reExpanded.mark === '−' &&
  339. !reExpanded.bodyHidden,
  340. `before=${JSON.stringify(beforeToggle)} collapsed=${JSON.stringify(collapsed)} re=${JSON.stringify(reExpanded)}`,
  341. )
  342. // A9 收起一个面板后切走再切回,应恢复默认全展开。
  343. await page.click('.cockpit-panel .cockpit-panel__head') // 收起 warningResponse 第一块
  344. await new Promise((r) => setTimeout(r, 200))
  345. const buttons = await page.$$('.cockpit-shell__mode')
  346. await buttons[0].click() // 去 overview
  347. await new Promise((r) => setTimeout(r, 250))
  348. await buttons[3].click() // 回 warningResponse
  349. await new Promise((r) => setTimeout(r, 300))
  350. const afterRoundTrip = await snapshot()
  351. const allExpanded = [...afterRoundTrip.left, ...afterRoundTrip.right].every(
  352. (p) => p.expanded === 'true' && p.mark === '−' && !p.bodyHidden,
  353. )
  354. check(
  355. 'A9-reset-on-mode-switch',
  356. '切换一级模式后全部面板恢复默认展开',
  357. allExpanded && afterRoundTrip.panels === 4,
  358. JSON.stringify([...afterRoundTrip.left, ...afterRoundTrip.right].map((p) => p.expanded)),
  359. )
  360. // A10 全局筛选:切一级模式不得重置范围/行业(验收第 ⑦ 条,真行为断言)。
  361. const readFilters = () =>
  362. page.evaluate(() => {
  363. const picked = (sel, key) =>
  364. Array.from(document.querySelectorAll(sel))
  365. .filter((b) => b.getAttribute('aria-pressed') === 'true')
  366. .map((b) => b.dataset[key])
  367. return {
  368. scopeCount: document.querySelectorAll('.cockpit-shell__scope-btn').length,
  369. industryCount: document.querySelectorAll('.cockpit-shell__industry-btn').length,
  370. scope: picked('.cockpit-shell__scope-btn', 'scope'),
  371. industry: picked('.cockpit-shell__industry-btn', 'industry'),
  372. }
  373. })
  374. const filterBefore = await readFilters()
  375. await page.click('.cockpit-shell__industry-btn[data-industry="gas"]')
  376. await page.click('.cockpit-shell__scope-btn[data-scope="south"]')
  377. await new Promise((r) => setTimeout(r, 200))
  378. const filterPicked = await readFilters()
  379. // 切到基础设施再切回总体概览
  380. const modeButtonsForFilter = await page.$$('.cockpit-shell__mode')
  381. await modeButtonsForFilter[1].click()
  382. await new Promise((r) => setTimeout(r, 250))
  383. await modeButtonsForFilter[0].click()
  384. await new Promise((r) => setTimeout(r, 250))
  385. const filterAfter = await readFilters()
  386. check(
  387. 'A10-filter-persists',
  388. '切换一级模式不重置全局筛选(行业=燃气、范围=城南 保持不变)',
  389. filterBefore.industryCount === 5 &&
  390. filterBefore.scopeCount >= 2 &&
  391. JSON.stringify(filterPicked.industry) === '["gas"]' &&
  392. JSON.stringify(filterPicked.scope) === '["south"]' &&
  393. JSON.stringify(filterAfter.industry) === '["gas"]' &&
  394. JSON.stringify(filterAfter.scope) === '["south"]',
  395. `before=${JSON.stringify(filterBefore)} picked=${JSON.stringify(filterPicked)} after=${JSON.stringify(filterAfter)}`,
  396. )
  397. // 复位为默认筛选,避免影响后续断言与截图观感
  398. await page.click('.cockpit-shell__industry-btn[data-industry="all"]')
  399. await page.click('.cockpit-shell__scope-btn[data-scope="all"]')
  400. await new Promise((r) => setTimeout(r, 200))
  401. // A12 铺满视口:页面不锁 16:9,外壳/主体铺满,中央地图吃掉剩余宽高。
  402. // 这条是为「大气层被同权重规则改成普通网格项」那类布局塌陷加的回归断言 —— 只看结构不看尺寸会漏。
  403. const geom = await page.evaluate(() => {
  404. const rect = (sel) => {
  405. const el = document.querySelector(sel)
  406. if (!el) return null
  407. const b = el.getBoundingClientRect()
  408. return {
  409. x: Math.round(b.x),
  410. y: Math.round(b.y),
  411. w: Math.round(b.width),
  412. h: Math.round(b.height),
  413. bottom: Math.round(b.bottom),
  414. }
  415. }
  416. return {
  417. vw: window.innerWidth,
  418. vh: window.innerHeight,
  419. scrollH: document.documentElement.scrollHeight,
  420. // Fes 布局页头高度:null 布局(npm run dev / 产品构建)应为 0;
  421. // predev 演示布局是 54px 固定页头,会直接把外壳往下顶 54px。
  422. pageHeaderH: (() => {
  423. const h = document.querySelector('header.fes-layout-header')
  424. return h ? Math.round(h.getBoundingClientRect().height) : 0
  425. })(),
  426. shell: rect('.cockpit-shell'),
  427. topbar: rect('.cockpit-shell__topbar'),
  428. scope: rect('.cockpit-shell__scope'),
  429. main: rect('.cockpit-shell__main'),
  430. map: rect('.cockpit-shell__map'),
  431. }
  432. })
  433. const vh = geom.vh
  434. check(
  435. 'A12-layout-fill',
  436. '页面铺满视口:两条横栏占用受控,中央一张图铺满整个主体背景',
  437. !!geom.shell &&
  438. !!geom.topbar &&
  439. !!geom.scope &&
  440. !!geom.main &&
  441. !!geom.map &&
  442. Math.abs(geom.shell.h - vh) <= 2 &&
  443. geom.scrollH <= vh + 2 &&
  444. geom.topbar.h + geom.scope.h <= Math.round(vh * 0.16) &&
  445. geom.scope.h >= 24 &&
  446. geom.main.bottom >= vh - 24 &&
  447. geom.main.bottom <= vh &&
  448. geom.map.w >= Math.round(geom.vw * 0.85) &&
  449. geom.map.h >= Math.round(geom.main.h * 0.95),
  450. JSON.stringify(geom),
  451. )
  452. // A13 面板收放必须真正改变高度:收起的那块缩回标题栏,另一块吃掉腾出的空间。
  453. // 这条是针对「面板高度被写死、收放无视觉变化」的回归断言。
  454. const readLeftHeights = () =>
  455. page.evaluate(() => ({
  456. column: Math.round(
  457. document.querySelector('.cockpit-shell__column.is-left').getBoundingClientRect().height,
  458. ),
  459. panels: Array.from(
  460. document.querySelectorAll('.cockpit-shell__column.is-left .cockpit-panel'),
  461. ).map((p) => Math.round(p.getBoundingClientRect().height)),
  462. }))
  463. const heightBefore = await readLeftHeights()
  464. await page.click('.cockpit-shell__column.is-left .cockpit-panel .cockpit-panel__head')
  465. await new Promise((r) => setTimeout(r, 300))
  466. const heightCollapsed = await readLeftHeights()
  467. await page.click('.cockpit-shell__column.is-left .cockpit-panel .cockpit-panel__head')
  468. await new Promise((r) => setTimeout(r, 300))
  469. const heightRestored = await readLeftHeights()
  470. const grewEnough = heightCollapsed.panels[1] >= Math.round(heightBefore.panels[1] * 1.5)
  471. const firstShrankToHeader = heightCollapsed.panels[0] <= 70
  472. const fillsColumn =
  473. heightCollapsed.panels[0] + heightCollapsed.panels[1] >= heightCollapsed.column - 24
  474. const restored =
  475. Math.abs(heightRestored.panels[0] - heightBefore.panels[0]) <= 6 &&
  476. Math.abs(heightRestored.panels[1] - heightBefore.panels[1]) <= 6
  477. check(
  478. 'A13-panel-collapse-resize',
  479. '收起一块面板后它缩回标题栏、另一块吃掉腾出的高度;再展开恢复等分',
  480. heightBefore.panels.length === 2 &&
  481. firstShrankToHeader &&
  482. grewEnough &&
  483. fillsColumn &&
  484. restored,
  485. `before=${JSON.stringify(heightBefore)} collapsed=${JSON.stringify(heightCollapsed)} restored=${JSON.stringify(heightRestored)}`,
  486. )
  487. // A14 票 02 契约:走完「四模式切换 + 面板收放」之后,地图宿主仍是同一元素、实例 id 不变、全页只有一个。
  488. // A7 覆盖模式切换、A13 覆盖面板收放,本条把两者合起来做最终兜底 —— 这是「不销毁、不重建」的可观测证据。
  489. const mapAfterAll = await page.evaluate(() => {
  490. const hosts = document.querySelectorAll('.cockpit-shell__map .map-canvas')
  491. const host = hosts[0]
  492. return {
  493. count: hosts.length,
  494. tag: host?.__t01Map || null,
  495. instanceId: host?.dataset.mapInstance || '',
  496. photoLayers: host ? host.querySelectorAll('.map-canvas__photo').length : 0,
  497. }
  498. })
  499. check(
  500. 'A14-map-instance-persistent',
  501. '四模式切换 + 面板收放之后:地图宿主同一实例、实例 id 不变、全页只有一个地图',
  502. mapAfterAll.count === 1 &&
  503. mapAfterAll.tag === 'map-host' &&
  504. mapAfterAll.instanceId !== '' &&
  505. mapAfterAll.instanceId === mapInstanceAtStart &&
  506. mapAfterAll.photoLayers === 1,
  507. `after=${JSON.stringify(mapAfterAll)} 初始 instance=${mapInstanceAtStart}`,
  508. )
  509. // A15 票 27:宿主内恰好一个 Cesium Viewer / 画布,引擎与底图状态如实,
  510. // 且走完「四模式切换 + 面板收放」后实例 id 仍是初始值。
  511. const engine = await page.evaluate(() => {
  512. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  513. return {
  514. baseUrl: window.CESIUM_BASE_URL || '',
  515. viewers: host ? host.querySelectorAll('.cesium-viewer').length : 0,
  516. canvases: host ? host.querySelectorAll('.cesium-widget canvas').length : 0,
  517. state: (host && host.dataset.mapEngine) || '',
  518. basemap: (host && host.dataset.mapBasemap) || '',
  519. status: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
  520. instanceId: (host && host.dataset.mapInstance) || '',
  521. // 自带控件必须关掉:工具栏容器 Cesium 总会建,但里面不该有任何控件;
  522. // 时间轴 / 动画容器则只在开启时才建。
  523. widgetChrome: host
  524. ? host.querySelectorAll(
  525. '.cesium-viewer-toolbar > *, .cesium-viewer-animationContainer, .cesium-viewer-timelineContainer',
  526. ).length
  527. : -1,
  528. }
  529. })
  530. check(
  531. 'A15-cesium-viewer',
  532. '宿主内恰好 1 个 Cesium Viewer/画布,引擎状态 ready 且如实标注「底图未配置」,实例 id 不变',
  533. engine.viewers === 1 &&
  534. engine.canvases === 1 &&
  535. engine.state === 'ready' &&
  536. engine.basemap === 'not-configured' &&
  537. engine.status.includes('底图未配置') &&
  538. engine.instanceId !== '' &&
  539. engine.instanceId === mapInstanceAtStart &&
  540. engine.widgetChrome === 0 &&
  541. /\/cesium\/$/.test(engine.baseUrl),
  542. JSON.stringify(engine),
  543. )
  544. // A16 票 27:Cesium 运行时依赖的静态资源确实可访问(Worker / Assets / Widgets 各取一个代表)。
  545. const cesiumAssets = await page.evaluate(async () => {
  546. const base = window.CESIUM_BASE_URL || ''
  547. const paths = [
  548. 'Workers/transferTypedArrayTest.js',
  549. 'Assets/approximateTerrainHeights.json',
  550. 'Widgets/widgets.css',
  551. ]
  552. const out = []
  553. for (const item of paths) {
  554. try {
  555. const res = await fetch(base + item)
  556. out.push({ path: item, status: res.status, type: res.headers.get('content-type') || '' })
  557. } catch (error) {
  558. out.push({ path: item, status: 0, type: String(error) })
  559. }
  560. }
  561. return out
  562. })
  563. check(
  564. 'A16-cesium-static-200',
  565. 'Cesium 静态资源可访问(Workers / Assets / Widgets 全部 HTTP 200)',
  566. cesiumAssets.length === 3 && cesiumAssets.every((a) => a.status === 200),
  567. JSON.stringify(cesiumAssets),
  568. )
  569. // A17 票 27:引擎初始化失败时页面不白屏。用 CDP 拦掉 cesium 依赖包,强制走失败分支,
  570. // 断言「失败状态 + 失败文案 + 仍保留可读兜底(临时影像)」三者同时成立。
  571. const failedPage = await browser.newPage()
  572. try {
  573. await failedPage.evaluateOnNewDocument(() => {
  574. window.__COCKPIT_CONFIG_URL__ = ''
  575. window.__COCKPIT_CONFIG__ = { map: { intro: { enabled: false } } }
  576. })
  577. const cdp = await failedPage.createCDPSession()
  578. await cdp.send('Network.enable')
  579. // dev 下 cesium 由 Vite 预打包成 deps 分块;build 下是 cesium 单独分包。
  580. await cdp.send('Network.setBlockedURLs', { urls: ['*deps/cesium*', '*js/cesium.*'] })
  581. await failedPage.goto(entry, { waitUntil: 'networkidle2', timeout: 60000 })
  582. await failedPage.waitForSelector('.cockpit-shell', { timeout: 20000 })
  583. const fellBack = await failedPage
  584. .waitForFunction(
  585. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'failed',
  586. { timeout: 30000 },
  587. )
  588. .then(() => true)
  589. .catch(() => false)
  590. const fallback = await failedPage.evaluate(() => {
  591. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  592. return {
  593. shell: !!document.querySelector('.cockpit-shell'),
  594. host: !!host,
  595. canvases: host ? host.querySelectorAll('canvas').length : -1,
  596. photoLayers: host ? host.querySelectorAll('.map-canvas__photo').length : -1,
  597. state: (host && host.dataset.mapEngine) || '',
  598. status: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
  599. }
  600. })
  601. check(
  602. 'A17-engine-failure-fallback',
  603. '引擎初始化失败时不白屏:状态如实为 failed、给出失败文案、中央区域保留可读兜底',
  604. fellBack &&
  605. fallback.shell &&
  606. fallback.host &&
  607. fallback.state === 'failed' &&
  608. fallback.status.includes('初始化失败') &&
  609. fallback.photoLayers === 1 &&
  610. fallback.canvases === 0,
  611. JSON.stringify(fallback),
  612. )
  613. } finally {
  614. await failedPage.close()
  615. }
  616. // A11 运行期无错误。
  617. check('A11-no-page-errors', '走查期间无页面级报错', pageErrors.length === 0, JSON.stringify(pageErrors.slice(0, 5)))
  618. await page.screenshot({ path: join(OUT, 'final-warningResponse.png'), fullPage: false })
  619. writeFileSync(join(OUT, 'results.json'), JSON.stringify(results, null, 2), 'utf8')
  620. } finally {
  621. await browser.close()
  622. }
  623. const failed = results.filter((r) => !r.pass)
  624. console.log('')
  625. console.log(`T01 浏览器走查:${results.length - failed.length}/${results.length} 项通过。截图与结果:${OUT}`)
  626. if (failed.length) {
  627. console.log('未通过:' + failed.map((f) => f.id).join(', '))
  628. process.exit(1)
  629. }
  630. console.log('T01 浏览器走查全部通过。')