Files
hermes-desktop/tests/client/style-tokens.test.ts
T
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

197 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readdirSync, readFileSync, statSync } from 'fs'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
/**
* CYBER 主题的裸色值护栏(docs/cyber-theme-plan.md §P4)。
*
* 全项目只允许一处写死颜色:图表色板(docs/ui-baseline.md §7)。
* 其余任何地方出现裸 hex / 具名颜色 / 字面量 rgba(0,0,0,...) 都算回退到旧主题,
* 一律拦在测试里,而不是等到肉眼看出色差。
*
* 豁免名单按「为什么能写死」逐条列在 EXEMPT,新增豁免必须同时补理由。
*/
const SRC_ROOT = 'packages/client/src'
/** 豁免:路径 → 理由。路径用正则匹配,新增必须写明为什么不能走令牌。 */
const EXEMPT: Array<{ pattern: RegExp; reason: string }> = [
// docs/ui-baseline.md §7 唯一授权的硬编码处
{ pattern: /styles[/\\]chart-colors\.scss$/, reason: '图表色板单一来源(§7 豁免)' },
{ pattern: /styles[/\\]chart-palette\.ts$/, reason: '图表色板单一来源(§7 豁免)' },
// xterm 的 theme 是 JS 对象,必须给字面量;令牌值已按 OKLCH 同源反算并逐行注释
{ pattern: /utils[/\\]hermes[/\\]terminal-theme\.ts$/, reason: 'xterm 主题预设需字面量,值与令牌同源' },
// Arco ConfigProvider 吃的是十六进制字符串,拿不到 CSS 变量
{ pattern: /styles[/\\]arco-theme\.ts$/, reason: 'Arco ConfigProvider 只接受十六进制' },
// 红线:vendor 只做装饰位面,pointer-events: none,不承载信息
{ pattern: /vendor[/\\]/, reason: 'arkscore vendor 红线:装饰位面,只读不改' },
// 语言徽标是品牌识别色(JS 黄 / Python 蓝 / HTML 橙…),换色会认不出语言
{ pattern: /chat[/\\]MessageItem\.vue$/, reason: '语言徽标为品牌识别色' },
]
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/
const NAMED_COLOR_RE =
/(?:^|[\s;{(,])(?:background(?:-color)?|color|border(?:-color)?|fill|stroke|outline|box-shadow|text-decoration-color|accent-color)\s*:\s*(?:!important\s*)?(white|black|red|blue|green|yellow|orange|purple|(?:dark|light)?(?:gray|grey)|pink|cyan|magenta|lime|navy|teal|olive|maroon|aqua|fuchsia|silver|gold|brown|coral|salmon|khaki|violet|indigo|crimson|tomato|wheat|tan|beige|ivory|linen|plum|orchid|skyblue|steelblue|slateblue|royalblue|dodgerblue|seagreen|forestgreen|darkgreen|darkblue|darkred|darkorange|darkkhaki|lightblue|lightgreen|lightyellow|lightpink|whitesmoke|ghostwhite|floralwhite|antiquewhite|blanchedalmond|blueviolet|mediumblue|mediumseagreen|mediumslateblue|mediumvioletred|palegreen|paleturquoise|peachpuff|powderblue|rosybrown|sandybrown|thistle|turquoise|azure|ivory|khaki|lavender|linen|mistyrose|moccasin|navajowhite|oldlace|papayawhip|peru|pink|plum|powderblue|red|salmon|sandybrown|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen)\s*[;}!]/i
const LITERAL_BLACK_RGBA_RE = /rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,/i
/**
* 扫描前先剥掉注释与 HTML 实体:
* - 注释里写「之前是 #b42318」是历史说明,不是样式;
* - `&#9662;` 是 HTML 实体(▽),不是颜色。
* 只做这两类预处理,不做其它"聪明"过滤,否则护栏会漏掉真问题。
*
* 逐行处理并保留行号,报错时能直接定位到行。
*/
function stripNonCode(source: string): string {
let inBlockComment = false
return source
.split(/\r?\n/)
.map(raw => {
let line = raw
// 块注释可能跨行:先判断这一行是否在注释里
if (inBlockComment) {
const end = line.indexOf('*/')
if (end === -1) return ' '.repeat(line.length)
line = ' '.repeat(end + 2) + line.slice(end + 2)
}
const blockStart = line.indexOf('/*')
if (blockStart !== -1) {
const end = line.indexOf('*/', blockStart + 2)
if (end === -1) {
line = line.slice(0, blockStart)
inBlockComment = true
} else {
line = line.slice(0, blockStart) + ' '.repeat(end - blockStart + 2) + line.slice(end + 2)
}
}
// 行注释:// 到行尾。但 http:// 这类 URL 不算,:// 前面不是行首空白时保留。
const lineComment = line.search(/(?:^|\s)\/\//)
if (lineComment !== -1) {
const keep = lineComment + (line[lineComment] === ' ' ? 1 : 0)
line = line.slice(0, keep)
}
// HTML 实体:&#9662; 是 ▽ 不是颜色
line = line.replace(/&#[0-9a-fA-F]+;/g, ' ')
return line
})
.join('\n')
}
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const full = join(dir, name)
if (statSync(full).isDirectory()) {
walk(full, out)
} else if (/\.(vue|scss|css|ts)$/.test(name)) {
out.push(full)
}
}
return out
}
const isExempt = (path: string) => EXEMPT.some(({ pattern }) => pattern.test(path))
describe('CYBER 裸色值护栏', () => {
const files = walk(SRC_ROOT)
.map(p => p.replace(/\\/g, '/'))
.filter(p => !isExempt(p))
/** 找出所有命中正则的行,统一格式化成「路径 + 行号 + 内容」。 */
const scan = (re: RegExp) =>
files.flatMap(path => {
const source = stripNonCode(readFileSync(path, 'utf8'))
const hits = source
.split('\n')
.map((line, i) => ({ line: line.trim(), no: i + 1 }))
.filter(({ line }) => re.test(line))
return hits.length ? [`${path}\n ${hits.map(h => `${h.no}: ${h.line}`).join('\n ')}`] : []
})
it('扫得到源文件(护栏本身没失效)', () => {
expect(files.length).toBeGreaterThan(100)
})
it('除图表色板外没有裸 hex', () => {
expect(scan(HEX_RE), '裸 hex 必须换成令牌(variables.scss)').toEqual([])
})
it('没有具名颜色关键字', () => {
expect(scan(NAMED_COLOR_RE), '具名颜色必须换成令牌').toEqual([])
})
it('没有字面量 rgba(0,0,0) 阴影', () => {
expect(scan(LITERAL_BLACK_RGBA_RE), '黑色阴影要走 --shadow-* 令牌').toEqual([])
})
it('半透明一律用 RGB 三元组,不写死数值', () => {
// rgba(15, 23, 42, ...) 这类:数字三元组是旧主题残留,必须换成
// rgba(var(--*-rgb), α)。豁免名单里的文件已在上面几条单独覆盖。
const literalRgba = /rgba?\(\s*\d{1,3}\s*,/
expect(scan(literalRgba), 'rgba 数字三元组要换成 var(--*-rgb) 三元组').toEqual([])
})
it('护栏本身能抓到真问题(自检)', () => {
// 剥注释这一步最容易悄悄失效:改坏了护栏还会一直"通过"。
// 这里用已知样本验证检测逻辑本身。
const dirty = [
'color: #ff0000;', // 裸 hex
'background: rgba(1, 2, 3, 0.5);', // 字面量三元组
'box-shadow: rgba(0, 0, 0, 0.4);', // 字面量黑影
'color: red;', // 具名颜色
'background: #0f766e;', // 裸 hex
].join('\n')
const cleaned = stripNonCode(dirty)
expect(HEX_RE.test(cleaned)).toBe(true)
expect(NAMED_COLOR_RE.test(cleaned)).toBe(true)
expect(LITERAL_BLACK_RGBA_RE.test(cleaned)).toBe(true)
expect(/rgba?\(\s*\d{1,3}\s*,/.test(cleaned)).toBe(true)
})
it('剥注释不会把真问题也剥掉', () => {
const dirty = [
'// 注释里的 #ff0000 是历史说明',
'/* 块注释里的 #00ff00 也是 */',
'color: #0000ff;',
'background: rgba(9, 9, 9, 0.3);',
].join('\n')
const cleaned = stripNonCode(dirty)
expect(cleaned).not.toContain('#ff0000')
expect(cleaned).not.toContain('#00ff00')
expect(cleaned).toContain('#0000ff')
expect(cleaned).toContain('rgba(9, 9, 9, 0.3)')
})
it('语言徽标品牌色被锁死(豁免也要有断言)', () => {
// MessageItem.vue 整体豁免(语言徽标是品牌识别色),但豁免范围不能悄悄扩大:
// 这里把徽标色板逐个锁住,改色必须同时改这里并说明理由。
const source = readFileSync('packages/client/src/components/hermes/chat/MessageItem.vue', 'utf8')
const expected: Record<string, string> = {
script: '#f7df1e', // JS
dynamic: '#3776ab', // Python
jvm: '#f0642f', // Kotlin
systems: '#5e63b6', // C
markup: '#e34f26', // HTML
style: '#8b5cf6', // CSS
data: '#64748b', // SQL
doc: '#0f766e', // YAML
shell: '#111827', // Shell
default: '#6b7280', // 其它
}
for (const [lang, color] of Object.entries(expected)) {
expect(
source,
`语言徽标 ${lang} 的品牌色必须是 ${color}(语言识别色,不参与主题令牌)`,
).toContain(`background: ${color};`)
}
})
})