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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user