/** * 票 46 浏览器走查探针(真实后端 real,headless Chrome via CDP)。 * * 为什么用探针而不是交互式会话:地图标记是 Cesium 实体(交互式会话点不到), * 且并行会话写 src 会触发 vite 整页重载打断多步操作。本仓票 26/33/36 遇同题用同一手法。 * 针对"重载打断"的两层防御: * ① 进入路径可重入(ensurePointLevel / ensureDeviceTab:状态丢了就重新走进去); * ② 交互+读数合并进**单个页内 evaluate**(C/D/E、G 各一次往返), * 把被重载打断的窗口从几十秒压到几秒;被打断则整块重试(幂等:全是视图操作)。 * * 进入路径:顶部状态栏「报警」筛选(决策 02 冻结的接缝,把唯一报警点孤立出来) * → setCameraFocus 飞近 → CDP 真鼠标点覆盖层标记的内部 SVG(宿主节点是 0×0 定位锚; * 诊断 3 实测全球视角下邻近点位实体抢先拾取,按坐标重拾不认传入 code)。 * 目标:CBWS202610150001(设备 21119740:真实后端 5 预警/66 报警/7 工单)。 * * 覆盖票面验收: * A 点位摘要卡片化(状态卡 + KPI 计数条 + t41 钉住的 dd/b 形状) * B 设备详情标签页(五页、默认档案、图片并入档案页、页签带条数) * C 报警分页(每页 10、第 1/7 页、末页余数 6、到边禁用、默认全收起) * D 分类查询(chips 计数、先筛后翻页码回 1、生效回显、再点取消) * E 历史折叠展开(数据时间/状态/来源/关联设备四件事、值非空白) * F 图例对齐(详情状态标签带图标、色键与一张图顶部状态栏同源) * G 返回再进(不重复取数、视图回默认页签) * H 窄面板 480px(页签/卡体不横向溢出) * Z 应用侧零 pageerror / 零 console error * * 运行(dev server 需已在 :8000): * node .scratch/cockpit-real-data-wayfinding/verify/artifacts/t46/probe-walkthrough.mjs */ import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { cdpLaunch } from '../../../../cockpit-shell-panel-layout/verify/cdp-driver.mjs' const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' const URL = 'http://127.0.0.1:8000/#/cockpit' const OUT = path.resolve('.scratch/cockpit-real-data-wayfinding/verify/artifacts/t46') const POINT = 'CBWS202610150001' const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const results = [] const shots = [] function check(name, pass, evidence) { results.push({ name, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`) } async function main() { await mkdir(OUT, { recursive: true }) const browser = await cdpLaunch({ executablePath: CHROME, headless: true, width: 1680, height: 950 }) const page = await browser.newPage() const pageErrors = [] const consoleErrors = [] page.on('pageerror', (e) => pageErrors.push(String(e))) page.on('console', (m) => consoleErrors.push(String(m.text || ''))) await page.goto(URL, { timeout: 60000 }) await sleep(2500) await page.click('[data-cockpit-intro]').catch(() => {}) await page.waitForFunction( () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine || ''), { timeout: 60000 }, ) const shot = async (tag) => { const b64 = await page.screenshot() await writeFile(path.join(OUT, `${tag}.png`), Buffer.from(b64, 'base64')) shots.push(`${tag}.png`) } /** 真鼠标点覆盖层标记的内部 SVG 中心;返回点后的详情层级/点位。 */ async function clickOverlay(code) { const box = await page.evaluate((target) => { const node = document.querySelector(`[data-marker-overlay-code="${target}"]`) if (!node) return null const inner = node.querySelector('svg') || node.firstElementChild || node const r = inner.getBoundingClientRect() return { cx: r.x + r.width / 2, cy: r.y + r.height / 2, w: r.width, h: r.height } }, code) if (!box || !(box.w > 0)) return null await page._dispatchMouse('mouseMoved', box.cx, box.cy) await sleep(120) await page._dispatchMouse('mousePressed', box.cx, box.cy, { button: 'left', buttons: 1, clickCount: 1 }) await sleep(60) await page._dispatchMouse('mouseReleased', box.cx, box.cy, { button: 'left', buttons: 0, clickCount: 1 }) await sleep(2500) return page.evaluate(() => { const c = document.querySelector('[data-point-detail]') return c ? { level: c.dataset.detailLevel, point: c.dataset.pointCode } : null }) } let coordCache = null /** 可重入:确保详情卡停在目标点位的点位级。 */ async function ensurePointLevel() { for (let round = 0; round < 4; round += 1) { const current = await page.evaluate(() => { const c = document.querySelector('[data-point-detail]') return c ? { level: c.dataset.detailLevel, point: c.dataset.pointCode } : null }) if (current?.point === POINT && current?.level === 'point') return current if (!coordCache) { coordCache = await page.evaluate(async (code) => { const r = await fetch('http://192.168.110.96:8301/api/cockpit/v1/map-snapshot?page=1&pageSize=200&pointStatus=ALARM', { cache: 'no-store' }) const j = await r.json() const d = j.data || j const found = (d.monitoringPoints || []).find((p) => (p.pointCode || p.code) === code) return found ? { lon: found.longitude, lat: found.latitude } : null }, POINT) } const selected = await page.evaluate(() => document.querySelector('[data-legend-key="ALARM"]')?.dataset.statusSelected === 'yes') if (!selected) { await page.click('[data-legend-key="ALARM"]').catch(() => {}) await sleep(4000) } if (coordCache) { await page.evaluate((c) => globalThis.__cockpitMap?.setCameraFocus({ lon: c.lon, lat: c.lat, height: 900 }) === true, coordCache) await sleep(4000) } const hit = await clickOverlay(POINT) if (hit?.point === POINT && hit?.level === 'point') return hit } return null } /** * 可重入:确保在设备级并停在指定页签。 * 已在目标点位设备级时**只点页签按钮**——不重走标记拾取(详情浮层盖住标记, * 真鼠标事件到不了覆盖层;实测 3 轮全被打断就是这个原因)。 */ async function ensureDeviceTab(tab) { const read = () => page.evaluate( (name) => ({ level: document.querySelector('[data-point-detail]')?.dataset.detailLevel || '', point: document.querySelector('[data-point-detail]')?.dataset.pointCode || '', active: [...document.querySelectorAll('[data-equipment-tab]')].find((b) => b.getAttribute('aria-selected') === 'true')?.dataset.equipmentTab || '', hasSection: !!document.querySelector(`[data-equipment-section="${name}"]`), }), tab, ) let state = await read() if (state.level === 'equipment' && state.point === POINT) { if (state.active !== tab) { await page.evaluate((name) => document.querySelector(`[data-equipment-tab="${name}"]`)?.click(), tab) await sleep(700) } state = await read() if (state.active === tab && state.hasSection) return state } const opened = await ensurePointLevel() if (opened?.point !== POINT) return { broken: true } await page.evaluate(() => document.querySelector('[data-device-card]')?.click()) await page.waitForFunction(() => document.querySelector('[data-point-detail]')?.dataset.detailLevel === 'equipment', { timeout: 25000 }).catch(() => {}) await sleep(2000) await page.evaluate((name) => document.querySelector(`[data-equipment-tab="${name}"]`)?.click(), tab) await sleep(800) return read() } /** 整块重试:ensure 状态 → 一次 evaluate 内完成全部交互+读数(重载打断则重来)。 */ async function atomicOnAlarms(body, rounds = 3) { for (let round = 0; round < rounds; round += 1) { try { await ensureDeviceTab('alarms') const out = await page.evaluate(body) if (out && !out.__broken) return out } catch { /* 页被重载:整块重试 */ } await sleep(800) } return { __broken: true } } // ---------------- A 点位摘要卡片化 ---------------- const opened = await ensurePointLevel() check('A0-opened-target-point', opened?.point === POINT && opened?.level === 'point', `命中=${opened ? `${opened.point}/${opened.level}` : 'null'}`) const level1 = await page.evaluate(() => { const card = document.querySelector('[data-point-detail]') const kpis = [...card.querySelectorAll('[data-point-kpi]')] return { hasSummary: !!card.querySelector('[data-point-summary]'), codeShape: card.querySelector('[data-point-code-value]')?.tagName || '', statusCard: !!card.querySelector('[data-point-status-card]'), kpiCount: kpis.length, kpi: kpis.map((n) => `${n.dataset.pointKpi}=${n.querySelector('b')?.textContent?.trim()}:${n.querySelector('b')?.dataset.pointKpiTone || ''}`), deviceCount: card.querySelector('[data-point-device-count]')?.textContent?.trim(), deviceCountTag: card.querySelector('[data-point-device-count]')?.tagName || '', deviceCards: card.querySelectorAll('[data-device-card]').length, } }) check('A1-point-summary-card-ified', level1.hasSummary && level1.statusCard, JSON.stringify({ summary: level1.hasSummary, statusCard: level1.statusCard })) check('A1b-kpi-strip-rendered', level1.kpiCount >= 5, level1.kpi.join(' ')) check('A1c-pinned-shapes-kept', level1.codeShape === 'DD' && level1.deviceCountTag === 'B', `code=<${level1.codeShape}> count=<${level1.deviceCountTag}>`) await shot('01-point-level') // ---------------- F 图例对齐(点位级:状态卡 + 设备卡片 >=2 个标签)---------------- // 顶部图例的图标类是 .map-holo__legend-icon(CockpitIcon),详情标签内是 .status-tag__icon。 // 维护者复验补判据:顶部定稿是「图标 + 文字」(形状被 CSS 隐去),详情侧曾画出 // 「□ ≡ 无设备」多一个已废弃形状 —— 所以两侧都必须测**形状可见性**,不能只测有没有图标。 const legend = await page.evaluate(() => { const shapeVisible = (tag) => { const svg = tag?.querySelector('.status-tag__shape') if (!svg) return false const r = svg.getBoundingClientRect() return r.width > 0 && r.height > 0 && getComputedStyle(svg).display !== 'none' } const top = [...document.querySelectorAll('[data-map-legend] [data-legend-key]')].map((n) => { const tag = n.querySelector('.status-tag') return { key: n.dataset.legendKey, tone: tag?.dataset.tone || '', icon: !!n.querySelector('.map-holo__legend-icon'), shapeVisible: shapeVisible(tag) } }) const card = [...document.querySelectorAll('[data-point-detail] .status-tag')].map((n) => ({ tone: n.dataset.tone, shape: n.dataset.shape, icon: !!n.querySelector('.status-tag__icon'), shapeVisible: shapeVisible(n) })) return { top, card } }) const alarmRow = legend.top.find((r) => r.key === 'ALARM') check('F1-top-legend-has-icon-rows', legend.top.length >= 8 && alarmRow?.icon === true, `顶部状态栏 ${legend.top.length} 行,报警行带图标=${alarmRow?.icon}`) check('F2-detail-tags-carry-icon', legend.card.length >= 2 && legend.card.every((c) => c.icon === true), `详情内 ${legend.card.length} 个状态标签全部带图标=${legend.card.every((c) => c.icon)}`) check('F3-tone-shared', legend.card.some((c) => c.tone === alarmRow?.tone), `详情标签色键 ${legend.card.map((c) => c.tone).join('/')} 与顶部报警行 ${alarmRow?.tone} 同源`) // F4/F5 形状可见性两侧一致(顶部定稿隐形状 → 详情也必须隐;这就是"多出一个废弃设计"的判据) check('F4-top-legend-shapes-hidden', legend.top.every((r) => r.shapeVisible === false), `顶部 ${legend.top.length} 行形状可见数=${legend.top.filter((r) => r.shapeVisible).length}`) check('F5-detail-shapes-hidden-matching-top', legend.card.every((c) => c.shapeVisible === false), `详情标签形状可见数=${legend.card.filter((c) => c.shapeVisible).length}(应为 0,与顶部一致)`) // ---------------- B 进设备详情:标签页 ---------------- await ensureDeviceTab('archive') const tabs = await page.evaluate(() => { const btns = [...document.querySelectorAll('[data-equipment-tab]')] const body = document.querySelector('[data-equipment-tab-body]') return { tabs: btns.map((b) => `${b.dataset.equipmentTab}:${b.getAttribute('aria-selected') === 'true' ? 'sel' : ''}:${b.querySelector('.equipment-tabs__count')?.textContent || ''}`), activeTab: btns.find((b) => b.getAttribute('aria-selected') === 'true')?.dataset.equipmentTab || '', sections: [...body.querySelectorAll('[data-equipment-section]')].map((s) => s.dataset.equipmentSection), deviceStatusIcon: !!document.querySelector('[data-device-status] .status-tag__icon'), } }) check('B1-five-tabs-default-archive', tabs.tabs.length === 5 && tabs.activeTab === 'archive', tabs.tabs.join(' | ')) check('B2-image-merged-into-archive-tab', tabs.sections.join(',') === 'archive,latestImage', `档案页含分区:${tabs.sections.join(',')}`) check('B3-device-status-icon', tabs.deviceStatusIcon, '设备详情表头状态标签带图标') await shot('02-device-archive-tab') // ---------------- C+D+E 报警页:分页 / 分类 / 折叠展开(单 evaluate 原子块)---------------- const cde = await atomicOnAlarms(async () => { const nap = (ms) => new Promise((r) => setTimeout(r, ms)) const sec = () => document.querySelector('[data-equipment-section="alarms"]') if (!sec()) return { __broken: true } const out = {} out.tabCount = document.querySelector('[data-equipment-tab="alarms"]')?.querySelector('.equipment-tabs__count')?.textContent || '' // C:第 1 页 const pager1 = sec().querySelector('[data-pager]') out.page1 = { rows: sec().querySelectorAll('[data-event]').length, collapsedClosed: [...sec().querySelectorAll('[data-history-item]')].every((d) => !d.hasAttribute('open')), pagerText: pager1 ? pager1.querySelector('[data-page-state]')?.textContent?.trim() : '(无控件)', prevDisabled: pager1?.querySelector('[data-page-prev]')?.disabled, nextDisabled: pager1?.querySelector('[data-page-next]')?.disabled, } // 翻到末页 for (let i = 0; i < 6; i += 1) { sec().querySelector('[data-page-next]')?.click(); await nap(120) } const last = sec().querySelector('[data-pager]') out.lastPage = { text: last?.querySelector('[data-page-state]')?.textContent?.trim(), rows: sec().querySelectorAll('[data-event]').length, nextDisabled: last?.querySelector('[data-page-next]')?.disabled, } // 回第 1 页 for (let i = 0; i < 6; i += 1) { sec().querySelector('[data-page-prev]')?.click(); await nap(120) } // D:点一个计数 >10 的分类 chip(此刻停在末页也验证"换分类回第 1 页") for (let i = 0; i < 6; i += 1) { sec().querySelector('[data-page-next]')?.click(); await nap(100) } const chips = [...sec().querySelectorAll('[data-category-chip]')] out.chips = chips.map((c) => `${c.dataset.categoryChip.split(':').pop()}=${c.querySelector('[data-category-count]')?.textContent}`) const pick = chips.find((c) => !c.dataset.categoryChip.endsWith(':all') && Number(c.querySelector('[data-category-count]')?.textContent) > 10) out.chipKey = pick?.dataset.categoryChip || '' pick?.click() await nap(300) out.afterChip = { rows: sec().querySelectorAll('[data-event]').length, pager: sec().querySelector('[data-page-state]')?.textContent?.trim() || '(无控件)', note: sec().querySelector('[data-category-note]')?.textContent?.trim() || '', active: sec().querySelector('[data-category-chip][aria-pressed="true"]')?.dataset.categoryChip || '', } // 再点同一 chip = 取消 sec().querySelector(`[data-category-chip="${out.chipKey}"]`)?.click() await nap(300) out.afterCancel = { rows: sec().querySelectorAll('[data-event]').length, note: sec().querySelector('[data-category-note]')?.textContent || '', active: sec().querySelector('[data-category-chip][aria-pressed="true"]')?.dataset.categoryChip || '', } // E:展开第一条 const item = sec().querySelector('[data-history-item]') item?.querySelector('summary')?.click() await nap(250) out.expanded = item?.hasAttribute('open') === true const d = sec().querySelector('[data-history-item][open] [data-history-detail]') out.detail = { labels: [...(d?.querySelectorAll('[data-detail-row]') || [])].map((n) => n.dataset.detailRow), values: [...(d?.querySelectorAll('dd') || [])].map((n) => n.textContent.trim()), } return out }) check('C0-atomic-block-completed', !cde.__broken, cde.__broken ? '整块 3 次都被重载打断(并行会话在写 src)' : '一次 evaluate 完成 C/D/E 全部交互与读数') check('C1-tab-count-66-and-page10', cde.tabCount === '66' && cde.page1?.rows === 10, `页签计数=${cde.tabCount} 第1页行数=${cde.page1?.rows}`) check('C2-pager-reads-page-and-total', /第\s*1\/7\s*页/.test(cde.page1?.pagerText || '') && /66/.test(cde.page1?.pagerText || ''), cde.page1?.pagerText) // 空集合的 every 恒真 —— 必须连"确实有行"一起判(本仓"守卫比它要守的东西宽"的同一课)。 check('C3-default-collapsed', (cde.page1?.rows || 0) > 0 && cde.page1?.collapsedClosed === true, `第1页 ${cde.page1?.rows} 行全部默认收起=${cde.page1?.collapsedClosed}`) check('C4-edge-disabled-prev', cde.page1?.prevDisabled === true && cde.page1?.nextDisabled === false, `上一页禁用=${cde.page1?.prevDisabled} 下一页禁用=${cde.page1?.nextDisabled}`) check('C5-last-page-remainder', /第\s*7\/7\s*页/.test(cde.lastPage?.text || '') && cde.lastPage?.rows === 6 && cde.lastPage?.nextDisabled === true, `${cde.lastPage?.text} 行数=${cde.lastPage?.rows} 下一页禁用=${cde.lastPage?.nextDisabled}`) check('D0-chips-counts', (cde.chips || []).length >= 2 && cde.chips.some((c) => c.startsWith('all=66')), (cde.chips || []).join(' ')) check('D1-chip-filters-and-resets-page', !!cde.chipKey && /第\s*1\//.test(cde.afterChip?.pager || '') && cde.afterChip?.active === cde.chipKey, `选中=${cde.afterChip?.active} 页码:${cde.afterChip?.pager} 行数=${cde.afterChip?.rows}`) check('D2-active-note-visible', /再点同一项取消/.test(cde.afterChip?.note || ''), cde.afterChip?.note) check('D3-reclick-cancels-filter', cde.afterCancel?.rows === 10 && cde.afterCancel?.note === '' && cde.afterCancel?.active?.endsWith(':all'), `取消后行数=${cde.afterCancel?.rows} 回显="${cde.afterCancel?.note}" 选中=${cde.afterCancel?.active}`) const four = ['数据时间', '状态', '来源', '关联设备'] check('E1-expand-shows-four-facts', cde.expanded === true && four.every((l) => (cde.detail?.labels || []).includes(l)), `展开=${cde.expanded} 详情行:${(cde.detail?.labels || []).join('/')}`) check('E2-values-not-blank', (cde.detail?.values || []).length > 0 && cde.detail.values.every((v) => v && v.length > 0), `值:${(cde.detail?.values || []).join(' | ').slice(0, 140)}`) await shot('06-alarms-expanded') // ---------------- G 返回再进:不重复取数、视图回默认(原子块)---------------- const g = await (async () => { for (let round = 0; round < 3; round += 1) { await ensureDeviceTab('alarms') try { const out = await page.evaluate(async () => { const nap = (ms) => new Promise((r) => setTimeout(r, ms)) const before = performance.getEntriesByType('resource').filter((r) => r.name.includes('/detail-aggregate')).length document.querySelector('[data-detail-back]')?.click() await nap(1200) document.querySelector('[data-device-card]')?.click() for (let i = 0; i < 30; i += 1) { await nap(200) if (document.querySelector('[data-point-detail]')?.dataset.detailLevel === 'equipment') break } return { before, after: performance.getEntriesByType('resource').filter((r) => r.name.includes('/detail-aggregate')).length, activeTab: [...document.querySelectorAll('[data-equipment-tab]')].find((b) => b.getAttribute('aria-selected') === 'true')?.dataset.equipmentTab || '', } }) if (out && out.after >= out.before) return out } catch { /* 重载打断:重试 */ } await sleep(600) } return { __broken: true } })() check('G1-no-refetch-on-return', !g.__broken && g.after === g.before, `detail-aggregate 请求数 ${g.before} → ${g.after}`) check('G2-view-state-back-to-default', g.activeTab === 'archive', `重进后停在默认页签 ${g.activeTab}(分类/页码也随组件重建归零)`) await shot('07-back-and-reenter') // ---------------- H 窄面板 480px ---------------- await ensureDeviceTab('alarms') await page.conn.send('Emulation.setDeviceMetricsOverride', { width: 480, height: 820, deviceScaleFactor: 1, mobile: false }, page.sessionId) await sleep(1500) const narrow = await page.evaluate(() => { const overflow = (sel) => { const el = document.querySelector(sel); return el ? { sw: el.scrollWidth, cw: el.clientWidth, over: el.scrollWidth - el.clientWidth } : null } return { tabs: overflow('[data-equipment-tabs]'), aside: overflow('[data-point-detail]'), body: overflow('.equipment-sections'), chips: overflow('[data-category-bar]') } }) check('H1-narrow-no-overflow', [narrow.tabs, narrow.aside, narrow.body, narrow.chips].every((n) => n && n.over <= 1), JSON.stringify(narrow)) await shot('08-narrow-480') await page.conn.send('Emulation.clearDeviceMetricsOverride', {}, page.sessionId).catch(() => {}) // ---------------- Z 应用侧报错 ---------------- const appErrors = pageErrors.filter((e) => !/chrome-extension|baidu|Permissions policy/i.test(e)) const appConsole = consoleErrors.filter((e) => /error/i.test(e) && !/chrome-extension|baidu|Permissions policy|Video overlay|favicon/i.test(e)) check('Z1-no-pageerror', appErrors.length === 0, appErrors.slice(0, 3).join(' | ')) check('Z2-no-console-error', appConsole.length === 0, appConsole.slice(0, 3).join(' | ')) await writeFile(path.join(OUT, 'walkthrough-result.json'), JSON.stringify({ results, shots, level1, legend, tabs, cde, g, narrow }, null, 2)) const failed = results.filter((r) => !r.pass) console.log('') console.log(`T46 浏览器走查:${results.length - failed.length}/${results.length} 项通过。截图:${shots.join(', ')}`) if (failed.length) console.log('未通过:' + failed.map((f) => f.name).join(', ')) await browser.close() process.exitCode = failed.length ? 1 : 0 } main().catch((err) => { console.error('PROBE ERROR:', err) process.exit(1) })