t15-websocket-walkthrough.mjs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. /**
  2. * T15 真机走查:状态推送的连接/重连/鉴权失败与**就地更新**(票 15)。
  3. *
  4. * 本脚本对应的断言 2026-09-24 已在 `http://127.0.0.1:8001/#/cockpit` **实际逐项验证通过**
  5. * (A–F 六组,见票 15 正文的实测记录);保留成可复跑脚本供回归时重跑。
  6. * 当时抓到的真 bug(推送早于分页快照 → 翻页后旧状态复活)已修,并加了 E7c/E7d 两条逻辑回归。
  7. *
  8. * 逻辑脚本(t15-websocket.mjs,45/45)已经把协议时序、旧消息丢弃、就地合并、
  9. * 退避与清理测透了;这里只补**只有真机才成立**的部分:
  10. * A 顶栏真的画出连接状态(`data-ws-state` 走到 open);
  11. * B 脚本化假连接按后端时序发帧 → 点位状态真的变了,且**地图点位数不变**(只改已有实体);
  12. * C 旧消息被丢弃(界面显示的是较新的那个状态);
  13. * D 鉴权失败样例 → 状态转 `unauthorized`,**REST 快照照常显示**(面板仍有数据);
  14. * E 断线重连样例 → 状态经过 `retrying` 并回到 `open`;
  15. * F 全程无页面级报错。
  16. *
  17. * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,脚本由维护者按需执行):
  18. * node .scratch/cockpit-shell-panel-layout/verify/t15-websocket-walkthrough.mjs
  19. * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
  20. *
  21. * 演示开关(宿主全局键注入,不改代码重新构建):
  22. * `__COCKPIT_WS_SCENARIO__` = 'success'(默认)| 'unauthorized' | 'flaky'
  23. *
  24. * 注意:本机 `@puppeteer/browsers` 是空目录时本脚本起不来(与 t28 同一环境问题);
  25. * 那种情况下可用内建浏览器按 A–F 手工驱动同一批断言(票 15 正文记了那次实测的做法)。
  26. */
  27. import { join } from 'node:path'
  28. import { tmpdir } from 'node:os'
  29. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  30. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  31. const CHROME = 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. const results = []
  34. function check(id, pass, evidence) {
  35. results.push({ id, pass })
  36. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${evidence === undefined ? '' : String(evidence)}`)
  37. }
  38. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  39. const puppeteer = (await import(PUPPETEER)).default
  40. const browser = await puppeteer.launch({
  41. executablePath: CHROME,
  42. headless: true,
  43. userDataDir: join(tmpdir(), 'cockpit-t15-profile'),
  44. defaultViewport: { width: 1680, height: 950 },
  45. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  46. })
  47. console.log(`Chrome: ${await browser.version()}`)
  48. /** 页面状态:连接状态、点位数、点位状态、面板是否仍有数据。 */
  49. const readState = (page) =>
  50. page.evaluate(() => {
  51. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  52. const chip = document.querySelector('[data-ws-state]')
  53. const dev = window.__cockpitMap
  54. const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
  55. const states = markers && Array.isArray(markers.markers) ? markers.markers.map((m) => `${m.code}:${m.status}`) : []
  56. // 任一面板有内容 = REST 快照照常显示(鉴权失败不该影响它)。
  57. const panels = Array.from(document.querySelectorAll('[data-panel-id]'))
  58. const withData = panels.filter((panel) => panel.textContent && panel.textContent.trim().length > 20).length
  59. return {
  60. wsState: chip ? chip.dataset.wsState || '' : '(无状态胶囊)',
  61. wsText: chip ? (chip.textContent || '').trim() : '',
  62. markerOnMap: markers && typeof markers.onMap === 'number' ? markers.onMap : null,
  63. markerStates: states,
  64. panelsWithData: withData,
  65. pageErrors: null,
  66. }
  67. })
  68. async function openPage(scenario) {
  69. const page = await browser.newPage()
  70. const errors = []
  71. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  72. page.on('console', (m) => {
  73. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  74. })
  75. await page.evaluateOnNewDocument((name) => {
  76. window.__COCKPIT_WS_SCENARIO__ = name
  77. }, scenario)
  78. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  79. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  80. await page
  81. .waitForFunction(() => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine), { timeout: 40000 })
  82. .catch(() => {})
  83. if (await page.$('[data-cockpit-intro]')) {
  84. await page.click('[data-cockpit-intro]')
  85. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  86. }
  87. // 等握手 + 脚本帧发完(假连接用 40ms 间隔)。
  88. await page.waitForFunction(() => document.querySelector('[data-ws-state]')?.dataset.wsState === 'open', { timeout: 20000 }).catch(() => {})
  89. await sleep(1200)
  90. return { page, errors }
  91. }
  92. // ================================================================ A/B/C 正常握手 + 增量
  93. {
  94. const { page, errors } = await openPage('success')
  95. const state = await readState(page)
  96. check('A1-ws-state-open', state.wsState === 'open', `顶栏 data-ws-state=${state.wsState}(「${state.wsText}」)`)
  97. check('A2-markers-still-present', state.markerOnMap !== null && state.markerOnMap > 0, `地图点位数 ${state.markerOnMap}`)
  98. check(
  99. 'B1-pushed-status-visible',
  100. state.markerStates.some((item) => item.endsWith(':ALARM')) || state.markerStates.some((item) => item.endsWith(':OFFLINE')),
  101. `推送后的点位状态:${state.markerStates.join(' / ')}(脚本样例把 MP-002 推到 ALARM)`,
  102. )
  103. // 旧消息负例:脚本第 3 条把 MP-001 回退成 ALARM(时间更早),必须被丢弃。
  104. check(
  105. 'C1-stale-delta-ignored',
  106. !state.markerStates.includes('MP-001:ALARM') && state.markerStates.includes('MP-001:OFFLINE'),
  107. `MP-001 停在较新的 ${state.markerStates.find((i) => i.startsWith('MP-001'))}(更早时间戳的 ALARM 被丢弃)`,
  108. )
  109. check('F1-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
  110. await page.close()
  111. }
  112. // ================================================================ D 鉴权失败不牵连数据
  113. {
  114. const { page, errors } = await openPage('unauthorized')
  115. const state = await readState(page)
  116. check(
  117. 'D1-unauthorized-state',
  118. state.wsState === 'unauthorized',
  119. `顶栏 data-ws-state=${state.wsState}(「${state.wsText}」)`,
  120. )
  121. check(
  122. 'D2-rest-snapshot-still-shown',
  123. state.panelsWithData > 0 && state.markerOnMap !== null,
  124. `鉴权失败但面板仍有数据的面板数=${state.panelsWithData}、点位数=${state.markerOnMap}(REST 快照照常显示)`,
  125. )
  126. check('F2-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
  127. await page.close()
  128. }
  129. // ================================================================ E 断线重连
  130. {
  131. const page = await browser.newPage()
  132. const errors = []
  133. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  134. const seen = []
  135. await page.evaluateOnNewDocument(() => {
  136. window.__COCKPIT_WS_SCENARIO__ = 'flaky'
  137. })
  138. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  139. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  140. if (await page.$('[data-cockpit-intro]')) {
  141. await page.click('[data-cockpit-intro]')
  142. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  143. }
  144. // 采样状态序列(掉线 → retrying → 重连 → open)。
  145. for (let i = 0; i < 60; i += 1) {
  146. const value = await page.evaluate(() => document.querySelector('[data-ws-state]')?.dataset.wsState || '')
  147. if (value && seen[seen.length - 1] !== value) seen.push(value)
  148. if (seen.includes('retrying') && value === 'open') break
  149. await sleep(250)
  150. }
  151. const state = await readState(page)
  152. check(
  153. 'E1-retry-then-reopen',
  154. seen.includes('retrying') && seen[seen.length - 1] === 'open',
  155. `状态序列:${seen.join(' → ')}(真的断线并重连成功,不是只改文案)`,
  156. )
  157. check('E2-markers-alive-after-reconnect', state.markerOnMap !== null && state.markerOnMap > 0, `重连后点位数 ${state.markerOnMap}`)
  158. check('F3-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
  159. await page.close()
  160. }
  161. await browser.close()
  162. const failed = results.filter((item) => !item.pass)
  163. console.log('')
  164. console.log(`T15 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
  165. if (failed.length) {
  166. console.log('未通过:')
  167. for (const item of failed) console.log(` · ${item.id}`)
  168. process.exit(1)
  169. }
  170. console.log('T15 状态推送真机走查全部通过。')