| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- /**
- * T20 变异测试:把每条守卫**故意改坏**,确认闸门真的因断言失败而红。
- *
- * 为什么需要它(票 11/17/18/19 的成例):一条守卫全绿只说明"当前代码没触发它",
- * 不说明"它抓得住违规"。票 18 有两次守卫比它要守的东西宽(H12 对全文匹配、
- * H13 被退化分支满足),都是变异测试逮到的。
- *
- * 判红口径(票 18 的教训):**必须跑到断言汇总行且因断言失败而红**。
- * 脚本自己改坏语法导致的崩溃不算有效的红 —— 那种红对自查毫无价值。
- *
- * 用法:node .scratch/cockpit-shell-panel-layout/verify/t20-mutations.mjs
- * 它会在临时副本上改文件、跑 t20、还原,不改动工作区的最终状态。
- */
- import { readFileSync, writeFileSync, copyFileSync, unlinkSync, openSync, closeSync } from 'node:fs'
- import { execFileSync } from 'node:child_process'
- import { fileURLToPath } from 'node:url'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- const verifyDir = new URL('.', import.meta.url)
- const srcRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
- const file = (relative) => fileURLToPath(new URL(relative, srcRoot))
- const gate = fileURLToPath(new URL('t20-overview-panels.mjs', verifyDir))
- /**
- * 跑一次闸门,返回 `{ red, output, reachedSummary }`。
- *
- * **为什么把子进程输出写进临时文件而不是用管道**:本机沙箱(Windows confined mode)
- * 不允许程序开命名管道,`execFileSync` 默认的 `stdio: 'pipe'` 会直接 EPERM ——
- * 那是沙箱边界,不是被测代码的失败。把 stdout/stderr 换成**文件描述符**即可绕开,
- * 语义与"捕获输出"完全一致(这是沙箱文档建议的"改结构,别重试"那条路)。
- */
- function runGate() {
- const outPath = join(tmpdir(), `t20-mut-${process.pid}-${Date.now()}.out`)
- const errPath = `${outPath}.err`
- let outFd = null
- let errFd = null
- let code = 0
- try {
- outFd = openSync(outPath, 'w')
- errFd = openSync(errPath, 'w')
- execFileSync(process.execPath, [gate], { stdio: ['ignore', outFd, errFd] })
- } catch (error) {
- code = typeof error.status === 'number' ? error.status : 1
- } finally {
- if (outFd !== null) closeSync(outFd)
- if (errFd !== null) closeSync(errFd)
- }
- let output = ''
- try {
- output = readFileSync(outPath, 'utf8')
- } catch (error) {
- output = ''
- }
- try {
- output += readFileSync(errPath, 'utf8')
- } catch (error) {
- /* 没有 stderr 文件也算正常 */
- }
- for (const path of [outPath, errPath]) {
- try {
- unlinkSync(path)
- } catch (error) {
- /* 清不掉就留着,不影响判定 */
- }
- }
- // 必须跑到断言汇总行才算"有效的红"(脚本自己崩了不算 —— 票 18 的教训)。
- const reachedSummary = /T20 overview-panels: \d+\/\d+/.test(output)
- return { red: code !== 0 || !reachedSummary, output, reachedSummary }
- }
- /**
- * 每条变异:在哪个文件里把 `from` 换成 `to`,期望哪条断言打红。
- * `expect` 是期望**至少一条**失败断言的名字前缀(不必精确,看得见即可)。
- */
- const MUTATIONS = [
- {
- id: 'M1',
- why: '适配层凭空造一个后端没有的「故障」桶',
- file: 'domain/overviewPanels.js',
- // 打在**适配层**上(不是视图层):B2b 守的正是"领域对象里不许冒出 fault 桶"。
- from: " unknownOrMissing: numberish(status.raw.unknownOrMissing),\n denominator: numberish(status.raw.denominator),",
- to: " unknownOrMissing: numberish(status.raw.unknownOrMissing),\n fault: numberish(status.raw.offline),\n denominator: numberish(status.raw.denominator),",
- expect: 'B2b-no-fault-bucket-is-invented',
- },
- {
- id: 'M2',
- why: '空缺口的监测面板不再声明「故障」缺口(静默略过验收第 3 条点名项)',
- file: 'domain/overviewPanels.js',
- from: ' gap: MONITORING_FAULT_GAP,',
- to: " gap: '',",
- expect: 'C3c-fault-gap-is-declared',
- },
- {
- id: 'M3',
- why: '分母为 0 的在线率照 value 直画(虚假 0.0%,票 18 的 M3/M14 同一族)',
- file: 'domain/overviewPanels.js',
- from: " const available = row.onlineState !== 'EMPTY' && typeof row.onlineDenominator === 'number' && row.onlineDenominator > 0\n return available ? ratioText(row.onlineRate) : DETAIL_PLACEHOLDER",
- to: ' return ratioText(row.onlineRate)',
- expect: 'C1d-zero-denominator-is-not-zero-percent',
- },
- {
- id: 'M4',
- why: '工单状态分布退回旧的 1–7 七态(字典错位一格)',
- file: 'domain/overviewPanels.js',
- from: ' workOrderStates: WORK_ORDER_STATES.map((state) => {',
- to: ' workOrderStates: WORK_ORDER_STATES.slice(1).map((state) => {',
- expect: 'B3c-work-order-states-cover-all-eight-codes',
- },
- {
- id: 'M5',
- why: '态势总览改从别处拿一份**拷贝**而不是同一引用(两处数字可能对不上)',
- file: 'stores/overviewStore.js',
- from: ' warningSettings: warnings.data || null,',
- to: ' warningSettings: warnings.data ? { ...warnings.data } : null,',
- expect: 'D2b-situation-and-alert-share-the-same-payload',
- },
- {
- id: 'M6',
- why: '区域分布失败时整块基础设施面板一起判失败(一段坏遮住其它读数)',
- file: 'stores/overviewStore.js',
- from: " regions: regions && (regions.state === SECTION_STATE.SUCCESS || regions.state === SECTION_STATE.STALE) ? regions.data : null,\n regionsState: regions ? regions.state : SECTION_STATE.EMPTY,",
- to: ' regions: null,\n regionsState: SECTION_STATE.FAILED,',
- expect: 'D3-regions-are-merged-into-infrastructure',
- },
- {
- id: 'M7',
- why: '指标联动改成"什么都不做"(验收第 5 条名存实亡)',
- file: 'stores/overviewStore.js',
- from: ' uiStore.setMode(mode)\n return true',
- to: ' return true',
- expect: 'E1-metric-link-switches-mode',
- },
- {
- id: 'M8',
- why: '行业联动不写全局筛选(点了没反应)',
- file: 'stores/overviewStore.js',
- from: ' filterStore.setIndustry(industryKey)\n return true',
- to: ' return true',
- expect: 'E2-industry-link-writes-the-global-filter',
- },
- {
- id: 'M9',
- why: '行业筛选不重取(只记在 store 里,界面纹丝不动)',
- file: 'services/overviewApi.js',
- from: ' params: { industry: industryParam(options.industry) },\n toData: toOverviewMonitoring,',
- to: ' params: {},\n toData: toOverviewMonitoring,',
- expect: 'D6-industry-filter-really-changes-the-numbers',
- },
- {
- id: 'M10',
- why: '渲染层又长出 panelId 分支(票 18 解除的耦合长回来)',
- file: 'components/PanelBlocks.vue',
- from: 'const emit = defineEmits(',
- to: 'const panelIdForMutation = null\nconst emit = defineEmits(',
- expect: 'F5-renderer-does-not-know-panel-ids',
- },
- {
- id: 'M11',
- why: 'domain 反向 import service(层次倒挂)',
- file: 'domain/overviewPanels.js',
- from: "import { fieldReader } from './fields.js'",
- to: "import { fieldReader } from './fields.js'\nimport { fetchOverviewMonitoring } from '../services/overviewApi.js'",
- expect: 'F1-domain-is-pure',
- },
- {
- id: 'M12',
- why: 'store 里写后端裸字段名(字段改名该归 domain)',
- file: 'stores/overviewStore.js',
- from: ' const sections = ref(',
- to: ' const deviceStatistics = null\n const sections = ref(',
- expect: 'F3-store-has-no-raw-backend-fields',
- },
- {
- id: 'M13',
- why: '外壳不再把概览联动动作接上(验收第 5 条的出口断掉)',
- file: 'CockpitShell.vue',
- from: ' @badge-needed="loadLedgerBadge"\n @panel-action="handlePanelAction"',
- to: ' @badge-needed="loadLedgerBadge"',
- expect: 'F6b-shell-wires-overview-store-and-links',
- },
- {
- id: 'M14',
- why: '不声明趋势窗口("近 7 日"变成一句空话,桶是无界的)',
- file: 'services/overviewApi.js',
- from: "export const OVERVIEW_RANGE = 'LAST_7_DAYS'",
- to: "export const OVERVIEW_RANGE = ''",
- expect: 'D7-range-is-declared-and-echoed',
- },
- ]
- const results = []
- for (const mutation of MUTATIONS) {
- const target = file(mutation.file)
- const backup = `${target}.bak`
- const original = readFileSync(target, 'utf8')
- // 本仓的 `.vue` 与部分 `.js` 是 CRLF(Windows 检出)。变异源文本一律按 `\n` 写,
- // 所以这里按目标文件的实际换行风格归一,否则会出现"变异源文本未找到"这种假失败。
- const from = original.includes('\r\n') ? mutation.from.replace(/\n/g, '\r\n') : mutation.from
- const to = original.includes('\r\n') ? mutation.to.replace(/\n/g, '\r\n') : mutation.to
- if (!original.includes(from)) {
- results.push({ ...mutation, ok: false, note: '变异源文本未找到(变异脚本自身需修)' })
- continue
- }
- copyFileSync(target, backup)
- try {
- writeFileSync(target, original.replace(from, to))
- const { red, output, reachedSummary } = runGate()
- const failLines = (output.match(/^FAIL {2}\S+/gm) || []).map((line) => line.replace(/^FAIL {2}/, '').split(' ')[0])
- const caught = red && reachedSummary && failLines.some((name) => name === mutation.expect || name.startsWith(mutation.expect))
- results.push({ ...mutation, ok: caught, red, reachedSummary, failLines, note: caught ? '' : red ? `红了但不是期望的那条:${failLines.join(', ') || '(无)'}` : '闸门仍然全绿(守卫没牙)' })
- } finally {
- copyFileSync(backup, target)
- unlinkSync(backup)
- }
- }
- // 还原后复跑一次,确认工作区回到全绿(避免变异脚本自己把状态改坏)。
- const after = runGate()
- console.log('\n===== T20 变异测试 =====')
- let bad = 0
- for (const row of results) {
- if (!row.ok) bad += 1
- console.log(`${row.ok ? 'CAUGHT ' : 'MISSED '} ${row.id} ${row.why}`)
- console.log(` 期望打红:${row.expect}${row.failLines ? ` · 实际红:${row.failLines.join(', ')}` : ''}${row.note ? ` · ${row.note}` : ''}`)
- }
- console.log(`\n变异 ${results.length - bad}/${results.length} 条被闸门抓住。`)
- console.log(`还原后复跑:${after.red ? '仍红(需人工检查!)' : '全绿'}`)
- if (bad || after.red) process.exitCode = 1
|