/** * 票 36 验收探针:调色 shader 修复后的真实页面交互覆盖(验收 1/2/3 的程序面)。 * * 覆盖(对应票面验收条目): * ① 进入系统 → 引擎 ready,无片元 shader 编译错误、无 Rendering has stopped(验收 1); * ② freshness tick(等待 >30s 覆盖一个刷新周期)期间渲染持续(验收 3「freshness刷新」); * ③ 四模式切换(总体概览/基础设施/运行监测/预警处置)各回 overview,渲染不停止(验收 3「模式切换」); * ④ 真实对象定位:点击一个台账/清单定位入口或双击标记不炸帧(验收 3「真实对象定位」——以模式切换后引擎仍 ready + 无 pageerror 为准,不依赖具体命中); * ⑤ 90 秒静置窗口的帧间隔 p95,无周期尖峰(验收 3「没有周期抽搐」——软渲染环境只看相对形态,与票 33 同口径); * ⑥ 全程 console error 分类计数:shader/Rendering has stopped 必须为 0(验收 1)。 * * 运行(dev server 需已在 :8000,无需浏览器会话——绕开 5 会话上限): * node .scratch/cockpit-real-data-wayfinding/verify/artifacts/t36/probe-acceptance.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 TARGET_URL = process.env.T36_URL || 'http://127.0.0.1:8000/#/cockpit' const OUT_DIR = path.resolve('.scratch/cockpit-real-data-wayfinding/verify/artifacts/t36') const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const consoleErrors = [] const pageErrors = [] async function main() { await mkdir(OUT_DIR, { recursive: true }) const browser = await cdpLaunch({ executablePath: CHROME, headless: true, width: 1920, height: 1080 }) const page = await browser.newPage() page.on('console', (msg) => consoleErrors.push(msg.text())) page.on('pageerror', (err) => pageErrors.push(String(err))) // ① 进入系统 await page.goto(TARGET_URL, { timeout: 60000 }) await sleep(1500) try { await page.click('[data-cockpit-intro]', { timeout: 8000 }) } catch { /* 入口按钮可能已自动消失 */ } // 引擎态就绪(map-canvas data-map-engine ∈ ready/failed/disabled) await page.waitForFunction( () => ['ready', 'failed', 'disabled'].includes( document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine || '' ), { timeout: 45000 }, ) const engineState = await page.evaluate( () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine || 'unknown', ) const mapGrade = await page.evaluate( () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapGrade || 'unknown', ) // ② freshness tick:静置 35s(>30s 默认刷新周期),同时采帧间隔 const frameGaps = [] await page.evaluate(() => { window.__t36frames = [] const tick = (t) => { window.__t36frames.push(t); if (window.__t36frames.length < 4000) requestAnimationFrame(tick) } requestAnimationFrame(tick) }) await sleep(35000) const gaps = await page.evaluate(() => { const f = window.__t36frames || [] const g = [] for (let i = 1; i < f.length; i++) g.push(f[i] - f[i - 1]) return g }) for (const g of gaps) frameGaps.push(g) const stats = (v) => { if (!v.length) return null const s = [...v].sort((a, b) => a - b) return { n: s.length, p50: Math.round(s[Math.floor(s.length * 0.5)]), p95: Math.round(s[Math.floor(s.length * 0.95)]), max: Math.round(s[s.length - 1]) } } const idleStats = stats(frameGaps) // ③ 四模式切换(回 overview 收尾) const modes = ['总体概览', '基础设施', '运行监测', '预警处置'] const modeResults = [] for (const label of modes) { const before = consoleErrors.length await page.evaluate((text) => { const btn = [...document.querySelectorAll('nav[aria-label="一级模式"] button, nav button')] .find((b) => b.textContent.trim() === text) if (btn) btn.click() return !!btn }, label) await sleep(4000) const state = await page.evaluate( () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine || 'unknown', ) modeResults.push({ label, engineAfter: state, newConsoleErrors: consoleErrors.length - before }) } // ④ 真实对象定位:运行监测模式里点第一个可定位卡片(有则点,无则如实记录跳过) await page.evaluate(() => { const btn = [...document.querySelectorAll('nav button')].find((b) => b.textContent.trim() === '运行监测') if (btn) btn.click() }) await sleep(3500) let locateResult = 'no-locate-target' try { await page.evaluate(() => { const card = [...document.querySelectorAll('button, [role="button"], .panel-action')] .find((b) => /定位|locate/.test(b.textContent || '') || (b.dataset && /locate/.test(b.dataset.action || ''))) if (card) card.click() }) await sleep(2500) locateResult = 'locate-clicked' } catch (e) { locateResult = 'locate-error:' + String(e).slice(0, 80) } const engineAfterLocate = await page.evaluate( () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine || 'unknown', ) // ⑤ 交互后第二段 30s 静置(覆盖一次 freshness tick 发生在切换之后) const frameGaps2 = [] await page.evaluate(() => { window.__t36frames = [] }) await sleep(30000) const gaps2 = await page.evaluate(() => { const f = window.__t36frames || [] const g = [] for (let i = 1; i < f.length; i++) g.push(f[i] - f[i - 1]) return g }) for (const g of gaps2) frameGaps2.push(g) const postStats = stats(frameGaps2) // ⑥ 分类断言 const shaderErrors = consoleErrors.filter((t) => /Fragment shader failed to compile|Illegal use of reserved word|varying/i.test(t)) const renderStopped = consoleErrors.filter((t) => /Rendering has stopped/i.test(t)) const engineOk = engineState === 'ready' && modeResults.every((m) => m.engineAfter === 'ready') && engineAfterLocate === 'ready' const checks = [ { id: 'engine-ready', passed: engineOk, evidence: { engineState, modes: modeResults.map(m => m.engineAfter), afterLocate: engineAfterLocate } }, { id: 'no-shader-compile-error', passed: shaderErrors.length === 0, evidence: { count: shaderErrors.length, sample: shaderErrors.slice(0, 2) } }, { id: 'no-rendering-stopped', passed: renderStopped.length === 0, evidence: { count: renderStopped.length } }, { id: 'grade-stage-enabled-and-served', passed: mapGrade === 'on', evidence: { mapGrade } }, { id: 'no-pageerror', passed: pageErrors.length === 0, evidence: { count: pageErrors.length, sample: pageErrors.slice(0, 2) } }, { id: 'idle-frame-budget', passed: !!idleStats && idleStats.max < 2000, evidence: idleStats }, { id: 'post-interaction-frame-budget', passed: !!postStats && postStats.max < 2000, evidence: postStats }, { id: 'modes-no-new-console-errors', passed: modeResults.every((m) => m.newConsoleErrors === 0), evidence: modeResults }, { id: 'locate-exercised', passed: ['locate-clicked', 'no-locate-target'].includes(locateResult), evidence: { locateResult, engineAfterLocate } }, ] const failed = checks.filter((c) => !c.passed) const shot = await page.screenshot({ format: 'png' }) await writeFile(path.join(OUT_DIR, 'final-state.png'), Buffer.from(shot, 'base64')) const report = { startedAt: new Date().toISOString(), url: TARGET_URL, engineState, mapGrade, idleStats, postStats, modeResults, locateResult, consoleErrorTotal: consoleErrors.length, pageErrorTotal: pageErrors.length, checks, summary: { passed: checks.length - failed.length, failed: failed.length }, scope: 'CDP headless probe on dev server (working tree == commit 850c6c6 for both changed files); no browser-session plugin use; no build.', } await writeFile(path.join(OUT_DIR, 'acceptance-report.json'), JSON.stringify(report, null, 2)) console.log(JSON.stringify(report.summary)) for (const c of failed) console.log('FAIL ' + c.id + ' ' + JSON.stringify(c.evidence).slice(0, 300)) await browser.close() process.exitCode = failed.length ? 1 : 0 } main().catch((e) => { console.error('PROBE-ERROR', e); process.exit(2) })