t01-browser-walkthrough.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  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. * 运行(需先起 npm run dev,服务在 http://localhost:8000/):
  11. * node .scratch/cockpit-shell-panel-layout/verify/t01-browser-walkthrough.mjs
  12. */
  13. import { mkdirSync, writeFileSync } from 'node:fs'
  14. import { join } from 'node:path'
  15. import { tmpdir } from 'node:os'
  16. // 产物写到系统临时目录:Chrome 的 profile 会 churn 出成千上万个文件,而项目根的
  17. // `fes dev` 正在 watch 项目树 —— 往被 watch 的树里写文件会拖垮甚至打断 dev server
  18. // (实测把 .scratch 下的临时文件写进去会让 Vite watcher 抛 EBUSY 并整进程退出)。
  19. const OUT = join(tmpdir(), 'cockpit-t01-walkthrough')
  20. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  21. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  22. const CHROME =
  23. process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  24. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  25. // ADR 0003 的词典表:mode -> 左右两栏的 panelId 与中文标题,顺序即左栏上/下、右栏上/下。
  26. const ADR = {
  27. overview: {
  28. label: '总体概览',
  29. left: [
  30. ['situationSummary', '态势总览'],
  31. ['infrastructureSummary', '基础设施'],
  32. ],
  33. right: [
  34. ['monitoringSummary', '运行监测'],
  35. ['alertSettings', '预警设置'],
  36. ],
  37. },
  38. infrastructure: {
  39. label: '基础设施',
  40. left: [
  41. ['archive', '基础档案'],
  42. ['operation', '运行信息'],
  43. ],
  44. right: [
  45. ['management', '管理信息'],
  46. ['emergency', '应急保障'],
  47. ],
  48. },
  49. monitoring: {
  50. label: '运行监测',
  51. left: [
  52. ['monitoringOverview', '监测概览'],
  53. ['monitoringDistribution', '监测分布'],
  54. ],
  55. right: [
  56. ['operationOverview', '运行概况'],
  57. ['monitoringPointLedger', '监测点台账'],
  58. ],
  59. },
  60. warningResponse: {
  61. label: '预警处置',
  62. left: [
  63. ['currentEvents', '当前预警'],
  64. ['historyAnalysis', '历史分析'],
  65. ],
  66. right: [
  67. ['responseEfficiency', '预警效率'],
  68. ['eventAnalysis', '预警分析'],
  69. ],
  70. },
  71. }
  72. const MODE_ORDER = Object.keys(ADR)
  73. const results = []
  74. function check(id, desc, pass, evidence) {
  75. results.push({ id, desc, pass, evidence })
  76. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`)
  77. }
  78. const puppeteer = (await import(PUPPETEER)).default
  79. mkdirSync(OUT, { recursive: true })
  80. // 用受支持的 puppeteer.launch 路径。先前试过自己 spawn + puppeteer.connect(为了避开
  81. // 带管道 stdio 的 spawn EPERM),但连到既有 Chrome 后 puppeteer 的 page/frame 就绪时机
  82. // 完全不可控 —— mainFrame 始终不可用,goto 直接抛 "Requesting main frame too early!"。
  83. // launch 默认用管道 stdio,本机沙箱会拒绝,所以本脚本要以更宽的文件权限运行。
  84. const PROFILE_DIR = join(OUT, '.chrome-profile')
  85. mkdirSync(PROFILE_DIR, { recursive: true })
  86. const browser = await puppeteer.launch({
  87. executablePath: CHROME,
  88. headless: true,
  89. userDataDir: PROFILE_DIR,
  90. defaultViewport: { width: 1680, height: 950 },
  91. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  92. })
  93. console.log(`Chrome: ${await browser.version()}`)
  94. try {
  95. const page = await browser.newPage()
  96. // 页面自身的报错也算走查证据:编译不过就不会有外壳。
  97. const pageErrors = []
  98. page.on('pageerror', (e) => pageErrors.push(String(e && e.message ? e.message : e)))
  99. page.on('console', (m) => {
  100. if (m.type() === 'error') pageErrors.push(`console.error: ${m.text()}`)
  101. })
  102. // Fes 默认 hash 路由;票里写的也是 #/cockpit。失败则退回 history 路由形态。
  103. let entry = ENTRY_URL
  104. await page.goto(entry, { waitUntil: 'networkidle2', timeout: 60000 })
  105. let ok = await page
  106. .waitForSelector('.cockpit-shell', { timeout: 20000 })
  107. .then(() => true)
  108. .catch(() => false)
  109. if (!ok) {
  110. entry = `${BASE}/cockpit`
  111. await page.goto(entry, { waitUntil: 'networkidle2', timeout: 60000 })
  112. ok = await page
  113. .waitForSelector('.cockpit-shell', { timeout: 20000 })
  114. .then(() => true)
  115. .catch(() => false)
  116. }
  117. check('A1-entry', '/cockpit 可打开并渲染外壳', ok, `entry=${entry}`)
  118. if (!ok) throw new Error('cockpit shell never rendered')
  119. // A2 顶栏、左右两栏、中央一张图同时出现,且各只有一份。
  120. const structure = await page.evaluate(() => {
  121. const q = (s) => document.querySelector(s)
  122. const qa = (s) => Array.from(document.querySelectorAll(s))
  123. return {
  124. brand: q('.cockpit-shell__brand')?.textContent?.trim() || '',
  125. topbar: !!q('.cockpit-shell__topbar'),
  126. shells: qa('.cockpit-shell').length,
  127. left: qa('.cockpit-shell__column[aria-label="左栏"]').length,
  128. right: qa('.cockpit-shell__column[aria-label="右栏"]').length,
  129. maps: qa('.cockpit-shell__map').length,
  130. mapTitle: q('.map-placeholder__title')?.textContent?.trim() || '',
  131. panels: qa('.cockpit-panel').length,
  132. }
  133. })
  134. check(
  135. 'A2-structure',
  136. '顶栏、左栏、右栏、中央地图占位同时出现且各一份',
  137. structure.topbar &&
  138. structure.shells === 1 &&
  139. structure.left === 1 &&
  140. structure.right === 1 &&
  141. structure.maps === 1 &&
  142. structure.mapTitle === '中央一张图占位',
  143. JSON.stringify(structure),
  144. )
  145. // A3 四个一级模式按钮,文案按 ADR 0003 顺序。
  146. const modeLabels = await page.$$eval('.cockpit-shell__mode', (els) =>
  147. els.map((e) => e.textContent.trim()),
  148. )
  149. check(
  150. 'A3-mode-buttons',
  151. '顶栏四个一级模式可切且文案正确',
  152. JSON.stringify(modeLabels) === JSON.stringify(MODE_ORDER.map((m) => ADR[m].label)),
  153. JSON.stringify(modeLabels),
  154. )
  155. // A4 默认高亮 overview。
  156. const initialActive = await page.evaluate(() => {
  157. const active = Array.from(document.querySelectorAll('.cockpit-shell__mode')).filter(
  158. (e) => e.getAttribute('aria-pressed') === 'true',
  159. )
  160. return {
  161. count: active.length,
  162. text: active[0]?.textContent?.trim() || '',
  163. hasClass: active[0]?.classList.contains('is-active') || false,
  164. meta: document.querySelector('.cockpit-shell__meta')?.textContent?.trim() || '',
  165. href: location.href,
  166. historyLength: history.length,
  167. }
  168. })
  169. check(
  170. 'A4-default-mode',
  171. '默认模式为 overview,高亮与 aria-pressed 同步',
  172. initialActive.count === 1 &&
  173. initialActive.text === '总体概览' &&
  174. initialActive.hasClass &&
  175. initialActive.meta.includes('overview'),
  176. JSON.stringify(initialActive),
  177. )
  178. // 打标记:模式切换后这两个节点必须仍是同一实例,否则就是重建了外壳/地图。
  179. await page.evaluate(() => {
  180. document.querySelector('.cockpit-shell').__t01 = 'shell-instance'
  181. document.querySelector('.cockpit-shell__map').__t01 = 'map-instance'
  182. const m = document.querySelector('.map-placeholder')
  183. if (m) m.__t01 = 'map-inner'
  184. })
  185. const snapshot = () =>
  186. page.evaluate(() => {
  187. const cols = Array.from(document.querySelectorAll('.cockpit-shell__column'))
  188. const read = (col) =>
  189. Array.from(col.querySelectorAll('.cockpit-panel')).map((p) => ({
  190. title: p.querySelector('.cockpit-panel__title')?.textContent?.trim() || '',
  191. body: p.querySelector('.cockpit-shell__placeholder')?.textContent?.trim() || '',
  192. expanded: p.querySelector('.cockpit-panel__head')?.getAttribute('aria-expanded'),
  193. mark: p.querySelector('.cockpit-panel__mark')?.textContent?.trim() || '',
  194. bodyHidden: (() => {
  195. const b = p.querySelector('.cockpit-panel__body')
  196. return !b || b.style.display === 'none'
  197. })(),
  198. }))
  199. return {
  200. left: cols[0] ? read(cols[0]) : [],
  201. right: cols[1] ? read(cols[1]) : [],
  202. panels: document.querySelectorAll('.cockpit-panel').length,
  203. href: location.href,
  204. historyLength: history.length,
  205. active: Array.from(document.querySelectorAll('.cockpit-shell__mode'))
  206. .filter((e) => e.getAttribute('aria-pressed') === 'true')
  207. .map((e) => e.textContent.trim()),
  208. shellTag: document.querySelector('.cockpit-shell').__t01 || null,
  209. mapTag: document.querySelector('.cockpit-shell__map').__t01 || null,
  210. mapInnerTag: document.querySelector('.map-placeholder')?.__t01 || null,
  211. mapCount: document.querySelectorAll('.map-placeholder').length,
  212. }
  213. })
  214. // A5 逐模式断言:4 块面板、左右各 2、panelId 与中文标题都按 ADR 0003。
  215. for (let i = 0; i < MODE_ORDER.length; i++) {
  216. const mode = MODE_ORDER[i]
  217. const spec = ADR[mode]
  218. if (i > 0) {
  219. const buttons = await page.$$('.cockpit-shell__mode')
  220. await buttons[i].click()
  221. await new Promise((r) => setTimeout(r, 350))
  222. }
  223. const s = await snapshot()
  224. const want = (arr) => arr.map(([id, label]) => ({ id, label }))
  225. const got = (arr) => arr.map((p) => ({ id: (p.body.split('·')[1] || '').trim(), label: p.title }))
  226. const leftOk = JSON.stringify(got(s.left)) === JSON.stringify(want(spec.left))
  227. const rightOk = JSON.stringify(got(s.right)) === JSON.stringify(want(spec.right))
  228. check(
  229. `A5-${mode}`,
  230. `${spec.label}:左右各 2 块,panelId 与中文标题按 ADR 0003`,
  231. s.panels === 4 &&
  232. s.left.length === 2 &&
  233. s.right.length === 2 &&
  234. leftOk &&
  235. rightOk &&
  236. s.active.length === 1 &&
  237. s.active[0] === spec.label,
  238. `left=${JSON.stringify(got(s.left))} right=${JSON.stringify(got(s.right))}`,
  239. )
  240. await page.screenshot({ path: join(OUT, `mode-${i + 1}-${mode}.png`) })
  241. }
  242. // A6/A7 切完四个模式后:URL 与 history 未变(不新增路由),外壳与地图仍是原实例。
  243. const after = await snapshot()
  244. check(
  245. 'A6-no-route-change',
  246. '切换四个一级模式不新增路由(URL 与 history 长度不变)',
  247. after.href === initialActive.href && after.historyLength === initialActive.historyLength,
  248. `href=${after.href} history=${after.historyLength} (初始 ${initialActive.historyLength})`,
  249. )
  250. check(
  251. 'A7-persistent-nodes',
  252. '外壳与中央地图占位未被重建(DOM 实例标记存活)',
  253. after.shellTag === 'shell-instance' &&
  254. after.mapTag === 'map-instance' &&
  255. after.mapInnerTag === 'map-inner' &&
  256. after.mapCount === 1,
  257. `shell=${after.shellTag} map=${after.mapTag} inner=${after.mapInnerTag} mapCount=${after.mapCount}`,
  258. )
  259. // A8 点标题栏收起/展开。
  260. const beforeToggle = await page.evaluate(() => {
  261. const p = document.querySelector('.cockpit-panel')
  262. return {
  263. expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'),
  264. mark: p.querySelector('.cockpit-panel__mark').textContent.trim(),
  265. bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none',
  266. }
  267. })
  268. await page.click('.cockpit-panel .cockpit-panel__head')
  269. await new Promise((r) => setTimeout(r, 250))
  270. const collapsed = await page.evaluate(() => {
  271. const p = document.querySelector('.cockpit-panel')
  272. return {
  273. expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'),
  274. mark: p.querySelector('.cockpit-panel__mark').textContent.trim(),
  275. bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none',
  276. isCollapsedClass: p.classList.contains('is-collapsed'),
  277. }
  278. })
  279. await page.screenshot({ path: join(OUT, 'mode-4-collapsed.png') })
  280. await page.click('.cockpit-panel .cockpit-panel__head')
  281. await new Promise((r) => setTimeout(r, 250))
  282. const reExpanded = await page.evaluate(() => {
  283. const p = document.querySelector('.cockpit-panel')
  284. return {
  285. expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'),
  286. mark: p.querySelector('.cockpit-panel__mark').textContent.trim(),
  287. bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none',
  288. }
  289. })
  290. check(
  291. 'A8-toggle',
  292. '点面板标题栏可收起并再展开(aria-expanded / 符号 / 正文显隐同步)',
  293. beforeToggle.expanded === 'true' &&
  294. beforeToggle.mark === '−' &&
  295. collapsed.expanded === 'false' &&
  296. collapsed.mark === '+' &&
  297. collapsed.bodyHidden &&
  298. collapsed.isCollapsedClass &&
  299. reExpanded.expanded === 'true' &&
  300. reExpanded.mark === '−' &&
  301. !reExpanded.bodyHidden,
  302. `before=${JSON.stringify(beforeToggle)} collapsed=${JSON.stringify(collapsed)} re=${JSON.stringify(reExpanded)}`,
  303. )
  304. // A9 收起一个面板后切走再切回,应恢复默认全展开。
  305. await page.click('.cockpit-panel .cockpit-panel__head') // 收起 warningResponse 第一块
  306. await new Promise((r) => setTimeout(r, 200))
  307. const buttons = await page.$$('.cockpit-shell__mode')
  308. await buttons[0].click() // 去 overview
  309. await new Promise((r) => setTimeout(r, 250))
  310. await buttons[3].click() // 回 warningResponse
  311. await new Promise((r) => setTimeout(r, 300))
  312. const afterRoundTrip = await snapshot()
  313. const allExpanded = [...afterRoundTrip.left, ...afterRoundTrip.right].every(
  314. (p) => p.expanded === 'true' && p.mark === '−' && !p.bodyHidden,
  315. )
  316. check(
  317. 'A9-reset-on-mode-switch',
  318. '切换一级模式后全部面板恢复默认展开',
  319. allExpanded && afterRoundTrip.panels === 4,
  320. JSON.stringify([...afterRoundTrip.left, ...afterRoundTrip.right].map((p) => p.expanded)),
  321. )
  322. // A10 全局筛选:切一级模式不得重置范围/行业(验收第 ⑦ 条,真行为断言)。
  323. const readFilters = () =>
  324. page.evaluate(() => {
  325. const picked = (sel, key) =>
  326. Array.from(document.querySelectorAll(sel))
  327. .filter((b) => b.getAttribute('aria-pressed') === 'true')
  328. .map((b) => b.dataset[key])
  329. return {
  330. scopeCount: document.querySelectorAll('.cockpit-shell__scope-btn').length,
  331. industryCount: document.querySelectorAll('.cockpit-shell__industry-btn').length,
  332. scope: picked('.cockpit-shell__scope-btn', 'scope'),
  333. industry: picked('.cockpit-shell__industry-btn', 'industry'),
  334. }
  335. })
  336. const filterBefore = await readFilters()
  337. await page.click('.cockpit-shell__industry-btn[data-industry="gas"]')
  338. await page.click('.cockpit-shell__scope-btn[data-scope="south"]')
  339. await new Promise((r) => setTimeout(r, 200))
  340. const filterPicked = await readFilters()
  341. // 切到基础设施再切回总体概览
  342. const modeButtonsForFilter = await page.$$('.cockpit-shell__mode')
  343. await modeButtonsForFilter[1].click()
  344. await new Promise((r) => setTimeout(r, 250))
  345. await modeButtonsForFilter[0].click()
  346. await new Promise((r) => setTimeout(r, 250))
  347. const filterAfter = await readFilters()
  348. check(
  349. 'A10-filter-persists',
  350. '切换一级模式不重置全局筛选(行业=燃气、范围=城南 保持不变)',
  351. filterBefore.industryCount === 5 &&
  352. filterBefore.scopeCount >= 2 &&
  353. JSON.stringify(filterPicked.industry) === '["gas"]' &&
  354. JSON.stringify(filterPicked.scope) === '["south"]' &&
  355. JSON.stringify(filterAfter.industry) === '["gas"]' &&
  356. JSON.stringify(filterAfter.scope) === '["south"]',
  357. `before=${JSON.stringify(filterBefore)} picked=${JSON.stringify(filterPicked)} after=${JSON.stringify(filterAfter)}`,
  358. )
  359. // 复位为默认筛选,避免影响后续断言与截图观感
  360. await page.click('.cockpit-shell__industry-btn[data-industry="all"]')
  361. await page.click('.cockpit-shell__scope-btn[data-scope="all"]')
  362. await new Promise((r) => setTimeout(r, 200))
  363. // A12 铺满视口:页面不锁 16:9,外壳/主体铺满,中央地图吃掉剩余宽高。
  364. // 这条是为「大气层被同权重规则改成普通网格项」那类布局塌陷加的回归断言 —— 只看结构不看尺寸会漏。
  365. const geom = await page.evaluate(() => {
  366. const rect = (sel) => {
  367. const el = document.querySelector(sel)
  368. if (!el) return null
  369. const b = el.getBoundingClientRect()
  370. return {
  371. x: Math.round(b.x),
  372. y: Math.round(b.y),
  373. w: Math.round(b.width),
  374. h: Math.round(b.height),
  375. bottom: Math.round(b.bottom),
  376. }
  377. }
  378. return {
  379. vw: window.innerWidth,
  380. vh: window.innerHeight,
  381. scrollH: document.documentElement.scrollHeight,
  382. shell: rect('.cockpit-shell'),
  383. topbar: rect('.cockpit-shell__topbar'),
  384. scope: rect('.cockpit-shell__scope'),
  385. main: rect('.cockpit-shell__main'),
  386. map: rect('.cockpit-shell__map'),
  387. }
  388. })
  389. const vh = geom.vh
  390. check(
  391. 'A12-layout-fill',
  392. '页面铺满视口:无纵向溢出、顶栏与筛选条矮、中央地图吃掉剩余宽高',
  393. !!geom.shell &&
  394. !!geom.topbar &&
  395. !!geom.scope &&
  396. !!geom.main &&
  397. !!geom.map &&
  398. Math.abs(geom.shell.h - vh) <= 2 &&
  399. geom.scrollH <= vh + 2 &&
  400. geom.topbar.h <= Math.round(vh * 0.12) &&
  401. geom.scope.h >= 28 &&
  402. geom.scope.h <= 90 &&
  403. geom.main.bottom >= vh - 24 &&
  404. geom.main.bottom <= vh &&
  405. geom.map.w >= Math.round(geom.vw * 0.4) &&
  406. geom.map.h >= Math.round(geom.main.h * 0.8),
  407. JSON.stringify(geom),
  408. )
  409. // A11 运行期无错误。
  410. check('A11-no-page-errors', '走查期间无页面级报错', pageErrors.length === 0, JSON.stringify(pageErrors.slice(0, 5)))
  411. await page.screenshot({ path: join(OUT, 'final-warningResponse.png'), fullPage: false })
  412. writeFileSync(join(OUT, 'results.json'), JSON.stringify(results, null, 2), 'utf8')
  413. } finally {
  414. await browser.close()
  415. }
  416. const failed = results.filter((r) => !r.pass)
  417. console.log('')
  418. console.log(`T01 浏览器走查:${results.length - failed.length}/${results.length} 项通过。截图与结果:${OUT}`)
  419. if (failed.length) {
  420. console.log('未通过:' + failed.map((f) => f.id).join(', '))
  421. process.exit(1)
  422. }
  423. console.log('T01 浏览器走查全部通过。')