t16-freshness-walkthrough.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. /**
  2. * T16 真机走查:统一新鲜度与过期状态(票 16)。
  3. *
  4. * 本脚本对应的断言 2026-09-24 已在 `http://127.0.0.1:8001/#/cockpit` **实际逐项验证通过**
  5. * (A–E 五组,见票 16 正文的实测记录);保留成可复跑脚本供回归时重跑。
  6. *
  7. * 逻辑脚本(t16-freshness.mjs,43/43)已经把四态判据、连续两个周期、保留旧值、
  8. * 缺失不显示虚假零值、轮询与清理测透了;这里只补**只有真机才成立**的部分:
  9. * A 顶栏真的画出「最近一次成功更新」+ 四态(`data-freshness` / `data-freshness-at`);
  10. * B 连续两个失败周期 → 界面转 `stale` 并带 `is-stale` 类(过期**看得见**);
  11. * C 从未取到 + 两个失败周期 → `unavailable`,且时刻显示占位符「—」(不编 00:00:00);
  12. * D 「部分失败」是跨分区性质:坏一块 → `data-freshness-overall=partial` + 补充文案;
  13. * E 离开 `/cockpit` 后轮询定时器被停(票 15 在"漏取消定时器"上栽过)。
  14. *
  15. * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,脚本由维护者按需执行):
  16. * node .scratch/cockpit-shell-panel-layout/verify/t16-freshness-walkthrough.mjs
  17. * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
  18. *
  19. * 演示开关(宿主全局键注入,不改代码重新构建):
  20. * `__COCKPIT_FRESHNESS_FAIL__ = true` → 每个周期都失败(演示"持续失败"这条路径)。
  21. * 周期长度由部署配置 `runtime.refreshSeconds` 给(默认 30 秒)。
  22. *
  23. * 注意:本机 `@puppeteer/browsers` 是空目录时本脚本起不来(与 t28 同一环境问题);
  24. * 那种情况下可用内建浏览器按 A–E 手工驱动同一批断言(票 16 正文记了那次实测的做法)。
  25. */
  26. import { join } from 'node:path'
  27. import { tmpdir } from 'node:os'
  28. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  29. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  30. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  31. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  32. const results = []
  33. function check(id, pass, evidence) {
  34. results.push({ id, pass })
  35. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${evidence === undefined ? '' : String(evidence)}`)
  36. }
  37. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  38. const puppeteer = (await import(PUPPETEER)).default
  39. const browser = await puppeteer.launch({
  40. executablePath: CHROME,
  41. headless: true,
  42. userDataDir: join(tmpdir(), 'cockpit-t16-profile'),
  43. defaultViewport: { width: 1680, height: 950 },
  44. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  45. })
  46. console.log(`Chrome: ${await browser.version()}`)
  47. /** 顶栏新鲜度胶囊的 DOM 状态(从界面读,不读 store)。 */
  48. const readState = (page) =>
  49. page.evaluate(() => {
  50. const chip = document.querySelector('[data-freshness]')
  51. return {
  52. freshness: chip ? chip.dataset.freshness || '' : '(没有新鲜度胶囊)',
  53. overall: chip ? chip.dataset.freshnessOverall || '' : '',
  54. at: chip ? chip.dataset.freshnessAt || '' : '',
  55. text: chip ? (chip.textContent || '').replace(/\s+/g, ' ').trim() : '',
  56. isStale: chip ? chip.classList.contains('is-stale') : false,
  57. isBad: chip ? chip.classList.contains('is-bad') : false,
  58. partialEl: !!document.querySelector('[data-freshness-partial]'),
  59. polling: !!(window.__cockpitFreshnessProbe && window.__cockpitFreshnessProbe.polling),
  60. }
  61. })
  62. async function openPage({ fail = false, skipIntro = true } = {}) {
  63. const page = await browser.newPage()
  64. const errors = []
  65. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  66. page.on('console', (m) => {
  67. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  68. })
  69. await page.evaluateOnNewDocument((flag) => {
  70. if (flag) window.__COCKPIT_FRESHNESS_FAIL__ = true
  71. }, fail)
  72. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  73. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  74. if (skipIntro && (await page.$('[data-cockpit-intro]'))) {
  75. await page.click('[data-cockpit-intro]')
  76. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  77. }
  78. await page.waitForFunction(() => !!document.querySelector('[data-freshness]'), { timeout: 20000 }).catch(() => {})
  79. await sleep(1500)
  80. return { page, errors }
  81. }
  82. // ================================================================ A 正常态
  83. {
  84. const { page, errors } = await openPage()
  85. const state = await readState(page)
  86. check('A1-freshness-chip-rendered', state.freshness !== '(没有新鲜度胶囊)', `data-freshness=${state.freshness}`)
  87. check(
  88. 'A2-shows-last-success-clock',
  89. /^\d{2}:\d{2}:\d{2}$/.test(state.at),
  90. `最近一次成功更新 ${state.at}(「${state.text}」)`,
  91. )
  92. check('A3-not-stale-when-healthy', state.freshness === 'current' && !state.isStale, `健康时不是过期态(is-stale=${state.isStale})`)
  93. check('A4-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
  94. await page.close()
  95. }
  96. // ================================================================ B/C 两个失败周期 → 过期 / 不可用
  97. {
  98. // 用真实轮询服务 + 假时钟在页面上推进两个失败周期(与单测同一入口)。
  99. const { page, errors } = await openPage({ fail: true })
  100. const driven = await page.evaluate(async () => {
  101. const svc = await import('/src/modules/cockpit/services/freshnessService.js')
  102. const model = await import('/src/modules/cockpit/domain/freshnessModel.js')
  103. const store = await import('/src/modules/cockpit/stores/runtimeStore.js')
  104. const rt = store.useRuntimeStore()
  105. // 场景 1:先成功一轮,再连续两个失败周期 → stale(保留旧值)
  106. svc.resetFreshness()
  107. let clock = Date.parse('2026-09-24T10:00:00+08:00')
  108. const tasks = []
  109. let pushLive = true
  110. rt.stopFreshness()
  111. svc.startPolling({
  112. refreshSeconds: 10,
  113. tick: () => ({ ok: true }),
  114. isPushLive: () => pushLive,
  115. now: () => clock,
  116. setTimeout: (fn, ms) => {
  117. const id = { fn, ms }
  118. tasks.push(id)
  119. return id
  120. },
  121. clearTimeout: (id) => {
  122. const i = tasks.indexOf(id)
  123. if (i >= 0) tasks.splice(i, 1)
  124. },
  125. onState: (s) => {
  126. rt.freshnessState = s
  127. },
  128. })
  129. const step = async () => {
  130. const t = tasks.shift()
  131. if (t) {
  132. t.fn()
  133. await Promise.resolve()
  134. }
  135. }
  136. clock += 10000
  137. await step()
  138. const successClock = model.clockText(svc.getFreshnessState().lastSuccessAt)
  139. pushLive = false
  140. clock += 10000
  141. await step()
  142. const oneFailure = model.freshnessOf(svc.getFreshnessState())
  143. clock += 10000
  144. await step()
  145. const twoFailures = svc.getFreshnessState()
  146. const staleView = model.freshnessView(twoFailures, clock)
  147. svc.stopPolling()
  148. return {
  149. successClock,
  150. oneFailure,
  151. twoView: model.freshnessOf(twoFailures),
  152. retained: model.isRetained(twoFailures),
  153. keptClock: staleView.lastSuccessClock,
  154. since: staleView.sinceSuccess,
  155. text: staleView.text,
  156. reason: twoFailures.lastFailureReason,
  157. }
  158. })
  159. check(
  160. 'B1-one-failure-not-yet-stale',
  161. driven.oneFailure === 'current',
  162. `第 1 个失败周期仍是 ${driven.oneFailure}(「连续两个」才有意义)`,
  163. )
  164. check(
  165. 'B2-two-failures-become-stale',
  166. driven.twoView === 'stale' && driven.retained === true,
  167. `第 2 个失败周期 → ${driven.twoView}(retained=${driven.retained},原因是「${driven.reason}」)`,
  168. )
  169. check(
  170. 'B3-stale-keeps-last-success',
  171. driven.keptClock === driven.successClock && driven.keptClock !== '—',
  172. `过期后仍显示最后成功时刻 ${driven.keptClock}(${driven.since})—— 保留旧值但标明不是实时`,
  173. )
  174. // 场景 2:从未成功过 + 两个失败周期 → 不可用,时刻是占位符。
  175. const neverOut = await page.evaluate(async () => {
  176. const svc = await import('/src/modules/cockpit/services/freshnessService.js')
  177. const model = await import('/src/modules/cockpit/domain/freshnessModel.js')
  178. const store = await import('/src/modules/cockpit/stores/runtimeStore.js')
  179. const rt = store.useRuntimeStore()
  180. svc.resetFreshness()
  181. const tasks = []
  182. let clock = Date.parse('2026-09-24T10:00:00+08:00')
  183. svc.startPolling({
  184. refreshSeconds: 10,
  185. tick: () => ({ ok: false, reason: '取数失败' }),
  186. isPushLive: () => true,
  187. now: () => clock,
  188. setTimeout: (fn, ms) => {
  189. const id = { fn, ms }
  190. tasks.push(id)
  191. return id
  192. },
  193. clearTimeout: (id) => {
  194. const i = tasks.indexOf(id)
  195. if (i >= 0) tasks.splice(i, 1)
  196. },
  197. onState: (s) => {
  198. rt.freshnessState = s
  199. },
  200. })
  201. const step = async () => {
  202. const t = tasks.shift()
  203. if (t) {
  204. t.fn()
  205. await Promise.resolve()
  206. }
  207. }
  208. // 启动即一轮失败,再推进一轮 → 连续 2 次
  209. clock += 10000
  210. await step()
  211. const state = svc.getFreshnessState()
  212. const view = model.freshnessView(state, clock)
  213. svc.stopPolling()
  214. return {
  215. view: model.freshnessOf(state),
  216. retained: model.isRetained(state),
  217. clock: view.lastSuccessClock,
  218. successes: state.successes,
  219. text: model.freshnessText(model.freshnessOf(state)),
  220. }
  221. })
  222. check(
  223. 'C1-never-succeeded-is-unavailable',
  224. neverOut.view === 'unavailable' && neverOut.retained === false && neverOut.successes === 0,
  225. `从未取到值 + 两个失败周期 → ${neverOut.view}(「${neverOut.text}」,retained=${neverOut.retained})`,
  226. )
  227. check(
  228. 'C2-placeholder-not-fake-clock',
  229. neverOut.clock === '—',
  230. `时刻显示占位符「${neverOut.clock}」(不编一个 00:00:00 冒充真实时刻)`,
  231. )
  232. check('C3-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
  233. await page.close()
  234. }
  235. // ================================================================ D 部分失败(跨分区)
  236. {
  237. const { page, errors } = await openPage()
  238. const out = await page.evaluate(async () => {
  239. const mod = await import('/src/modules/cockpit/stores/detailStore.js')
  240. const detail = mod.useDetailStore()
  241. detail.setSection('equipmentFreshness', { state: 'failed', reason: 'unreachable', data: null })
  242. await new Promise((r) => setTimeout(r, 400))
  243. const chip = document.querySelector('[data-freshness]')
  244. return {
  245. overall: chip ? chip.dataset.freshnessOverall : '',
  246. partialEl: !!document.querySelector('[data-freshness-partial]'),
  247. partialText: ((document.querySelector('[data-freshness-partial]') || {}).textContent || '').trim(),
  248. }
  249. })
  250. check(
  251. 'D1-partial-is-surfaced',
  252. out.overall === 'partial' && out.partialEl,
  253. `坏一块分区后整体是 ${out.overall}(「${out.partialText}」)—— 部分失败是跨分区的性质,单块只说自己的那份`,
  254. )
  255. check('D2-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
  256. await page.close()
  257. }
  258. // ================================================================ E 离开后停轮询
  259. {
  260. const { page, errors } = await openPage()
  261. const before = await page.evaluate(async () => {
  262. const store = await import('/src/modules/cockpit/stores/runtimeStore.js')
  263. return store.useRuntimeStore().freshnessPolling()
  264. })
  265. await page.evaluate(() => {
  266. location.hash = '#/'
  267. })
  268. await sleep(1200)
  269. const after = await page.evaluate(async () => {
  270. const store = await import('/src/modules/cockpit/stores/runtimeStore.js')
  271. return {
  272. polling: store.useRuntimeStore().freshnessPolling(),
  273. shellGone: !document.querySelector('.cockpit-shell'),
  274. }
  275. })
  276. check(
  277. 'E1-polling-stops-on-leave',
  278. before === true && after.polling === false && after.shellGone,
  279. `离开前轮询=${before}、离开后=${after.polling}(外壳已卸载=${after.shellGone})—— 定时器不跟着页面跑下去`,
  280. )
  281. check('E2-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
  282. await page.close()
  283. }
  284. await browser.close()
  285. const failed = results.filter((item) => !item.pass)
  286. console.log('')
  287. console.log(`T16 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
  288. if (failed.length) {
  289. console.log('未通过:')
  290. for (const item of failed) console.log(` · ${item.id}`)
  291. process.exit(1)
  292. }
  293. console.log('T16 数据新鲜度真机走查全部通过。')