tmp_c2_verify.js 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. #!/usr/bin/env node
  2. /* C-2 视觉变更验证(旧蓝→品牌橙/信息蓝分流批次专用)
  3. * 用法:node scripts/tmp_c2_verify.js <页面路径> <替换规则.json>
  4. * 替换规则.json 支持两种规则形态(按序施加):
  5. * 1. 全局规则:["#5B9BD5", "#F97316"] 字面替换;["/^color: #5B9BD5/m", "color: #0EA5E9"] 正则(flags 直通,缺省补 g)
  6. * 2. 块级规则:{"block": ".channel-tip-text", "pairs": [["color: #5B9BD5", "color: #0EA5E9"]]}
  7. * —— 仅在该选择器规则块(至第一个 } 行)内应用 pairs;块内未命中即报错
  8. * 原理:before(HEAD) 与 after(工作区) 分别拼接 uni.scss 编译并归一;
  9. * 对 before 产物按序施加预期替换后,必须与 after 产物逐字节相同
  10. * → 证明除预期颜色替换外零其他变化。
  11. */
  12. const { execSync } = require('child_process')
  13. const fs = require('fs')
  14. const path = require('path')
  15. const PAGE = process.argv[2]
  16. const SUBST_FILE = process.argv[3]
  17. if (!PAGE || !SUBST_FILE) {
  18. console.error('用法: node scripts/tmp_c2_verify.js <页面路径> <替换规则.json>')
  19. process.exit(2)
  20. }
  21. const ROOT = path.resolve(__dirname, '..')
  22. const REPO = path.resolve(ROOT, '..')
  23. const REL = path.relative(REPO, path.join(ROOT, PAGE)).split(path.sep).join('/')
  24. const TMP = path.join(ROOT, '.tmp_equiv')
  25. fs.mkdirSync(TMP, { recursive: true })
  26. function extractStyle(src) {
  27. const m = src.match(/^[ \t]*<style[^>]*>[\s\S]*?<\/style>/m)
  28. if (!m) throw new Error('未找到 style 块: ' + PAGE)
  29. return m[0].replace(/^[ \t]*<style[^>]*>/, '').replace(/<\/style>$/, '')
  30. }
  31. const uniScss = fs.readFileSync(path.join(ROOT, 'uni.scss'), 'utf8')
  32. const afterVue = fs.readFileSync(path.join(ROOT, PAGE), 'utf8')
  33. const beforeVue = execSync(`git -C "${REPO}" show HEAD:${REL}`, { encoding: 'utf8', maxBuffer: 1e8 })
  34. fs.writeFileSync(path.join(TMP, 'after.scss'), uniScss + '\n' + extractStyle(afterVue))
  35. fs.writeFileSync(path.join(TMP, 'before.scss'), uniScss + '\n' + extractStyle(beforeVue))
  36. const sass = path.join(ROOT, 'node_modules/.bin/sass.cmd')
  37. execSync(`"${sass}" --no-source-map "${TMP}/after.scss" "${TMP}/after.out.css"`, { stdio: 'pipe' })
  38. execSync(`"${sass}" --no-source-map "${TMP}/before.scss" "${TMP}/before.out.css"`, { stdio: 'pipe' })
  39. function norm(s) {
  40. return s
  41. .replace(/rgba\(([^)]+)\)/g, (m, inner) => 'rgba(' + inner.split(',').map((x) => x.trim()).join(',') + ')') // canonical rgba spacing
  42. .replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
  43. .replace(/#([0-9a-fA-F]{3,8})\b/g, (m) => {
  44. let h = m.slice(1).toUpperCase()
  45. if (h.length === 3) h = h.split('').map((c) => c + c).join('')
  46. return '#' + h
  47. })
  48. .split('\n').map((l) => l.trim()).filter(Boolean).join('\n')
  49. }
  50. function toRegExp(pat) {
  51. const last = pat.lastIndexOf('/')
  52. const flags = pat.slice(last + 1)
  53. return new RegExp(pat.slice(1, last), flags.includes('g') ? flags : flags + 'g')
  54. }
  55. function applyBlockRules(text, sel, pairs) {
  56. const lines = text.split('\n')
  57. let idx = -1
  58. for (let i = 0; i < lines.length; i++) {
  59. if (lines[i].trim() === sel + ' {') { idx = i; break }
  60. }
  61. if (idx === -1) throw new Error('块选择器未找到: ' + sel)
  62. let end = -1
  63. for (let j = idx + 1; j < lines.length; j++) {
  64. if (lines[j].trim() === '}') { end = j; break }
  65. }
  66. if (end === -1) throw new Error('块未闭合: ' + sel)
  67. const body = lines.slice(idx + 1, end)
  68. for (const [pat, rep] of pairs) {
  69. const before = body.join('\n')
  70. const after = pat.startsWith('/') && pat.lastIndexOf('/') > 0
  71. ? before.replace(toRegExp(pat), rep)
  72. : before.split(pat).join(rep)
  73. if (after === before) throw new Error('块内替换未生效: ' + sel + ' :: ' + pat)
  74. body.length = 0
  75. body.push(...after.split('\n'))
  76. }
  77. lines.splice(idx + 1, end - idx - 1, ...body)
  78. return lines.join('\n')
  79. }
  80. const substs = JSON.parse(fs.readFileSync(SUBST_FILE, 'utf8'))
  81. let expected = norm(fs.readFileSync(path.join(TMP, 'before.out.css'), 'utf8'))
  82. for (const rule of substs) {
  83. if (Array.isArray(rule)) {
  84. const [pat, rep] = rule
  85. expected = pat.startsWith('/') && pat.lastIndexOf('/') > 0
  86. ? expected.replace(toRegExp(pat), rep)
  87. : expected.split(pat).join(rep)
  88. } else if (rule && rule.block && Array.isArray(rule.pairs)) {
  89. expected = applyBlockRules(expected, rule.block, rule.pairs)
  90. } else {
  91. throw new Error('未知规则形态: ' + JSON.stringify(rule))
  92. }
  93. }
  94. const after = norm(fs.readFileSync(path.join(TMP, 'after.out.css'), 'utf8'))
  95. if (expected === after) {
  96. console.log(`C-2 验证 PASS(${PAGE}):before 施加预期替换后与 after 逐字节相同,仅含预期颜色变更`)
  97. } else {
  98. const el = expected.split('\n'), al = after.split('\n')
  99. console.log(`C-2 验证 DIFF(${PAGE}):expected ${el.length} 行 / after ${al.length} 行`)
  100. let shown = 0
  101. for (let i = 0; i < Math.max(el.length, al.length) && shown < 40; i++) {
  102. if (el[i] !== al[i]) {
  103. console.log(`L${i + 1}\n expected: ${el[i]}\n after: ${al[i]}`)
  104. shown++
  105. }
  106. }
  107. if (shown >= 40) console.log('...(仅显示前 40 处差异)')
  108. process.exit(1)
  109. }