|
@@ -0,0 +1,121 @@
|
|
|
|
|
+#!/usr/bin/env node
|
|
|
|
|
+/* C-2 视觉变更验证(旧蓝→品牌橙/信息蓝分流批次专用)
|
|
|
|
|
+ * 用法:node scripts/tmp_c2_verify.js <页面路径> <替换规则.json>
|
|
|
|
|
+ * 替换规则.json 支持两种规则形态(按序施加):
|
|
|
|
|
+ * 1. 全局规则:["#5B9BD5", "#F97316"] 字面替换;["/^color: #5B9BD5/m", "color: #0EA5E9"] 正则(flags 直通,缺省补 g)
|
|
|
|
|
+ * 2. 块级规则:{"block": ".channel-tip-text", "pairs": [["color: #5B9BD5", "color: #0EA5E9"]]}
|
|
|
|
|
+ * —— 仅在该选择器规则块(至第一个 } 行)内应用 pairs;块内未命中即报错
|
|
|
|
|
+ * 原理:before(HEAD) 与 after(工作区) 分别拼接 uni.scss 编译并归一;
|
|
|
|
|
+ * 对 before 产物按序施加预期替换后,必须与 after 产物逐字节相同
|
|
|
|
|
+ * → 证明除预期颜色替换外零其他变化。
|
|
|
|
|
+ */
|
|
|
|
|
+const { execSync } = require('child_process')
|
|
|
|
|
+const fs = require('fs')
|
|
|
|
|
+const path = require('path')
|
|
|
|
|
+
|
|
|
|
|
+const PAGE = process.argv[2]
|
|
|
|
|
+const SUBST_FILE = process.argv[3]
|
|
|
|
|
+if (!PAGE || !SUBST_FILE) {
|
|
|
|
|
+ console.error('用法: node scripts/tmp_c2_verify.js <页面路径> <替换规则.json>')
|
|
|
|
|
+ process.exit(2)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const ROOT = path.resolve(__dirname, '..')
|
|
|
|
|
+const REPO = path.resolve(ROOT, '..')
|
|
|
|
|
+const REL = path.relative(REPO, path.join(ROOT, PAGE)).split(path.sep).join('/')
|
|
|
|
|
+const TMP = path.join(ROOT, '.tmp_equiv')
|
|
|
|
|
+fs.mkdirSync(TMP, { recursive: true })
|
|
|
|
|
+
|
|
|
|
|
+function extractStyle(src) {
|
|
|
|
|
+ const m = src.match(/^[ \t]*<style[^>]*>[\s\S]*?<\/style>/m)
|
|
|
|
|
+ if (!m) throw new Error('未找到 style 块: ' + PAGE)
|
|
|
|
|
+ return m[0].replace(/^[ \t]*<style[^>]*>/, '').replace(/<\/style>$/, '')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const uniScss = fs.readFileSync(path.join(ROOT, 'uni.scss'), 'utf8')
|
|
|
|
|
+const afterVue = fs.readFileSync(path.join(ROOT, PAGE), 'utf8')
|
|
|
|
|
+const beforeVue = execSync(`git -C "${REPO}" show HEAD:${REL}`, { encoding: 'utf8', maxBuffer: 1e8 })
|
|
|
|
|
+
|
|
|
|
|
+fs.writeFileSync(path.join(TMP, 'after.scss'), uniScss + '\n' + extractStyle(afterVue))
|
|
|
|
|
+fs.writeFileSync(path.join(TMP, 'before.scss'), uniScss + '\n' + extractStyle(beforeVue))
|
|
|
|
|
+
|
|
|
|
|
+const sass = path.join(ROOT, 'node_modules/.bin/sass.cmd')
|
|
|
|
|
+execSync(`"${sass}" --no-source-map "${TMP}/after.scss" "${TMP}/after.out.css"`, { stdio: 'pipe' })
|
|
|
|
|
+execSync(`"${sass}" --no-source-map "${TMP}/before.scss" "${TMP}/before.out.css"`, { stdio: 'pipe' })
|
|
|
|
|
+
|
|
|
|
|
+function norm(s) {
|
|
|
|
|
+ return s
|
|
|
|
|
+ .replace(/rgba\(([^)]+)\)/g, (m, inner) => 'rgba(' + inner.split(',').map((x) => x.trim()).join(',') + ')') // canonical rgba spacing
|
|
|
|
|
+ .replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
|
|
|
|
|
+ .replace(/#([0-9a-fA-F]{3,8})\b/g, (m) => {
|
|
|
|
|
+ let h = m.slice(1).toUpperCase()
|
|
|
|
|
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
|
|
|
|
+ return '#' + h
|
|
|
|
|
+ })
|
|
|
|
|
+ .split('\n').map((l) => l.trim()).filter(Boolean).join('\n')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toRegExp(pat) {
|
|
|
|
|
+ const last = pat.lastIndexOf('/')
|
|
|
|
|
+ const flags = pat.slice(last + 1)
|
|
|
|
|
+ return new RegExp(pat.slice(1, last), flags.includes('g') ? flags : flags + 'g')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function applyBlockRules(text, sel, pairs) {
|
|
|
|
|
+ const lines = text.split('\n')
|
|
|
|
|
+ let idx = -1
|
|
|
|
|
+ for (let i = 0; i < lines.length; i++) {
|
|
|
|
|
+ if (lines[i].trim() === sel + ' {') { idx = i; break }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (idx === -1) throw new Error('块选择器未找到: ' + sel)
|
|
|
|
|
+ let end = -1
|
|
|
|
|
+ for (let j = idx + 1; j < lines.length; j++) {
|
|
|
|
|
+ if (lines[j].trim() === '}') { end = j; break }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (end === -1) throw new Error('块未闭合: ' + sel)
|
|
|
|
|
+ const body = lines.slice(idx + 1, end)
|
|
|
|
|
+ for (const [pat, rep] of pairs) {
|
|
|
|
|
+ const before = body.join('\n')
|
|
|
|
|
+ const after = pat.startsWith('/') && pat.lastIndexOf('/') > 0
|
|
|
|
|
+ ? before.replace(toRegExp(pat), rep)
|
|
|
|
|
+ : before.split(pat).join(rep)
|
|
|
|
|
+ if (after === before) throw new Error('块内替换未生效: ' + sel + ' :: ' + pat)
|
|
|
|
|
+ body.length = 0
|
|
|
|
|
+ body.push(...after.split('\n'))
|
|
|
|
|
+ }
|
|
|
|
|
+ lines.splice(idx + 1, end - idx - 1, ...body)
|
|
|
|
|
+ return lines.join('\n')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const substs = JSON.parse(fs.readFileSync(SUBST_FILE, 'utf8'))
|
|
|
|
|
+let expected = norm(fs.readFileSync(path.join(TMP, 'before.out.css'), 'utf8'))
|
|
|
|
|
+for (const rule of substs) {
|
|
|
|
|
+ if (Array.isArray(rule)) {
|
|
|
|
|
+ const [pat, rep] = rule
|
|
|
|
|
+ expected = pat.startsWith('/') && pat.lastIndexOf('/') > 0
|
|
|
|
|
+ ? expected.replace(toRegExp(pat), rep)
|
|
|
|
|
+ : expected.split(pat).join(rep)
|
|
|
|
|
+ } else if (rule && rule.block && Array.isArray(rule.pairs)) {
|
|
|
|
|
+ expected = applyBlockRules(expected, rule.block, rule.pairs)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ throw new Error('未知规则形态: ' + JSON.stringify(rule))
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const after = norm(fs.readFileSync(path.join(TMP, 'after.out.css'), 'utf8'))
|
|
|
|
|
+
|
|
|
|
|
+if (expected === after) {
|
|
|
|
|
+ console.log(`C-2 验证 PASS(${PAGE}):before 施加预期替换后与 after 逐字节相同,仅含预期颜色变更`)
|
|
|
|
|
+} else {
|
|
|
|
|
+ const el = expected.split('\n'), al = after.split('\n')
|
|
|
|
|
+ console.log(`C-2 验证 DIFF(${PAGE}):expected ${el.length} 行 / after ${al.length} 行`)
|
|
|
|
|
+ let shown = 0
|
|
|
|
|
+ for (let i = 0; i < Math.max(el.length, al.length) && shown < 40; i++) {
|
|
|
|
|
+ if (el[i] !== al[i]) {
|
|
|
|
|
+ console.log(`L${i + 1}\n expected: ${el[i]}\n after: ${al[i]}`)
|
|
|
|
|
+ shown++
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (shown >= 40) console.log('...(仅显示前 40 处差异)')
|
|
|
|
|
+ process.exit(1)
|
|
|
|
|
+}
|