initialize Electron desktop app and remove unused files
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s

This commit is contained in:
2026-10-25 01:09:01 +08:00
commit 01d9e301fc
877 changed files with 262513 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
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};`)
}
})
})