test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构

- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
This commit is contained in:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env tsx
/**
* 品牌红「文字通道」契约 + 「透明度修饰符打在裸 var() 任意值上」死样式检查
*
* 本脚本守护两条机械可判定的规则(npm run check:brand-token,已并入 check:a11y):
*
* ── 规则 A:DESIGN.md 的 The Two-Channel Red Rule(DESIGN.md:154 / DESIGN.md:232)
* 底色用 `--color-brand`(暗黑不翻转),文字用 `--color-brand-ink`(暗黑翻至 #F87171)。
* 把 `--color-brand` 当文字色用是契约违反:亮色下 #FFFFFF 上 5.84:1(侥幸过 AA),
* 暗色下 --color-bg-primary (#0A0E14) 仅 3.31:1、--color-brand-bg (#2A1418) 仅 2.97:1,
* 均低于小字 WCAG AA 的 4.5:1 —— 该失败已由 axe(Playwright 移动 + Lighthouse 桌面)
* 双引擎在全站 62 页实测复现(ACCEPTANCE_REVIEW_2026-09-21 A-12 / R-3)。
*
* ── 规则 B:Tailwind 无法对「任意值形式的裸 var()」套用 /NN 透明度修饰符
* `bg-[var(--color-bg-section)]` 再加 `/60`、`border-[var(--color-border-primary)]` 加 `/50`、
* `group-hover:bg-[var(--color-brand-bg)]` 加 `/30` 这类写法 **编译后不生成任何 CSS 规则**
* (已用 `npx tailwindcss -i ./src/app/globals.css -o /tmp/x.css` 实测:0 条选择器;
* 对照 `.bg-bg-section\/60`、`.border-border-primary\/50` 各 1 条,正常生成)。
* 它同时满足三个特征:不报错、不进入产物、Code Review 里看起来完全合法 —— 即「静默死样式」。
* 正确写法:为该色建 `-rgb` 三元组 token(:root 与 html[data-theme='dark'] 都声明),
* 并在 tailwind.config.js 用 `rgb(var(--color-x-rgb) / <alpha-value>)` 注册,
* 然后写 token class:`bg-bg-section/60`、`border-border-primary/50`、`bg-brand-bg/30`。
* 注:不带 alpha 修饰符的 `bg-[var(--color-x)]` 是可正常编译的(本脚本不报),
* 规则 B 只锁定 `]/<alpha>` 这一形态。
*
* 违规判定见 FORBIDDEN_RULES;合法写法示例:`text-brand-ink`、`bg-brand/10`、`bg-bg-primary/80`。
*/
import fs from 'fs';
import path from 'path';
/** 扫描根目录(相对仓库根)与纳入扫描的源码扩展名 */
const SCAN_ROOT = 'src';
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.scss', '.mdx'];
interface ForbiddenRule {
id: 'A' | 'B';
/** 人类可读的规则名 */
title: string;
pattern: RegExp;
/** 由命中 token 生成修复建议 */
suggestion: (token: string) => string;
/** 命中时的补充说明 */
footnote?: string;
}
/**
* 规则 A —— 只锁定「文字通道误用」:`--color-brand` 后必须紧跟 `)]` 才算命中,
* 因此 `--color-brand-ink` / `--color-brand-bg` / `--color-brand-hover` 不会误报;
* `bg-` / `border-` / `fill-` / `stroke-` / `ring-` 等底色通道也不在匹配范围内。
*
* 规则 B —— 只锁定「颜色类工具 + 裸 var() 任意值 + 透明度修饰符」。
* 关键约束(避免误伤可正常编译的写法):
* 1. `\[var\(` 紧跟工具名前缀 —— 排除 `bg-[rgba(var(--color-brand-rgb),0.2)]`(合法,有实际色值);
* 2. 必须命中 `/\d` 或 `/[…]` —— 排除 `bg-[var(--color-brand-bg)]`(合法,无修饰符);
* 3. token 名限 `--color-*` 族 —— 排除 `w-[var(--spacing-md)]/2` 之类非颜色任意值;
* 4. 允许多级 variant 前缀(`hover:` / `group-hover:` / `sm:` / `dark:` …)。
* fill / stroke 与已列十个工具同属 Tailwind 颜色工具族、同样会静默失效,故一并纳入。
*/
const ALPHA_SUFFIX = String.raw`(?:\/(?:\[[^\]]*\]|[0-9]+(?:\.[0-9]+)?))`;
const VARIANT_PREFIX = String.raw`(?:[a-zA-Z0-9_-]+:)*`;
const COLOR_UTILITY = 'bg|border|from|via|to|shadow|text|ring|divide|placeholder|fill|stroke';
const FORBIDDEN_RULES: ForbiddenRule[] = [
{
id: 'A',
title: 'Two-Channel Red Rule:文字通道禁用 --color-brand',
pattern: new RegExp(
String.raw`(?:[a-zA-Z0-9:_-]+:)?text-\[var\(--color-brand\)\](?:\/(?:\[[^\]]*\]|[0-9.]+))?`,
'g',
),
suggestion: (token) => {
const prefix = token.slice(0, token.indexOf('text-'));
return token.includes(']/')
? `${prefix}text-brand-ink/${token.split(']/')[1]}`
: `${prefix}text-brand-ink`;
},
footnote: '规则出处: DESIGN.md:154 / DESIGN.md:232。带 alpha 的 text-[var(--color-brand)]/NN 同时是死样式(编译后 0 条规则)。',
},
{
id: 'B',
title: '死样式:Tailwind 不能对裸 var() 任意值套用透明度修饰符',
pattern: new RegExp(
String.raw`(${VARIANT_PREFIX})(?:${COLOR_UTILITY})-\[var\(--color-[^\]\s]*\)\]${ALPHA_SUFFIX}`,
'g',
),
suggestion: (token) => {
const [, utility] = token.match(
new RegExp(String.raw`(?:${VARIANT_PREFIX})((?:${COLOR_UTILITY})-)`),
) ?? [];
const varName = token.match(/\[var\(--color-([^\]]+)\)\]/)?.[1];
const alpha = token.split(']/')[1];
if (!utility || !varName) return '<改用对应 token class>/<alpha>';
const prefix = token.slice(0, token.indexOf(utility));
// --color-bg-section -> bg-bg-section;--color-brand-bg -> bg-brand-bg;--color-ink -> text-ink
const colorKey = varName.replace(/^color-/, '');
return `${prefix}${utility}${colorKey}${alpha ? `/${alpha}` : ''}`;
},
footnote:
'改为 token class:先补 <name>-rgb 三元组(:root 与 html[data-theme=\'dark\'] 两版),' +
'再在 tailwind.config.js 注册 rgb(var(--color-<name>-rgb) / <alpha-value>),最后写 <util>-<color>/<alpha>。',
},
];
/**
* 已知死样式、但由并发清理任务负责下线的文件 —— 降级为 warning 而非 failure。
* 目的不是掩盖,而是让门禁在「他人正在删除」的窗口期仍可退出 0;
* 一旦文件仍存在,下方会打印待办修复;一旦文件消失,会提示从本清单移除,避免清单腐烂。
*/
const PENDING_CLEANUP_ALLOWLIST: Array<{ file: string; reason: string; fix: string }> = [
{
file: 'src/components/ui/loading-state.tsx',
reason: '无页面引用(仅自身测试导入),且 loading 组件族正由另一并发任务删除(loading-skeleton.tsx 已删除)',
fix: 'L137 `bg-[var(--color-bg-primary)]/80` → `bg-bg-primary/80`(token 已存在,无需新增)',
},
];
interface Violation {
file: string;
line: number;
token: string;
rule: ForbiddenRule;
suppressed: boolean;
}
function parseArgs(): string {
const args = process.argv.slice(2);
const dirIndex = args.indexOf('--dir');
if (dirIndex !== -1 && args[dirIndex + 1]) {
return args[dirIndex + 1]!;
}
return SCAN_ROOT;
}
function collectSourceFiles(absoluteDir: string, accumulator: string[]): void {
for (const entry of fs.readdirSync(absoluteDir, { withFileTypes: true })) {
const entryPath = path.join(absoluteDir, entry.name);
if (entry.isDirectory()) {
collectSourceFiles(entryPath, accumulator);
continue;
}
if (SOURCE_EXTENSIONS.includes(path.extname(entry.name))) {
accumulator.push(entryPath);
}
}
}
function isAllowlisted(relativeFile: string): boolean {
return PENDING_CLEANUP_ALLOWLIST.some((entry) => entry.file === relativeFile);
}
function scanFile(absoluteFile: string, repoRoot: string): Violation[] {
const violations: Violation[] = [];
const relativeFile = path.relative(repoRoot, absoluteFile);
const suppressed = isAllowlisted(relativeFile);
const lines = fs.readFileSync(absoluteFile, 'utf8').split(/\r?\n/);
lines.forEach((line, index) => {
const seen = new Set<string>();
for (const rule of FORBIDDEN_RULES) {
rule.pattern.lastIndex = 0;
for (const match of line.matchAll(rule.pattern)) {
const token = match[0]!;
// 规则 A 已覆盖的 token 不再由规则 B 重复计数(两者命中同一处写法)
const coveredByEarlierRule = [...seen].some((previous) => previous.includes(token));
if (coveredByEarlierRule) continue;
seen.add(token);
violations.push({ file: relativeFile, line: index + 1, token, rule, suppressed });
}
}
});
return violations;
}
function main(): void {
const repoRoot = path.resolve(__dirname, '../..');
// resolve(而非 join)以同时支持 --dir 传入相对路径与绝对路径(如临时对照夹具目录)
const scanRoot = path.resolve(repoRoot, parseArgs());
console.log('\n🔴 品牌红双通道契约 + 裸 var() 透明度死样式 检查\n');
if (!fs.existsSync(scanRoot)) {
console.error(`❌ 扫描目录不存在: ${path.relative(repoRoot, scanRoot)}`);
process.exit(1);
}
const files: string[] = [];
collectSourceFiles(scanRoot, files);
files.sort();
const violations: Violation[] = [];
for (const file of files) {
violations.push(...scanFile(file, repoRoot));
}
const blocking = violations.filter((v) => !v.suppressed);
const suppressed = violations.filter((v) => v.suppressed);
for (const violation of blocking) {
console.log(`❌ [规则 ${violation.rule.id}] ${violation.file}:${violation.line} ${violation.token}`);
console.log(` 改为 ${violation.rule.suggestion(violation.token)}`);
}
for (const entry of PENDING_CLEANUP_ALLOWLIST) {
const absolute = path.join(repoRoot, entry.file);
if (!fs.existsSync(absolute)) {
console.log(`ℹ️ 白名单条目已失效(文件不存在,请从本脚本移除): ${entry.file}`);
continue;
}
if (suppressed.some((v) => v.file === entry.file)) {
console.log(`\n⚠️ [已降级为 warning] ${entry.file} —— ${entry.reason}`);
console.log(` 待办修复: ${entry.fix}`);
}
}
console.log('\n📊 检查摘要');
console.log(` 扫描目录: ${path.relative(repoRoot, scanRoot)}`);
console.log(` 扫描文件: ${files.length}`);
console.log(` 违规处数: ${blocking.length}(另有 ${suppressed.length} 处在清理白名单内,已降级)`);
if (blocking.length > 0) {
const ruleIds = [...new Set(blocking.map((v) => v.rule.id))].sort();
for (const id of ruleIds) {
const rule = FORBIDDEN_RULES.find((r) => r.id === id)!;
console.log(`\n⚠️ 规则 ${id}: ${rule.title}`);
console.log(` ${rule.footnote ?? ''}`);
}
console.log('\n❌ 存在契约违反或静默死样式!');
process.exit(1);
}
console.log('\n✅ 文字通道使用 --color-brand-ink,且无「裸 var() + 透明度」死样式!');
process.exit(0);
}
main();