// scripts/check-tailwind-opacity.mjs が本当に効くことを守るテスト (#1377)。 // // 守る対象は「いま何件あるか」ではなく、**効かない組み合わせを書いたら赤くなる // 経路が残っていること**。控え(baseline)方式は放っておくと「全部控えに入れて // 常に緑」になりやすいので、増えたら落ちる・減ったら知らせる、の両方を固定する。 import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const script = join(repoRoot, 'scripts', 'check-tailwind-opacity.mjs'); /** 不透明度の置き場がある色(黒・白・ 付き)と、無い色(var())。 */ const FIXTURE_CONFIG = `export default { content: ['./index.html', './src/**/*.{ts,tsx}'], theme: { extend: { colors: { accent: 'var(--brand-primary, #18181b)', 'accent-soft': 'var(--brand-primary-soft, #f4f4f5)', themed: 'rgb(var(--themed) / )', plain: '#123456', slate: { 900: 'var(--slate-900)' }, }, }, }, }; `; let dir: string; const ui = () => join(dir, 'ui'); const baseline = () => join(dir, 'baseline.json'); function write(rel: string, body: string) { const full = join(dir, rel); mkdirSync(dirname(full), { recursive: true }); writeFileSync(full, body); } function run(...extra: string[]) { return spawnSync(process.execPath, [script, '--ui', ui(), '--baseline', baseline(), ...extra], { cwd: repoRoot, encoding: 'utf8', }); } beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tw-opacity-')); // 見つけ先の tailwind.config.js を ESM として読ませる。 write('ui/package.json', '{ "type": "module" }\n'); write('ui/tailwind.config.js', FIXTURE_CONFIG); // index.html も content に載っているので、こちらの 1 件も拾われる。 write('ui/index.html', '
\n'); write( 'ui/src/Panel.tsx', [ 'export const Panel = () => (', '
', ' ', '
', ');', ].join('\n'), ); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); describe('check-tailwind-opacity', () => { it('不透明度の置き場が無い色だけを拾う(黒・白・・具体値は拾わない)', () => { const written = run('--update'); expect(written.status).toBe(0); const found = JSON.parse(readFileSync(baseline(), 'utf8')) as { total: number; entries: { file: string; class: string; count: number }[]; }; const classes = found.entries.map((e) => e.class).sort(); // 変化形(dark: 等)は付いたまま数える。安全な色(黒・白・・ // 具体値・すでに直した arbitrary value)は 1 件も混じらない。 expect(classes).toEqual( ['bg-accent/5', 'dark:bg-accent-soft/80', 'bg-slate-900/50', 'border-accent/30'].sort(), ); expect(found.total).toBe(4); expect(found.entries.some((e) => e.file.endsWith('index.html'))).toBe(true); }); it('控えと同じなら通る', () => { expect(run('--update').status).toBe(0); const check = run(); expect(check.status).toBe(0); expect(check.stdout).toContain('no new ones'); }); it('効かない組み合わせを 1 つ足すと落ちる', () => { expect(run('--update').status).toBe(0); write('ui/src/New.tsx', 'export const New = () =>
;\n'); const check = run(); expect(check.status).toBe(1); expect(check.stderr).toContain('bg-slate-900/25'); expect(check.stderr).toContain('New.tsx'); }); it('同じファイルで件数が増えても落ちる', () => { expect(run('--update').status).toBe(0); write( 'ui/src/Panel.tsx', [ 'export const Panel = () => (', '
', ' ', '
', ');', ].join('\n'), ); const check = run(); expect(check.status).toBe(1); expect(check.stderr).toContain('baseline allows 1, found 2'); }); it('控えにある分を直したら、通したうえで控えの更新を促す', () => { expect(run('--update').status).toBe(0); write( 'ui/src/Panel.tsx', 'export const Panel = () =>
;\n', ); const check = run(); expect(check.status).toBe(0); expect(check.stdout).toContain('-- --update'); }); it('控えが無ければ落ちる(黙って通さない)', () => { const check = run(); expect(check.status).toBe(1); expect(check.stderr).toContain('--update'); }); it('このリポジトリ自身も控えと一致している', () => { if (!existsSync(join(repoRoot, 'ui', 'node_modules'))) return; // 依存未導入なら見送る const check = spawnSync(process.execPath, [script], { cwd: repoRoot, encoding: 'utf8' }); expect(check.stderr + check.stdout).toContain('OK'); expect(check.status).toBe(0); }); });