ratchet-eslint-rules.test.ts149 lines · main
1import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2import os from 'node:os'
3import path from 'node:path'
4import { afterEach, describe, expect, it, vi } from 'vitest'
5
6import { runRatchet } from '../ratchet-eslint-rules'
7
8const studioRoot = path.resolve(__dirname, '../..')
9const repoRoot = path.resolve(studioRoot, '..', '..')
10const scriptArgvPlaceholder = path.resolve(studioRoot, 'scripts', 'ratchet-eslint-rules.ts')
11
12const tempDirs: string[] = []
13
14afterEach(() => {
15 vi.restoreAllMocks()
16 while (tempDirs.length) {
17 const dir = tempDirs.pop()
18 if (dir) {
19 rmSync(dir, { recursive: true, force: true })
20 }
21 }
22})
23
24describe('ratchet-eslint-rules integration', () => {
25 it('captures per-file counts when initializing baselines', () => {
26 const tmp = createTempDir()
27 const metadataPath = path.join(tmp, 'baseline.json')
28
29 const eslintResults = buildEslintResults([
30 { filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 1 } },
31 { filePath: repoPath('apps/studio/src/b.ts'), rules: { 'no-console': 2 } },
32 ])
33
34 const result = invokeRatchet(
35 ['--metadata', metadataPath, '--rule', 'no-console', '--init'],
36 eslintResults
37 )
38
39 expect(result).toBe(0)
40
41 const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'))
42 expect(metadata.rules['no-console']).toBe(3)
43 expect(metadata.ruleFiles['no-console']).toEqual({
44 [relativeToCwd('apps/studio/src/a.ts')]: 1,
45 [relativeToCwd('apps/studio/src/b.ts')]: 2,
46 })
47 })
48
49 it('reports offending files when regressions occur and metadata has per-file data', () => {
50 const tmp = createTempDir()
51 const metadataPath = path.join(tmp, 'baseline.json')
52
53 writeFileSync(
54 metadataPath,
55 JSON.stringify(
56 {
57 rules: { 'no-console': 2 },
58 ruleFiles: {
59 'no-console': {
60 [relativeToCwd('apps/studio/src/a.ts')]: 2,
61 },
62 },
63 },
64 null,
65 2
66 )
67 )
68
69 const eslintResults = buildEslintResults([
70 { filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 3 } },
71 { filePath: repoPath('apps/studio/src/b.ts'), rules: { 'no-console': 1 } },
72 ])
73
74 const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
75 const result = invokeRatchet(
76 ['--metadata', metadataPath, '--rule', 'no-console'],
77 eslintResults
78 )
79
80 expect(result).toBe(1)
81 const combinedErrors = errorSpy.mock.calls.map((args) => args.join(' ')).join('\n')
82 expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/a.ts')} (+1)`)
83 expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/b.ts')} (+1)`)
84 })
85
86 it('falls back gracefully when baseline is missing per-file data', () => {
87 const tmp = createTempDir()
88 const metadataPath = path.join(tmp, 'baseline.json')
89
90 writeFileSync(
91 metadataPath,
92 JSON.stringify(
93 {
94 rules: { 'no-console': 1 },
95 },
96 null,
97 2
98 )
99 )
100
101 const eslintResults = buildEslintResults([
102 { filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 2 } },
103 ])
104
105 const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
106 const result = invokeRatchet(
107 ['--metadata', metadataPath, '--rule', 'no-console'],
108 eslintResults
109 )
110
111 expect(result).toBe(1)
112 const combinedErrors = errorSpy.mock.calls.map((args) => args.join(' ')).join('\n')
113 expect(combinedErrors).toContain('baseline missing file breakdown')
114 expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/a.ts')} (2 current)`)
115 })
116})
117
118function buildEslintResults(
119 files: Array<{ filePath: string; rules: Record<string, number> }>
120): unknown[] {
121 return files.map(({ filePath, rules }) => ({
122 filePath,
123 messages: Object.entries(rules).flatMap(([ruleId, count]) =>
124 Array.from({ length: count }, () => ({ ruleId }))
125 ),
126 }))
127}
128
129function createTempDir(): string {
130 const dir = mkdtempSync(path.join(os.tmpdir(), 'ratchet-eslint'))
131 tempDirs.push(dir)
132 return dir
133}
134
135function repoPath(relPath: string): string {
136 return path.join(repoRoot, relPath)
137}
138
139function invokeRatchet(args: string[], eslintResults: unknown[]): number {
140 const argv = ['node', scriptArgvPlaceholder, ...args]
141 return runRatchet(argv, () => ({
142 results: eslintResults as any,
143 stderr: '',
144 }))
145}
146
147function relativeToCwd(relPath: string): string {
148 return path.relative(process.cwd(), repoPath(relPath)).split(path.sep).join('/')
149}