/** * T05 真机走查:领域适配层在浏览器里确实成立(票 05)。 * * 逻辑脚本(t05-adapter.mjs)已经把转换语义、mock 五类样例与分区隔离测透了; * 这里只补**只有在真机才成立**的部分 —— 也就是票 05 验收第 6 条的渲染侧原句: * 「401 或凭据类失败只把受影响分区标为数据不可用,保留已成功分区与地图骨架」。 * 纯逻辑断言证明不了「地图骨架还在」:那要求页面真的渲染出来、且 Cesium 宿主没被拆掉。 * * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务): * node .scratch/cockpit-shell-panel-layout/verify/t05-adapter-walkthrough.mjs */ import { join } from 'node:path' import { tmpdir } from 'node:os' 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' const results = [] function check(id, desc, pass, evidence) { results.push({ id, desc, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`) } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const puppeteer = (await import(PUPPETEER)).default const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: join(tmpdir(), 'cockpit-t05-profile'), 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()}`) /** * 开一页并等分区状态板就绪。 * `scenario` / `onlyPath` 通过宿主全局键注入(mocks/index.js 读它们), * 因此走查不必改代码或重新构建就能演示五类样例。 */ async function openPage(inject) { const page = await browser.newPage() const errors = [] page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e))) page.on('console', (m) => { if (m.type() === 'error') errors.push(`console.error: ${m.text()}`) }) await page.evaluateOnNewDocument(() => { // 引导层会盖住页面,走查不需要它(票 30 的开关)。 window.__COCKPIT_CONFIG__ = { dataSource: 'mock', map: { intro: { enabled: false } } } }) if (inject) await page.evaluateOnNewDocument(inject) await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 }) await page.waitForSelector('.cockpit-shell', { timeout: 20000 }) await page.waitForSelector('[data-section-board]', { timeout: 20000 }).catch(() => {}) // 等分区板从「未加载」变成有内容(请求 + 转换 + 渲染)。 await page .waitForFunction(() => document.querySelectorAll('[data-section-board] [data-section-state]').length >= 3, { timeout: 20000 }) .catch(() => {}) // 引擎要拉静态资源/地形,比分区取数慢:等它落到终态再断言,否则会把 booting 误判成失败。 // (t04/t29 的走查同样先等 dataset.mapEngine 变成终态。) await page .waitForFunction( () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine), { timeout: 30000 }, ) .catch(() => {}) await sleep(400) return { page, errors } } const readBoard = (page) => page.evaluate(() => { const tags = Array.from(document.querySelectorAll('[data-section-board] [data-section-state]')) const host = document.querySelector('.cockpit-shell__map .map-canvas') return { sections: tags.map((t) => ({ name: t.dataset.sectionName, state: t.dataset.sectionState, tone: t.dataset.sectionTone, retained: t.dataset.sectionRetained, text: t.textContent.trim(), })), engine: (host && host.dataset.mapEngine) || '', mapHostCount: document.querySelectorAll('.cockpit-shell__map .map-canvas').length, shellPresent: !!document.querySelector('.cockpit-shell'), panels: document.querySelectorAll('.cockpit-panel').length, bodyText: document.body.innerText, href: location.href, } }) try { // ---------- A1 默认(成功样例):三条共享分区都就绪 ---------- { const { page, errors } = await openPage() const board = await readBoard(page) check( 'A1-success-sections', '默认 mock:地图摘要/设备详情/数据新鲜度三条共享分区都取到数据并标为已就绪', board.sections.length === 3 && board.sections.every((s) => s.state === 'success' && s.tone === 'lv4'), JSON.stringify(board.sections), ) check( 'A1-map-skeleton-alive', '地图骨架常驻:Cesium 引擎就绪、宿主只有一个(分区取数不影响地图)', board.engine === 'ready' && board.mapHostCount === 1 && board.panels === 4, `engine=${board.engine} hosts=${board.mapHostCount} panels=${board.panels}`, ) check('A1-no-page-errors', '默认态无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3))) await page.close() } // ---------- A2 空数据:分区显示「无数据」,不是「失败」 ---------- { const { page } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'empty' }) const board = await readBoard(page) check( 'A2-empty-sections', '空数据样例:设备详情与新鲜度显示「无数据」(中性色),不误报成失败', board.sections.length === 3 && board.sections.filter((s) => s.state === 'empty').length >= 2 && board.sections.every((s) => s.state !== 'failed' && s.state !== 'unavailable'), JSON.stringify(board.sections), ) await page.close() } // ---------- A3 过期:保留旧值但带过期标识 ---------- { const { page } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'stale' }) const board = await readBoard(page) const fresh = board.sections.find((s) => s.name === 'equipmentFreshness') check( 'A3-stale-flagged', '过期样例:新鲜度分区显示「数据已过期」并带 retained 标识(不得伪装成实时)', !!fresh && fresh.state === 'stale' && fresh.tone === 'lv2' && fresh.retained === 'true' && fresh.text.includes('过期'), JSON.stringify(fresh), ) await page.close() } // ---------- A4 失败:分区失败但地图与其它分区照常 ---------- { const { page } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'failed' }) const board = await readBoard(page) check( 'A4-failed-keeps-skeleton', '失败样例:分区标为加载失败,外壳、四个面板与地图骨架全部保留', board.sections.every((s) => s.state === 'failed') && board.shellPresent && board.panels === 4 && board.mapHostCount === 1 && board.engine === 'ready', JSON.stringify({ sections: board.sections, engine: board.engine, panels: board.panels }), ) await page.close() } // ---------- A5 票 05 验收第 6 条:401 只标受影响分区 ---------- { const { page, errors } = await openPage(() => { window.__COCKPIT_MOCK_SCENARIO__ = 'unauthorized' // 只让「设备详情聚合」这一个分区凭据失败。必须细到该端点: // 若用 `/equipments/` 前缀,新鲜度分区会被一起打掉,就证不了「只标受影响分区」。 window.__COCKPIT_MOCK_ONLY__ = '/equipments/*/detail-aggregate' }) const board = await readBoard(page) const affected = board.sections.find((s) => s.name === 'equipmentAggregate') const healthy = board.sections.filter((s) => s.name !== 'equipmentAggregate') check( 'A5-401-marks-affected-only', '401/凭据类失败:只有受影响分区标为「数据不可用」(红色),其余分区保持已就绪', !!affected && affected.state === 'unavailable' && affected.tone === 'lv1' && affected.text.includes('数据不可用') && healthy.length === 2 && healthy.every((s) => s.state === 'success'), JSON.stringify({ affected, healthy }), ) check( 'A5-map-skeleton-preserved', '同一次 401:地图骨架与外壳未被牵连(引擎就绪、宿主与面板都在)', board.engine === 'ready' && board.mapHostCount === 1 && board.panels === 4 && board.shellPresent, `engine=${board.engine} hosts=${board.mapHostCount} panels=${board.panels}`, ) check( 'A5-no-login-redirect', '凭据类失败不跳登录页:URL 不变、页面里没有登录入口(ADR 0005)', board.href === ENTRY_URL && !/登录|login/i.test(board.bodyText), JSON.stringify({ href: board.href }), ) check('A5-no-page-errors', '401 场景无页面级报错(失败被收敛成分区状态,没有抛出)', errors.length === 0, JSON.stringify(errors.slice(0, 3))) await page.close() } } finally { await browser.close() } const failed = results.filter((r) => !r.pass) console.log('') console.log(`T05 走查:${results.length - failed.length}/${results.length} 项通过。`) if (failed.length) { console.log('未通过:' + failed.map((f) => f.id).join(', ')) process.exit(1) } console.log('T05 走查全部通过。')