t20-mutations.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. /**
  2. * T20 变异测试:把每条守卫**故意改坏**,确认闸门真的因断言失败而红。
  3. *
  4. * 为什么需要它(票 11/17/18/19 的成例):一条守卫全绿只说明"当前代码没触发它",
  5. * 不说明"它抓得住违规"。票 18 有两次守卫比它要守的东西宽(H12 对全文匹配、
  6. * H13 被退化分支满足),都是变异测试逮到的。
  7. *
  8. * 判红口径(票 18 的教训):**必须跑到断言汇总行且因断言失败而红**。
  9. * 脚本自己改坏语法导致的崩溃不算有效的红 —— 那种红对自查毫无价值。
  10. *
  11. * 用法:node .scratch/cockpit-shell-panel-layout/verify/t20-mutations.mjs
  12. * 它会在临时副本上改文件、跑 t20、还原,不改动工作区的最终状态。
  13. */
  14. import { readFileSync, writeFileSync, copyFileSync, unlinkSync, openSync, closeSync } from 'node:fs'
  15. import { execFileSync } from 'node:child_process'
  16. import { fileURLToPath } from 'node:url'
  17. import { tmpdir } from 'node:os'
  18. import { join } from 'node:path'
  19. const verifyDir = new URL('.', import.meta.url)
  20. const srcRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  21. const file = (relative) => fileURLToPath(new URL(relative, srcRoot))
  22. const gate = fileURLToPath(new URL('t20-overview-panels.mjs', verifyDir))
  23. /**
  24. * 跑一次闸门,返回 `{ red, output, reachedSummary }`。
  25. *
  26. * **为什么把子进程输出写进临时文件而不是用管道**:本机沙箱(Windows confined mode)
  27. * 不允许程序开命名管道,`execFileSync` 默认的 `stdio: 'pipe'` 会直接 EPERM ——
  28. * 那是沙箱边界,不是被测代码的失败。把 stdout/stderr 换成**文件描述符**即可绕开,
  29. * 语义与"捕获输出"完全一致(这是沙箱文档建议的"改结构,别重试"那条路)。
  30. */
  31. function runGate() {
  32. const outPath = join(tmpdir(), `t20-mut-${process.pid}-${Date.now()}.out`)
  33. const errPath = `${outPath}.err`
  34. let outFd = null
  35. let errFd = null
  36. let code = 0
  37. try {
  38. outFd = openSync(outPath, 'w')
  39. errFd = openSync(errPath, 'w')
  40. execFileSync(process.execPath, [gate], { stdio: ['ignore', outFd, errFd] })
  41. } catch (error) {
  42. code = typeof error.status === 'number' ? error.status : 1
  43. } finally {
  44. if (outFd !== null) closeSync(outFd)
  45. if (errFd !== null) closeSync(errFd)
  46. }
  47. let output = ''
  48. try {
  49. output = readFileSync(outPath, 'utf8')
  50. } catch (error) {
  51. output = ''
  52. }
  53. try {
  54. output += readFileSync(errPath, 'utf8')
  55. } catch (error) {
  56. /* 没有 stderr 文件也算正常 */
  57. }
  58. for (const path of [outPath, errPath]) {
  59. try {
  60. unlinkSync(path)
  61. } catch (error) {
  62. /* 清不掉就留着,不影响判定 */
  63. }
  64. }
  65. // 必须跑到断言汇总行才算"有效的红"(脚本自己崩了不算 —— 票 18 的教训)。
  66. const reachedSummary = /T20 overview-panels: \d+\/\d+/.test(output)
  67. return { red: code !== 0 || !reachedSummary, output, reachedSummary }
  68. }
  69. /**
  70. * 每条变异:在哪个文件里把 `from` 换成 `to`,期望哪条断言打红。
  71. * `expect` 是期望**至少一条**失败断言的名字前缀(不必精确,看得见即可)。
  72. */
  73. const MUTATIONS = [
  74. {
  75. id: 'M1',
  76. why: '适配层凭空造一个后端没有的「故障」桶',
  77. file: 'domain/overviewPanels.js',
  78. // 打在**适配层**上(不是视图层):B2b 守的正是"领域对象里不许冒出 fault 桶"。
  79. from: " unknownOrMissing: numberish(status.raw.unknownOrMissing),\n denominator: numberish(status.raw.denominator),",
  80. to: " unknownOrMissing: numberish(status.raw.unknownOrMissing),\n fault: numberish(status.raw.offline),\n denominator: numberish(status.raw.denominator),",
  81. expect: 'B2b-no-fault-bucket-is-invented',
  82. },
  83. {
  84. id: 'M2',
  85. why: '空缺口的监测面板不再声明「故障」缺口(静默略过验收第 3 条点名项)',
  86. file: 'domain/overviewPanels.js',
  87. from: ' gap: MONITORING_FAULT_GAP,',
  88. to: " gap: '',",
  89. expect: 'C3c-fault-gap-is-declared',
  90. },
  91. {
  92. id: 'M3',
  93. why: '分母为 0 的在线率照 value 直画(虚假 0.0%,票 18 的 M3/M14 同一族)',
  94. file: 'domain/overviewPanels.js',
  95. from: " const available = row.onlineState !== 'EMPTY' && typeof row.onlineDenominator === 'number' && row.onlineDenominator > 0\n return available ? ratioText(row.onlineRate) : DETAIL_PLACEHOLDER",
  96. to: ' return ratioText(row.onlineRate)',
  97. expect: 'C1d-zero-denominator-is-not-zero-percent',
  98. },
  99. {
  100. id: 'M4',
  101. why: '工单状态分布退回旧的 1–7 七态(字典错位一格)',
  102. file: 'domain/overviewPanels.js',
  103. from: ' workOrderStates: WORK_ORDER_STATES.map((state) => {',
  104. to: ' workOrderStates: WORK_ORDER_STATES.slice(1).map((state) => {',
  105. expect: 'B3c-work-order-states-cover-all-eight-codes',
  106. },
  107. {
  108. id: 'M5',
  109. why: '态势总览改从别处拿一份**拷贝**而不是同一引用(两处数字可能对不上)',
  110. file: 'stores/overviewStore.js',
  111. from: ' warningSettings: warnings.data || null,',
  112. to: ' warningSettings: warnings.data ? { ...warnings.data } : null,',
  113. expect: 'D2b-situation-and-alert-share-the-same-payload',
  114. },
  115. {
  116. id: 'M6',
  117. why: '区域分布失败时整块基础设施面板一起判失败(一段坏遮住其它读数)',
  118. file: 'stores/overviewStore.js',
  119. from: " regions: regions && (regions.state === SECTION_STATE.SUCCESS || regions.state === SECTION_STATE.STALE) ? regions.data : null,\n regionsState: regions ? regions.state : SECTION_STATE.EMPTY,",
  120. to: ' regions: null,\n regionsState: SECTION_STATE.FAILED,',
  121. expect: 'D3-regions-are-merged-into-infrastructure',
  122. },
  123. {
  124. id: 'M7',
  125. why: '指标联动改成"什么都不做"(验收第 5 条名存实亡)',
  126. file: 'stores/overviewStore.js',
  127. from: ' uiStore.setMode(mode)\n return true',
  128. to: ' return true',
  129. expect: 'E1-metric-link-switches-mode',
  130. },
  131. {
  132. id: 'M8',
  133. why: '行业联动不写全局筛选(点了没反应)',
  134. file: 'stores/overviewStore.js',
  135. from: ' filterStore.setIndustry(industryKey)\n return true',
  136. to: ' return true',
  137. expect: 'E2-industry-link-writes-the-global-filter',
  138. },
  139. {
  140. id: 'M9',
  141. why: '行业筛选不重取(只记在 store 里,界面纹丝不动)',
  142. file: 'services/overviewApi.js',
  143. from: ' params: { industry: industryParam(options.industry) },\n toData: toOverviewMonitoring,',
  144. to: ' params: {},\n toData: toOverviewMonitoring,',
  145. expect: 'D6-industry-filter-really-changes-the-numbers',
  146. },
  147. {
  148. id: 'M10',
  149. why: '渲染层又长出 panelId 分支(票 18 解除的耦合长回来)',
  150. file: 'components/PanelBlocks.vue',
  151. from: 'const emit = defineEmits(',
  152. to: 'const panelIdForMutation = null\nconst emit = defineEmits(',
  153. expect: 'F5-renderer-does-not-know-panel-ids',
  154. },
  155. {
  156. id: 'M11',
  157. why: 'domain 反向 import service(层次倒挂)',
  158. file: 'domain/overviewPanels.js',
  159. from: "import { fieldReader } from './fields.js'",
  160. to: "import { fieldReader } from './fields.js'\nimport { fetchOverviewMonitoring } from '../services/overviewApi.js'",
  161. expect: 'F1-domain-is-pure',
  162. },
  163. {
  164. id: 'M12',
  165. why: 'store 里写后端裸字段名(字段改名该归 domain)',
  166. file: 'stores/overviewStore.js',
  167. from: ' const sections = ref(',
  168. to: ' const deviceStatistics = null\n const sections = ref(',
  169. expect: 'F3-store-has-no-raw-backend-fields',
  170. },
  171. {
  172. id: 'M13',
  173. why: '外壳不再把概览联动动作接上(验收第 5 条的出口断掉)',
  174. file: 'CockpitShell.vue',
  175. from: ' @badge-needed="loadLedgerBadge"\n @panel-action="handlePanelAction"',
  176. to: ' @badge-needed="loadLedgerBadge"',
  177. expect: 'F6b-shell-wires-overview-store-and-links',
  178. },
  179. {
  180. id: 'M14',
  181. why: '不声明趋势窗口("近 7 日"变成一句空话,桶是无界的)',
  182. file: 'services/overviewApi.js',
  183. from: "export const OVERVIEW_RANGE = 'LAST_7_DAYS'",
  184. to: "export const OVERVIEW_RANGE = ''",
  185. expect: 'D7-range-is-declared-and-echoed',
  186. },
  187. ]
  188. const results = []
  189. for (const mutation of MUTATIONS) {
  190. const target = file(mutation.file)
  191. const backup = `${target}.bak`
  192. const original = readFileSync(target, 'utf8')
  193. // 本仓的 `.vue` 与部分 `.js` 是 CRLF(Windows 检出)。变异源文本一律按 `\n` 写,
  194. // 所以这里按目标文件的实际换行风格归一,否则会出现"变异源文本未找到"这种假失败。
  195. const from = original.includes('\r\n') ? mutation.from.replace(/\n/g, '\r\n') : mutation.from
  196. const to = original.includes('\r\n') ? mutation.to.replace(/\n/g, '\r\n') : mutation.to
  197. if (!original.includes(from)) {
  198. results.push({ ...mutation, ok: false, note: '变异源文本未找到(变异脚本自身需修)' })
  199. continue
  200. }
  201. copyFileSync(target, backup)
  202. try {
  203. writeFileSync(target, original.replace(from, to))
  204. const { red, output, reachedSummary } = runGate()
  205. const failLines = (output.match(/^FAIL {2}\S+/gm) || []).map((line) => line.replace(/^FAIL {2}/, '').split(' ')[0])
  206. const caught = red && reachedSummary && failLines.some((name) => name === mutation.expect || name.startsWith(mutation.expect))
  207. results.push({ ...mutation, ok: caught, red, reachedSummary, failLines, note: caught ? '' : red ? `红了但不是期望的那条:${failLines.join(', ') || '(无)'}` : '闸门仍然全绿(守卫没牙)' })
  208. } finally {
  209. copyFileSync(backup, target)
  210. unlinkSync(backup)
  211. }
  212. }
  213. // 还原后复跑一次,确认工作区回到全绿(避免变异脚本自己把状态改坏)。
  214. const after = runGate()
  215. console.log('\n===== T20 变异测试 =====')
  216. let bad = 0
  217. for (const row of results) {
  218. if (!row.ok) bad += 1
  219. console.log(`${row.ok ? 'CAUGHT ' : 'MISSED '} ${row.id} ${row.why}`)
  220. console.log(` 期望打红:${row.expect}${row.failLines ? ` · 实际红:${row.failLines.join(', ')}` : ''}${row.note ? ` · ${row.note}` : ''}`)
  221. }
  222. console.log(`\n变异 ${results.length - bad}/${results.length} 条被闸门抓住。`)
  223. console.log(`还原后复跑:${after.red ? '仍红(需人工检查!)' : '全绿'}`)
  224. if (bad || after.red) process.exitCode = 1