/** * T15 真机走查:状态推送的连接/重连/鉴权失败与**就地更新**(票 15)。 * * 本脚本对应的断言 2026-09-24 已在 `http://127.0.0.1:8001/#/cockpit` **实际逐项验证通过** * (A–F 六组,见票 15 正文的实测记录);保留成可复跑脚本供回归时重跑。 * 当时抓到的真 bug(推送早于分页快照 → 翻页后旧状态复活)已修,并加了 E7c/E7d 两条逻辑回归。 * * 逻辑脚本(t15-websocket.mjs,45/45)已经把协议时序、旧消息丢弃、就地合并、 * 退避与清理测透了;这里只补**只有真机才成立**的部分: * A 顶栏真的画出连接状态(`data-ws-state` 走到 open); * B 脚本化假连接按后端时序发帧 → 点位状态真的变了,且**地图点位数不变**(只改已有实体); * C 旧消息被丢弃(界面显示的是较新的那个状态); * D 鉴权失败样例 → 状态转 `unauthorized`,**REST 快照照常显示**(面板仍有数据); * E 断线重连样例 → 状态经过 `retrying` 并回到 `open`; * F 全程无页面级报错。 * * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,脚本由维护者按需执行): * node .scratch/cockpit-shell-panel-layout/verify/t15-websocket-walkthrough.mjs * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时) * * 演示开关(宿主全局键注入,不改代码重新构建): * `__COCKPIT_WS_SCENARIO__` = 'success'(默认)| 'unauthorized' | 'flaky' * * 注意:本机 `@puppeteer/browsers` 是空目录时本脚本起不来(与 t28 同一环境问题); * 那种情况下可用内建浏览器按 A–F 手工驱动同一批断言(票 15 正文记了那次实测的做法)。 */ 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-t15-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()}`) /** 页面状态:连接状态、点位数、点位状态、面板是否仍有数据。 */ const readState = (page) => page.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const chip = document.querySelector('[data-ws-state]') const dev = window.__cockpitMap const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null const states = markers && Array.isArray(markers.markers) ? markers.markers.map((m) => `${m.code}:${m.status}`) : [] // 任一面板有内容 = REST 快照照常显示(鉴权失败不该影响它)。 const panels = Array.from(document.querySelectorAll('[data-panel-id]')) const withData = panels.filter((panel) => panel.textContent && panel.textContent.trim().length > 20).length return { wsState: chip ? chip.dataset.wsState || '' : '(无状态胶囊)', wsText: chip ? (chip.textContent || '').trim() : '', markerOnMap: markers && typeof markers.onMap === 'number' ? markers.onMap : null, markerStates: states, panelsWithData: withData, pageErrors: null, } }) async function openPage(scenario) { 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((name) => { window.__COCKPIT_WS_SCENARIO__ = name }, scenario) await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 }) await page.waitForSelector('.cockpit-shell', { timeout: 20000 }) await page .waitForFunction(() => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine), { timeout: 40000 }) .catch(() => {}) if (await page.$('[data-cockpit-intro]')) { await page.click('[data-cockpit-intro]') await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {}) } // 等握手 + 脚本帧发完(假连接用 40ms 间隔)。 await page.waitForFunction(() => document.querySelector('[data-ws-state]')?.dataset.wsState === 'open', { timeout: 20000 }).catch(() => {}) await sleep(1200) return { page, errors } } // ================================================================ A/B/C 正常握手 + 增量 { const { page, errors } = await openPage('success') const state = await readState(page) check('A1-ws-state-open', state.wsState === 'open', `顶栏 data-ws-state=${state.wsState}(「${state.wsText}」)`) check('A2-markers-still-present', state.markerOnMap !== null && state.markerOnMap > 0, `地图点位数 ${state.markerOnMap}`) check( 'B1-pushed-status-visible', state.markerStates.some((item) => item.endsWith(':ALARM')) || state.markerStates.some((item) => item.endsWith(':OFFLINE')), `推送后的点位状态:${state.markerStates.join(' / ')}(脚本样例把 MP-002 推到 ALARM)`, ) // 旧消息负例:脚本第 3 条把 MP-001 回退成 ALARM(时间更早),必须被丢弃。 check( 'C1-stale-delta-ignored', !state.markerStates.includes('MP-001:ALARM') && state.markerStates.includes('MP-001:OFFLINE'), `MP-001 停在较新的 ${state.markerStates.find((i) => i.startsWith('MP-001'))}(更早时间戳的 ALARM 被丢弃)`, ) check('F1-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | ')) await page.close() } // ================================================================ D 鉴权失败不牵连数据 { const { page, errors } = await openPage('unauthorized') const state = await readState(page) check( 'D1-unauthorized-state', state.wsState === 'unauthorized', `顶栏 data-ws-state=${state.wsState}(「${state.wsText}」)`, ) check( 'D2-rest-snapshot-still-shown', state.panelsWithData > 0 && state.markerOnMap !== null, `鉴权失败但面板仍有数据的面板数=${state.panelsWithData}、点位数=${state.markerOnMap}(REST 快照照常显示)`, ) check('F2-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | ')) await page.close() } // ================================================================ E 断线重连 { const page = await browser.newPage() const errors = [] page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e))) const seen = [] await page.evaluateOnNewDocument(() => { window.__COCKPIT_WS_SCENARIO__ = 'flaky' }) await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 }) await page.waitForSelector('.cockpit-shell', { timeout: 20000 }) if (await page.$('[data-cockpit-intro]')) { await page.click('[data-cockpit-intro]') await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {}) } // 采样状态序列(掉线 → retrying → 重连 → open)。 for (let i = 0; i < 60; i += 1) { const value = await page.evaluate(() => document.querySelector('[data-ws-state]')?.dataset.wsState || '') if (value && seen[seen.length - 1] !== value) seen.push(value) if (seen.includes('retrying') && value === 'open') break await sleep(250) } const state = await readState(page) check( 'E1-retry-then-reopen', seen.includes('retrying') && seen[seen.length - 1] === 'open', `状态序列:${seen.join(' → ')}(真的断线并重连成功,不是只改文案)`, ) check('E2-markers-alive-after-reconnect', state.markerOnMap !== null && state.markerOnMap > 0, `重连后点位数 ${state.markerOnMap}`) check('F3-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(`T15 真机走查:${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('T15 状态推送真机走查全部通过。')