| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314 |
- /**
- * T16 真机走查:统一新鲜度与过期状态(票 16)。
- *
- * 本脚本对应的断言 2026-09-24 已在 `http://127.0.0.1:8001/#/cockpit` **实际逐项验证通过**
- * (A–E 五组,见票 16 正文的实测记录);保留成可复跑脚本供回归时重跑。
- *
- * 逻辑脚本(t16-freshness.mjs,43/43)已经把四态判据、连续两个周期、保留旧值、
- * 缺失不显示虚假零值、轮询与清理测透了;这里只补**只有真机才成立**的部分:
- * A 顶栏真的画出「最近一次成功更新」+ 四态(`data-freshness` / `data-freshness-at`);
- * B 连续两个失败周期 → 界面转 `stale` 并带 `is-stale` 类(过期**看得见**);
- * C 从未取到 + 两个失败周期 → `unavailable`,且时刻显示占位符「—」(不编 00:00:00);
- * D 「部分失败」是跨分区性质:坏一块 → `data-freshness-overall=partial` + 补充文案;
- * E 离开 `/cockpit` 后轮询定时器被停(票 15 在"漏取消定时器"上栽过)。
- *
- * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,脚本由维护者按需执行):
- * node .scratch/cockpit-shell-panel-layout/verify/t16-freshness-walkthrough.mjs
- * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
- *
- * 演示开关(宿主全局键注入,不改代码重新构建):
- * `__COCKPIT_FRESHNESS_FAIL__ = true` → 每个周期都失败(演示"持续失败"这条路径)。
- * 周期长度由部署配置 `runtime.refreshSeconds` 给(默认 30 秒)。
- *
- * 注意:本机 `@puppeteer/browsers` 是空目录时本脚本起不来(与 t28 同一环境问题);
- * 那种情况下可用内建浏览器按 A–E 手工驱动同一批断言(票 16 正文记了那次实测的做法)。
- */
- import { join } from 'node:path'
- import { tmpdir } from 'node:os'
- const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
- const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
- const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
- const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
- const results = []
- function check(id, pass, evidence) {
- results.push({ id, pass })
- console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${evidence === undefined ? '' : String(evidence)}`)
- }
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
- const puppeteer = (await import(PUPPETEER)).default
- const browser = await puppeteer.launch({
- executablePath: CHROME,
- headless: true,
- userDataDir: join(tmpdir(), 'cockpit-t16-profile'),
- defaultViewport: { width: 1680, height: 950 },
- args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
- })
- console.log(`Chrome: ${await browser.version()}`)
- /** 顶栏新鲜度胶囊的 DOM 状态(从界面读,不读 store)。 */
- const readState = (page) =>
- page.evaluate(() => {
- const chip = document.querySelector('[data-freshness]')
- return {
- freshness: chip ? chip.dataset.freshness || '' : '(没有新鲜度胶囊)',
- overall: chip ? chip.dataset.freshnessOverall || '' : '',
- at: chip ? chip.dataset.freshnessAt || '' : '',
- text: chip ? (chip.textContent || '').replace(/\s+/g, ' ').trim() : '',
- isStale: chip ? chip.classList.contains('is-stale') : false,
- isBad: chip ? chip.classList.contains('is-bad') : false,
- partialEl: !!document.querySelector('[data-freshness-partial]'),
- polling: !!(window.__cockpitFreshnessProbe && window.__cockpitFreshnessProbe.polling),
- }
- })
- async function openPage({ fail = false, skipIntro = true } = {}) {
- const page = await browser.newPage()
- const errors = []
- page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
- page.on('console', (m) => {
- if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
- })
- await page.evaluateOnNewDocument((flag) => {
- if (flag) window.__COCKPIT_FRESHNESS_FAIL__ = true
- }, fail)
- await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
- await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
- if (skipIntro && (await page.$('[data-cockpit-intro]'))) {
- await page.click('[data-cockpit-intro]')
- await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
- }
- await page.waitForFunction(() => !!document.querySelector('[data-freshness]'), { timeout: 20000 }).catch(() => {})
- await sleep(1500)
- return { page, errors }
- }
- // ================================================================ A 正常态
- {
- const { page, errors } = await openPage()
- const state = await readState(page)
- check('A1-freshness-chip-rendered', state.freshness !== '(没有新鲜度胶囊)', `data-freshness=${state.freshness}`)
- check(
- 'A2-shows-last-success-clock',
- /^\d{2}:\d{2}:\d{2}$/.test(state.at),
- `最近一次成功更新 ${state.at}(「${state.text}」)`,
- )
- check('A3-not-stale-when-healthy', state.freshness === 'current' && !state.isStale, `健康时不是过期态(is-stale=${state.isStale})`)
- check('A4-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
- await page.close()
- }
- // ================================================================ B/C 两个失败周期 → 过期 / 不可用
- {
- // 用真实轮询服务 + 假时钟在页面上推进两个失败周期(与单测同一入口)。
- const { page, errors } = await openPage({ fail: true })
- const driven = await page.evaluate(async () => {
- const svc = await import('/src/modules/cockpit/services/freshnessService.js')
- const model = await import('/src/modules/cockpit/domain/freshnessModel.js')
- const store = await import('/src/modules/cockpit/stores/runtimeStore.js')
- const rt = store.useRuntimeStore()
- // 场景 1:先成功一轮,再连续两个失败周期 → stale(保留旧值)
- svc.resetFreshness()
- let clock = Date.parse('2026-09-24T10:00:00+08:00')
- const tasks = []
- let pushLive = true
- rt.stopFreshness()
- svc.startPolling({
- refreshSeconds: 10,
- tick: () => ({ ok: true }),
- isPushLive: () => pushLive,
- now: () => clock,
- setTimeout: (fn, ms) => {
- const id = { fn, ms }
- tasks.push(id)
- return id
- },
- clearTimeout: (id) => {
- const i = tasks.indexOf(id)
- if (i >= 0) tasks.splice(i, 1)
- },
- onState: (s) => {
- rt.freshnessState = s
- },
- })
- const step = async () => {
- const t = tasks.shift()
- if (t) {
- t.fn()
- await Promise.resolve()
- }
- }
- clock += 10000
- await step()
- const successClock = model.clockText(svc.getFreshnessState().lastSuccessAt)
- pushLive = false
- clock += 10000
- await step()
- const oneFailure = model.freshnessOf(svc.getFreshnessState())
- clock += 10000
- await step()
- const twoFailures = svc.getFreshnessState()
- const staleView = model.freshnessView(twoFailures, clock)
- svc.stopPolling()
- return {
- successClock,
- oneFailure,
- twoView: model.freshnessOf(twoFailures),
- retained: model.isRetained(twoFailures),
- keptClock: staleView.lastSuccessClock,
- since: staleView.sinceSuccess,
- text: staleView.text,
- reason: twoFailures.lastFailureReason,
- }
- })
- check(
- 'B1-one-failure-not-yet-stale',
- driven.oneFailure === 'current',
- `第 1 个失败周期仍是 ${driven.oneFailure}(「连续两个」才有意义)`,
- )
- check(
- 'B2-two-failures-become-stale',
- driven.twoView === 'stale' && driven.retained === true,
- `第 2 个失败周期 → ${driven.twoView}(retained=${driven.retained},原因是「${driven.reason}」)`,
- )
- check(
- 'B3-stale-keeps-last-success',
- driven.keptClock === driven.successClock && driven.keptClock !== '—',
- `过期后仍显示最后成功时刻 ${driven.keptClock}(${driven.since})—— 保留旧值但标明不是实时`,
- )
- // 场景 2:从未成功过 + 两个失败周期 → 不可用,时刻是占位符。
- const neverOut = await page.evaluate(async () => {
- const svc = await import('/src/modules/cockpit/services/freshnessService.js')
- const model = await import('/src/modules/cockpit/domain/freshnessModel.js')
- const store = await import('/src/modules/cockpit/stores/runtimeStore.js')
- const rt = store.useRuntimeStore()
- svc.resetFreshness()
- const tasks = []
- let clock = Date.parse('2026-09-24T10:00:00+08:00')
- svc.startPolling({
- refreshSeconds: 10,
- tick: () => ({ ok: false, reason: '取数失败' }),
- isPushLive: () => true,
- now: () => clock,
- setTimeout: (fn, ms) => {
- const id = { fn, ms }
- tasks.push(id)
- return id
- },
- clearTimeout: (id) => {
- const i = tasks.indexOf(id)
- if (i >= 0) tasks.splice(i, 1)
- },
- onState: (s) => {
- rt.freshnessState = s
- },
- })
- const step = async () => {
- const t = tasks.shift()
- if (t) {
- t.fn()
- await Promise.resolve()
- }
- }
- // 启动即一轮失败,再推进一轮 → 连续 2 次
- clock += 10000
- await step()
- const state = svc.getFreshnessState()
- const view = model.freshnessView(state, clock)
- svc.stopPolling()
- return {
- view: model.freshnessOf(state),
- retained: model.isRetained(state),
- clock: view.lastSuccessClock,
- successes: state.successes,
- text: model.freshnessText(model.freshnessOf(state)),
- }
- })
- check(
- 'C1-never-succeeded-is-unavailable',
- neverOut.view === 'unavailable' && neverOut.retained === false && neverOut.successes === 0,
- `从未取到值 + 两个失败周期 → ${neverOut.view}(「${neverOut.text}」,retained=${neverOut.retained})`,
- )
- check(
- 'C2-placeholder-not-fake-clock',
- neverOut.clock === '—',
- `时刻显示占位符「${neverOut.clock}」(不编一个 00:00:00 冒充真实时刻)`,
- )
- check('C3-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
- await page.close()
- }
- // ================================================================ D 部分失败(跨分区)
- {
- const { page, errors } = await openPage()
- const out = await page.evaluate(async () => {
- const mod = await import('/src/modules/cockpit/stores/detailStore.js')
- const detail = mod.useDetailStore()
- detail.setSection('equipmentFreshness', { state: 'failed', reason: 'unreachable', data: null })
- await new Promise((r) => setTimeout(r, 400))
- const chip = document.querySelector('[data-freshness]')
- return {
- overall: chip ? chip.dataset.freshnessOverall : '',
- partialEl: !!document.querySelector('[data-freshness-partial]'),
- partialText: ((document.querySelector('[data-freshness-partial]') || {}).textContent || '').trim(),
- }
- })
- check(
- 'D1-partial-is-surfaced',
- out.overall === 'partial' && out.partialEl,
- `坏一块分区后整体是 ${out.overall}(「${out.partialText}」)—— 部分失败是跨分区的性质,单块只说自己的那份`,
- )
- check('D2-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
- await page.close()
- }
- // ================================================================ E 离开后停轮询
- {
- const { page, errors } = await openPage()
- const before = await page.evaluate(async () => {
- const store = await import('/src/modules/cockpit/stores/runtimeStore.js')
- return store.useRuntimeStore().freshnessPolling()
- })
- await page.evaluate(() => {
- location.hash = '#/'
- })
- await sleep(1200)
- const after = await page.evaluate(async () => {
- const store = await import('/src/modules/cockpit/stores/runtimeStore.js')
- return {
- polling: store.useRuntimeStore().freshnessPolling(),
- shellGone: !document.querySelector('.cockpit-shell'),
- }
- })
- check(
- 'E1-polling-stops-on-leave',
- before === true && after.polling === false && after.shellGone,
- `离开前轮询=${before}、离开后=${after.polling}(外壳已卸载=${after.shellGone})—— 定时器不跟着页面跑下去`,
- )
- check('E2-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
- await page.close()
- }
- await browser.close()
- const failed = results.filter((item) => !item.pass)
- console.log('')
- console.log(`T16 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:')
- for (const item of failed) console.log(` · ${item.id}`)
- process.exit(1)
- }
- console.log('T16 数据新鲜度真机走查全部通过。')
|