/** * 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 数据新鲜度真机走查全部通过。')