Files
pbakaus_impeccable/tests/lib/impeccable-config.test.js
T
Paul BakausandGitHub a075d89bdb Simplify CSS color channel parsing (#520)
Centralize CSS numeric token parsing and characterize every supported color unit while preserving config and filtering behavior.

AI-assisted: Codex implemented this refactor under pbakaus’s scheduled architecture-simplification authorization.
2026-08-05 15:28:17 -07:00

302 lines
15 KiB
JavaScript

import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import {
extractFindingIgnoreValue,
filterDetectionFindings,
getHookConsent,
setHookConsent,
getLocalConfigPath,
getConfigPath,
ensureConfigGitExclude,
readDetectionConfig,
readRawDetectionConfig,
shouldIgnoreDetectionFile,
writeDetectionConfig,
} from '../../cli/lib/impeccable-config.mjs';
describe('cli/lib/impeccable-config', () => {
let root;
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'imp-cfg-')); });
afterEach(() => rmSync(root, { recursive: true, force: true }));
test('getHookConsent is undefined until a decision is recorded, then round-trips', () => {
expect(getHookConsent(root)).toBeUndefined();
setHookConsent(root, 'declined');
expect(getHookConsent(root)).toBe('declined');
setHookConsent(root, 'accepted');
expect(getHookConsent(root)).toBe('accepted');
});
test('setHookConsent preserves unrelated keys in config.local.json', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getLocalConfigPath(root), JSON.stringify({ updateCheck: false, hook: { quiet: true } }));
setHookConsent(root, 'declined');
const raw = JSON.parse(readFileSync(getLocalConfigPath(root), 'utf-8'));
expect(raw.updateCheck).toBe(false);
expect(raw.hook.quiet).toBe(true);
expect(raw.hook.consent).toBe('declined');
});
test('config.local.json (per-developer) overrides config.json for consent', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getConfigPath(root), JSON.stringify({ hook: { consent: 'accepted' } }));
writeFileSync(getLocalConfigPath(root), JSON.stringify({ hook: { consent: 'declined' } }));
expect(getHookConsent(root)).toBe('declined');
});
test('malformed config is tolerated (no throw, undefined consent)', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getLocalConfigPath(root), '{ not json');
expect(getHookConsent(root)).toBeUndefined();
});
test('writing consent gitignores config.local.json via .git/info/exclude', () => {
execFileSync('git', ['init', '-q'], { cwd: root });
setHookConsent(root, 'declined');
const exclude = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8');
expect(exclude).toContain('.impeccable/config.local.json');
// Idempotent: a second write does not duplicate the marker block.
ensureConfigGitExclude(root);
const again = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8');
expect((again.match(/impeccable-config-ignore-start/g) || []).length).toBe(1);
// It uses .git/info/exclude, not a tracked .gitignore.
expect(existsSync(join(root, '.gitignore'))).toBe(false);
});
test('ensureConfigGitExclude is a no-op outside a git repo', () => {
expect(ensureConfigGitExclude(root)).toBe(false);
});
test('readDetectionConfig merges shared and local detector filters', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getConfigPath(root), JSON.stringify({
detector: {
ignoreRules: ['side-tab'],
ignoreFiles: ['src/legacy/**'],
ignoreValues: [
{ rule: 'overused-font', value: 'Avenir Next', reason: 'team default' },
{ rule: 'design-system-color', value: '*', files: ['src/demo.css'] },
],
designSystem: { enabled: false },
},
}));
writeFileSync(getLocalConfigPath(root), JSON.stringify({
detector: {
ignoreRules: ['gradient-text'],
ignoreFiles: ['src/local/**'],
ignoreValues: [
{ rule: 'overused-font', value: 'Avenir Next', reason: 'local override' },
{ rule: 'bounce-easing', value: 'bounce-ball' },
],
designSystem: { enabled: true },
},
}));
const cfg = readDetectionConfig(root);
expect(cfg.ignoreRules).toEqual(['side-tab', 'gradient-text']);
expect(cfg.ignoreFiles).toEqual(['src/legacy/**', 'src/local/**']);
expect(cfg.ignoreValues).toEqual([
{ rule: 'overused-font', value: 'avenir next', reason: 'local override' },
{ rule: 'design-system-color', value: '*', files: ['src/demo.css'] },
{ rule: 'bounce-easing', value: 'bounce-ball' },
]);
expect(cfg.designSystem).toEqual({ enabled: true });
});
test('readDetectionConfig remains backward-compatible with legacy hook filters', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getConfigPath(root), JSON.stringify({
hook: {
ignoreRules: ['side-tab'],
ignoreFiles: ['src/legacy/**'],
ignoreValues: [{ rule: 'overused-font', value: 'Avenir Next' }],
designSystem: { enabled: false },
},
}));
const cfg = readDetectionConfig(root);
expect(cfg.ignoreRules).toEqual(['side-tab']);
expect(cfg.ignoreFiles).toEqual(['src/legacy/**']);
expect(cfg.ignoreValues).toEqual([{ rule: 'overused-font', value: 'avenir next' }]);
expect(cfg.designSystem).toEqual({ enabled: false });
});
test('writeDetectionConfig writes detector config and strips legacy hook filters', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getConfigPath(root), JSON.stringify({
updateCheck: false,
hook: {
consent: 'accepted',
quiet: true,
ignoreRules: ['legacy-rule'],
ignoreFiles: ['legacy/**'],
ignoreValues: [{ rule: 'overused-font', value: 'Legacy' }],
},
}));
const config = readRawDetectionConfig(root);
config.ignoreRules.push('side-tab');
config.ignoreFiles.push('src/legacy/**');
writeDetectionConfig(root, config);
const raw = JSON.parse(readFileSync(getConfigPath(root), 'utf-8'));
expect(raw.updateCheck).toBe(false);
expect(raw.hook).toEqual({ consent: 'accepted', quiet: true });
expect(raw.detector.ignoreRules).toEqual(['legacy-rule', 'side-tab']);
expect(raw.detector.ignoreFiles).toEqual(['legacy/**', 'src/legacy/**']);
expect(raw.detector.ignoreValues).toEqual([{ rule: 'overused-font', value: 'legacy' }]);
expect(raw.detector.designSystem).toBeUndefined();
});
test('writeDetectionConfig local ignores do not create an implicit design-system override', () => {
execFileSync('git', ['init', '-q'], { cwd: root });
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getConfigPath(root), JSON.stringify({
detector: { designSystem: { enabled: false } },
}));
const local = readRawDetectionConfig(root, { local: true });
local.ignoreValues.push({ rule: 'overused-font', value: 'Inter' });
writeDetectionConfig(root, local, { local: true });
const rawLocal = JSON.parse(readFileSync(getLocalConfigPath(root), 'utf-8'));
expect(rawLocal.detector.designSystem).toBeUndefined();
expect(readDetectionConfig(root).designSystem).toEqual({ enabled: false });
expect(readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8')).toContain('.impeccable/config.local.json');
});
test('shouldIgnoreDetectionFile matches relative and absolute paths', () => {
const cfg = { ignoreFiles: ['src/legacy/**', '*.generated.tsx'] };
expect(shouldIgnoreDetectionFile(join(root, 'src', 'legacy', 'Card.tsx'), root, cfg)).toBe(true);
expect(shouldIgnoreDetectionFile(join(root, 'src', 'Card.generated.tsx'), root, cfg)).toBe(true);
expect(shouldIgnoreDetectionFile(join(root, 'src', 'Card.tsx'), root, cfg)).toBe(false);
});
test('filterDetectionFindings matches hook ignore value semantics', () => {
const findings = [
{ antipattern: 'overused-font', file: join(root, 'src', 'main.css'), line: 1, snippet: 'Primary font: Avenir Next' },
{ antipattern: 'overused-font', file: join(root, 'src', 'other.css'), line: 2, snippet: 'Primary font: Karla' },
{ antipattern: 'design-system-color', file: join(root, 'src', 'demo.css'), line: 3, ignoreValue: '#8b5cf6' },
{ antipattern: 'design-system-color', file: join(root, 'src', 'real.css'), line: 4, ignoreValue: '#8b5cf6' },
{ antipattern: 'design-system-font', file: join(root, 'src', 'demo.css'), line: 5, ignoreValue: 'Avenir Next' },
{ antipattern: 'overused-font', file: join(root, 'src', 'fonts.css'), line: 6, snippet: 'Google Fonts: space grotesk' },
];
const filtered = filterDetectionFindings(findings, {
ignoreRules: [],
ignoreValues: [
{ rule: 'overused-font', value: 'avenir next' },
{ rule: 'design-system-color', value: '*', files: ['src/demo.css'] },
{ rule: 'design-system-font', value: '*' },
{ rule: 'overused-font', value: 'space grotesk' },
],
});
expect(filtered.map((f) => `${f.antipattern}:${f.line}`)).toEqual([
'overused-font:2',
'design-system-color:4',
'design-system-font:5',
]);
});
test('filterDetectionFindings honors file-scoped wildcard ignores for non-value-bearing rules', () => {
const findings = [
{ antipattern: 'side-tab', file: join(root, 'components', 'TopicCard.jsx'), line: 331, snippet: "borderLeft: '7px solid" },
{ antipattern: 'side-tab', file: join(root, 'components', 'Other.jsx'), line: 12, snippet: "borderLeft: '7px solid" },
];
const filtered = filterDetectionFindings(findings, {
ignoreRules: [],
ignoreValues: [{ rule: 'side-tab', value: '*', files: ['**/TopicCard.jsx'] }],
});
expect(filtered.map((f) => `${f.antipattern}:${f.line}`)).toEqual(['side-tab:12']);
});
test('filterDetectionFindings matches equivalent design-system color values', () => {
const findings = [
{ antipattern: 'design-system-color', file: join(root, 'src', 'rgb.css'), line: 1, ignoreValue: 'rgb(139, 92, 246)' },
{ antipattern: 'design-system-color', file: join(root, 'src', 'hex.css'), line: 2, ignoreValue: '#8b5cf6' },
{ antipattern: 'design-system-color', file: join(root, 'src', 'alpha.css'), line: 3, ignoreValue: 'rgba(139, 92, 246, 0.5)' },
{ antipattern: 'design-system-color', file: join(root, 'src', 'other.css'), line: 4, ignoreValue: '#8b5cf7' },
{ antipattern: 'design-system-radius', file: join(root, 'src', 'radius.css'), line: 5, ignoreValue: 'rgb(139, 92, 246)' },
];
const filtered = filterDetectionFindings(findings, {
ignoreValues: [
{ rule: 'design-system-color', value: '#8b5cf6' },
{ rule: 'design-system-color', value: 'rgb(139 92 246 / 100%)' },
],
});
expect(filtered.map((f) => `${f.antipattern}:${f.line}`)).toEqual([
'design-system-color:3',
'design-system-color:4',
'design-system-radius:5',
]);
});
test('filterDetectionFindings normalizes every supported CSS color unit', () => {
const findings = [
{ antipattern: 'design-system-color', line: 1, ignoreValue: '#f00' },
{ antipattern: 'design-system-color', line: 2, ignoreValue: 'rgb(100% 0% 0%)' },
{ antipattern: 'design-system-color', line: 3, ignoreValue: 'hsl(360deg 100% 50%)' },
{ antipattern: 'design-system-color', line: 4, ignoreValue: 'hsl(180deg 100% 50%)' },
{ antipattern: 'design-system-color', line: 5, ignoreValue: 'hsl(3.141592653589793rad 100% 50%)' },
{ antipattern: 'design-system-color', line: 6, ignoreValue: 'hsl(0.5turn 100% 50%)' },
{ antipattern: 'design-system-color', line: 7, ignoreValue: 'hsl(200grad 100% 50%)' },
{ antipattern: 'design-system-color', line: 8, ignoreValue: 'rgba(255, 0, 0, 0.5)' },
{ antipattern: 'design-system-color', line: 9, ignoreValue: 'rgb(100% 0% 0% / 50%)' },
{ antipattern: 'design-system-color', line: 10, ignoreValue: 'hsla(0, 100%, 50%, 50%)' },
{ antipattern: 'design-system-color', line: 11, ignoreValue: '#f008' },
];
const filtered = filterDetectionFindings(findings, {
ignoreValues: [
{ rule: 'design-system-color', value: '#ff0000' },
{ rule: 'design-system-color', value: '#00ffff' },
{ rule: 'design-system-color', value: '#ff000080' },
{ rule: 'design-system-color', value: '#ff000088' },
],
});
expect(filtered).toEqual([]);
});
test('filterDetectionFindings rejects out-of-range and malformed CSS colors', () => {
const findings = [
{ antipattern: 'design-system-color', line: 1, ignoreValue: 'rgb(256 0 0)' },
{ antipattern: 'design-system-color', line: 2, ignoreValue: 'rgb(100.1% 0% 0%)' },
{ antipattern: 'design-system-color', line: 3, ignoreValue: 'rgba(255, 0, 0, 101%)' },
{ antipattern: 'design-system-color', line: 4, ignoreValue: 'rgba(255, 0, 0, -0.1)' },
{ antipattern: 'design-system-color', line: 5, ignoreValue: 'hsl(0 100 50%)' },
{ antipattern: 'design-system-color', line: 6, ignoreValue: 'hsl(0 101% 50%)' },
{ antipattern: 'design-system-color', line: 7, ignoreValue: 'hsl(0foo 100% 50%)' },
{ antipattern: 'design-system-color', line: 8, ignoreValue: '#ff00000' },
];
const filtered = filterDetectionFindings(findings, {
ignoreValues: [
{ rule: 'design-system-color', value: '#ff0000' },
{ rule: 'design-system-color', value: '#ff000080' },
],
});
expect(filtered.map((finding) => finding.line)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
});
test('extractFindingIgnoreValue handles fonts, Google font URLs, and motion snippets', () => {
expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'Primary font: Avenir Next (80% of text)' })).toBe('avenir next');
expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'https://fonts.googleapis.com/css2?family=Alumni+Sans:wght@700' })).toBe('alumni sans');
expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'Google Fonts: space grotesk' })).toBe('space grotesk');
expect(extractFindingIgnoreValue({ antipattern: 'bounce-easing', snippet: 'animation: bounce-ball 1s infinite' })).toBe('bounce-ball');
});
// This list is duplicated in skill/scripts/hook-lib.mjs. The two had drifted:
// font-size waivers worked in the hook but not in the CLI, so the same config
// filtered differently depending on which entry point read it.
test('extractFindingIgnoreValue covers design-system-font-size, matching the hook', () => {
expect(extractFindingIgnoreValue({ antipattern: 'design-system-font-size', ignoreValue: '0.82rem' })).toBe('0.82rem');
expect(extractFindingIgnoreValue({ antipattern: 'design-system-radius', ignoreValue: '18px' })).toBe('18px');
// A rule with no waivable value still extracts nothing.
expect(extractFindingIgnoreValue({ antipattern: 'side-tab', snippet: 'border-left: 4px' })).toBe('');
});
});