1+
2+ /**
3+ * @fileoverview Unified test runner that provides a smooth, single-script experience.
4+ * Combines check, build, and test steps with clean, consistent output.
5+ */
6+
7+ import { spawn } from 'node:child_process'
8+ import { existsSync } from 'node:fs'
9+ import path from 'node:path'
10+ import { fileURLToPath } from 'node:url'
11+ import { parseArgs } from 'node:util'
12+
13+ import colors from 'yoctocolors-cjs'
14+
15+ import WIN32 from '@socketsecurity/registry/lib/constants/WIN32'
16+
17+ import { getTestsToRun } from './utils/affected-test-mapper.mjs'
18+
19+ const __dirname = path . dirname ( fileURLToPath ( import . meta. url ) )
20+ const rootPath = path . join ( __dirname , '..' )
21+ const nodeModulesBinPath = path . join ( rootPath , 'node_modules' , '.bin' )
22+
23+ // Simple clean logging without prefixes
24+ const log = {
25+ info : msg => console . log ( msg ) ,
26+ error : msg => console . error ( `${ colors . red ( '✗' ) } ${ msg } ` ) ,
27+ success : msg => console . log ( `${ colors . green ( '✓' ) } ${ msg } ` ) ,
28+ step : msg => console . log ( `\n${ msg } ` ) ,
29+ substep : msg => console . log ( ` ${ msg } ` ) ,
30+ progress : msg => {
31+ // Write progress message without newline for in-place updates
32+ process . stdout . write ( ` ∴ ${ msg } ` )
33+ } ,
34+ done : msg => {
35+ // Clear current line and write success message
36+ // Carriage return + clear line
37+ process . stdout . write ( '\r\x1b[K' )
38+ console . log ( ` ${ colors . green ( '✓' ) } ${ msg } ` )
39+ } ,
40+ failed : msg => {
41+ // Clear current line and write failure message
42+ // Carriage return + clear line
43+ process . stdout . write ( '\r\x1b[K' )
44+ console . log ( ` ${ colors . red ( '✗' ) } ${ msg } ` )
45+ }
46+ }
47+
48+ async function runCommand ( command , args = [ ] , options = { } ) {
49+ return new Promise ( ( resolve , reject ) => {
50+ const child = spawn ( command , args , {
51+ stdio : 'inherit' ,
52+ ...( WIN32 && { shell : true } ) ,
53+ ...options ,
54+ } )
55+
56+ child . on ( 'exit' , code => {
57+ resolve ( code || 0 )
58+ } )
59+
60+ child . on ( 'error' , error => {
61+ reject ( error )
62+ } )
63+ } )
64+ }
65+
66+ async function runCheck ( ) {
67+ log . step ( 'Running checks' )
68+
69+ // Run fix (auto-format) quietly since it has its own output
70+ log . progress ( 'Formatting code' )
71+ let exitCode = await runCommand ( 'pnpm' , [ 'run' , 'fix' ] , {
72+ stdio : 'pipe'
73+ } )
74+ if ( exitCode !== 0 ) {
75+ log . failed ( 'Formatting failed' )
76+ // Re-run with output to show errors
77+ await runCommand ( 'pnpm' , [ 'run' , 'fix' ] )
78+ return exitCode
79+ }
80+ log . done ( 'Code formatted' )
81+
82+ // Run ESLint to check for remaining issues
83+ log . progress ( 'Checking ESLint' )
84+ exitCode = await runCommand ( 'eslint' , [
85+ '--config' ,
86+ '.config/eslint.config.mjs' ,
87+ '--report-unused-disable-directives' ,
88+ '.'
89+ ] , {
90+ stdio : 'pipe'
91+ } )
92+ if ( exitCode !== 0 ) {
93+ log . failed ( 'ESLint failed' )
94+ // Re-run with output to show errors
95+ await runCommand ( 'eslint' , [
96+ '--config' ,
97+ '.config/eslint.config.mjs' ,
98+ '--report-unused-disable-directives' ,
99+ '.'
100+ ] )
101+ return exitCode
102+ }
103+ log . done ( 'ESLint passed' )
104+
105+ // Run TypeScript check
106+ log . progress ( 'Checking TypeScript' )
107+ exitCode = await runCommand ( 'tsgo' , [
108+ '--noEmit' ,
109+ '-p' ,
110+ '.config/tsconfig.check.json'
111+ ] , {
112+ stdio : 'pipe'
113+ } )
114+ if ( exitCode !== 0 ) {
115+ log . failed ( 'TypeScript check failed' )
116+ // Re-run with output to show errors
117+ await runCommand ( 'tsgo' , [
118+ '--noEmit' ,
119+ '-p' ,
120+ '.config/tsconfig.check.json'
121+ ] )
122+ return exitCode
123+ }
124+ log . done ( 'TypeScript check passed' )
125+
126+ return exitCode
127+ }
128+
129+ async function runBuild ( ) {
130+ const distIndexPath = path . join ( rootPath , 'dist' , 'index.js' )
131+ if ( ! existsSync ( distIndexPath ) ) {
132+ log . step ( 'Building project' )
133+ return runCommand ( 'pnpm' , [ 'run' , 'build' ] )
134+ }
135+ return 0
136+ }
137+
138+ async function runTests ( options ) {
139+ const { all, coverage, force, staged } = options
140+ const runAll = all || force
141+
142+ // Get tests to run
143+ const testInfo = getTestsToRun ( { staged, all : runAll } )
144+ const { reason, tests : testsToRun } = testInfo
145+
146+ // No tests needed
147+ if ( testsToRun === null ) {
148+ log . substep ( 'No relevant changes detected, skipping tests' )
149+ return 0
150+ }
151+
152+ // Prepare vitest command
153+ const vitestCmd = WIN32 ? 'vitest.cmd' : 'vitest'
154+ const vitestPath = path . join ( nodeModulesBinPath , vitestCmd )
155+
156+ const vitestArgs = [ '--config' , '.config/vitest.config.mts' , 'run' ]
157+
158+ // Add coverage if requested
159+ if ( coverage ) {
160+ vitestArgs . push ( '--coverage' )
161+ }
162+
163+ // Add test patterns if not running all
164+ if ( testsToRun === 'all' ) {
165+ const reasonText = reason ? ` (${ reason } )` : ''
166+ log . step ( `Running all tests${ reasonText } ` )
167+ } else {
168+ log . step ( `Running affected tests:` )
169+ testsToRun . forEach ( test => log . substep ( test ) )
170+ vitestArgs . push ( ...testsToRun )
171+ }
172+
173+ const spawnOptions = {
174+ cwd : rootPath ,
175+ env : {
176+ ...process . env ,
177+ NODE_OPTIONS :
178+ `${ process . env . NODE_OPTIONS || '' } --max-old-space-size=${ process . env . CI ? 8192 : 4096 } ` . trim ( ) ,
179+ } ,
180+ stdio : 'inherit' ,
181+ }
182+
183+ // Use dotenvx to load test environment
184+ const dotenvxCmd = WIN32 ? 'dotenvx.cmd' : 'dotenvx'
185+ const dotenvxPath = path . join ( nodeModulesBinPath , dotenvxCmd )
186+
187+ return runCommand ( dotenvxPath , [
188+ '-q' ,
189+ 'run' ,
190+ '-f' ,
191+ '.env.test' ,
192+ '--' ,
193+ vitestPath ,
194+ ...vitestArgs
195+ ] , spawnOptions )
196+ }
197+
198+ async function main ( ) {
199+ try {
200+ // Parse arguments
201+ const { values } = parseArgs ( {
202+ options : {
203+ 'skip-checks' : {
204+ type : 'boolean' ,
205+ default : false ,
206+ } ,
207+ 'skip-build' : {
208+ type : 'boolean' ,
209+ default : false ,
210+ } ,
211+ staged : {
212+ type : 'boolean' ,
213+ default : false ,
214+ } ,
215+ all : {
216+ type : 'boolean' ,
217+ default : false ,
218+ } ,
219+ force : {
220+ type : 'boolean' ,
221+ default : false ,
222+ } ,
223+ coverage : {
224+ type : 'boolean' ,
225+ default : false ,
226+ } ,
227+ } ,
228+ allowPositionals : false ,
229+ strict : false ,
230+ } )
231+
232+ console . log ( '═══════════════════════════════════════════════════════' )
233+ console . log ( ' Socket PackageURL Test Runner' )
234+ console . log ( '═══════════════════════════════════════════════════════' )
235+
236+ let exitCode = 0
237+
238+ // Run checks unless skipped
239+ if ( ! values [ 'skip-checks' ] ) {
240+ exitCode = await runCheck ( )
241+ if ( exitCode !== 0 ) {
242+ log . error ( 'Checks failed' )
243+ process . exitCode = exitCode
244+ return
245+ }
246+ log . success ( 'All checks passed' )
247+ }
248+
249+ // Run build unless skipped
250+ if ( ! values [ 'skip-build' ] ) {
251+ exitCode = await runBuild ( )
252+ if ( exitCode !== 0 ) {
253+ log . error ( 'Build failed' )
254+ process . exitCode = exitCode
255+ return
256+ }
257+ }
258+
259+ // Run tests
260+ exitCode = await runTests ( values )
261+
262+ if ( exitCode !== 0 ) {
263+ log . error ( 'Tests failed' )
264+ process . exitCode = exitCode
265+ } else {
266+ console . log ( '\n═══════════════════════════════════════════════════════' )
267+ log . success ( 'All tests passed!' )
268+ console . log ( '═══════════════════════════════════════════════════════' )
269+ }
270+ } catch ( error ) {
271+ log . error ( `Test runner failed: ${ error . message } ` )
272+ process . exitCode = 1
273+ }
274+ }
275+
276+ main ( ) . catch ( console . error )
0 commit comments