/** * 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 的四点完全一致。 * * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/): * node .scratch/cockpit-shell-panel-layout/verify/t01-browser-walkthrough.mjs * * ⚠ 用 `yarn dev`(= `fes dev` → .fes.js:layout.navigation = null,BaseLayout 走 v-else, * 只渲染 ,页面没有页头)。`yarn pre.dev` / `pre.dev.one` 是 TvT 预演站布局 * (.fes.predev*.js:navigation='top' + isFixedHeader + 演示菜单,顶部压出 54px 固定页头)。 * 维护者已明确:**真实应用环境不使用 predev 启动服务** —— 那里出来的红(A12)是环境差异, * 不是驾驶舱回归;A12 证据里的 pageHeaderH 就是用来分辨这一点的(见票 28)。 * **复跑前先重启 dev server**:长跑实例可能仍给浏览器发旧模块,曾把 A15(Cesium 默认控件) * 误判为红一次;重启后同一份代码 20/20。 */ 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') // 票 27:引擎是在地图服务里动态 import 的,先等它就绪;等不到就交给 A15 报失败, // 不在这里抛错中断整轮走查(引擎起不来也该看到其余断言的真实结果)。 const engineReady = await page .waitForFunction( () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready', { timeout: 60000 }, ) .then(() => true) .catch(() => false) console.log(`Cesium 引擎就绪:${engineReady}`) // 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-canvas__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), ) // 打标记:模式切换与面板收放之后,外壳与地图宿主必须仍是同一实例(票 02 的核心契约)。 const mapInstanceAtStart = await page.evaluate(() => { document.querySelector('.cockpit-shell').__t01 = 'shell-instance' document.querySelector('.cockpit-shell__map').__t01 = 'map-instance' const host = document.querySelector('.cockpit-shell__map .map-canvas') if (host) host.__t01Map = 'map-host' return host ? host.dataset.mapInstance || '' : '' }) 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() || '', // 票 06 把面板体占位抽成 PanelPlaceholder 组件,选择器随之改名(断言语义不变)。 body: p.querySelector('.cockpit-placeholder__text')?.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, mapHostTag: document.querySelector('.cockpit-shell__map .map-canvas')?.__t01Map || null, mapInstanceId: document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapInstance || '', mapCount: document.querySelectorAll('.cockpit-shell__map .map-canvas').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 实例标记与实例 id 均存活)', after.shellTag === 'shell-instance' && after.mapTag === 'map-instance' && after.mapHostTag === 'map-host' && after.mapCount === 1 && after.mapInstanceId !== '' && after.mapInstanceId === mapInstanceAtStart, `shell=${after.shellTag} map=${after.mapTag} host=${after.mapHostTag} mapCount=${after.mapCount} instance=${after.mapInstanceId}(初始 ${mapInstanceAtStart})`, ) // 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, // Fes 布局页头高度:null 布局(npm run dev / 产品构建)应为 0; // predev 演示布局是 54px 固定页头,会直接把外壳往下顶 54px。 pageHeaderH: (() => { const h = document.querySelector('header.fes-layout-header') return h ? Math.round(h.getBoundingClientRect().height) : 0 })(), 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 + geom.scope.h <= Math.round(vh * 0.16) && geom.scope.h >= 24 && geom.main.bottom >= vh - 24 && geom.main.bottom <= vh && geom.map.w >= Math.round(geom.vw * 0.85) && geom.map.h >= Math.round(geom.main.h * 0.95), JSON.stringify(geom), ) // A13 面板收放必须真正改变高度:收起的那块缩回标题栏,另一块吃掉腾出的空间。 // 这条是针对「面板高度被写死、收放无视觉变化」的回归断言。 const readLeftHeights = () => page.evaluate(() => ({ column: Math.round( document.querySelector('.cockpit-shell__column.is-left').getBoundingClientRect().height, ), panels: Array.from( document.querySelectorAll('.cockpit-shell__column.is-left .cockpit-panel'), ).map((p) => Math.round(p.getBoundingClientRect().height)), })) const heightBefore = await readLeftHeights() await page.click('.cockpit-shell__column.is-left .cockpit-panel .cockpit-panel__head') await new Promise((r) => setTimeout(r, 300)) const heightCollapsed = await readLeftHeights() await page.click('.cockpit-shell__column.is-left .cockpit-panel .cockpit-panel__head') await new Promise((r) => setTimeout(r, 300)) const heightRestored = await readLeftHeights() const grewEnough = heightCollapsed.panels[1] >= Math.round(heightBefore.panels[1] * 1.5) const firstShrankToHeader = heightCollapsed.panels[0] <= 70 const fillsColumn = heightCollapsed.panels[0] + heightCollapsed.panels[1] >= heightCollapsed.column - 24 const restored = Math.abs(heightRestored.panels[0] - heightBefore.panels[0]) <= 6 && Math.abs(heightRestored.panels[1] - heightBefore.panels[1]) <= 6 check( 'A13-panel-collapse-resize', '收起一块面板后它缩回标题栏、另一块吃掉腾出的高度;再展开恢复等分', heightBefore.panels.length === 2 && firstShrankToHeader && grewEnough && fillsColumn && restored, `before=${JSON.stringify(heightBefore)} collapsed=${JSON.stringify(heightCollapsed)} restored=${JSON.stringify(heightRestored)}`, ) // A14 票 02 契约:走完「四模式切换 + 面板收放」之后,地图宿主仍是同一元素、实例 id 不变、全页只有一个。 // A7 覆盖模式切换、A13 覆盖面板收放,本条把两者合起来做最终兜底 —— 这是「不销毁、不重建」的可观测证据。 const mapAfterAll = await page.evaluate(() => { const hosts = document.querySelectorAll('.cockpit-shell__map .map-canvas') const host = hosts[0] return { count: hosts.length, tag: host?.__t01Map || null, instanceId: host?.dataset.mapInstance || '', photoLayers: host ? host.querySelectorAll('.map-canvas__photo').length : 0, } }) check( 'A14-map-instance-persistent', '四模式切换 + 面板收放之后:地图宿主同一实例、实例 id 不变、全页只有一个地图', mapAfterAll.count === 1 && mapAfterAll.tag === 'map-host' && mapAfterAll.instanceId !== '' && mapAfterAll.instanceId === mapInstanceAtStart && mapAfterAll.photoLayers === 1, `after=${JSON.stringify(mapAfterAll)} 初始 instance=${mapInstanceAtStart}`, ) // A15 票 27:宿主内恰好一个 Cesium Viewer / 画布,引擎与底图状态如实, // 且走完「四模式切换 + 面板收放」后实例 id 仍是初始值。 const engine = await page.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') return { baseUrl: window.CESIUM_BASE_URL || '', viewers: host ? host.querySelectorAll('.cesium-viewer').length : 0, canvases: host ? host.querySelectorAll('.cesium-widget canvas').length : 0, state: (host && host.dataset.mapEngine) || '', basemap: (host && host.dataset.mapBasemap) || '', status: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '', instanceId: (host && host.dataset.mapInstance) || '', // 自带控件必须关掉:工具栏容器 Cesium 总会建,但里面不该有任何控件; // 时间轴 / 动画容器则只在开启时才建。 widgetChrome: host ? host.querySelectorAll( '.cesium-viewer-toolbar > *, .cesium-viewer-animationContainer, .cesium-viewer-timelineContainer', ).length : -1, } }) check( 'A15-cesium-viewer', '宿主内恰好 1 个 Cesium Viewer/画布,引擎状态 ready 且如实标注「底图未配置」,实例 id 不变', engine.viewers === 1 && engine.canvases === 1 && engine.state === 'ready' && engine.basemap === 'not-configured' && engine.status.includes('底图未配置') && engine.instanceId !== '' && engine.instanceId === mapInstanceAtStart && engine.widgetChrome === 0 && /\/cesium\/$/.test(engine.baseUrl), JSON.stringify(engine), ) // A16 票 27:Cesium 运行时依赖的静态资源确实可访问(Worker / Assets / Widgets 各取一个代表)。 const cesiumAssets = await page.evaluate(async () => { const base = window.CESIUM_BASE_URL || '' const paths = [ 'Workers/transferTypedArrayTest.js', 'Assets/approximateTerrainHeights.json', 'Widgets/widgets.css', ] const out = [] for (const item of paths) { try { const res = await fetch(base + item) out.push({ path: item, status: res.status, type: res.headers.get('content-type') || '' }) } catch (error) { out.push({ path: item, status: 0, type: String(error) }) } } return out }) check( 'A16-cesium-static-200', 'Cesium 静态资源可访问(Workers / Assets / Widgets 全部 HTTP 200)', cesiumAssets.length === 3 && cesiumAssets.every((a) => a.status === 200), JSON.stringify(cesiumAssets), ) // A17 票 27:引擎初始化失败时页面不白屏。用 CDP 拦掉 cesium 依赖包,强制走失败分支, // 断言「失败状态 + 失败文案 + 仍保留可读兜底(临时影像)」三者同时成立。 const failedPage = await browser.newPage() try { const cdp = await failedPage.createCDPSession() await cdp.send('Network.enable') // dev 下 cesium 由 Vite 预打包成 deps 分块;build 下是 cesium 单独分包。 await cdp.send('Network.setBlockedURLs', { urls: ['*deps/cesium*', '*js/cesium.*'] }) await failedPage.goto(entry, { waitUntil: 'networkidle2', timeout: 60000 }) await failedPage.waitForSelector('.cockpit-shell', { timeout: 20000 }) const fellBack = await failedPage .waitForFunction( () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'failed', { timeout: 30000 }, ) .then(() => true) .catch(() => false) const fallback = await failedPage.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') return { shell: !!document.querySelector('.cockpit-shell'), host: !!host, canvases: host ? host.querySelectorAll('canvas').length : -1, photoLayers: host ? host.querySelectorAll('.map-canvas__photo').length : -1, state: (host && host.dataset.mapEngine) || '', status: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '', } }) check( 'A17-engine-failure-fallback', '引擎初始化失败时不白屏:状态如实为 failed、给出失败文案、中央区域保留可读兜底', fellBack && fallback.shell && fallback.host && fallback.state === 'failed' && fallback.status.includes('初始化失败') && fallback.photoLayers === 1 && fallback.canvases === 0, JSON.stringify(fallback), ) } finally { await failedPage.close() } // 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 浏览器走查全部通过。')