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
+222 -49
View File
@@ -1,62 +1,235 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { meetsWCAGStandard } from '../../src/lib/color-contrast';
interface ColorPair {
name: string;
/**
* 对比度门禁(验收 A-11-1 重写)。
*
* 旧实现是 7 组硬编码十六进制(全部 #FFFFFF 底),既不读令牌表也不含暗色配对 ——
* 主题令牌改了它仍对陈旧色值报绿,因此全站 62 页的暗色违规能从这道门禁底下漏过。
* 现在:色值一律从 globals.css 的两个主题根解析,缺令牌即失败(不允许静默跳过)。
*/
const THEME_BLOCKS = [
{ theme: 'light', selector: ':root' },
{ theme: 'dark', selector: "html[data-theme='dark']" },
] as const;
interface Pair {
foreground: string;
background: string;
/** 前景 alpha(0-1),用于检验带 alpha 修饰类的正文(如 text-text-secondary 80%) */
alpha?: number;
textSize?: 'normal' | 'large';
}
const criticalColorPairs: ColorPair[] = [
{ name: 'Primary text on primary background', foreground: '#1C1C1C', background: '#FFFFFF' },
{ name: 'Secondary text on primary background', foreground: '#3D3D3D', background: '#FFFFFF' },
{ name: 'Tertiary text on primary background', foreground: '#4A4A4A', background: '#FFFFFF' },
{ name: 'Muted text on primary background', foreground: '#6B6B6B', background: '#FFFFFF' },
{ name: 'Brand primary on white', foreground: '#C41E3A', background: '#FFFFFF' },
{ name: 'Brand primary on brand bg', foreground: '#C41E3A', background: '#FEF2F4' },
{ name: 'Link on hover', foreground: '#C41E3A', background: '#FFFFFF' },
/** 契约要求的配对:来自 DESIGN.md 的文字/底色通道与深色区块 token。 */
/** 契约要求的配对:来自 DESIGN.md 的文字/底色通道与深色区块 token,两主题各解一次。 */
const REQUIRED_GROUPS: Array<{ label: string; pairs: Pair[] }> = [
{
label: '正文层级 × 页面底色',
pairs: [
{ foreground: '--color-text-primary', background: '--color-bg-primary' },
{ foreground: '--color-text-primary', background: '--color-bg-secondary' },
{ foreground: '--color-text-primary', background: '--color-bg-tertiary' },
{ foreground: '--color-text-secondary', background: '--color-bg-primary' },
{ foreground: '--color-text-secondary', background: '--color-bg-secondary' },
{ foreground: '--color-text-tertiary', background: '--color-bg-primary' },
{ foreground: '--color-text-muted', background: '--color-bg-primary' },
],
},
{
label: '品牌红文字通道(text-brand-ink 及 hover 档)',
pairs: [
{ foreground: '--color-brand-ink', background: '--color-bg-primary' },
{ foreground: '--color-brand-ink', background: '--color-bg-secondary' },
{ foreground: '--color-brand-ink', background: '--color-brand-bg' },
{ foreground: '--color-brand-ink-hover', background: '--color-bg-primary' },
{ foreground: '--color-brand-ink-hover', background: '--color-brand-bg' },
// 红底按钮的真实组合是 bg-brand + text-white(button.tsx:14,text-sm 属 normal 字号);
// --color-text-inverse 全仓 0 引用且暗色翻成墨色,不构成 UI 组合,故此处用字面量白。
{ foreground: '#FFFFFF', background: '--color-brand' },
],
},
{
label: 'alpha 修饰正文(带 /80 等修饰类的正文)',
pairs: [{ foreground: '--color-text-secondary', background: '--color-bg-primary', alpha: 0.8 }],
},
{
// 验收 A-12 残留:装饰序号/占位字(aria-hidden + ≥24px)。axe 的 color-contrast 不认 aria-hidden
// (实测 4.11.4 仍按可见文字判定),所以这类文字必须自己过 AA 大字 3:1,不能再靠低 alpha 淡化。
label: '大号装饰文本(text-text-hint)× 各级卡片底色',
pairs: [
{ foreground: '--color-text-hint', background: '--color-bg-primary', textSize: 'large' },
{ foreground: '--color-text-hint', background: '--color-bg-secondary', textSize: 'large' },
{ foreground: '--color-text-hint', background: '--color-bg-tertiary', textSize: 'large' },
{ foreground: '--color-text-hint', background: '--color-bg-elevated', textSize: 'large' },
],
},
{
label: '深色区块(不随主题翻转的独立 token)',
pairs: [
{ foreground: '--color-dark-text-primary', background: '--color-dark-bg' },
{ foreground: '--color-dark-text-secondary', background: '--color-dark-bg' },
{ foreground: '--color-dark-text-muted', background: '--color-dark-bg' },
{ foreground: '--color-brand-section-text', background: '--color-brand-section' },
],
},
];
function checkContrast() {
console.log('🎨 Color Contrast Check\n');
let failures = 0;
let passes = 0;
criticalColorPairs.forEach(pair => {
const result = meetsWCAGStandard(
pair.foreground,
pair.background,
'AA',
pair.textSize || 'normal'
);
const status = result.passes ? '✅ PASS' : '❌ FAIL';
const ratio = result.ratio.toFixed(2);
console.log(`${status} ${pair.name}`);
console.log(` Ratio: ${ratio}:1 (Required: ${result.requiredRatio}:1)`);
console.log(` FG: ${pair.foreground} | BG: ${pair.background}\n`);
if (result.passes) {
passes++;
} else {
failures++;
function extractBlock(css: string, selector: string): string {
const start = css.indexOf(`${selector} {`);
if (start < 0) throw new Error(`globals.css 中找不到主题根:${selector}`);
let depth = 0;
let cursor = css.indexOf('{', start);
const bodyStart = cursor + 1;
while (cursor < css.length) {
const ch = css[cursor];
if (ch === '{') depth += 1;
else if (ch === '}') {
depth -= 1;
if (depth === 0) break;
}
});
console.log(`\n📊 Summary:`);
console.log(` Total: ${criticalColorPairs.length}`);
console.log(` ✅ Passes: ${passes}`);
console.log(` ❌ Failures: ${failures}`);
if (failures > 0) {
console.log('\n⚠️ Some color pairs do not meet WCAG AA standards!');
process.exit(1);
} else {
console.log('\n✅ All color pairs meet WCAG AA standards!');
process.exit(0);
cursor += 1;
}
return css.slice(bodyStart, cursor);
}
checkContrast();
function parseTokens(block: string): Map<string, string> {
const tokens = new Map<string, string>();
for (const match of block.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
const name = match[1] as string;
const value = (match[2] as string).trim();
tokens.set(name, tokens.get(name) ?? value);
}
return tokens;
}
interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
function parseColor(raw: string, tokens: Map<string, string>, depth = 0): Rgba {
if (depth > 5) throw new Error(`var() 递归超过 5 层:${raw}`);
const value = raw.startsWith('var(') ? resolveVar(raw, tokens) : raw;
if (value !== raw && value.startsWith('var(')) return parseColor(value, tokens, depth + 1);
const hex = value.match(/^#([\da-f]{3}|[\da-f]{6})$/i)?.[1];
if (hex) {
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16),
a: 1,
};
}
const rgb = value.match(/^rgba?\(([^)]+)\)$/);
if (rgb) {
const parts = (rgb[1] as string).split(/[\s,]+/).map((p) => Number.parseFloat(p));
const [r, g, b, a] = [parts[0], parts[1], parts[2], parts[3]];
if ([r, g, b].some((n) => n === undefined || Number.isNaN(n))) throw new Error(`无法解析颜色:${value}`);
return { r: r ?? 0, g: g ?? 0, b: b ?? 0, a: a === undefined || Number.isNaN(a) ? 1 : a };
}
throw new Error(`无法解析颜色:${raw}`);
}
function resolveVar(raw: string, tokens: Map<string, string>): string {
const inner = raw.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)$/);
if (!inner) return raw;
const name = inner[1] as string;
const fallback = inner[2];
const value = tokens.get(name) ?? fallback;
if (value === undefined) throw new Error(`令牌未定义:${name}`);
return value.trim();
}
function composite(fg: Rgba, bg: Rgba): Rgba {
const a = fg.a;
return {
r: Math.round(fg.r * a + bg.r * (1 - a)),
g: Math.round(fg.g * a + bg.g * (1 - a)),
b: Math.round(fg.b * a + bg.b * (1 - a)),
a: 1,
};
}
function toHex({ r, g, b }: Rgba): string {
const hex = (n: number) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
return `#${hex(r)}${hex(g)}${hex(b)}`.toUpperCase();
}
function resolve(tokenName: string, tokens: Map<string, string>): string {
if (tokenName.startsWith('#')) return tokenName;
const raw = tokens.get(tokenName);
if (raw === undefined) throw new Error(`主题令牌缺失:${tokenName}`);
return raw;
}
function check() {
const cssPath = join(process.cwd(), 'src', 'app', 'globals.css');
const css = readFileSync(cssPath, 'utf8');
let passes = 0;
const failures: string[] = [];
const missing: string[] = [];
for (const { theme, selector } of THEME_BLOCKS) {
const own = parseTokens(extractBlock(css, selector));
// 按 CSS 层叠语义取色:暗色块只声明需要翻转的令牌,其余沿用 :root。
// 不做层叠就会把「刻意不翻」的底色通道误报成令牌缺失(DESIGN.md 双通道红规则)。
const tokens = theme === 'dark' ? new Map([...parseTokens(extractBlock(css, ':root')), ...own]) : own;
console.log(`\n🎨 ${theme.toUpperCase()} 主题(${selector},${tokens.size} 个令牌)`);
for (const group of REQUIRED_GROUPS) {
console.log(` ${group.label}`);
for (const pair of group.pairs) {
let label = `${pair.foreground} on ${pair.background}${pair.alpha !== undefined ? ` @${pair.alpha * 100}%` : ''}`;
try {
const fgToken = parseColor(resolve(pair.foreground, tokens), tokens);
const bgToken = parseColor(resolve(pair.background, tokens), tokens);
const fg = pair.alpha === undefined ? fgToken : { ...fgToken, a: pair.alpha };
const composited = composite(fg, bgToken);
const bgHex = toHex(bgToken);
const fgHex = toHex(composited);
const result = meetsWCAGStandard(fgHex, bgHex, 'AA', pair.textSize ?? 'normal');
const ratio = result.ratio.toFixed(2);
if (result.passes) {
passes += 1;
console.log(` ✅ ${label} — ${ratio}:1`);
} else {
failures.push(`${theme} · ${label} = ${ratio}:1(需 ${result.requiredRatio}:1;${fgHex} on ${bgHex})`);
console.log(` ❌ ${label} — ${ratio}:1(需 ${result.requiredRatio}:1)`);
}
} catch (error) {
missing.push(`${theme} · ${label} — ${(error as Error).message}`);
console.log(` ❌ ${label} — ${(error as Error).message}`);
}
}
}
}
console.log(`\n📊 合计 ${passes + failures.length + missing.length} 组:✅ ${passes} / ❌ ${failures.length} 不达标 / ⚠️ ${missing.length} 令牌缺失`);
if (failures.length > 0) {
console.log('\n不达标:');
failures.forEach((f) => console.log(` · ${f}`));
}
if (missing.length > 0) {
console.log('\n令牌缺失(门禁视为失败,避免对着陈旧色值报绿):');
missing.forEach((m) => console.log(` · ${m}`));
}
if (failures.length > 0 || missing.length > 0) {
console.log('\n⚠️ 存在不满足 WCAG 2.1 AA 的令牌配对');
process.exit(1);
}
console.log('\n✅ 全部令牌配对满足 WCAG 2.1 AA');
process.exit(0);
}
check();