probe-walkthrough.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. /**
  2. * 票 46 浏览器走查探针(真实后端 real,headless Chrome via CDP)。
  3. *
  4. * 为什么用探针而不是交互式会话:地图标记是 Cesium 实体(交互式会话点不到),
  5. * 且并行会话写 src 会触发 vite 整页重载打断多步操作。本仓票 26/33/36 遇同题用同一手法。
  6. * 针对"重载打断"的两层防御:
  7. * ① 进入路径可重入(ensurePointLevel / ensureDeviceTab:状态丢了就重新走进去);
  8. * ② 交互+读数合并进**单个页内 evaluate**(C/D/E、G 各一次往返),
  9. * 把被重载打断的窗口从几十秒压到几秒;被打断则整块重试(幂等:全是视图操作)。
  10. *
  11. * 进入路径:顶部状态栏「报警」筛选(决策 02 冻结的接缝,把唯一报警点孤立出来)
  12. * → setCameraFocus 飞近 → CDP 真鼠标点覆盖层标记的内部 SVG(宿主节点是 0×0 定位锚;
  13. * 诊断 3 实测全球视角下邻近点位实体抢先拾取,按坐标重拾不认传入 code)。
  14. * 目标:CBWS202610150001(设备 21119740:真实后端 5 预警/66 报警/7 工单)。
  15. *
  16. * 覆盖票面验收:
  17. * A 点位摘要卡片化(状态卡 + KPI 计数条 + t41 钉住的 dd/b 形状)
  18. * B 设备详情标签页(五页、默认档案、图片并入档案页、页签带条数)
  19. * C 报警分页(每页 10、第 1/7 页、末页余数 6、到边禁用、默认全收起)
  20. * D 分类查询(chips 计数、先筛后翻页码回 1、生效回显、再点取消)
  21. * E 历史折叠展开(数据时间/状态/来源/关联设备四件事、值非空白)
  22. * F 图例对齐(详情状态标签带图标、色键与一张图顶部状态栏同源)
  23. * G 返回再进(不重复取数、视图回默认页签)
  24. * H 窄面板 480px(页签/卡体不横向溢出)
  25. * Z 应用侧零 pageerror / 零 console error
  26. *
  27. * 运行(dev server 需已在 :8000):
  28. * node .scratch/cockpit-real-data-wayfinding/verify/artifacts/t46/probe-walkthrough.mjs
  29. */
  30. import { mkdir, writeFile } from 'node:fs/promises'
  31. import path from 'node:path'
  32. import { cdpLaunch } from '../../../../cockpit-shell-panel-layout/verify/cdp-driver.mjs'
  33. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  34. const URL = 'http://127.0.0.1:8000/#/cockpit'
  35. const OUT = path.resolve('.scratch/cockpit-real-data-wayfinding/verify/artifacts/t46')
  36. const POINT = 'CBWS202610150001'
  37. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  38. const results = []
  39. const shots = []
  40. function check(name, pass, evidence) {
  41. results.push({ name, pass })
  42. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  43. }
  44. async function main() {
  45. await mkdir(OUT, { recursive: true })
  46. const browser = await cdpLaunch({ executablePath: CHROME, headless: true, width: 1680, height: 950 })
  47. const page = await browser.newPage()
  48. const pageErrors = []
  49. const consoleErrors = []
  50. page.on('pageerror', (e) => pageErrors.push(String(e)))
  51. page.on('console', (m) => consoleErrors.push(String(m.text || '')))
  52. await page.goto(URL, { timeout: 60000 })
  53. await sleep(2500)
  54. await page.click('[data-cockpit-intro]').catch(() => {})
  55. await page.waitForFunction(
  56. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine || ''),
  57. { timeout: 60000 },
  58. )
  59. const shot = async (tag) => {
  60. const b64 = await page.screenshot()
  61. await writeFile(path.join(OUT, `${tag}.png`), Buffer.from(b64, 'base64'))
  62. shots.push(`${tag}.png`)
  63. }
  64. /** 真鼠标点覆盖层标记的内部 SVG 中心;返回点后的详情层级/点位。 */
  65. async function clickOverlay(code) {
  66. const box = await page.evaluate((target) => {
  67. const node = document.querySelector(`[data-marker-overlay-code="${target}"]`)
  68. if (!node) return null
  69. const inner = node.querySelector('svg') || node.firstElementChild || node
  70. const r = inner.getBoundingClientRect()
  71. return { cx: r.x + r.width / 2, cy: r.y + r.height / 2, w: r.width, h: r.height }
  72. }, code)
  73. if (!box || !(box.w > 0)) return null
  74. await page._dispatchMouse('mouseMoved', box.cx, box.cy)
  75. await sleep(120)
  76. await page._dispatchMouse('mousePressed', box.cx, box.cy, { button: 'left', buttons: 1, clickCount: 1 })
  77. await sleep(60)
  78. await page._dispatchMouse('mouseReleased', box.cx, box.cy, { button: 'left', buttons: 0, clickCount: 1 })
  79. await sleep(2500)
  80. return page.evaluate(() => {
  81. const c = document.querySelector('[data-point-detail]')
  82. return c ? { level: c.dataset.detailLevel, point: c.dataset.pointCode } : null
  83. })
  84. }
  85. let coordCache = null
  86. /** 可重入:确保详情卡停在目标点位的点位级。 */
  87. async function ensurePointLevel() {
  88. for (let round = 0; round < 4; round += 1) {
  89. const current = await page.evaluate(() => {
  90. const c = document.querySelector('[data-point-detail]')
  91. return c ? { level: c.dataset.detailLevel, point: c.dataset.pointCode } : null
  92. })
  93. if (current?.point === POINT && current?.level === 'point') return current
  94. if (!coordCache) {
  95. coordCache = await page.evaluate(async (code) => {
  96. const r = await fetch('http://192.168.110.96:8301/api/cockpit/v1/map-snapshot?page=1&pageSize=200&pointStatus=ALARM', { cache: 'no-store' })
  97. const j = await r.json()
  98. const d = j.data || j
  99. const found = (d.monitoringPoints || []).find((p) => (p.pointCode || p.code) === code)
  100. return found ? { lon: found.longitude, lat: found.latitude } : null
  101. }, POINT)
  102. }
  103. const selected = await page.evaluate(() => document.querySelector('[data-legend-key="ALARM"]')?.dataset.statusSelected === 'yes')
  104. if (!selected) {
  105. await page.click('[data-legend-key="ALARM"]').catch(() => {})
  106. await sleep(4000)
  107. }
  108. if (coordCache) {
  109. await page.evaluate((c) => globalThis.__cockpitMap?.setCameraFocus({ lon: c.lon, lat: c.lat, height: 900 }) === true, coordCache)
  110. await sleep(4000)
  111. }
  112. const hit = await clickOverlay(POINT)
  113. if (hit?.point === POINT && hit?.level === 'point') return hit
  114. }
  115. return null
  116. }
  117. /**
  118. * 可重入:确保在设备级并停在指定页签。
  119. * 已在目标点位设备级时**只点页签按钮**——不重走标记拾取(详情浮层盖住标记,
  120. * 真鼠标事件到不了覆盖层;实测 3 轮全被打断就是这个原因)。
  121. */
  122. async function ensureDeviceTab(tab) {
  123. const read = () => page.evaluate(
  124. (name) => ({
  125. level: document.querySelector('[data-point-detail]')?.dataset.detailLevel || '',
  126. point: document.querySelector('[data-point-detail]')?.dataset.pointCode || '',
  127. active: [...document.querySelectorAll('[data-equipment-tab]')].find((b) => b.getAttribute('aria-selected') === 'true')?.dataset.equipmentTab || '',
  128. hasSection: !!document.querySelector(`[data-equipment-section="${name}"]`),
  129. }),
  130. tab,
  131. )
  132. let state = await read()
  133. if (state.level === 'equipment' && state.point === POINT) {
  134. if (state.active !== tab) {
  135. await page.evaluate((name) => document.querySelector(`[data-equipment-tab="${name}"]`)?.click(), tab)
  136. await sleep(700)
  137. }
  138. state = await read()
  139. if (state.active === tab && state.hasSection) return state
  140. }
  141. const opened = await ensurePointLevel()
  142. if (opened?.point !== POINT) return { broken: true }
  143. await page.evaluate(() => document.querySelector('[data-device-card]')?.click())
  144. await page.waitForFunction(() => document.querySelector('[data-point-detail]')?.dataset.detailLevel === 'equipment', { timeout: 25000 }).catch(() => {})
  145. await sleep(2000)
  146. await page.evaluate((name) => document.querySelector(`[data-equipment-tab="${name}"]`)?.click(), tab)
  147. await sleep(800)
  148. return read()
  149. }
  150. /** 整块重试:ensure 状态 → 一次 evaluate 内完成全部交互+读数(重载打断则重来)。 */
  151. async function atomicOnAlarms(body, rounds = 3) {
  152. for (let round = 0; round < rounds; round += 1) {
  153. try {
  154. await ensureDeviceTab('alarms')
  155. const out = await page.evaluate(body)
  156. if (out && !out.__broken) return out
  157. } catch {
  158. /* 页被重载:整块重试 */
  159. }
  160. await sleep(800)
  161. }
  162. return { __broken: true }
  163. }
  164. // ---------------- A 点位摘要卡片化 ----------------
  165. const opened = await ensurePointLevel()
  166. check('A0-opened-target-point', opened?.point === POINT && opened?.level === 'point', `命中=${opened ? `${opened.point}/${opened.level}` : 'null'}`)
  167. const level1 = await page.evaluate(() => {
  168. const card = document.querySelector('[data-point-detail]')
  169. const kpis = [...card.querySelectorAll('[data-point-kpi]')]
  170. return {
  171. hasSummary: !!card.querySelector('[data-point-summary]'),
  172. codeShape: card.querySelector('[data-point-code-value]')?.tagName || '',
  173. statusCard: !!card.querySelector('[data-point-status-card]'),
  174. kpiCount: kpis.length,
  175. kpi: kpis.map((n) => `${n.dataset.pointKpi}=${n.querySelector('b')?.textContent?.trim()}:${n.querySelector('b')?.dataset.pointKpiTone || ''}`),
  176. deviceCount: card.querySelector('[data-point-device-count]')?.textContent?.trim(),
  177. deviceCountTag: card.querySelector('[data-point-device-count]')?.tagName || '',
  178. deviceCards: card.querySelectorAll('[data-device-card]').length,
  179. }
  180. })
  181. check('A1-point-summary-card-ified', level1.hasSummary && level1.statusCard, JSON.stringify({ summary: level1.hasSummary, statusCard: level1.statusCard }))
  182. check('A1b-kpi-strip-rendered', level1.kpiCount >= 5, level1.kpi.join(' '))
  183. check('A1c-pinned-shapes-kept', level1.codeShape === 'DD' && level1.deviceCountTag === 'B', `code=<${level1.codeShape}> count=<${level1.deviceCountTag}>`)
  184. await shot('01-point-level')
  185. // ---------------- F 图例对齐(点位级:状态卡 + 设备卡片 >=2 个标签)----------------
  186. // 顶部图例的图标类是 .map-holo__legend-icon(CockpitIcon),详情标签内是 .status-tag__icon。
  187. // 维护者复验补判据:顶部定稿是「图标 + 文字」(形状被 CSS 隐去),详情侧曾画出
  188. // 「□ ≡ 无设备」多一个已废弃形状 —— 所以两侧都必须测**形状可见性**,不能只测有没有图标。
  189. const legend = await page.evaluate(() => {
  190. const shapeVisible = (tag) => {
  191. const svg = tag?.querySelector('.status-tag__shape')
  192. if (!svg) return false
  193. const r = svg.getBoundingClientRect()
  194. return r.width > 0 && r.height > 0 && getComputedStyle(svg).display !== 'none'
  195. }
  196. const top = [...document.querySelectorAll('[data-map-legend] [data-legend-key]')].map((n) => {
  197. const tag = n.querySelector('.status-tag')
  198. return { key: n.dataset.legendKey, tone: tag?.dataset.tone || '', icon: !!n.querySelector('.map-holo__legend-icon'), shapeVisible: shapeVisible(tag) }
  199. })
  200. 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) }))
  201. return { top, card }
  202. })
  203. const alarmRow = legend.top.find((r) => r.key === 'ALARM')
  204. check('F1-top-legend-has-icon-rows', legend.top.length >= 8 && alarmRow?.icon === true, `顶部状态栏 ${legend.top.length} 行,报警行带图标=${alarmRow?.icon}`)
  205. 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)}`)
  206. check('F3-tone-shared', legend.card.some((c) => c.tone === alarmRow?.tone), `详情标签色键 ${legend.card.map((c) => c.tone).join('/')} 与顶部报警行 ${alarmRow?.tone} 同源`)
  207. // F4/F5 形状可见性两侧一致(顶部定稿隐形状 → 详情也必须隐;这就是"多出一个废弃设计"的判据)
  208. check('F4-top-legend-shapes-hidden', legend.top.every((r) => r.shapeVisible === false), `顶部 ${legend.top.length} 行形状可见数=${legend.top.filter((r) => r.shapeVisible).length}`)
  209. check('F5-detail-shapes-hidden-matching-top', legend.card.every((c) => c.shapeVisible === false), `详情标签形状可见数=${legend.card.filter((c) => c.shapeVisible).length}(应为 0,与顶部一致)`)
  210. // ---------------- B 进设备详情:标签页 ----------------
  211. await ensureDeviceTab('archive')
  212. const tabs = await page.evaluate(() => {
  213. const btns = [...document.querySelectorAll('[data-equipment-tab]')]
  214. const body = document.querySelector('[data-equipment-tab-body]')
  215. return {
  216. tabs: btns.map((b) => `${b.dataset.equipmentTab}:${b.getAttribute('aria-selected') === 'true' ? 'sel' : ''}:${b.querySelector('.equipment-tabs__count')?.textContent || ''}`),
  217. activeTab: btns.find((b) => b.getAttribute('aria-selected') === 'true')?.dataset.equipmentTab || '',
  218. sections: [...body.querySelectorAll('[data-equipment-section]')].map((s) => s.dataset.equipmentSection),
  219. deviceStatusIcon: !!document.querySelector('[data-device-status] .status-tag__icon'),
  220. }
  221. })
  222. check('B1-five-tabs-default-archive', tabs.tabs.length === 5 && tabs.activeTab === 'archive', tabs.tabs.join(' | '))
  223. check('B2-image-merged-into-archive-tab', tabs.sections.join(',') === 'archive,latestImage', `档案页含分区:${tabs.sections.join(',')}`)
  224. check('B3-device-status-icon', tabs.deviceStatusIcon, '设备详情表头状态标签带图标')
  225. await shot('02-device-archive-tab')
  226. // ---------------- C+D+E 报警页:分页 / 分类 / 折叠展开(单 evaluate 原子块)----------------
  227. const cde = await atomicOnAlarms(async () => {
  228. const nap = (ms) => new Promise((r) => setTimeout(r, ms))
  229. const sec = () => document.querySelector('[data-equipment-section="alarms"]')
  230. if (!sec()) return { __broken: true }
  231. const out = {}
  232. out.tabCount = document.querySelector('[data-equipment-tab="alarms"]')?.querySelector('.equipment-tabs__count')?.textContent || ''
  233. // C:第 1 页
  234. const pager1 = sec().querySelector('[data-pager]')
  235. out.page1 = {
  236. rows: sec().querySelectorAll('[data-event]').length,
  237. collapsedClosed: [...sec().querySelectorAll('[data-history-item]')].every((d) => !d.hasAttribute('open')),
  238. pagerText: pager1 ? pager1.querySelector('[data-page-state]')?.textContent?.trim() : '(无控件)',
  239. prevDisabled: pager1?.querySelector('[data-page-prev]')?.disabled,
  240. nextDisabled: pager1?.querySelector('[data-page-next]')?.disabled,
  241. }
  242. // 翻到末页
  243. for (let i = 0; i < 6; i += 1) { sec().querySelector('[data-page-next]')?.click(); await nap(120) }
  244. const last = sec().querySelector('[data-pager]')
  245. out.lastPage = {
  246. text: last?.querySelector('[data-page-state]')?.textContent?.trim(),
  247. rows: sec().querySelectorAll('[data-event]').length,
  248. nextDisabled: last?.querySelector('[data-page-next]')?.disabled,
  249. }
  250. // 回第 1 页
  251. for (let i = 0; i < 6; i += 1) { sec().querySelector('[data-page-prev]')?.click(); await nap(120) }
  252. // D:点一个计数 >10 的分类 chip(此刻停在末页也验证"换分类回第 1 页")
  253. for (let i = 0; i < 6; i += 1) { sec().querySelector('[data-page-next]')?.click(); await nap(100) }
  254. const chips = [...sec().querySelectorAll('[data-category-chip]')]
  255. out.chips = chips.map((c) => `${c.dataset.categoryChip.split(':').pop()}=${c.querySelector('[data-category-count]')?.textContent}`)
  256. const pick = chips.find((c) => !c.dataset.categoryChip.endsWith(':all') && Number(c.querySelector('[data-category-count]')?.textContent) > 10)
  257. out.chipKey = pick?.dataset.categoryChip || ''
  258. pick?.click()
  259. await nap(300)
  260. out.afterChip = {
  261. rows: sec().querySelectorAll('[data-event]').length,
  262. pager: sec().querySelector('[data-page-state]')?.textContent?.trim() || '(无控件)',
  263. note: sec().querySelector('[data-category-note]')?.textContent?.trim() || '',
  264. active: sec().querySelector('[data-category-chip][aria-pressed="true"]')?.dataset.categoryChip || '',
  265. }
  266. // 再点同一 chip = 取消
  267. sec().querySelector(`[data-category-chip="${out.chipKey}"]`)?.click()
  268. await nap(300)
  269. out.afterCancel = {
  270. rows: sec().querySelectorAll('[data-event]').length,
  271. note: sec().querySelector('[data-category-note]')?.textContent || '',
  272. active: sec().querySelector('[data-category-chip][aria-pressed="true"]')?.dataset.categoryChip || '',
  273. }
  274. // E:展开第一条
  275. const item = sec().querySelector('[data-history-item]')
  276. item?.querySelector('summary')?.click()
  277. await nap(250)
  278. out.expanded = item?.hasAttribute('open') === true
  279. const d = sec().querySelector('[data-history-item][open] [data-history-detail]')
  280. out.detail = {
  281. labels: [...(d?.querySelectorAll('[data-detail-row]') || [])].map((n) => n.dataset.detailRow),
  282. values: [...(d?.querySelectorAll('dd') || [])].map((n) => n.textContent.trim()),
  283. }
  284. return out
  285. })
  286. check('C0-atomic-block-completed', !cde.__broken, cde.__broken ? '整块 3 次都被重载打断(并行会话在写 src)' : '一次 evaluate 完成 C/D/E 全部交互与读数')
  287. check('C1-tab-count-66-and-page10', cde.tabCount === '66' && cde.page1?.rows === 10, `页签计数=${cde.tabCount} 第1页行数=${cde.page1?.rows}`)
  288. check('C2-pager-reads-page-and-total', /第\s*1\/7\s*页/.test(cde.page1?.pagerText || '') && /66/.test(cde.page1?.pagerText || ''), cde.page1?.pagerText)
  289. // 空集合的 every 恒真 —— 必须连"确实有行"一起判(本仓"守卫比它要守的东西宽"的同一课)。
  290. check('C3-default-collapsed', (cde.page1?.rows || 0) > 0 && cde.page1?.collapsedClosed === true, `第1页 ${cde.page1?.rows} 行全部默认收起=${cde.page1?.collapsedClosed}`)
  291. check('C4-edge-disabled-prev', cde.page1?.prevDisabled === true && cde.page1?.nextDisabled === false, `上一页禁用=${cde.page1?.prevDisabled} 下一页禁用=${cde.page1?.nextDisabled}`)
  292. 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}`)
  293. check('D0-chips-counts', (cde.chips || []).length >= 2 && cde.chips.some((c) => c.startsWith('all=66')), (cde.chips || []).join(' '))
  294. 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}`)
  295. check('D2-active-note-visible', /再点同一项取消/.test(cde.afterChip?.note || ''), cde.afterChip?.note)
  296. 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}`)
  297. const four = ['数据时间', '状态', '来源', '关联设备']
  298. check('E1-expand-shows-four-facts', cde.expanded === true && four.every((l) => (cde.detail?.labels || []).includes(l)), `展开=${cde.expanded} 详情行:${(cde.detail?.labels || []).join('/')}`)
  299. 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)}`)
  300. await shot('06-alarms-expanded')
  301. // ---------------- G 返回再进:不重复取数、视图回默认(原子块)----------------
  302. const g = await (async () => {
  303. for (let round = 0; round < 3; round += 1) {
  304. await ensureDeviceTab('alarms')
  305. try {
  306. const out = await page.evaluate(async () => {
  307. const nap = (ms) => new Promise((r) => setTimeout(r, ms))
  308. const before = performance.getEntriesByType('resource').filter((r) => r.name.includes('/detail-aggregate')).length
  309. document.querySelector('[data-detail-back]')?.click()
  310. await nap(1200)
  311. document.querySelector('[data-device-card]')?.click()
  312. for (let i = 0; i < 30; i += 1) {
  313. await nap(200)
  314. if (document.querySelector('[data-point-detail]')?.dataset.detailLevel === 'equipment') break
  315. }
  316. return {
  317. before,
  318. after: performance.getEntriesByType('resource').filter((r) => r.name.includes('/detail-aggregate')).length,
  319. activeTab: [...document.querySelectorAll('[data-equipment-tab]')].find((b) => b.getAttribute('aria-selected') === 'true')?.dataset.equipmentTab || '',
  320. }
  321. })
  322. if (out && out.after >= out.before) return out
  323. } catch {
  324. /* 重载打断:重试 */
  325. }
  326. await sleep(600)
  327. }
  328. return { __broken: true }
  329. })()
  330. check('G1-no-refetch-on-return', !g.__broken && g.after === g.before, `detail-aggregate 请求数 ${g.before} → ${g.after}`)
  331. check('G2-view-state-back-to-default', g.activeTab === 'archive', `重进后停在默认页签 ${g.activeTab}(分类/页码也随组件重建归零)`)
  332. await shot('07-back-and-reenter')
  333. // ---------------- H 窄面板 480px ----------------
  334. await ensureDeviceTab('alarms')
  335. await page.conn.send('Emulation.setDeviceMetricsOverride', { width: 480, height: 820, deviceScaleFactor: 1, mobile: false }, page.sessionId)
  336. await sleep(1500)
  337. const narrow = await page.evaluate(() => {
  338. const overflow = (sel) => { const el = document.querySelector(sel); return el ? { sw: el.scrollWidth, cw: el.clientWidth, over: el.scrollWidth - el.clientWidth } : null }
  339. return { tabs: overflow('[data-equipment-tabs]'), aside: overflow('[data-point-detail]'), body: overflow('.equipment-sections'), chips: overflow('[data-category-bar]') }
  340. })
  341. check('H1-narrow-no-overflow', [narrow.tabs, narrow.aside, narrow.body, narrow.chips].every((n) => n && n.over <= 1), JSON.stringify(narrow))
  342. await shot('08-narrow-480')
  343. await page.conn.send('Emulation.clearDeviceMetricsOverride', {}, page.sessionId).catch(() => {})
  344. // ---------------- Z 应用侧报错 ----------------
  345. const appErrors = pageErrors.filter((e) => !/chrome-extension|baidu|Permissions policy/i.test(e))
  346. const appConsole = consoleErrors.filter((e) => /error/i.test(e) && !/chrome-extension|baidu|Permissions policy|Video overlay|favicon/i.test(e))
  347. check('Z1-no-pageerror', appErrors.length === 0, appErrors.slice(0, 3).join(' | '))
  348. check('Z2-no-console-error', appConsole.length === 0, appConsole.slice(0, 3).join(' | '))
  349. await writeFile(path.join(OUT, 'walkthrough-result.json'), JSON.stringify({ results, shots, level1, legend, tabs, cde, g, narrow }, null, 2))
  350. const failed = results.filter((r) => !r.pass)
  351. console.log('')
  352. console.log(`T46 浏览器走查:${results.length - failed.length}/${results.length} 项通过。截图:${shots.join(', ')}`)
  353. if (failed.length) console.log('未通过:' + failed.map((f) => f.name).join(', '))
  354. await browser.close()
  355. process.exitCode = failed.length ? 1 : 0
  356. }
  357. main().catch((err) => {
  358. console.error('PROBE ERROR:', err)
  359. process.exit(1)
  360. })