/** * T01 浏览器交互走查(票 01 的唯一剩项)。 * * 为什么不是 browser_* 工具:本机 dsh-browser 不可用 —— dsh-builtin-browser 的 * Electron provider 需要一份可 spawn 的 Electron 二进制,而本机既没下载过 * (node_modules/electron 下无 dist/ 与 path.txt),也装不成(GitHub / npmjs / * npmmirror 全部不可达,只有内网 GitLab 通)。改用已安装的 Chrome + puppeteer-core * 直接走查同一份页面,验证目标与票 01 的四点完全一致。 * * 运行(需先起 npm run dev,服务在 http://localhost:8000/): * node .scratch/cockpit-shell-panel-layout/verify/t01-browser-walkthrough.mjs */ import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' // 产物写到系统临时目录:Chrome 的 profile 会 churn 出成千上万个文件,而项目根的 // `fes dev` 正在 watch 项目树 —— 往被 watch 的树里写文件会拖垮甚至打断 dev server // (实测把 .scratch 下的临时文件写进去会让 Vite watcher 抛 EBUSY 并整进程退出)。 const OUT = join(tmpdir(), 'cockpit-t01-walkthrough') 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' // ADR 0003 的词典表:mode -> 左右两栏的 panelId 与中文标题,顺序即左栏上/下、右栏上/下。 const ADR = { overview: { label: '总体概览', left: [ ['situationSummary', '态势总览'], ['infrastructureSummary', '基础设施'], ], right: [ ['monitoringSummary', '运行监测'], ['alertSettings', '预警设置'], ], }, infrastructure: { label: '基础设施', left: [ ['archive', '基础档案'], ['operation', '运行信息'], ], right: [ ['management', '管理信息'], ['emergency', '应急保障'], ], }, monitoring: { label: '运行监测', left: [ ['monitoringOverview', '监测概览'], ['monitoringDistribution', '监测分布'], ], right: [ ['operationOverview', '运行概况'], ['monitoringPointLedger', '监测点台账'], ], }, warningResponse: { label: '预警处置', left: [ ['currentEvents', '当前预警'], ['historyAnalysis', '历史分析'], ], right: [ ['responseEfficiency', '预警效率'], ['eventAnalysis', '预警分析'], ], }, } const MODE_ORDER = Object.keys(ADR) const results = [] function check(id, desc, pass, evidence) { results.push({ id, desc, pass, evidence }) console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`) } const puppeteer = (await import(PUPPETEER)).default mkdirSync(OUT, { recursive: true }) // 用受支持的 puppeteer.launch 路径。先前试过自己 spawn + puppeteer.connect(为了避开 // 带管道 stdio 的 spawn EPERM),但连到既有 Chrome 后 puppeteer 的 page/frame 就绪时机 // 完全不可控 —— mainFrame 始终不可用,goto 直接抛 "Requesting main frame too early!"。 // launch 默认用管道 stdio,本机沙箱会拒绝,所以本脚本要以更宽的文件权限运行。 const PROFILE_DIR = join(OUT, '.chrome-profile') mkdirSync(PROFILE_DIR, { recursive: true }) const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: PROFILE_DIR, 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()}`) try { const page = await browser.newPage() // 页面自身的报错也算走查证据:编译不过就不会有外壳。 const pageErrors = [] page.on('pageerror', (e) => pageErrors.push(String(e && e.message ? e.message : e))) page.on('console', (m) => { if (m.type() === 'error') pageErrors.push(`console.error: ${m.text()}`) }) // Fes 默认 hash 路由;票里写的也是 #/cockpit。失败则退回 history 路由形态。 let entry = ENTRY_URL await page.goto(entry, { waitUntil: 'networkidle2', timeout: 60000 }) let ok = await page .waitForSelector('.cockpit-shell', { timeout: 20000 }) .then(() => true) .catch(() => false) if (!ok) { entry = `${BASE}/cockpit` await page.goto(entry, { waitUntil: 'networkidle2', timeout: 60000 }) ok = await page .waitForSelector('.cockpit-shell', { timeout: 20000 }) .then(() => true) .catch(() => false) } check('A1-entry', '/cockpit 可打开并渲染外壳', ok, `entry=${entry}`) if (!ok) throw new Error('cockpit shell never rendered') // A2 顶栏、左右两栏、中央一张图同时出现,且各只有一份。 const structure = await page.evaluate(() => { const q = (s) => document.querySelector(s) const qa = (s) => Array.from(document.querySelectorAll(s)) return { brand: q('.cockpit-shell__brand')?.textContent?.trim() || '', topbar: !!q('.cockpit-shell__topbar'), shells: qa('.cockpit-shell').length, left: qa('.cockpit-shell__column[aria-label="左栏"]').length, right: qa('.cockpit-shell__column[aria-label="右栏"]').length, maps: qa('.cockpit-shell__map').length, mapTitle: q('.map-placeholder__title')?.textContent?.trim() || '', panels: qa('.cockpit-panel').length, } }) check( 'A2-structure', '顶栏、左栏、右栏、中央地图占位同时出现且各一份', structure.topbar && structure.shells === 1 && structure.left === 1 && structure.right === 1 && structure.maps === 1 && structure.mapTitle === '中央一张图占位', JSON.stringify(structure), ) // A3 四个一级模式按钮,文案按 ADR 0003 顺序。 const modeLabels = await page.$$eval('.cockpit-shell__mode', (els) => els.map((e) => e.textContent.trim()), ) check( 'A3-mode-buttons', '顶栏四个一级模式可切且文案正确', JSON.stringify(modeLabels) === JSON.stringify(MODE_ORDER.map((m) => ADR[m].label)), JSON.stringify(modeLabels), ) // A4 默认高亮 overview。 const initialActive = await page.evaluate(() => { const active = Array.from(document.querySelectorAll('.cockpit-shell__mode')).filter( (e) => e.getAttribute('aria-pressed') === 'true', ) return { count: active.length, text: active[0]?.textContent?.trim() || '', hasClass: active[0]?.classList.contains('is-active') || false, meta: document.querySelector('.cockpit-shell__meta')?.textContent?.trim() || '', href: location.href, historyLength: history.length, } }) check( 'A4-default-mode', '默认模式为 overview,高亮与 aria-pressed 同步', initialActive.count === 1 && initialActive.text === '总体概览' && initialActive.hasClass && initialActive.meta.includes('overview'), JSON.stringify(initialActive), ) // 打标记:模式切换后这两个节点必须仍是同一实例,否则就是重建了外壳/地图。 await page.evaluate(() => { document.querySelector('.cockpit-shell').__t01 = 'shell-instance' document.querySelector('.cockpit-shell__map').__t01 = 'map-instance' const m = document.querySelector('.map-placeholder') if (m) m.__t01 = 'map-inner' }) const snapshot = () => page.evaluate(() => { const cols = Array.from(document.querySelectorAll('.cockpit-shell__column')) const read = (col) => Array.from(col.querySelectorAll('.cockpit-panel')).map((p) => ({ title: p.querySelector('.cockpit-panel__title')?.textContent?.trim() || '', body: p.querySelector('.cockpit-shell__placeholder')?.textContent?.trim() || '', expanded: p.querySelector('.cockpit-panel__head')?.getAttribute('aria-expanded'), mark: p.querySelector('.cockpit-panel__mark')?.textContent?.trim() || '', bodyHidden: (() => { const b = p.querySelector('.cockpit-panel__body') return !b || b.style.display === 'none' })(), })) return { left: cols[0] ? read(cols[0]) : [], right: cols[1] ? read(cols[1]) : [], panels: document.querySelectorAll('.cockpit-panel').length, href: location.href, historyLength: history.length, active: Array.from(document.querySelectorAll('.cockpit-shell__mode')) .filter((e) => e.getAttribute('aria-pressed') === 'true') .map((e) => e.textContent.trim()), shellTag: document.querySelector('.cockpit-shell').__t01 || null, mapTag: document.querySelector('.cockpit-shell__map').__t01 || null, mapInnerTag: document.querySelector('.map-placeholder')?.__t01 || null, mapCount: document.querySelectorAll('.map-placeholder').length, } }) // A5 逐模式断言:4 块面板、左右各 2、panelId 与中文标题都按 ADR 0003。 for (let i = 0; i < MODE_ORDER.length; i++) { const mode = MODE_ORDER[i] const spec = ADR[mode] if (i > 0) { const buttons = await page.$$('.cockpit-shell__mode') await buttons[i].click() await new Promise((r) => setTimeout(r, 350)) } const s = await snapshot() const want = (arr) => arr.map(([id, label]) => ({ id, label })) const got = (arr) => arr.map((p) => ({ id: (p.body.split('·')[1] || '').trim(), label: p.title })) const leftOk = JSON.stringify(got(s.left)) === JSON.stringify(want(spec.left)) const rightOk = JSON.stringify(got(s.right)) === JSON.stringify(want(spec.right)) check( `A5-${mode}`, `${spec.label}:左右各 2 块,panelId 与中文标题按 ADR 0003`, s.panels === 4 && s.left.length === 2 && s.right.length === 2 && leftOk && rightOk && s.active.length === 1 && s.active[0] === spec.label, `left=${JSON.stringify(got(s.left))} right=${JSON.stringify(got(s.right))}`, ) await page.screenshot({ path: join(OUT, `mode-${i + 1}-${mode}.png`) }) } // A6/A7 切完四个模式后:URL 与 history 未变(不新增路由),外壳与地图仍是原实例。 const after = await snapshot() check( 'A6-no-route-change', '切换四个一级模式不新增路由(URL 与 history 长度不变)', after.href === initialActive.href && after.historyLength === initialActive.historyLength, `href=${after.href} history=${after.historyLength} (初始 ${initialActive.historyLength})`, ) check( 'A7-persistent-nodes', '外壳与中央地图占位未被重建(DOM 实例标记存活)', after.shellTag === 'shell-instance' && after.mapTag === 'map-instance' && after.mapInnerTag === 'map-inner' && after.mapCount === 1, `shell=${after.shellTag} map=${after.mapTag} inner=${after.mapInnerTag} mapCount=${after.mapCount}`, ) // A8 点标题栏收起/展开。 const beforeToggle = await page.evaluate(() => { const p = document.querySelector('.cockpit-panel') return { expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'), mark: p.querySelector('.cockpit-panel__mark').textContent.trim(), bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none', } }) await page.click('.cockpit-panel .cockpit-panel__head') await new Promise((r) => setTimeout(r, 250)) const collapsed = await page.evaluate(() => { const p = document.querySelector('.cockpit-panel') return { expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'), mark: p.querySelector('.cockpit-panel__mark').textContent.trim(), bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none', isCollapsedClass: p.classList.contains('is-collapsed'), } }) await page.screenshot({ path: join(OUT, 'mode-4-collapsed.png') }) await page.click('.cockpit-panel .cockpit-panel__head') await new Promise((r) => setTimeout(r, 250)) const reExpanded = await page.evaluate(() => { const p = document.querySelector('.cockpit-panel') return { expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'), mark: p.querySelector('.cockpit-panel__mark').textContent.trim(), bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none', } }) check( 'A8-toggle', '点面板标题栏可收起并再展开(aria-expanded / 符号 / 正文显隐同步)', beforeToggle.expanded === 'true' && beforeToggle.mark === '−' && collapsed.expanded === 'false' && collapsed.mark === '+' && collapsed.bodyHidden && collapsed.isCollapsedClass && reExpanded.expanded === 'true' && reExpanded.mark === '−' && !reExpanded.bodyHidden, `before=${JSON.stringify(beforeToggle)} collapsed=${JSON.stringify(collapsed)} re=${JSON.stringify(reExpanded)}`, ) // A9 收起一个面板后切走再切回,应恢复默认全展开。 await page.click('.cockpit-panel .cockpit-panel__head') // 收起 warningResponse 第一块 await new Promise((r) => setTimeout(r, 200)) const buttons = await page.$$('.cockpit-shell__mode') await buttons[0].click() // 去 overview await new Promise((r) => setTimeout(r, 250)) await buttons[3].click() // 回 warningResponse await new Promise((r) => setTimeout(r, 300)) const afterRoundTrip = await snapshot() const allExpanded = [...afterRoundTrip.left, ...afterRoundTrip.right].every( (p) => p.expanded === 'true' && p.mark === '−' && !p.bodyHidden, ) check( 'A9-reset-on-mode-switch', '切换一级模式后全部面板恢复默认展开', allExpanded && afterRoundTrip.panels === 4, JSON.stringify([...afterRoundTrip.left, ...afterRoundTrip.right].map((p) => p.expanded)), ) // A10 全局筛选:切一级模式不得重置范围/行业(验收第 ⑦ 条,真行为断言)。 const readFilters = () => page.evaluate(() => { const picked = (sel, key) => Array.from(document.querySelectorAll(sel)) .filter((b) => b.getAttribute('aria-pressed') === 'true') .map((b) => b.dataset[key]) return { scopeCount: document.querySelectorAll('.cockpit-shell__scope-btn').length, industryCount: document.querySelectorAll('.cockpit-shell__industry-btn').length, scope: picked('.cockpit-shell__scope-btn', 'scope'), industry: picked('.cockpit-shell__industry-btn', 'industry'), } }) const filterBefore = await readFilters() await page.click('.cockpit-shell__industry-btn[data-industry="gas"]') await page.click('.cockpit-shell__scope-btn[data-scope="south"]') await new Promise((r) => setTimeout(r, 200)) const filterPicked = await readFilters() // 切到基础设施再切回总体概览 const modeButtonsForFilter = await page.$$('.cockpit-shell__mode') await modeButtonsForFilter[1].click() await new Promise((r) => setTimeout(r, 250)) await modeButtonsForFilter[0].click() await new Promise((r) => setTimeout(r, 250)) const filterAfter = await readFilters() check( 'A10-filter-persists', '切换一级模式不重置全局筛选(行业=燃气、范围=城南 保持不变)', filterBefore.industryCount === 5 && filterBefore.scopeCount >= 2 && JSON.stringify(filterPicked.industry) === '["gas"]' && JSON.stringify(filterPicked.scope) === '["south"]' && JSON.stringify(filterAfter.industry) === '["gas"]' && JSON.stringify(filterAfter.scope) === '["south"]', `before=${JSON.stringify(filterBefore)} picked=${JSON.stringify(filterPicked)} after=${JSON.stringify(filterAfter)}`, ) // 复位为默认筛选,避免影响后续断言与截图观感 await page.click('.cockpit-shell__industry-btn[data-industry="all"]') await page.click('.cockpit-shell__scope-btn[data-scope="all"]') await new Promise((r) => setTimeout(r, 200)) // A12 铺满视口:页面不锁 16:9,外壳/主体铺满,中央地图吃掉剩余宽高。 // 这条是为「大气层被同权重规则改成普通网格项」那类布局塌陷加的回归断言 —— 只看结构不看尺寸会漏。 const geom = await page.evaluate(() => { const rect = (sel) => { const el = document.querySelector(sel) if (!el) return null const b = el.getBoundingClientRect() return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height), bottom: Math.round(b.bottom), } } return { vw: window.innerWidth, vh: window.innerHeight, scrollH: document.documentElement.scrollHeight, shell: rect('.cockpit-shell'), topbar: rect('.cockpit-shell__topbar'), scope: rect('.cockpit-shell__scope'), main: rect('.cockpit-shell__main'), map: rect('.cockpit-shell__map'), } }) const vh = geom.vh check( 'A12-layout-fill', '页面铺满视口:无纵向溢出、顶栏与筛选条矮、中央地图吃掉剩余宽高', !!geom.shell && !!geom.topbar && !!geom.scope && !!geom.main && !!geom.map && Math.abs(geom.shell.h - vh) <= 2 && geom.scrollH <= vh + 2 && geom.topbar.h <= Math.round(vh * 0.12) && geom.scope.h >= 28 && geom.scope.h <= 90 && geom.main.bottom >= vh - 24 && geom.main.bottom <= vh && geom.map.w >= Math.round(geom.vw * 0.4) && geom.map.h >= Math.round(geom.main.h * 0.8), JSON.stringify(geom), ) // A11 运行期无错误。 check('A11-no-page-errors', '走查期间无页面级报错', pageErrors.length === 0, JSON.stringify(pageErrors.slice(0, 5))) await page.screenshot({ path: join(OUT, 'final-warningResponse.png'), fullPage: false }) writeFileSync(join(OUT, 'results.json'), JSON.stringify(results, null, 2), 'utf8') } finally { await browser.close() } const failed = results.filter((r) => !r.pass) console.log('') console.log(`T01 浏览器走查:${results.length - failed.length}/${results.length} 项通过。截图与结果:${OUT}`) if (failed.length) { console.log('未通过:' + failed.map((f) => f.id).join(', ')) process.exit(1) } console.log('T01 浏览器走查全部通过。')