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
@@ -0,0 +1,431 @@
/**
* `scripts/utils/check-motion-constraints.ts` 的回归测试。
*
* 这个门禁的存在理由(N-29 + N-22/N-24/A-6「永不可失败的门禁」),决定了测试的形状:
* 每条规则都必须**两头都被证明** ——
* ① 合规夹具必须判 0(否则门禁是「常红」,会把人训练成忽略它);
* ② 违规夹具必须判非 0,且**是它对应的那条规则**判红(否则门禁是「常绿」)。
* 再加一条元测试:断言四条规则的并集 == 声明的规则全集,任何一条规则变得不可触发即红。
*
* 跑法(jest 的 roots 是 src,故本目录需显式指定):npm run check:motion:test
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import {
MOTION_RULES,
EXIT_CLEAN,
EXIT_VIOLATION,
EXIT_UNMEASURABLE,
markTransitionLines,
parseDurationMs,
parseTailwindDuration,
runScan,
type RuleId,
} from '../check-motion-constraints';
const SCRIPT_PATH = path.resolve(__dirname, '..', 'check-motion-constraints.ts');
/** 合规令牌层:曲线允许集合与 --transition-* 档位都齐活(脚本从这里读允许集合,不硬编码) */
const CLEAN_CSS = `
:root {
--transition-instant: 100ms;
--transition-fast: 180ms;
--transition-normal: 280ms;
--transition-slow: 450ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
--card-transition: transform var(--transition-normal) var(--ease-ink);
}
.card {
transition: color var(--transition-fast) var(--ease-ink);
}
.glow {
animation: pulse-soft 2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
`;
const CLEAN_TSX = `
'use client';
import { motion } from 'framer-motion';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
export function Card() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.28, ease: EASE_OUT }}
className="transition-colors duration-150"
/>
);
}
`;
const CONFIG_JS = `
module.exports = {
theme: {
extend: {
animation: {
'fade-in-up': 'fadeInUp 0.7s var(--ease-ink) forwards',
'pulse-soft': 'pulse-soft 2s ease-in-out infinite',
},
},
},
};
`;
let workspace: string;
beforeAll(() => {
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-gate-'));
});
afterAll(() => {
fs.rmSync(workspace, { recursive: true, force: true });
});
/**
* 写一棵最小夹具树并按门禁口径跑一遍。
* 每次调用用独立子目录,避免用例间互相污染。
*/
function scanFixture(fixture: {
css?: string;
source?: string;
config?: string;
omitSrc?: boolean;
omitCss?: boolean;
omitConfig?: boolean;
}) {
const root = fs.mkdtempSync(path.join(workspace, 'case-'));
fs.mkdirSync(path.join(root, 'src', 'app'), { recursive: true });
fs.mkdirSync(path.join(root, 'src', 'components'), { recursive: true });
if (!fixture.omitCss) {
fs.writeFileSync(path.join(root, 'src', 'app', 'globals.css'), fixture.css ?? CLEAN_CSS, 'utf8');
}
if (!fixture.omitSrc) {
fs.writeFileSync(
path.join(root, 'src', 'components', 'card.tsx'),
fixture.source ?? CLEAN_TSX,
'utf8',
);
}
if (!fixture.omitConfig) {
fs.writeFileSync(path.join(root, 'tailwind.config.js'), fixture.config ?? CONFIG_JS, 'utf8');
}
const result = runScan({
root,
srcDir: 'src',
cssFiles: ['src/app/globals.css'],
configFiles: ['tailwind.config.js'],
});
return {
root,
...result,
rules: new Set<RuleId>(result.violations.map((violation) => violation.rule)),
countOf: (rule: RuleId) => result.violations.filter((violation) => violation.rule === rule).length,
byRule: (rule: RuleId) => result.violations.filter((violation) => violation.rule === rule),
};
}
describe('parseDurationMs —— 时长单位口径', () => {
it('ms 与 s 都能解析,非时长形态返回 null', () => {
expect(parseDurationMs('180ms')).toBe(180);
expect(parseDurationMs(' 0.6s')).toBe(600);
expect(parseDurationMs('.28s')).toBeNull(); // 前导点写法不被承认,故不会静默算成 0
expect(parseDurationMs('24px')).toBeNull();
expect(parseDurationMs('var(--transition-fast)')).toBeNull();
});
});
describe('parseTailwindDuration —— Tailwind 时长类的单位口径', () => {
it('裸数字按 ms 解释,带单位则换算(duration-900 必须是 900ms,不能被静默忽略)', () => {
expect(parseTailwindDuration('150')).toBe(150);
expect(parseTailwindDuration('900')).toBe(900);
expect(parseTailwindDuration('0.6s')).toBe(600);
expect(parseTailwindDuration('1200ms')).toBe(1200);
});
});
describe('markTransitionLines —— 区分 framer transition 对象与其他 duration', () => {
it('只在 transition 对象内部为 true', () => {
const lines = [
'const variants = {',
' visible: {',
' transition: {',
' duration: 0.28,',
' },',
' },',
'};',
"toast('已保存', { duration: 3000 });",
];
// 只有 transition 对象自身(声明行 + 其内部行)为 true;外层对象与末尾的 sonner toast 都不算
expect(markTransitionLines(lines)).toEqual([false, false, true, true, false, false, false, false]);
});
});
describe('合规夹具:门禁必须判 0(防「常红」)', () => {
it('干净令牌层 + 干净源码 → EXIT_CLEAN,且确实看到了文件', () => {
const result = scanFixture({});
expect(result.violations).toEqual([]);
expect(result.exitCode).toBe(EXIT_CLEAN);
expect(result.counts.sourceFiles).toBe(1);
expect(result.counts.durationLiterals).toBeGreaterThan(0);
expect(result.allowedCurves.length).toBeGreaterThanOrEqual(2);
});
it('infinite 循环动画与 !important reduced-motion 硬开关按契约豁免', () => {
const result = scanFixture({});
expect(result.countOf('entrance-cap')).toBe(0);
expect(result.countOf('css-transition-token')).toBe(0);
});
it('*-delay 是延迟/错落而非时长:800ms 的 animation-delay 不判 entrance-cap', () => {
// 回归:首轮实现把 .animation-delay-800 误判成超上限(stagger 属 CONTEXT.md:78 的另一条口径)
const result = scanFixture({
css: `${CLEAN_CSS}\n.animation-delay-800 { animation-delay: 800ms; }\n.slow-in { transition-delay: 900ms; }\n`,
});
expect(result.countOf('entrance-cap')).toBe(0);
expect(result.countOf('css-transition-token')).toBe(0);
});
it('允许集合来自 CSS 令牌层而非硬编码:自定义曲线声明后被使用即合规', () => {
const result = scanFixture({
css: `
:root {
--transition-fast: 200ms;
--transition-normal: 280ms;
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
--ease-bespoke: cubic-bezier(0.1, 0.2, 0.3, 0.4);
}
`,
source: `const EASE_CUSTOM = [0.1, 0.2, 0.3, 0.4] as const;
export const C = () => <motion.div transition={{ duration: 0.2, ease: [0.1, 0.2, 0.3, 0.4] }} />;
`,
});
expect(result.violations).toEqual([]);
});
});
describe('违规夹具:每条规则都必须能判红(防「常绿」)', () => {
it('R1 token-band —— fast 超入场档 / instant 非反馈档', () => {
const result = scanFixture({
css: `
:root {
--transition-instant: 250ms;
--transition-fast: 400ms;
--transition-normal: 280ms;
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
}
`,
});
expect(result.exitCode).toBe(EXIT_VIOLATION);
expect(result.countOf('token-band')).toBe(2);
expect(result.byRule('token-band').map((v) => v.token)).toEqual(['250ms', '400ms']);
});
it('R2 entrance-cap —— 令牌 1200ms / framer 1.4s / duration-900 类 / 配置串 1.1s', () => {
const result = scanFixture({
css: `
:root {
--transition-instant: 100ms;
--transition-fast: 180ms;
--transition-normal: 280ms;
--transition-gentle: 1200ms;
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
}
.banner { transition: opacity var(--transition-gentle) var(--ease-ink); }
`,
source: `export const Bad = () => (
<motion.div
transition={{ duration: 1.4, ease: [0.22, 1, 0.36, 1] }}
className="transition-all duration-900"
style={{ transition: 'opacity 0.9s var(--ease-ink)' }}
/>
);
`,
config: `module.exports = { theme: { extend: { animation: { 'slow-in': 'fadeIn 1.1s var(--ease-ink) forwards' } } } };`,
});
expect(result.exitCode).toBe(EXIT_VIOLATION);
// 令牌自身 + 经 var() 解析到该令牌的声明 + framer 1.4s + duration-900 类 + 内联 0.9s + 配置串 1.1s
expect(result.countOf('entrance-cap')).toBe(6);
expect(result.byRule('entrance-cap').map((violation) => violation.file)).toEqual([
'src/app/globals.css',
'src/app/globals.css',
'src/components/card.tsx',
'src/components/card.tsx',
'src/components/card.tsx',
'tailwind.config.js',
]);
});
it('R3 css-transition-token —— 裸 ms/s 字面量与悬空 var() 引用', () => {
const result = scanFixture({
css: `
:root {
--transition-instant: 100ms;
--transition-fast: 180ms;
--transition-normal: 280ms;
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
}
.glow-border::before {
transition: opacity 0.6s var(--ease-ink);
}
.ghost { transition: transform var(--transition-ghost) var(--ease-ink); }
`,
});
expect(result.exitCode).toBe(EXIT_VIOLATION);
expect(result.countOf('css-transition-token')).toBe(2);
expect(result.byRule('css-transition-token').map((v) => v.token)).toEqual(['0.6s', 'var(--transition-ghost)']);
});
it('R4 easing-palette —— 非令牌曲线(framer 数组 / 具名常量 / CSS / Tailwind 任意值)与 --ease-ink 基准漂移', () => {
const result = scanFixture({
css: `
:root {
--transition-instant: 100ms;
--transition-fast: 180ms;
--transition-normal: 280ms;
--ease-ink: cubic-bezier(0.4, 0, 0.2, 1);
}
.bad { transition: opacity var(--transition-fast) cubic-bezier(0.9, 0, 0.1, 0.9); }
`,
source: `const EASE = [0.16, 1, 0.3, 1] as const;
export const Bad = () => (
<motion.div
transition={{ duration: 0.28, ease: [0.25, 1, 0.5, 1] }}
className="ease-[cubic-bezier(0.5, 0.5, 0.5, 0.5)]"
/>
);
`,
});
expect(result.exitCode).toBe(EXIT_VIOLATION);
// --ease-ink 基准 + CSS 写死曲线 + const EASE + ease: [] + Tailwind 任意值
expect(result.countOf('easing-palette')).toBe(5);
const files = result.byRule('easing-palette').map((violation) => violation.file);
expect(new Set(files)).toEqual(new Set(['src/app/globals.css', 'src/components/card.tsx']));
});
it('测试文件与被排除目录不参与判定(e2e 断言里出现违规是合规的)', () => {
const root = fs.mkdtempSync(path.join(workspace, 'excluded-'));
fs.mkdirSync(path.join(root, 'src', 'app'), { recursive: true });
fs.mkdirSync(path.join(root, 'src', 'components'), { recursive: true });
fs.writeFileSync(path.join(root, 'src', 'app', 'globals.css'), CLEAN_CSS, 'utf8');
fs.writeFileSync(path.join(root, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8');
fs.writeFileSync(
path.join(root, 'src', 'components', 'card.test.tsx'),
'const EASE = [0.99, 0.98, 0.97, 0.96] as const;\n',
'utf8',
);
fs.writeFileSync(path.join(root, 'tailwind.config.js'), CONFIG_JS, 'utf8');
const result = runScan({
root,
srcDir: 'src',
cssFiles: ['src/app/globals.css'],
configFiles: ['tailwind.config.js'],
});
expect(result.exitCode).toBe(EXIT_CLEAN);
expect(result.counts.sourceFiles).toBe(1);
});
});
describe('元测试:规则可达性(N-22 / N-24 / A-6 的直接防线)', () => {
it('四条规则各自都有至少一个能触发它的夹具', () => {
const fixtures = [
scanFixture({ css: CLEAN_CSS.replace('--transition-fast: 180ms', '--transition-fast: 999ms') }),
scanFixture({ source: CLEAN_TSX.replace('duration: 0.28', 'duration: 2.8') }),
scanFixture({ css: `${CLEAN_CSS}\n.x { transition: opacity 0.6s var(--ease-ink); }` }),
scanFixture({ source: CLEAN_TSX.replace('[0.22, 1, 0.36, 1]', '[0.7, 0.7, 0.7, 0.7]') }),
];
const covered = new Set<RuleId>();
for (const fixture of fixtures) {
expect(fixture.exitCode).toBe(EXIT_VIOLATION);
for (const violation of fixture.violations) covered.add(violation.rule);
}
expect([...covered].sort()).toEqual([...MOTION_RULES.map((rule) => rule.id)].sort());
});
it('MOTION_RULES 声明的四条规则 id 唯一且与退出码语义一致', () => {
const ids = MOTION_RULES.map((rule) => rule.id);
expect(new Set(ids).size).toBe(ids.length);
expect([EXIT_CLEAN, EXIT_VIOLATION, EXIT_UNMEASURABLE]).toEqual([0, 1, 2]);
});
});
describe('「空扫描 ≠ 干净」:无法度量必须判 2', () => {
it('源码目录不存在 → 2', () => {
const result = scanFixture({ omitSrc: true });
// 目录仍在(夹具建了 src/app),故这里用空目录场景另证
expect(result.counts.sourceFiles).toBe(0);
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
});
it('CSS 令牌文件缺失 → 2', () => {
const result = scanFixture({ omitCss: true });
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
expect(result.blockers.join(' ')).toContain('CSS 令牌文件不存在');
});
it('令牌层解析不到任何 --ease-* / --transition-* → 2(不允许静默放行)', () => {
const result = scanFixture({ css: '.a { transition: opacity 200ms linear; }' });
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
expect(result.blockers.join(' ')).toContain('--ease-*');
});
it('tailwind.config.js 缺失 → 2', () => {
const result = scanFixture({ omitConfig: true });
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
expect(result.blockers.join(' ')).toContain('tailwind.config.js');
});
});
describe('CLI 退出码 plumbing(process.exit 真的接上了判定)', () => {
function runCli(root: string, extra: string[] = []) {
return spawnSync('npx', ['tsx', SCRIPT_PATH, '--root', root, ...extra], {
encoding: 'utf8',
cwd: path.resolve(__dirname, '..', '..', '..'),
});
}
it('合规夹具 0 / 违规夹具 1 / 目标缺失 2', () => {
const clean = fs.mkdtempSync(path.join(workspace, 'cli-clean-'));
fs.mkdirSync(path.join(clean, 'src', 'app'), { recursive: true });
fs.mkdirSync(path.join(clean, 'src', 'components'), { recursive: true });
fs.writeFileSync(path.join(clean, 'src', 'app', 'globals.css'), CLEAN_CSS, 'utf8');
fs.writeFileSync(path.join(clean, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8');
fs.writeFileSync(path.join(clean, 'tailwind.config.js'), CONFIG_JS, 'utf8');
expect(runCli(clean).status).toBe(EXIT_CLEAN);
const dirty = fs.mkdtempSync(path.join(workspace, 'cli-dirty-'));
fs.mkdirSync(path.join(dirty, 'src', 'app'), { recursive: true });
fs.mkdirSync(path.join(dirty, 'src', 'components'), { recursive: true });
fs.writeFileSync(
path.join(dirty, 'src', 'app', 'globals.css'),
`${CLEAN_CSS}\n.bad { transition: opacity 1.2s cubic-bezier(0.9, 0, 0.1, 0.9); }\n`,
'utf8',
);
fs.writeFileSync(path.join(dirty, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8');
fs.writeFileSync(path.join(dirty, 'tailwind.config.js'), CONFIG_JS, 'utf8');
const dirtyRun = runCli(dirty);
expect(dirtyRun.status).toBe(EXIT_VIOLATION);
expect(dirtyRun.stdout).toContain('css-transition-token');
const missing = fs.mkdtempSync(path.join(workspace, 'cli-missing-'));
expect(runCli(missing).status).toBe(EXIT_UNMEASURABLE);
}, 120000);
});