t04-config-walkthrough.mjs 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. /**
  2. * T04 真机走查:配置三态、四层来源与「凭据不进界面」在浏览器里确实成立(票 04)。
  3. *
  4. * 逻辑脚本(t04-config.mjs)用假 fetch 把语义测透了,这里只补浏览器里才成立的部分:
  5. * · 默认(仓库里没有 cockpit/config.json)= 缺失态,用内置 mock;
  6. * · 宿主注入 `window.__COCKPIT_CONFIG__` = 成功态,且**令牌不出现在 DOM 里**;
  7. * · 运行时配置文件(走 HTTP 真拉一份 JSON)= 成功态,source 记成 file;
  8. * · 配置文件坏掉 = 失败态,顶栏说「数据不可用」,且**不发生任何登录跳转**;
  9. * · `map.cesium.enabled=false` 确实让中央一张图退回临时影像。
  10. *
  11. * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了引擎/懒加载模块先重启服务):
  12. * node .scratch/cockpit-shell-panel-layout/verify/t04-config-walkthrough.mjs
  13. */
  14. import { join } from 'node:path'
  15. import { tmpdir } from 'node:os'
  16. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  17. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  18. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  19. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  20. /** 走查用的假令牌:它只要出现在 DOM 里就说明凭据泄漏到了界面层。 */
  21. const SECRET = 'walkthrough-readonly-token'
  22. const results = []
  23. function check(id, desc, pass, evidence) {
  24. results.push({ id, desc, pass })
  25. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`)
  26. }
  27. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  28. const puppeteer = (await import(PUPPETEER)).default
  29. const browser = await puppeteer.launch({
  30. executablePath: CHROME,
  31. headless: true,
  32. userDataDir: join(tmpdir(), 'cockpit-t04-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. /** 按注入脚本开一页,等外壳与配置标签就绪。 */
  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. if (inject) await page.evaluateOnNewDocument(inject)
  46. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  47. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  48. await page
  49. .waitForFunction(() => !!document.querySelector('.cockpit-shell__config'), { timeout: 20000 })
  50. .catch(() => {})
  51. await sleep(300)
  52. return { page, errors }
  53. }
  54. const readState = (page) =>
  55. page.evaluate(() => {
  56. const indicator = document.querySelector('.cockpit-shell__config')
  57. const tag = indicator ? indicator.querySelector('.status-tag') : null
  58. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  59. return {
  60. state: (indicator && indicator.dataset.configState) || '',
  61. dataSource: (indicator && indicator.dataset.dataSource) || '',
  62. source: (indicator && indicator.dataset.source) || '',
  63. tone: (tag && tag.dataset.tone) || '',
  64. label: indicator ? indicator.textContent.trim() : '',
  65. engine: (host && host.dataset.mapEngine) || '',
  66. canvases: host ? host.querySelectorAll('canvas').length : -1,
  67. photoLayers: host ? host.querySelectorAll('.map-canvas__photo').length : -1,
  68. bodyText: document.body.innerText,
  69. htmlHasSecret: document.documentElement.outerHTML.includes('walkthrough-readonly-token'),
  70. href: location.href,
  71. }
  72. })
  73. try {
  74. // ---------- A1 默认:没有配置文件 = 缺失态,内置 mock ----------
  75. {
  76. // 本机 .env.local 可能已打开配置文件层(指向票 21 的本地 GIS);本条测的是「没有运行时配置」
  77. // 这个用例,所以显式给空串关掉文件层(宿主键优先于构建期环境变量)。
  78. const { page, errors } = await openPage(() => {
  79. window.__COCKPIT_CONFIG_URL__ = ''
  80. })
  81. const state = await readState(page)
  82. check(
  83. 'A1-config-missing-default',
  84. '仓库里没有 cockpit/config.json 时:缺失态 + 内置 mock,外壳照常渲染',
  85. state.state === 'missing' &&
  86. state.dataSource === 'mock' &&
  87. state.source === 'defaults' &&
  88. state.label.includes('配置缺失') &&
  89. state.engine === 'ready',
  90. JSON.stringify({ ...state, bodyText: undefined, htmlHasSecret: undefined }),
  91. )
  92. check('A1-no-page-errors', '默认态无页面级报错(配置缺失不该刷错误)', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  93. await page.close()
  94. }
  95. // ---------- A2 宿主注入 = 成功态;令牌不得进界面 ----------
  96. {
  97. const { page, errors } = await openPage(() => {
  98. window.__COCKPIT_CONFIG__ = {
  99. dataSource: 'real',
  100. api: { baseUrl: '/api/cockpit/v1', token: 'walkthrough-readonly-token' },
  101. ws: { url: 'ws://localhost:8000/ws/deviceStatus' },
  102. }
  103. })
  104. const state = await readState(page)
  105. check(
  106. 'A2-config-injected-ready',
  107. '宿主注入 window.__COCKPIT_CONFIG__:成功态、数据源 real、来源记成 window',
  108. state.state === 'ready' && state.dataSource === 'real' && state.source === 'window' && state.label.includes('real'),
  109. JSON.stringify({ state: state.state, source: state.source, label: state.label }),
  110. )
  111. check(
  112. 'A2-credential-not-in-dom',
  113. '配置里的令牌不出现在 DOM/顶栏(页面与 store 不碰凭据)',
  114. state.htmlHasSecret === false && !state.bodyText.includes(SECRET),
  115. `htmlHasSecret=${state.htmlHasSecret}`,
  116. )
  117. check('A2-no-page-errors', '注入成功态无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  118. await page.close()
  119. }
  120. // ---------- A3 运行时配置文件(真 HTTP 拉一份 JSON)= 成功态 ----------
  121. {
  122. const { page } = await openPage(() => {
  123. window.__COCKPIT_CONFIG_URL__ = 'cockpit/config.example.json'
  124. })
  125. const state = await readState(page)
  126. check(
  127. 'A3-config-file-ready',
  128. '部署配置文件走 HTTP 真正加载:成功态、来源记成 file、地址可观测',
  129. state.state === 'ready' && state.source === 'file' && state.dataSource === 'mock',
  130. JSON.stringify({ state: state.state, source: state.source, label: state.label }),
  131. )
  132. await page.close()
  133. }
  134. // ---------- A4 配置文件坏掉 = 失败态,且绝不跳登录页 ----------
  135. {
  136. const { page } = await openPage(() => {
  137. // aerial.webp 是真文件但不是 JSON:模拟「部署放了错东西」。
  138. window.__COCKPIT_CONFIG_URL__ = 'cockpit/aerial.webp'
  139. })
  140. const state = await readState(page)
  141. check(
  142. 'A4-config-failed',
  143. '配置文件不可用:失败态、顶栏红色「数据不可用」、仍给出可跑的内置 mock',
  144. state.state === 'failed' &&
  145. state.tone === 'lv1' &&
  146. state.label.includes('数据不可用') &&
  147. state.photoLayers === 1,
  148. JSON.stringify({ state: state.state, tone: state.tone, label: state.label }),
  149. )
  150. check(
  151. 'A4-no-login-redirect',
  152. '失败时不跳登录页:URL 不变、页面里没有登录入口(ADR 0005)',
  153. state.href === ENTRY_URL && !/登录|login/i.test(state.bodyText),
  154. JSON.stringify({ href: state.href }),
  155. )
  156. await page.close()
  157. }
  158. // ---------- A5 Cesium 确实可由配置关闭 ----------
  159. {
  160. const { page } = await openPage(() => {
  161. window.__COCKPIT_CONFIG__ = { map: { cesium: { enabled: false } } }
  162. })
  163. await page
  164. .waitForFunction(
  165. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'disabled',
  166. { timeout: 20000 },
  167. )
  168. .catch(() => {})
  169. const state = await readState(page)
  170. check(
  171. 'A5-cesium-disabled-by-config',
  172. 'map.cesium.enabled=false:引擎不启动,中央区域保留临时影像兜底',
  173. state.state === 'ready' && state.engine === 'disabled' && state.canvases === 0 && state.photoLayers === 1,
  174. JSON.stringify({ state: state.state, engine: state.engine, canvases: state.canvases, photoLayers: state.photoLayers }),
  175. )
  176. await page.close()
  177. }
  178. } finally {
  179. await browser.close()
  180. }
  181. const failed = results.filter((r) => !r.pass)
  182. console.log('')
  183. console.log(`T04 走查:${results.length - failed.length}/${results.length} 项通过。`)
  184. if (failed.length) {
  185. console.log('未通过:' + failed.map((f) => f.id).join(', '))
  186. process.exit(1)
  187. }
  188. console.log('T04 走查全部通过。')