/** * T15 WebSocket 状态更新 纯逻辑检查(票 15)。 * * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t15-websocket.mjs * * 八段(每条对着票 15 的验收原句): * A 验收 1:连接、订阅、断线、重连都在运行时服务里(时序照后端真实协议) * B 验收 2:**免鉴权时序** —— open 后直接 cockpitSubscribe,不发 authenticate 帧 * C 验收 3:按 serverUpdatedAt 取舍,**旧消息被丢弃**(含跨偏移的时间比较) * D 验收 4:增量就地更新受影响点位与已打开详情,**不重建视图**(元素引用保持) * E 验收 5:当前选中对象、面板状态、滚动位置不受影响(结构保证:不碰这些状态) * F 验收 6:连接/重连/未配置三态可演示(脚本化假 socket 按真实时序发帧) * G 验收 7:增量合并与重连的单测(本段就是),含退避 * H 静态守卫:组件/store 不认识 socket 与帧类型;REST/WS 均不携带凭据 * * 协议依据(ADR 0005 修订,2026-09-28;后端 `DeviceStatusWebSocketHandler` 现协议): * · 免鉴权:连接后直接 `cockpitSubscribe`(scope 只认 "all")→ * `{"type":"cockpitSubscribed","scope","serverUpdatedAt"}`;**不再有 authenticate 往返** * · `DeviceStatusRedisListener.cockpitDeltaMessage`:`monitoringPointCode` 在**顶层**, * 状态在 `data.status` */ import { readFileSync } from 'node:fs' import { createPinia, setActivePinia } from 'pinia' import { POINT_STATUS } from '../../../src/modules/cockpit/domain/pointStatus.js' import { applyPointStates, emptyStatusState, isNewerDelta, mergeDeviceStatus, parseStamp, } from '../../../src/modules/cockpit/domain/statusPush.js' import { WS_MESSAGE, toAuthenticatedEvent, toDeviceStatusEvent, toSubscribedEvent, } from '../../../src/modules/cockpit/services/runtimeApi.js' import { RECONNECT_DELAYS, WS_STATE, connect, disconnect, getStatus, onDelta, onStatus, reconnectDelay, resetForTest, } from '../../../src/modules/cockpit/services/runtimeSocket.js' import { createScriptedSocketFactory, deltaFrame } from '../../../src/modules/cockpit/mocks/wsMock.js' import { useRuntimeStore } from '../../../src/modules/cockpit/stores/runtimeStore.js' import { useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js' import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js' import { useUiStore } from '../../../src/modules/cockpit/stores/uiStore.js' import { emptyMapSummary } from '../../../src/modules/cockpit/domain/mapSummary.js' const checks = [] function check(name, pass, evidence) { checks.push({ name, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`) } const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url) const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8') const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') const readCode = (relative) => code(read(relative)) const codeVue = (text) => code(text.replace(//g, '')) const readVueCode = (relative) => codeVue(read(relative)) const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) setActivePinia(createPinia()) /** 假定时器:把"等 1 秒重连"变成同步可控,且不真的等。 */ function fakeTimers() { const tasks = [] return { setTimeout: (fn, ms) => { const id = { fn, ms } tasks.push(id) return id }, clearTimeout: (id) => { const index = tasks.indexOf(id) if (index >= 0) tasks.splice(index, 1) }, /** 推进时间:执行所有到期任务(按到期顺序,支持任务里再排任务)。 * 有硬上限:万一脚本自己无限重排(说明被测行为真的失控), * 宁可让断言去红,也不让脚本挂住。 */ async run() { for (let guard = 0; guard < 200 && tasks.length; guard += 1) { const next = tasks.shift() next.fn() await Promise.resolve() } }, pending: () => tasks.length, delays: () => tasks.map((item) => item.ms), /** 只推进一个到期任务(用来观察"断线后待重连"的中间态)。 */ async step() { const next = tasks.shift() if (!next) return false next.fn() await Promise.resolve() return true }, } } // ================================================================ A 协议与时序 { check( 'A1-frame-shape-from-backend', WS_MESSAGE.DELTA === 'cockpitDeviceStatusDelta' && WS_MESSAGE.SUBSCRIBE === 'cockpitSubscribe' && WS_MESSAGE.AUTHENTICATE === 'authenticate' && WS_MESSAGE.AUTHENTICATED === 'authenticated', '帧类型名照抄后端 handleAuthenticate/handleCockpitSubscribe/cockpitDeltaMessage', ) // 增量帧:monitoringPointCode 在**顶层**(不是 data 里)—— 这是本次读源码纠正的关键一例。 const event = toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-001', monitoringPointCode: 'MP-001', status: 'ALARM', serverUpdatedAt: '2026-09-24T10:00:00+08:00' })) check( 'A2-delta-carries-point-code', event && event.deviceCode === 'EQ-001' && event.monitoringPointCode === 'MP-001' && event.status === 'ALARM', `顶层 monitoringPointCode=${event && event.monitoringPointCode}(点位联动的唯一依据)`, ) // 未关联设备:后端显式给 null,必须归一成空串(而不是 'null' 字符串)。 const free = toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-FREE-001', monitoringPointCode: null, status: 'OFFLINE', serverUpdatedAt: '2026-09-24T10:00:00+08:00' })) check( 'A3-null-point-code-normalised', free && free.monitoringPointCode === '', `未关联设备的 monitoringPointCode 归一为 "${free && free.monitoringPointCode}"(不会误当成点位码 "null")`, ) // 但 `cockpitSubscribed` **不带**初始状态数组(REST 才是初始快照)。 const subscribed = toSubscribedEvent({ type: 'cockpitSubscribed', scope: 'all', serverUpdatedAt: '2026-09-24T10:00:00+08:00' }) check( 'A4-subscribed-has-no-device-array', subscribed && subscribed.scope === 'all' && subscribed.devices === undefined, '订阅回帧只有 scope/serverUpdatedAt —— 不假装它带了初始状态(初始快照归 REST)', ) } // ================================================================ B 握手顺序与鉴权失败 { const timers = fakeTimers() resetForTest() const scripted = createScriptedSocketFactory({ setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout }) // token 故意传入:免鉴权后服务层必须忽略它(不进任何帧)。 connect({ url: 'mock://ws', token: 'STALE-1', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout }) check('B1-connecting-first', getStatus().state === WS_STATE.CONNECTING, `建连后状态 ${getStatus().state}`) await timers.run() const socket = scripted.log[0] const sent = socket.sent.map((item) => JSON.parse(item)) check( 'B2-subscribe-directly-without-authenticate', sent.length === 1 && sent[0].type === 'cockpitSubscribe' && sent[0].scope === 'all', `客户端发出的帧:${sent.map((f) => f.type).join(' → ')}(免鉴权:open 后直接订阅 all,无 authenticate 帧)`, ) check( 'B3-stale-token-never-sent', sent.every((frame) => frame.token === undefined && frame.Authorization === undefined), '传入的旧 token 不进任何帧(ADR 0005 修订:WS 免鉴权)', ) check('B4-open-after-subscribed', getStatus().state === WS_STATE.OPEN, `握手完成后状态 ${getStatus().state}`) disconnect() } { // 免鉴权后不再有「鉴权失败」场景:unauthorized 脚本退化为「订阅成功但无增量」, // 服务层不得因收不到增量而崩或假装连上(状态只到 subscribing,等下个周期重评)。 const timers = fakeTimers() resetForTest() const scripted = createScriptedSocketFactory({ scenario: 'unauthorized', setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout }) connect({ url: 'mock://ws', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout }) await timers.run() const status = getStatus() const sent = scripted.log[0].sent.map((item) => JSON.parse(item)) check( 'B5-silent-push-stays-subscribing-or-open', sent.length === 1 && sent[0].type === 'cockpitSubscribe' && (status.state === WS_STATE.SUBSCRIBING || status.state === WS_STATE.OPEN), `订阅成功但无增量:只发 1 帧 cockpitSubscribe,状态 ${status.state}(不伪造 open,也不崩)`, ) disconnect() } // ================================================================ C 旧消息被忽略 { const base = '2026-09-24T10:00:02+08:00' check( 'C1-newer-accepted-older-rejected', isNewerDelta({ serverUpdatedAt: '2026-09-24T10:00:03+08:00' }, base) === true && isNewerDelta({ serverUpdatedAt: '2026-09-24T10:00:01+08:00' }, base) === false && isNewerDelta({ serverUpdatedAt: base }, base) === false, '严格更新才采纳:更晚 → true;更早/相同 → false', ) check( 'C2-compares-instants-not-strings', // 同一个瞬间,偏移不同:字符串比大小会得出错误结论,按时刻比才对。 parseStamp('2026-09-24T10:00:00+08:00') === parseStamp('2026-09-24T02:00:00Z') && isNewerDelta({ serverUpdatedAt: '2026-09-24T02:00:00Z' }, '2026-09-24T10:00:00+08:00') === false, '同一时刻的不同偏移写法判为"不更新"(按 epoch 比,不按字符串)', ) check( 'C3-unparsable-timestamp-rejected', isNewerDelta({ serverUpdatedAt: '' }, base) === false && isNewerDelta({ serverUpdatedAt: 'not-a-time' }, base) === false && parseStamp('') === null, '时间认不出的帧不采纳(宁可漏一次,也不让乱序旧值盖掉新值)', ) // 合并层:旧消息不得改动状态表,且**原样返回同一个引用**(调用方据此跳过重渲染)。 let state = emptyStatusState() const first = mergeDeviceStatus({ state, delta: toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-001', monitoringPointCode: 'MP-001', status: 'ALARM', serverUpdatedAt: '2026-09-24T10:00:02+08:00' })) }) state = first.state const stale = mergeDeviceStatus({ state, delta: toDeviceStatusEvent(deltaFrame({ deviceCode: 'EQ-001', monitoringPointCode: 'MP-001', status: 'OFFLINE', serverUpdatedAt: '2026-09-24T10:00:01+08:00' })) }) check( 'C4-merge-rejects-stale-without-copy', first.accepted === true && stale.accepted === false && stale.reason === 'STALE' && stale.state === state && state.deviceStates['EQ-001'].status === 'ALARM', `旧帧被丢弃(reason=${stale.reason}),状态表仍是 ${state.deviceStates['EQ-001'].status} 且引用未变`, ) check( 'C5-rejects-without-device-or-status', mergeDeviceStatus({ state, delta: { deviceCode: '', status: 'ALARM', serverUpdatedAt: '2026-09-24T11:00:00+08:00' } }).reason === 'NO_DEVICE_CODE' && mergeDeviceStatus({ state, delta: { deviceCode: 'EQ-001', status: '', serverUpdatedAt: '2026-09-24T11:00:00+08:00' } }).reason === 'NO_STATUS', '缺设备编码/缺状态的帧各有明确丢弃原因(不把已知状态抹成"未发布")', ) } // ================================================================ D 就地更新,不重建视图 { const objects = [ { kind: 'monitoring-point', code: 'MP-001', name: 'A 点', status: POINT_STATUS.NORMAL, longitude: 110.1, latitude: 28.1, deviceCodes: ['EQ-001'] }, { kind: 'monitoring-point', code: 'MP-002', name: 'B 点', status: POINT_STATUS.NORMAL, longitude: 110.2, latitude: 28.2, deviceCodes: ['EQ-002'] }, { kind: 'monitoring-point', code: 'MP-003', name: 'C 点', status: POINT_STATUS.WARNING, longitude: 110.3, latitude: 28.3, deviceCodes: [] }, ] const { objects: next, changed } = applyPointStates(objects, { 'MP-002': { status: POINT_STATUS.ALARM } }) check( 'D1-only-affected-object-changes', changed.length === 1 && changed[0] === 'MP-002' && next[1].status === POINT_STATUS.ALARM && next[0].status === POINT_STATUS.NORMAL && next[2].status === POINT_STATUS.WARNING, `只有 MP-002 改状态(${changed.join(',')});MP-001/MP-003 原样`, ) check( 'D2-untouched-keep-identity', next[0] === objects[0] && next[2] === objects[2] && next[1] !== objects[1] && next.length === objects.length, '未命中的元素**保持同一引用**(消费方据此只重画变动的那几个),顺序与长度不变', ) check( 'D3-does-not-touch-geometry', next[1].longitude === objects[1].longitude && next[1].name === objects[1].name && next[1].deviceCodes === objects[1].deviceCodes, '坐标/名称/关联设备一概不动(推送不带这些字段,凭空补就是编数据)', ) check( 'D4-same-status-is-noop', applyPointStates(objects, { 'MP-001': { status: POINT_STATUS.NORMAL } }).changed.length === 0 && applyPointStates(objects, { 'MP-001': { status: POINT_STATUS.NORMAL } }).objects === objects, '状态没变时不产生新数组(同值推送不引起无谓重渲染)', ) check( 'D5-unknown-point-is-ignored', applyPointStates(objects, { 'MP-999': { status: POINT_STATUS.ALARM } }).changed.length === 0, '推送指名了不在已加载范围的点位 → 不改任何对象(不是错误,可能还没翻到那一页)', ) } // ================================================================ E 选中/面板/滚动不受影响 { const uiStore = useUiStore() const mapStore = useMapStore() const detailStore = useDetailStore() const runtimeStore = useRuntimeStore() uiStore.reset?.() mapStore.reset() detailStore.reset() runtimeStore.reset() // 装两个对象(含被推送影响的 MP-002)与一个已打开的详情。 // 注意:经由 Pinia 取到的 `mapStore.summary` 已是解包后的对象(不是 ref), // 所以这里直接赋字段,不写 `.value`(写了会变成一个无关的键,对象数仍是 0)。 mapStore.summary = { ...emptyMapSummary({ pageSize: 2 }), objects: [ { kind: 'monitoring-point', code: 'MP-001', name: 'A 点', status: POINT_STATUS.NORMAL, longitude: 110.1, latitude: 28.1, deviceCodes: ['EQ-001'] }, { kind: 'monitoring-point', code: 'MP-002', name: 'B 点', status: POINT_STATUS.NORMAL, longitude: 110.2, latitude: 28.2, deviceCodes: ['EQ-002'] }, ], } // 已打开详情:MP-002(状态 NORMAL) detailStore.setSection('pointDetail', { state: 'success', data: { code: 'MP-002', name: 'B 点', status: POINT_STATUS.NORMAL, deviceCount: 1 } }) // 当前选中对象 + 展开面板 + 滚动位置(验收第 5 条要它们不变) uiStore.setMode?.('warningResponse') const modeBefore = uiStore.mode const expandedBefore = JSON.stringify(uiStore.expandedPanelIds) const selectedBefore = JSON.stringify(detailStore.selection) const scrollBefore = { top: 420, left: 0 } const result = runtimeStore.ingest({ deviceCode: 'EQ-002', monitoringPointCode: 'MP-002', status: POINT_STATUS.ALARM, serverUpdatedAt: '2026-09-24T10:00:09+08:00', statusTime: '', reason: '' }) check( 'E1-point-status-updated-in-place', result.accepted === true && mapStore.summary.objects.find((item) => item.code === 'MP-002').status === POINT_STATUS.ALARM && mapStore.summary.objects.find((item) => item.code === 'MP-001').status === POINT_STATUS.NORMAL, `MP-002 就地变 ${mapStore.summary.objects.find((i) => i.code === 'MP-002').status},MP-001 仍是 NORMAL`, ) check( 'E2-open-detail-status-updated', detailStore.sections.pointDetail.data.status === POINT_STATUS.ALARM && detailStore.sections.pointDetail.data.name === 'B 点', `已打开的详情状态随之更新为 ${detailStore.sections.pointDetail.data.status},其余字段保持快照里的值`, ) check( 'E3-selection-mode-panel-scroll-untouched', uiStore.mode === modeBefore && JSON.stringify(uiStore.expandedPanelIds) === expandedBefore && JSON.stringify(detailStore.selection) === selectedBefore && scrollBefore.top === 420, `当前模式/展开面板/选中对象/滚动位置全部原样(模式=${uiStore.mode}、选中=${selectedBefore})`, ) check( 'E4-detail-not-refetched', detailStore.sections.pointDetail.loaded === true && detailStore.pointLoading === false, '推送更新**不触发取数**(不重新拉详情、不换层级,浮层因此不闪)', ) check( 'E5-unrelated-point-untouched', runtimeStore.ingest({ deviceCode: 'EQ-999', monitoringPointCode: 'MP-999', status: POINT_STATUS.ALARM, serverUpdatedAt: '2026-09-24T10:00:10+08:00' }).accepted === true && detailStore.sections.pointDetail.data.status === POINT_STATUS.ALARM && detailStore.sections.pointDetail.data.code === 'MP-002', '指名别的点位时,已打开的这个详情不动', ) // 未关联设备(pointCode 为空):只更新设备维度,不改任何点位。 const beforeObjects = mapStore.summary.objects const freeResult = runtimeStore.ingest({ deviceCode: 'EQ-FREE-001', monitoringPointCode: '', status: POINT_STATUS.OFFLINE, serverUpdatedAt: '2026-09-24T10:00:11+08:00' }) check( 'E6-unassociated-updates-device-only', freeResult.accepted === true && freeResult.pointCode === '' && mapStore.summary.objects === beforeObjects && runtimeStore.statusState.deviceStates['EQ-FREE-001'].status === POINT_STATUS.OFFLINE, '未关联设备只落设备维度(对象数组引用未变 → 地图不重画)', ) } // ================================================================ E7 翻页补状态(真机 bug 的回归) { const mapStore = useMapStore() const runtimeStore = useRuntimeStore() mapStore.reset() runtimeStore.reset() const { registerMockTransport, resetMockTransportsForTest } = await import('../../../src/modules/cockpit/services/cockpitApi.js') const { registerCockpitMocks } = await import('../../../src/modules/cockpit/mocks/contractMocks.js') resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) mapStore.setup({ config: { dataSource: 'mock', api: { baseUrl: '', token: '' } } }) // 第 1 页(MP-001/MP-002)先到,并收到一条把 MP-003 推成 ALARM 的消息(MP-003 还没翻到)。 await mapStore.advance() const page1 = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`) runtimeStore.ingest({ deviceCode: 'EQ-003', monitoringPointCode: 'MP-003', status: POINT_STATUS.ALARM, serverUpdatedAt: '2026-09-24T10:00:20+08:00' }) const afterPush = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`) // 第 2 页到达(MP-003 在快照里是 WARNING)→ 必须显示推送来的 ALARM。 await mapStore.advance() const page2 = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`) const mp003 = mapStore.summary.objects.find((item) => item.code === 'MP-003') check( 'E7c-later-page-gets-cached-push-status', page1.length === 2 && JSON.stringify(afterPush) === JSON.stringify(page1) && !!mp003 && mp003.status === POINT_STATUS.ALARM && mp003.statusSource === 'push', `第 1 页 ${JSON.stringify(page1)} → 推 MP-003=ALARM(当时还没翻到,图上不变)→ 第 2 页 ${JSON.stringify(page2)}:MP-003 显示推送值 ${mp003 && mp003.status}(不是快照里的 WARNING)`, ) // 反向证据:**显式刷新**换新快照时丢弃推送缓存,让这一份快照说了算 // (否则一条较旧的推送会盖掉刚取回的新状态 —— 那正是"用旧值覆盖新值")。 // 注意:`refresh()` 是**整份换快照并自动连翻到完成**,所以 MP-003 刷新后会在对象列表里; // 要验的是它按快照值显示、不再带 push 标记。 await mapStore.refresh() const page1AfterRefresh = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`) const mp003AfterRefresh = mapStore.summary.objects.find((item) => item.code === 'MP-003') check( 'E7d-refresh-lets-snapshot-win', page1AfterRefresh.length >= 3 && !!mp003AfterRefresh && mp003AfterRefresh.status === POINT_STATUS.WARNING && mp003AfterRefresh.statusSource === undefined, `刷新后对象 ${JSON.stringify(page1AfterRefresh)} → MP-003=${mp003AfterRefresh && mp003AfterRefresh.status}(快照值 WARNING、无 push 标记)= 显式重新取数以快照为准`, ) } // ================================================================ F 四态可演示 { // 未配置地址:不是错误,如实说"未配置"。 resetForTest() const disabled = connect({ url: '' }) check('F1-disabled-when-no-url', disabled.state === WS_STATE.DISABLED && /未配置/.test(disabled.text), `未配地址 → ${disabled.state}(「${disabled.text}」)`) disconnect() // 断线 → 重连:`flaky` 脚本在第 2 条增量后掉线。 const timers = fakeTimers() resetForTest() const scripted = createScriptedSocketFactory({ scenario: 'flaky', disconnectAfter: 1, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout }) const seen = [] const offDelta = onDelta((event) => seen.push(event)) const states = [] const offStatus = onStatus((status) => states.push(status.state)) connect({ url: 'mock://ws', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout }) await timers.run() check( 'F2-retrying-state-observed', states.includes(WS_STATE.RETRYING), `连接状态序列:${states.join(' → ')}(含重连中)`, ) check( 'F3-reconnect-actually-happens', scripted.log.length >= 2 && getStatus().state === WS_STATE.OPEN, `工厂建了 ${scripted.log.length} 个连接,重连后状态 ${getStatus().state}(真的重连了,不是只改文案)`, ) check( 'F4-deltas-delivered-before-drop', seen.length >= 1 && seen.every((event) => !!event.deviceCode), `掉线前收到 ${seen.length} 条增量(每条都有设备编码)`, ) offDelta() offStatus() disconnect() } // ================================================================ G 退避与清理 { check( 'G1-backoff-grows-and-caps', reconnectDelay(1) === RECONNECT_DELAYS[0] && reconnectDelay(2) === RECONNECT_DELAYS[1] && reconnectDelay(3) === RECONNECT_DELAYS[2] && reconnectDelay(99) === RECONNECT_DELAYS[RECONNECT_DELAYS.length - 1], `退避 ${RECONNECT_DELAYS.join('/')} 毫秒,末档封顶(长跑后重连间隔不无限增长)`, ) // disconnect 必须取消待触发的重连(否则离开驾驶舱后定时器还在跑)。 // 这里**只推进到"掉线并排好重连定时器"为止**(不把定时器跑完), // 才能观察到"断开时确实有一个待触发的重连被取消"。 const timers = fakeTimers() resetForTest() const scripted = createScriptedSocketFactory({ scenario: 'flaky', disconnectAfter: 1, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout }) connect({ url: 'mock://ws', socketFactory: scripted.factory, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout }) // 一次只走一个定时器,直到状态落到 retrying(此时队列里应有下一个重连定时器)。 for (let i = 0; i < 40 && getStatus().state !== WS_STATE.RETRYING && timers.pending(); i += 1) { await timers.step() } const pendingBefore = timers.pending() disconnect() check( 'G2-disconnect-cancels-reconnect', pendingBefore > 0 && timers.pending() === 0 && getStatus().state === WS_STATE.IDLE, `断开前还有 ${pendingBefore} 个待触发定时器,disconnect 后剩 ${timers.pending()} 个、状态 ${getStatus().state}`, ) } // ================================================================ H 静态守卫 { // 组件不认识 socket / 帧类型(ADR 0004:页面只消费领域状态)。 const shellCode = readVueCode('CockpitShell.vue') const FRAME_PATTERN = /cockpitDeviceStatusDelta|cockpitSubscribed|authenticate|new WebSocket|onmessage/ check( 'H1-shell-knows-no-protocol', !FRAME_PATTERN.test(shellCode), '外壳只读 runtimeStore 的状态与文案,不认识帧类型与 socket', ) check( 'H2-shell-shows-state', /data-ws-state/.test(readVueCode('CockpitShell.vue')) && /runtimeStore\.statusText/.test(shellCode), '外壳把连接状态画出来(data-ws-state 是走查断言面)', ) check( 'H2b-shell-knows-no-mock-socket', !/createScriptedSocketFactory|wsMock|socketFactory/.test(shellCode), '外壳不认识脚本化假连接(mock/real 的连接方式归 store)', ) // runtimeStore 不碰 socket:连接归 service。 const storeCode = readCode('stores/runtimeStore.js') check( 'H3-store-has-no-socket', !/new WebSocket|onmessage|readyState|\.send\(/.test(storeCode) && /from '\.\.\/services\/runtimeSocket\.js'/.test(storeCode), 'runtimeStore 只调运行时服务,不自己建连/发帧', ) check( 'H4-domain-pure', !/\bfetch\s*\(|XMLHttpRequest|new WebSocket/.test(read('domain/statusPush.js')) && !/from '\.\.\/services\//.test(read('domain/statusPush.js')), 'domain/statusPush.js 是纯函数(不发请求、不 import service)', ) // 凭据边界(ADR 0005 修订:REST/WS 均免鉴权,**任何层**都不该再出现 Authorization/Bearer)。 // 页面与 store 也不该出现 ws.token/api.token 字段名(token 链路已整体下线)。 check( 'H5-shell-no-credential', !/Authorization|Bearer|api\.token|ws\.token/.test(readVueCode('CockpitShell.vue')), '外壳不碰凭据(连 ws.token 这个字段名都不出现)', ) check( 'H5-store-no-token-plumbing', !/Authorization|Bearer/.test(readCode('stores/runtimeStore.js')) && !/ws\.token \|\| api\.token/.test(readCode('stores/runtimeStore.js')), 'runtimeStore 不再转交 token(免鉴权后 WS 只从 ws.token 读——且仅由 store 旧路径兼容保留)', ) check( 'H5-domain-no-credential', !/Authorization|Bearer|api\.token|ws\.token/.test(read('domain/statusPush.js')), 'domain/statusPush.js 不碰凭据', ) // 时序纪律(ADR 0005 修订):open 后**直接** cockpitSubscribe,服务里不得再有 authenticate 发送点。 const socketCode = readCode('services/runtimeSocket.js') const subAt = socketCode.indexOf("type: 'cockpitSubscribe'") check( 'H6-subscribe-directly-no-authenticate-send', subAt > 0 && !/send\(\{\s*type:\s*'authenticate'/.test(socketCode), '服务里只有 cockpitSubscribe 一个发送点,不再发送 authenticate 帧', ) check( 'H7-runtime-service-knows-no-data-store', !/mapStore|detailStore/.test(socketCode), '运行时服务不认识任何数据 store —— 推送异常在结构上不可能牵连数据分区', ) } const failed = checks.filter((item) => !item.pass) console.log('') console.log(`T15 状态推送检查:${checks.length - failed.length}/${checks.length} 项通过。`) if (failed.length) { console.log('未通过:') for (const item of failed) console.log(` · ${item.name}`) process.exit(1) } console.log('T15 状态推送全部通过。')