-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Expand file tree
/
Copy pathcheck-test-patterns.ts
More file actions
319 lines (290 loc) · 11.1 KB
/
Copy pathcheck-test-patterns.ts
File metadata and controls
319 lines (290 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
#!/usr/bin/env bun
/**
* Keeps the test suite on one set of patterns (see `.claude/rules/sim-testing.md`):
*
* - `global-remock`: an apps/sim unit test re-mocks a module `apps/sim/vitest.setup.ts` already
* mocks. Drive the global mock through its knobs instead.
* - `local-factory`: a test hand-rolls a `vi.mock` factory for a module that has a central mock in
* `@sim/testing` (known from any test that mocks it with a `*Mock` imported from there).
* - `local-helper`: a test redefines a helper `@sim/testing` exports.
* - `redundant-hook`: a `beforeEach`/`afterEach` starts with a call the shared Vitest config
* already makes (`clearMocks`, `restoreMocks`, `unstubEnvs`, `unstubGlobals`). Integration files
* only get `clearMocks`, so only `vi.clearAllMocks()` is flagged there.
* - `test-dir`: a test lives in a `__tests__/` or `tests/` directory instead of next to its source.
*
* Existing exceptions are recorded in `scripts/test-patterns-baseline.json`. A new violation fails;
* so does a baseline entry that no longer occurs, so the baseline only ever shrinks. Regenerate it
* after removing violations with `bun run scripts/check-test-patterns.ts --update`.
*
* Run: `bun run check:test-patterns`
*/
import { execFileSync } from 'node:child_process'
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { parse } from '@babel/parser'
const ROOT = path.resolve(import.meta.dir, '..')
const BASELINE = path.join(ROOT, 'scripts/test-patterns-baseline.json')
const SETUP = 'apps/sim/vitest.setup.ts'
/** Helpers exported by `@sim/testing` that tests must import rather than redefine. */
const SHARED_HELPERS = new Set([
'jsonResponse',
'createRouteContext',
'createDeferred',
'flushMicrotasks',
'flushMacrotask',
'collectStream',
])
const CONFIG_HOOK_CALLS = new Set([
'clearAllMocks',
'restoreAllMocks',
'unstubAllEnvs',
'unstubAllGlobals',
])
interface Violation {
file: string
rule: string
detail: string
}
type Node = { type: string; [key: string]: unknown }
function walk(node: unknown, visit: (node: Node) => void): void {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) {
for (const child of node) walk(child, visit)
return
}
const current = node as Node
if (typeof current.type === 'string') visit(current)
for (const [key, value] of Object.entries(current)) {
if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra') continue
walk(value, visit)
}
}
function parseFile(file: string) {
const source = readFileSync(path.join(ROOT, file), 'utf8')
return parse(source, {
sourceType: 'module',
plugins: [
'typescript',
...(file.endsWith('x') ? (['jsx'] as const) : []),
'topLevelAwait',
'decorators',
],
errorRecovery: true,
}).program
}
function isViCall(node: Node, method: string): boolean {
const callee = node.callee as Node | undefined
return (
node.type === 'CallExpression' &&
callee?.type === 'MemberExpression' &&
(callee.object as Node).type === 'Identifier' &&
(callee.object as { name: string }).name === 'vi' &&
(callee.property as { name?: string }).name === method
)
}
function mockedId(node: Node): string | undefined {
if (!isViCall(node, 'mock')) return undefined
const first = (node.arguments as Node[])[0]
return first?.type === 'StringLiteral' ? (first as { value: string }).value : undefined
}
/**
* A factory that uses a central mock as-is: `() => fooMock`, `() => ({ ...fooMock, override })`, or
* `async () => (await import('@sim/testing/mocks/foo.mock')).fooMock`.
*/
function centralMockName(
factory: Node | undefined,
testingImports: Set<string>
): string | undefined {
if (!factory || factory.type !== 'ArrowFunctionExpression') return undefined
const body = factory.body as Node
if (body.type === 'Identifier' && testingImports.has((body as { name: string }).name)) {
return (body as { name: string }).name
}
const first = body.type === 'ObjectExpression' ? (body.properties as Node[])[0] : undefined
const spread = first?.type === 'SpreadElement' ? (first.argument as Node) : undefined
if (spread?.type === 'Identifier' && testingImports.has((spread as { name: string }).name)) {
return (spread as { name: string }).name
}
const awaited = body.type === 'MemberExpression' ? (body.object as Node) : undefined
if (awaited?.type === 'AwaitExpression' && importsTesting(awaited.argument as Node)) {
return (body.property as { name: string }).name
}
return undefined
}
/** A dynamic `import('@sim/testing/…')`. */
function importsTesting(node: Node | undefined): boolean {
const source =
node?.type === 'CallExpression' && (node.callee as Node).type === 'Import'
? (node.arguments as Node[])[0]
: node?.type === 'ImportExpression'
? (node.source as Node)
: undefined
return (
source?.type === 'StringLiteral' &&
(source as { value: string }).value.startsWith('@sim/testing')
)
}
function testFiles(): string[] {
return execFileSync('git', ['ls-files', '*.test.ts', '*.test.tsx', '*.integration.ts'], {
cwd: ROOT,
encoding: 'utf8',
})
.split('\n')
.filter(Boolean)
}
function globalMockIds(): Set<string> {
const ids = new Set<string>()
walk(parseFile(SETUP), (node) => {
const id = mockedId(node)
if (id) ids.add(id)
})
return ids
}
const workspaceUsesTesting = new Map<string, boolean>()
/** Whether the file's workspace depends on `@sim/testing` — only those can adopt a central mock. */
function canUseSharedMocks(file: string): boolean {
const workspace = file.split('/').slice(0, 2).join('/')
let uses = workspaceUsesTesting.get(workspace)
if (uses === undefined) {
try {
const manifest = JSON.parse(readFileSync(path.join(ROOT, workspace, 'package.json'), 'utf8'))
uses = Boolean(
manifest.dependencies?.['@sim/testing'] ?? manifest.devDependencies?.['@sim/testing']
)
} catch {
uses = false
}
workspaceUsesTesting.set(workspace, uses)
}
return uses
}
/**
* Module ids the central mocks declare — each `packages/testing/src/mocks/*.mock.ts` documents its
* target in an `@example` `vi.mock('<id>', () => xMock)` line. Reading the declarations (not just
* current usages) keeps a module covered after its last conforming usage disappears.
*/
function declaredCentralMockIds(): Set<string> {
const ids = new Set<string>()
const dir = path.join(ROOT, 'packages/testing/src/mocks')
for (const name of readdirSync(dir)) {
if (!name.endsWith('.mock.ts')) continue
for (const match of readFileSync(path.join(dir, name), 'utf8').matchAll(
/vi\.mock\(\s*['"]([^'"]+)['"],\s*(?:async\s*)?\(\)\s*=>\s*(?:\(\{\s*\.\.\.)?(?:\(await import\([^)]*\)\)\.)?\w+Mock\b/g
)) {
ids.add(match[1])
}
}
return ids
}
function collect(): Violation[] {
const files = testFiles()
const globals = globalMockIds()
const parsed = files.map((file) => ({ file, program: parseFile(file) }))
const testingImportsByFile = new Map<string, Set<string>>()
const centralIds = new Set<string>([...globals, ...declaredCentralMockIds()])
for (const { file, program } of parsed) {
const imports = new Set<string>()
for (const statement of program.body) {
if (
statement.type === 'ImportDeclaration' &&
statement.source.value.startsWith('@sim/testing')
) {
for (const specifier of statement.specifiers) imports.add(specifier.local.name)
}
}
testingImportsByFile.set(file, imports)
walk(program, (node) => {
const id = mockedId(node)
if (id && centralMockName((node.arguments as Node[])[1], imports)) centralIds.add(id)
})
}
const violations: Violation[] = []
for (const { file, program } of parsed) {
const integration = file.endsWith('.integration.ts')
const imports = testingImportsByFile.get(file) ?? new Set<string>()
if (/(^|\/)(__tests__|tests)\//.test(file)) {
violations.push({ file, rule: 'test-dir', detail: path.dirname(file) })
}
walk(program, (node) => {
const id = mockedId(node)
if (id) {
const factory = (node.arguments as Node[])[1]
const central = centralMockName(factory, imports)
if (!integration && file.startsWith('apps/sim/') && globals.has(id)) {
violations.push({ file, rule: 'global-remock', detail: id })
} else if (
!integration &&
factory &&
!central &&
centralIds.has(id) &&
canUseSharedMocks(file)
) {
violations.push({ file, rule: 'local-factory', detail: id })
}
}
if (
node.type === 'CallExpression' &&
(node.callee as Node).type === 'Identifier' &&
['beforeEach', 'afterEach'].includes((node.callee as { name: string }).name)
) {
const callback = (node.arguments as Node[])[0]
const body = callback?.body as Node | undefined
const first =
body?.type === 'BlockStatement'
? ((body.body as Node[])[0]?.expression as Node | undefined)
: body
if (first?.type !== 'CallExpression') return
for (const method of CONFIG_HOOK_CALLS) {
if (integration && method !== 'clearAllMocks') continue
if (isViCall(first, method)) {
violations.push({ file, rule: 'redundant-hook', detail: `vi.${method}()` })
}
}
}
if (
(node.type === 'FunctionDeclaration' || node.type === 'VariableDeclarator') &&
(node.id as Node | undefined)?.type === 'Identifier'
) {
const name = (node.id as { name: string }).name
if (SHARED_HELPERS.has(name) && !imports.has(name)) {
violations.push({ file, rule: 'local-helper', detail: name })
}
}
})
}
return violations
}
function key(violation: Violation): string {
return `${violation.rule}\t${violation.file}\t${violation.detail}`
}
const violations = collect()
const current = [...new Set(violations.map(key))].sort()
if (process.argv.includes('--update')) {
writeFileSync(BASELINE, `${JSON.stringify(current, null, 2)}\n`)
console.log(`Wrote ${current.length} baseline entries to ${path.relative(ROOT, BASELINE)}`)
process.exit(0)
}
const baseline = new Set<string>(JSON.parse(readFileSync(BASELINE, 'utf8')))
const added = current.filter((entry) => !baseline.has(entry))
const currentSet = new Set(current)
const stale = [...baseline].filter((entry) => !currentSet.has(entry))
if (added.length || stale.length) {
for (const entry of added) {
const [rule, file, detail] = entry.split('\t')
console.error(`✗ ${rule}: ${file} (${detail})`)
}
if (added.length) {
console.error(
`\n${added.length} new test-pattern violation(s). Use the central mock/helper from @sim/testing ` +
'or drive the global mock — see .claude/rules/sim-testing.md.'
)
}
if (stale.length) {
console.error(
`\n${stale.length} baseline entr${stale.length === 1 ? 'y is' : 'ies are'} fixed — shrink the ` +
'baseline: bun run scripts/check-test-patterns.ts --update'
)
}
process.exit(1)
}
console.log(`✓ test patterns (${current.length} baselined exceptions)`)