t05-adapter-walkthrough.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. /**
  2. * T05 真机走查:领域适配层在浏览器里确实成立(票 05)。
  3. *
  4. * 逻辑脚本(t05-adapter.mjs)已经把转换语义、mock 五类样例与分区隔离测透了;
  5. * 这里只补**只有在真机才成立**的部分 —— 也就是票 05 验收第 6 条的渲染侧原句:
  6. * 「401 或凭据类失败只把受影响分区标为数据不可用,保留已成功分区与地图骨架」。
  7. * 纯逻辑断言证明不了「地图骨架还在」:那要求页面真的渲染出来、且 Cesium 宿主没被拆掉。
  8. *
  9. * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务):
  10. * node .scratch/cockpit-shell-panel-layout/verify/t05-adapter-walkthrough.mjs
  11. */
  12. import { join } from 'node:path'
  13. import { tmpdir } from 'node:os'
  14. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  15. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  16. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  17. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  18. const results = []
  19. function check(id, desc, pass, evidence) {
  20. results.push({ id, desc, pass })
  21. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`)
  22. }
  23. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  24. const puppeteer = (await import(PUPPETEER)).default
  25. const browser = await puppeteer.launch({
  26. executablePath: CHROME,
  27. headless: true,
  28. userDataDir: join(tmpdir(), 'cockpit-t05-profile'),
  29. defaultViewport: { width: 1680, height: 950 },
  30. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  31. })
  32. console.log(`Chrome: ${await browser.version()}`)
  33. /**
  34. * 开一页并等分区状态板就绪。
  35. * `scenario` / `onlyPath` 通过宿主全局键注入(mocks/index.js 读它们),
  36. * 因此走查不必改代码或重新构建就能演示五类样例。
  37. */
  38. async function openPage(inject) {
  39. const page = await browser.newPage()
  40. const errors = []
  41. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  42. page.on('console', (m) => {
  43. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  44. })
  45. await page.evaluateOnNewDocument(() => {
  46. // 引导层会盖住页面,走查不需要它(票 30 的开关)。
  47. window.__COCKPIT_CONFIG__ = { dataSource: 'mock', map: { intro: { enabled: false } } }
  48. })
  49. if (inject) await page.evaluateOnNewDocument(inject)
  50. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  51. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  52. await page.waitForSelector('[data-section-board]', { timeout: 20000 }).catch(() => {})
  53. // 等分区板从「未加载」变成有内容(请求 + 转换 + 渲染)。
  54. await page
  55. .waitForFunction(() => document.querySelectorAll('[data-section-board] [data-section-state]').length >= 3, { timeout: 20000 })
  56. .catch(() => {})
  57. // 引擎要拉静态资源/地形,比分区取数慢:等它落到终态再断言,否则会把 booting 误判成失败。
  58. // (t04/t29 的走查同样先等 dataset.mapEngine 变成终态。)
  59. await page
  60. .waitForFunction(
  61. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  62. { timeout: 30000 },
  63. )
  64. .catch(() => {})
  65. await sleep(400)
  66. return { page, errors }
  67. }
  68. const readBoard = (page) =>
  69. page.evaluate(() => {
  70. const tags = Array.from(document.querySelectorAll('[data-section-board] [data-section-state]'))
  71. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  72. return {
  73. sections: tags.map((t) => ({
  74. name: t.dataset.sectionName,
  75. state: t.dataset.sectionState,
  76. tone: t.dataset.sectionTone,
  77. retained: t.dataset.sectionRetained,
  78. text: t.textContent.trim(),
  79. })),
  80. engine: (host && host.dataset.mapEngine) || '',
  81. mapHostCount: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
  82. shellPresent: !!document.querySelector('.cockpit-shell'),
  83. panels: document.querySelectorAll('.cockpit-panel').length,
  84. bodyText: document.body.innerText,
  85. href: location.href,
  86. }
  87. })
  88. try {
  89. // ---------- A1 默认(成功样例):三条共享分区都就绪 ----------
  90. {
  91. const { page, errors } = await openPage()
  92. const board = await readBoard(page)
  93. check(
  94. 'A1-success-sections',
  95. '默认 mock:地图摘要/设备详情/数据新鲜度三条共享分区都取到数据并标为已就绪',
  96. board.sections.length === 3 && board.sections.every((s) => s.state === 'success' && s.tone === 'lv4'),
  97. JSON.stringify(board.sections),
  98. )
  99. check(
  100. 'A1-map-skeleton-alive',
  101. '地图骨架常驻:Cesium 引擎就绪、宿主只有一个(分区取数不影响地图)',
  102. board.engine === 'ready' && board.mapHostCount === 1 && board.panels === 4,
  103. `engine=${board.engine} hosts=${board.mapHostCount} panels=${board.panels}`,
  104. )
  105. check('A1-no-page-errors', '默认态无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  106. await page.close()
  107. }
  108. // ---------- A2 空数据:分区显示「无数据」,不是「失败」 ----------
  109. {
  110. const { page } = await openPage(() => {
  111. window.__COCKPIT_MOCK_SCENARIO__ = 'empty'
  112. })
  113. const board = await readBoard(page)
  114. check(
  115. 'A2-empty-sections',
  116. '空数据样例:设备详情与新鲜度显示「无数据」(中性色),不误报成失败',
  117. board.sections.length === 3 &&
  118. board.sections.filter((s) => s.state === 'empty').length >= 2 &&
  119. board.sections.every((s) => s.state !== 'failed' && s.state !== 'unavailable'),
  120. JSON.stringify(board.sections),
  121. )
  122. await page.close()
  123. }
  124. // ---------- A3 过期:保留旧值但带过期标识 ----------
  125. {
  126. const { page } = await openPage(() => {
  127. window.__COCKPIT_MOCK_SCENARIO__ = 'stale'
  128. })
  129. const board = await readBoard(page)
  130. const fresh = board.sections.find((s) => s.name === 'equipmentFreshness')
  131. check(
  132. 'A3-stale-flagged',
  133. '过期样例:新鲜度分区显示「数据已过期」并带 retained 标识(不得伪装成实时)',
  134. !!fresh && fresh.state === 'stale' && fresh.tone === 'lv2' && fresh.retained === 'true' && fresh.text.includes('过期'),
  135. JSON.stringify(fresh),
  136. )
  137. await page.close()
  138. }
  139. // ---------- A4 失败:分区失败但地图与其它分区照常 ----------
  140. {
  141. const { page } = await openPage(() => {
  142. window.__COCKPIT_MOCK_SCENARIO__ = 'failed'
  143. })
  144. const board = await readBoard(page)
  145. check(
  146. 'A4-failed-keeps-skeleton',
  147. '失败样例:分区标为加载失败,外壳、四个面板与地图骨架全部保留',
  148. board.sections.every((s) => s.state === 'failed') &&
  149. board.shellPresent &&
  150. board.panels === 4 &&
  151. board.mapHostCount === 1 &&
  152. board.engine === 'ready',
  153. JSON.stringify({ sections: board.sections, engine: board.engine, panels: board.panels }),
  154. )
  155. await page.close()
  156. }
  157. // ---------- A5 票 05 验收第 6 条:401 只标受影响分区 ----------
  158. {
  159. const { page, errors } = await openPage(() => {
  160. window.__COCKPIT_MOCK_SCENARIO__ = 'unauthorized'
  161. // 只让「设备详情聚合」这一个分区凭据失败。必须细到该端点:
  162. // 若用 `/equipments/` 前缀,新鲜度分区会被一起打掉,就证不了「只标受影响分区」。
  163. window.__COCKPIT_MOCK_ONLY__ = '/equipments/*/detail-aggregate'
  164. })
  165. const board = await readBoard(page)
  166. const affected = board.sections.find((s) => s.name === 'equipmentAggregate')
  167. const healthy = board.sections.filter((s) => s.name !== 'equipmentAggregate')
  168. check(
  169. 'A5-401-marks-affected-only',
  170. '401/凭据类失败:只有受影响分区标为「数据不可用」(红色),其余分区保持已就绪',
  171. !!affected &&
  172. affected.state === 'unavailable' &&
  173. affected.tone === 'lv1' &&
  174. affected.text.includes('数据不可用') &&
  175. healthy.length === 2 &&
  176. healthy.every((s) => s.state === 'success'),
  177. JSON.stringify({ affected, healthy }),
  178. )
  179. check(
  180. 'A5-map-skeleton-preserved',
  181. '同一次 401:地图骨架与外壳未被牵连(引擎就绪、宿主与面板都在)',
  182. board.engine === 'ready' && board.mapHostCount === 1 && board.panels === 4 && board.shellPresent,
  183. `engine=${board.engine} hosts=${board.mapHostCount} panels=${board.panels}`,
  184. )
  185. check(
  186. 'A5-no-login-redirect',
  187. '凭据类失败不跳登录页:URL 不变、页面里没有登录入口(ADR 0005)',
  188. board.href === ENTRY_URL && !/登录|login/i.test(board.bodyText),
  189. JSON.stringify({ href: board.href }),
  190. )
  191. check('A5-no-page-errors', '401 场景无页面级报错(失败被收敛成分区状态,没有抛出)', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  192. await page.close()
  193. }
  194. } finally {
  195. await browser.close()
  196. }
  197. const failed = results.filter((r) => !r.pass)
  198. console.log('')
  199. console.log(`T05 走查:${results.length - failed.length}/${results.length} 项通过。`)
  200. if (failed.length) {
  201. console.log('未通过:' + failed.map((f) => f.id).join(', '))
  202. process.exit(1)
  203. }
  204. console.log('T05 走查全部通过。')