Skip to content

Commit 09ddc4b

Browse files
committed
feat: add affected test runner for faster test execution
Implements intelligent test selection based on git changes to speed up local development and precommit hooks. Maps source files to their corresponding test files, running only affected tests when possible. Key features: - Detects changed/staged files using git utilities - Maps commands to co-located test files - Maps utils to test files in src/utils/ and test/unit/utils/ - Core files (cli, constants, types) trigger all tests - Supports --staged, --all, --force, and --coverage flags - Builds project automatically if needed
1 parent f26e7d4 commit 09ddc4b

File tree

2 files changed

+279
-0
lines changed

2 files changed

+279
-0
lines changed

scripts/test-affected.mjs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @fileoverview Affected test runner that runs only tests affected by changes.
3+
* Uses git utilities to detect changes and maps them to relevant test files.
4+
*/
5+
6+
import { spawn } from 'node:child_process'
7+
import { existsSync } from 'node:fs'
8+
import path from 'node:path'
9+
import { fileURLToPath } from 'node:url'
10+
import { parseArgs } from 'node:util'
11+
12+
import WIN32 from '@socketsecurity/registry/lib/constants/WIN32'
13+
import { logger } from '@socketsecurity/registry/lib/logger'
14+
15+
import { getTestsToRun } from './utils/affected-test-mapper.mjs'
16+
17+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
18+
const rootPath = path.join(__dirname, '..')
19+
const nodeModulesBinPath = path.join(rootPath, 'node_modules', '.bin')
20+
21+
async function main() {
22+
try {
23+
// Parse arguments
24+
const { positionals, values } = parseArgs({
25+
options: {
26+
staged: {
27+
type: 'boolean',
28+
default: false,
29+
},
30+
all: {
31+
type: 'boolean',
32+
default: false,
33+
},
34+
force: {
35+
type: 'boolean',
36+
default: false,
37+
},
38+
coverage: {
39+
type: 'boolean',
40+
default: false,
41+
},
42+
},
43+
allowPositionals: true,
44+
strict: false,
45+
})
46+
47+
const { all, coverage, force, staged } = values
48+
// Support --force as alias for --all for backwards compatibility
49+
const runAll = all || force
50+
51+
// Build first if dist doesn't exist
52+
const distIndexPath = path.join(rootPath, 'dist', 'cli.js')
53+
if (!existsSync(distIndexPath)) {
54+
logger.info('Building project before tests...')
55+
const { execSync } = await import('node:child_process')
56+
execSync('pnpm run build:dist:src', {
57+
cwd: rootPath,
58+
stdio: 'inherit',
59+
})
60+
}
61+
62+
// Get tests to run
63+
const testsToRun = getTestsToRun({ staged, all: runAll })
64+
65+
// No tests needed
66+
if (testsToRun === null) {
67+
logger.info('No relevant changes detected, skipping tests')
68+
return
69+
}
70+
71+
// Prepare vitest command
72+
const vitestCmd = WIN32 ? 'vitest.cmd' : 'vitest'
73+
const vitestPath = path.join(nodeModulesBinPath, vitestCmd)
74+
75+
const vitestArgs = ['--config', '.config/vitest.config.mts', 'run']
76+
77+
// Add coverage if requested
78+
if (coverage) {
79+
vitestArgs.push('--coverage')
80+
}
81+
82+
// Add test patterns if not running all
83+
if (testsToRun === 'all') {
84+
logger.info('Running all tests')
85+
} else {
86+
logger.info(`Running affected tests: ${testsToRun.join(', ')}`)
87+
vitestArgs.push(...testsToRun)
88+
}
89+
90+
// Add any additional arguments
91+
if (positionals.length > 0) {
92+
vitestArgs.push(...positionals)
93+
}
94+
95+
const spawnOptions = {
96+
cwd: rootPath,
97+
env: {
98+
...process.env,
99+
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=${process.env.CI ? 8192 : 4096}`.trim(),
100+
},
101+
stdio: 'inherit',
102+
}
103+
104+
const child = spawn(vitestPath, vitestArgs, spawnOptions)
105+
106+
child.on('exit', code => {
107+
process.exitCode = code || 0
108+
})
109+
} catch (e) {
110+
logger.error('Error running tests:', e.message)
111+
process.exitCode = 1
112+
}
113+
}
114+
115+
main().catch(console.error)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* @fileoverview Maps changed source files to test files for affected test running.
3+
* Uses git utilities from socket-registry to detect changes.
4+
*/
5+
6+
import { existsSync } from 'node:fs'
7+
import path from 'node:path'
8+
9+
import {
10+
getChangedFilesSync,
11+
getStagedFilesSync,
12+
} from '@socketsecurity/registry/lib/git'
13+
import { normalizePath } from '@socketsecurity/registry/lib/path'
14+
15+
const rootPath = path.resolve(process.cwd())
16+
17+
/**
18+
* Core files that require running all tests when changed.
19+
*/
20+
const CORE_FILES = [
21+
'src/cli.mts',
22+
'src/constants.mts',
23+
'src/types.mts',
24+
'src/utils/debug.mts',
25+
'src/utils/errors.mts',
26+
'src/utils/logger.mts',
27+
'src/utils/config.mts',
28+
'src/utils/api-helpers.mts',
29+
]
30+
31+
/**
32+
* Patterns that trigger running all tests.
33+
*/
34+
const RUN_ALL_PATTERNS = [
35+
'.config/',
36+
'vitest.config.',
37+
'tsconfig',
38+
'package.json',
39+
'pnpm-lock.yaml',
40+
'.env.test',
41+
]
42+
43+
/**
44+
* Map source files to their corresponding test files.
45+
* @param {string} filepath - Path to source file
46+
* @returns {string[]} Array of test file paths or ['all'] to run all tests
47+
*/
48+
function mapSourceToTests(filepath) {
49+
const normalized = normalizePath(filepath)
50+
51+
// Core utilities affect all tests
52+
if (CORE_FILES.some(f => normalized.includes(f))) {
53+
return ['all']
54+
}
55+
56+
// Config changes run all tests
57+
if (RUN_ALL_PATTERNS.some(pattern => normalized.includes(pattern))) {
58+
return ['all']
59+
}
60+
61+
// Test files always run themselves
62+
if (normalized.includes('.test.')) {
63+
return [filepath]
64+
}
65+
66+
// Map command files to their co-located tests
67+
if (normalized.startsWith('src/commands/')) {
68+
const tests = []
69+
// Direct co-located test
70+
const dirname = path.dirname(normalized)
71+
const basename = path.basename(normalized, path.extname(normalized))
72+
const colocatedTest = path.join(dirname, `${basename}.test.mts`)
73+
if (existsSync(path.join(rootPath, colocatedTest))) {
74+
tests.push(colocatedTest)
75+
}
76+
// If it's a cmd-* file, also check for cmd-*.test.mts in same directory
77+
if (basename.startsWith('cmd-')) {
78+
const pattern = path.join(dirname, `${basename}.test.mts`)
79+
if (existsSync(path.join(rootPath, pattern))) {
80+
tests.push(pattern)
81+
}
82+
}
83+
return tests.length > 0 ? tests : ['all']
84+
}
85+
86+
// Map utils files to their tests
87+
if (normalized.startsWith('src/utils/')) {
88+
const tests = []
89+
const basename = path.basename(normalized, path.extname(normalized))
90+
91+
// Check for co-located test in src/utils/
92+
const colocatedTest = `src/utils/${basename}.test.mts`
93+
if (existsSync(path.join(rootPath, colocatedTest))) {
94+
tests.push(colocatedTest)
95+
}
96+
97+
// Check for test in test/unit/utils/
98+
const testUnitTest = `test/unit/utils/${basename}.test.mts`
99+
if (existsSync(path.join(rootPath, testUnitTest))) {
100+
tests.push(testUnitTest)
101+
}
102+
103+
return tests.length > 0 ? tests : ['all']
104+
}
105+
106+
// If no specific mapping, run all tests to be safe
107+
return ['all']
108+
}
109+
110+
/**
111+
* Get affected test files to run based on changed files.
112+
* @param {Object} options
113+
* @param {boolean} options.staged - Use staged files instead of all changes
114+
* @param {boolean} options.all - Run all tests
115+
* @returns {string[] | null} Array of test patterns, 'all', or null if no tests needed
116+
*/
117+
export function getTestsToRun(options = {}) {
118+
const { all = false, staged = false } = options
119+
120+
// All mode runs all tests
121+
if (all || process.env.FORCE_TEST === '1') {
122+
return 'all'
123+
}
124+
125+
// CI always runs all tests
126+
if (process.env.CI === 'true') {
127+
return 'all'
128+
}
129+
130+
// Get changed files
131+
const changedFiles = staged ? getStagedFilesSync() : getChangedFilesSync()
132+
133+
if (changedFiles.length === 0) {
134+
// No changes, skip tests
135+
return null
136+
}
137+
138+
const testFiles = new Set()
139+
let runAllTests = false
140+
141+
for (const file of changedFiles) {
142+
const normalized = normalizePath(file)
143+
const tests = mapSourceToTests(normalized)
144+
145+
if (tests.includes('all')) {
146+
runAllTests = true
147+
break
148+
}
149+
150+
for (const test of tests) {
151+
testFiles.add(test)
152+
}
153+
}
154+
155+
if (runAllTests) {
156+
return 'all'
157+
}
158+
159+
if (testFiles.size === 0) {
160+
return null
161+
}
162+
163+
return Array.from(testFiles)
164+
}

0 commit comments

Comments
 (0)