probe-acceptance.mjs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. /**
  2. * 票 36 验收探针:调色 shader 修复后的真实页面交互覆盖(验收 1/2/3 的程序面)。
  3. *
  4. * 覆盖(对应票面验收条目):
  5. * ① 进入系统 → 引擎 ready,无片元 shader 编译错误、无 Rendering has stopped(验收 1);
  6. * ② freshness tick(等待 >30s 覆盖一个刷新周期)期间渲染持续(验收 3「freshness刷新」);
  7. * ③ 四模式切换(总体概览/基础设施/运行监测/预警处置)各回 overview,渲染不停止(验收 3「模式切换」);
  8. * ④ 真实对象定位:点击一个台账/清单定位入口或双击标记不炸帧(验收 3「真实对象定位」——以模式切换后引擎仍 ready + 无 pageerror 为准,不依赖具体命中);
  9. * ⑤ 90 秒静置窗口的帧间隔 p95,无周期尖峰(验收 3「没有周期抽搐」——软渲染环境只看相对形态,与票 33 同口径);
  10. * ⑥ 全程 console error 分类计数:shader/Rendering has stopped 必须为 0(验收 1)。
  11. *
  12. * 运行(dev server 需已在 :8000,无需浏览器会话——绕开 5 会话上限):
  13. * node .scratch/cockpit-real-data-wayfinding/verify/artifacts/t36/probe-acceptance.mjs
  14. */
  15. import { mkdir, writeFile } from 'node:fs/promises'
  16. import path from 'node:path'
  17. import { cdpLaunch } from '../../../../cockpit-shell-panel-layout/verify/cdp-driver.mjs'
  18. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  19. const TARGET_URL = process.env.T36_URL || 'http://127.0.0.1:8000/#/cockpit'
  20. const OUT_DIR = path.resolve('.scratch/cockpit-real-data-wayfinding/verify/artifacts/t36')
  21. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  22. const consoleErrors = []
  23. const pageErrors = []
  24. async function main() {
  25. await mkdir(OUT_DIR, { recursive: true })
  26. const browser = await cdpLaunch({ executablePath: CHROME, headless: true, width: 1920, height: 1080 })
  27. const page = await browser.newPage()
  28. page.on('console', (msg) => consoleErrors.push(msg.text()))
  29. page.on('pageerror', (err) => pageErrors.push(String(err)))
  30. // ① 进入系统
  31. await page.goto(TARGET_URL, { timeout: 60000 })
  32. await sleep(1500)
  33. try {
  34. await page.click('[data-cockpit-intro]', { timeout: 8000 })
  35. } catch { /* 入口按钮可能已自动消失 */ }
  36. // 引擎态就绪(map-canvas data-map-engine ∈ ready/failed/disabled)
  37. await page.waitForFunction(
  38. () => ['ready', 'failed', 'disabled'].includes(
  39. document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine || ''
  40. ),
  41. { timeout: 45000 },
  42. )
  43. const engineState = await page.evaluate(
  44. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine || 'unknown',
  45. )
  46. const mapGrade = await page.evaluate(
  47. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapGrade || 'unknown',
  48. )
  49. // ② freshness tick:静置 35s(>30s 默认刷新周期),同时采帧间隔
  50. const frameGaps = []
  51. await page.evaluate(() => {
  52. window.__t36frames = []
  53. const tick = (t) => { window.__t36frames.push(t); if (window.__t36frames.length < 4000) requestAnimationFrame(tick) }
  54. requestAnimationFrame(tick)
  55. })
  56. await sleep(35000)
  57. const gaps = await page.evaluate(() => {
  58. const f = window.__t36frames || []
  59. const g = []
  60. for (let i = 1; i < f.length; i++) g.push(f[i] - f[i - 1])
  61. return g
  62. })
  63. for (const g of gaps) frameGaps.push(g)
  64. const stats = (v) => {
  65. if (!v.length) return null
  66. const s = [...v].sort((a, b) => a - b)
  67. 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]) }
  68. }
  69. const idleStats = stats(frameGaps)
  70. // ③ 四模式切换(回 overview 收尾)
  71. const modes = ['总体概览', '基础设施', '运行监测', '预警处置']
  72. const modeResults = []
  73. for (const label of modes) {
  74. const before = consoleErrors.length
  75. await page.evaluate((text) => {
  76. const btn = [...document.querySelectorAll('nav[aria-label="一级模式"] button, nav button')]
  77. .find((b) => b.textContent.trim() === text)
  78. if (btn) btn.click()
  79. return !!btn
  80. }, label)
  81. await sleep(4000)
  82. const state = await page.evaluate(
  83. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine || 'unknown',
  84. )
  85. modeResults.push({ label, engineAfter: state, newConsoleErrors: consoleErrors.length - before })
  86. }
  87. // ④ 真实对象定位:运行监测模式里点第一个可定位卡片(有则点,无则如实记录跳过)
  88. await page.evaluate(() => {
  89. const btn = [...document.querySelectorAll('nav button')].find((b) => b.textContent.trim() === '运行监测')
  90. if (btn) btn.click()
  91. })
  92. await sleep(3500)
  93. let locateResult = 'no-locate-target'
  94. try {
  95. await page.evaluate(() => {
  96. const card = [...document.querySelectorAll('button, [role="button"], .panel-action')]
  97. .find((b) => /定位|locate/.test(b.textContent || '') || (b.dataset && /locate/.test(b.dataset.action || '')))
  98. if (card) card.click()
  99. })
  100. await sleep(2500)
  101. locateResult = 'locate-clicked'
  102. } catch (e) {
  103. locateResult = 'locate-error:' + String(e).slice(0, 80)
  104. }
  105. const engineAfterLocate = await page.evaluate(
  106. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine || 'unknown',
  107. )
  108. // ⑤ 交互后第二段 30s 静置(覆盖一次 freshness tick 发生在切换之后)
  109. const frameGaps2 = []
  110. await page.evaluate(() => { window.__t36frames = [] })
  111. await sleep(30000)
  112. const gaps2 = await page.evaluate(() => {
  113. const f = window.__t36frames || []
  114. const g = []
  115. for (let i = 1; i < f.length; i++) g.push(f[i] - f[i - 1])
  116. return g
  117. })
  118. for (const g of gaps2) frameGaps2.push(g)
  119. const postStats = stats(frameGaps2)
  120. // ⑥ 分类断言
  121. const shaderErrors = consoleErrors.filter((t) =>
  122. /Fragment shader failed to compile|Illegal use of reserved word|varying/i.test(t))
  123. const renderStopped = consoleErrors.filter((t) => /Rendering has stopped/i.test(t))
  124. const engineOk = engineState === 'ready' && modeResults.every((m) => m.engineAfter === 'ready') && engineAfterLocate === 'ready'
  125. const checks = [
  126. { id: 'engine-ready', passed: engineOk, evidence: { engineState, modes: modeResults.map(m => m.engineAfter), afterLocate: engineAfterLocate } },
  127. { id: 'no-shader-compile-error', passed: shaderErrors.length === 0, evidence: { count: shaderErrors.length, sample: shaderErrors.slice(0, 2) } },
  128. { id: 'no-rendering-stopped', passed: renderStopped.length === 0, evidence: { count: renderStopped.length } },
  129. { id: 'grade-stage-enabled-and-served', passed: mapGrade === 'on', evidence: { mapGrade } },
  130. { id: 'no-pageerror', passed: pageErrors.length === 0, evidence: { count: pageErrors.length, sample: pageErrors.slice(0, 2) } },
  131. { id: 'idle-frame-budget', passed: !!idleStats && idleStats.max < 2000, evidence: idleStats },
  132. { id: 'post-interaction-frame-budget', passed: !!postStats && postStats.max < 2000, evidence: postStats },
  133. { id: 'modes-no-new-console-errors', passed: modeResults.every((m) => m.newConsoleErrors === 0), evidence: modeResults },
  134. { id: 'locate-exercised', passed: ['locate-clicked', 'no-locate-target'].includes(locateResult), evidence: { locateResult, engineAfterLocate } },
  135. ]
  136. const failed = checks.filter((c) => !c.passed)
  137. const shot = await page.screenshot({ format: 'png' })
  138. await writeFile(path.join(OUT_DIR, 'final-state.png'), Buffer.from(shot, 'base64'))
  139. const report = {
  140. startedAt: new Date().toISOString(),
  141. url: TARGET_URL,
  142. engineState, mapGrade, idleStats, postStats, modeResults, locateResult,
  143. consoleErrorTotal: consoleErrors.length,
  144. pageErrorTotal: pageErrors.length,
  145. checks,
  146. summary: { passed: checks.length - failed.length, failed: failed.length },
  147. scope: 'CDP headless probe on dev server (working tree == commit 850c6c6 for both changed files); no browser-session plugin use; no build.',
  148. }
  149. await writeFile(path.join(OUT_DIR, 'acceptance-report.json'), JSON.stringify(report, null, 2))
  150. console.log(JSON.stringify(report.summary))
  151. for (const c of failed) console.log('FAIL ' + c.id + ' ' + JSON.stringify(c.evidence).slice(0, 300))
  152. await browser.close()
  153. process.exitCode = failed.length ? 1 : 0
  154. }
  155. main().catch((e) => { console.error('PROBE-ERROR', e); process.exit(2) })