Files
archived-gitea-ai-assistant/frontend/scripts/ui-regression.ts

152 lines
4.7 KiB
TypeScript

import { Glob } from 'bun';
import { existsSync } from 'node:fs';
import { relative, resolve } from 'node:path';
type Violation = {
file: string;
line: number;
reason: string;
snippet: string;
};
type Rule = {
reason: string;
regex: RegExp;
};
type Requirement = {
file: string;
reason: string;
needles: string[];
};
const rules: Rule[] = [
{ reason: 'hardcoded zinc utility', regex: /\b(?:bg|text|border)-zinc-\d{2,3}\b/ },
{ reason: 'hardcoded white-alpha border', regex: /\bborder-white\/\d+\b/ },
{ reason: 'hardcoded black overlay', regex: /\bbg-black\/\d+\b/ },
{ reason: 'inline rgba color literal', regex: /rgba\(/ },
{ reason: 'inline rgb color literal', regex: /rgb\(/ },
{ reason: 'hex color literal', regex: /#[0-9a-fA-F]{3,8}\b/ },
{ reason: 'legacy red semantic marker', regex: /text-red-500/ },
{ reason: 'primary-tinted hover on non-primary surfaces', regex: /\b(?:hover:bg-primary\/(?:[1-8]0)|group-hover:bg-primary\/(?:[1-8]0))\b/ },
];
const requirements: Requirement[] = [
{
file: 'src/index.css',
reason: 'theme token and utility baseline',
needles: [
'--success:',
'--warning:',
'--danger:',
'--info:',
'--surface-muted:',
'--surface-elevated:',
'--surface-overlay:',
'.theme-glow-primary',
'.theme-surface-overlay',
'.theme-sticky-bar',
'.theme-page-frame',
'.theme-page-actions',
'.theme-page-content',
'.theme-card-shell',
'.theme-card-header',
'.theme-card-content',
'.theme-error-panel',
'.theme-dialog-panel',
'.theme-dialog-header',
'.theme-dialog-body',
'.theme-dialog-footer',
],
},
{
file: 'tailwind.config.js',
reason: 'semantic color mapping in Tailwind',
needles: ['danger:', 'success:', 'warning:', 'info:'],
},
{
file: 'src/components/llm/ProviderDialog.tsx',
reason: 'modal overlay must use semantic utility',
needles: ['theme-surface-overlay', 'theme-dialog-panel', 'theme-dialog-header', 'theme-dialog-body', 'theme-dialog-footer'],
},
{
file: 'src/components/llm/TestResultDialog.tsx',
reason: 'modal overlay must use semantic utility',
needles: ['theme-surface-overlay', 'theme-dialog-panel', 'theme-dialog-header', 'theme-dialog-body', 'theme-dialog-footer'],
},
{
file: '../docs/design/ui-theme-language.md',
reason: 'design language documentation baseline',
needles: ['强制规则', '页面级统一约束', 'destructive 与 danger 的约定', 'theme-surface-overlay'],
},
{
file: 'src/components/ConfigManager.tsx',
reason: 'system config page should use unified page shell utilities',
needles: ['theme-page-frame', 'theme-page-actions', 'theme-page-content', 'theme-error-panel'],
},
{
file: 'src/components/ReviewConfigPage.tsx',
reason: 'review config page should use unified page shell utilities',
needles: ['theme-page-frame', 'theme-page-actions', 'theme-page-content', 'theme-card-shell', 'theme-error-panel'],
},
];
const violations: Violation[] = [];
const addViolation = (file: string, line: number, reason: string, snippet: string) => {
violations.push({ file: relative(process.cwd(), file), line, reason, snippet });
};
const scanSourceFiles = async () => {
const glob = new Glob('src/**/*.tsx');
for await (const file of glob.scan('.')) {
const absoluteFile = resolve(process.cwd(), file);
const content = await Bun.file(absoluteFile).text();
const lines = content.split(/\r?\n/);
lines.forEach((lineContent, index) => {
rules.forEach((rule) => {
if (rule.regex.test(lineContent)) {
addViolation(absoluteFile, index + 1, rule.reason, lineContent.trim());
}
});
});
}
};
const verifyRequirements = async () => {
for (const requirement of requirements) {
const absoluteFile = resolve(process.cwd(), requirement.file);
if (!existsSync(absoluteFile)) {
addViolation(absoluteFile, 1, requirement.reason, 'required file missing');
continue;
}
const content = await Bun.file(absoluteFile).text();
requirement.needles.forEach((needle) => {
if (!content.includes(needle)) {
addViolation(absoluteFile, 1, requirement.reason, `missing token/pattern: ${needle}`);
}
});
}
};
const printResult = () => {
if (violations.length === 0) {
console.log('✅ UI regression guard passed');
return;
}
console.error(`❌ UI regression guard failed with ${violations.length} issue(s):`);
violations.forEach((violation) => {
console.error(`- ${violation.file}:${violation.line} [${violation.reason}] ${violation.snippet}`);
});
process.exit(1);
};
await scanSourceFiles();
await verifyRequirements();
printResult();