| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367 |
- /**
- * 票 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。
- const legend = await page.evaluate(() => {
- const top = [...document.querySelectorAll('[data-map-legend] [data-legend-key]')].map((n) => ({ key: n.dataset.legendKey, tone: n.querySelector('.status-tag')?.dataset.tone || '', icon: !!n.querySelector('.map-holo__legend-icon') }))
- 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') }))
- 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} 同源`)
- // ---------------- 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)
- })
|