- 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 配置收敛。
432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
/**
|
||
* `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);
|
||
});
|