22 * BSV Conformance Runner
33 *
44 * Loads test vectors from conformance/vectors/**\/*.json,
5- * executes them against the TypeScript implementations,
6- * and emits JUnit XML + JSON reports.
5+ * validates their structure, and emits JUnit XML + JSON reports.
76 *
87 * MBGA §8.5: per-language vector runner.
98 *
109 * Usage:
11- * node src/runner.js [--vectors <glob>] [--output <dir>]
10+ * node src/runner.js [--validate-only] [--report <path>] [--vectors <dir>]
11+ *
12+ * Exit codes:
13+ * 0 all vector files parsed cleanly
14+ * 1 one or more parse / validation errors
1215 */
1316
1417import { readdir , readFile , writeFile , mkdir } from 'node:fs/promises'
15- import { join , resolve } from 'node:path'
18+ import { join , resolve , dirname } from 'node:path'
19+ import { fileURLToPath } from 'node:url'
20+
21+ // ---------------------------------------------------------------------------
22+ // Paths
23+ // ---------------------------------------------------------------------------
24+
25+ const __filename = fileURLToPath ( import . meta. url )
26+ const __dirname = dirname ( __filename )
27+
28+ const VECTORS_DIR = resolve ( __dirname , '../../vectors' )
29+ const REPORT_DIR = resolve ( __dirname , '../reports' )
30+
31+ // ---------------------------------------------------------------------------
32+ // CLI args
33+ // ---------------------------------------------------------------------------
1634
17- const VECTORS_DIR = resolve ( import . meta. dirname , '../../vectors' )
18- const REPORT_DIR = resolve ( import . meta. dirname , '../reports' )
35+ function parseArgs ( argv ) {
36+ const args = argv . slice ( 2 )
37+ const opts = {
38+ validateOnly : false ,
39+ reportPath : null ,
40+ vectorsDir : VECTORS_DIR
41+ }
42+ for ( let i = 0 ; i < args . length ; i ++ ) {
43+ if ( args [ i ] === '--validate-only' ) opts . validateOnly = true
44+ else if ( args [ i ] === '--report' && args [ i + 1 ] ) opts . reportPath = resolve ( args [ ++ i ] )
45+ else if ( args [ i ] === '--vectors' && args [ i + 1 ] ) opts . vectorsDir = resolve ( args [ ++ i ] )
46+ }
47+ return opts
48+ }
1949
20- async function loadVectors ( dir ) {
21- const entries = await readdir ( dir , { withFileTypes : true , recursive : true } )
22- const vectors = [ ]
50+ // ---------------------------------------------------------------------------
51+ // Vector file loading — recursive glob over all *.json
52+ // ---------------------------------------------------------------------------
53+
54+ async function findJsonFiles ( dir ) {
55+ const results = [ ]
56+ let entries
57+ try {
58+ entries = await readdir ( dir , { withFileTypes : true } )
59+ } catch {
60+ return results
61+ }
2362 for ( const entry of entries ) {
24- if ( entry . isFile ( ) && entry . name . endsWith ( '.json' ) ) {
25- const path = join ( entry . parentPath ?? entry . path , entry . name )
26- const raw = await readFile ( path , 'utf-8' )
27- vectors . push ( { path, data : JSON . parse ( raw ) } )
63+ const full = join ( dir , entry . name )
64+ if ( entry . isDirectory ( ) ) {
65+ const sub = await findJsonFiles ( full )
66+ results . push ( ...sub )
67+ } else if ( entry . isFile ( ) && entry . name . endsWith ( '.json' ) ) {
68+ results . push ( full )
2869 }
2970 }
30- return vectors
71+ return results
3172}
3273
33- function toJUnit ( results ) {
34- const total = results . length
35- const failed = results . filter ( r => ! r . pass ) . length
36- const cases = results . map ( r => {
37- if ( r . pass ) {
38- return ` <testcase name="${ escXml ( r . name ) } " classname="${ escXml ( r . suite ) } " time="0"/>`
74+ // ---------------------------------------------------------------------------
75+ // Validation helpers
76+ // ---------------------------------------------------------------------------
77+
78+ const REQUIRED_TOP_LEVEL = [ 'vectors' ]
79+ const RECOMMENDED_TOP_LEVEL = [ '$schema' , 'id' , 'name' , 'brc' , 'version' , 'reference_impl' , 'parity_class' ]
80+ const REQUIRED_VECTOR_FIELDS = [ 'id' , 'input' , 'expected' ]
81+
82+ function validateFile ( path , data ) {
83+ const errors = [ ]
84+
85+ if ( typeof data !== 'object' || data === null || Array . isArray ( data ) ) {
86+ // Legacy format: bare array of vectors (no top-level envelope)
87+ if ( Array . isArray ( data ) ) {
88+ errors . push ( `WARN: ${ path } uses legacy bare-array format — wrap in { "vectors": [...] } envelope` )
89+ return { errors, vectors : data }
3990 }
40- return [
41- ` <testcase name="${ escXml ( r . name ) } " classname="${ escXml ( r . suite ) } " time="0">` ,
42- ` <failure message="${ escXml ( r . error ) } ">${ escXml ( r . error ) } </failure>` ,
43- ' </testcase>'
44- ] . join ( '\n' )
45- } ) . join ( '\n' )
91+ errors . push ( `ERROR: ${ path } top level must be an object or array` )
92+ return { errors, vectors : [ ] }
93+ }
4694
47- return [
48- '<?xml version="1.0" encoding="UTF-8"?>' ,
49- `<testsuite name="bsv-conformance" tests="${ total } " failures="${ failed } " errors="0" skipped="0">` ,
50- cases ,
51- '</testsuite>'
52- ] . join ( '\n' )
95+ // Check required top-level fields
96+ for ( const field of REQUIRED_TOP_LEVEL ) {
97+ if ( ! ( field in data ) ) {
98+ errors . push ( `ERROR: ${ path } missing required top-level field "${ field } "` )
99+ }
100+ }
101+
102+ // Warn on missing recommended fields
103+ for ( const field of RECOMMENDED_TOP_LEVEL ) {
104+ if ( ! ( field in data ) ) {
105+ errors . push ( `WARN: ${ path } missing recommended top-level field "${ field } "` )
106+ }
107+ }
108+
109+ const vectors = Array . isArray ( data . vectors ) ? data . vectors : [ ]
110+
111+ if ( ! Array . isArray ( data . vectors ) ) {
112+ errors . push ( `ERROR: ${ path } "vectors" must be an array` )
113+ return { errors, vectors : [ ] }
114+ }
115+
116+ return { errors, vectors }
53117}
54118
119+ function validateVector ( filePath , vec , index ) {
120+ const errors = [ ]
121+ for ( const field of REQUIRED_VECTOR_FIELDS ) {
122+ if ( ! ( field in vec ) ) {
123+ errors . push ( `ERROR: ${ filePath } vector[${ index } ] missing required field "${ field } "` )
124+ }
125+ }
126+ return errors
127+ }
128+
129+ // ---------------------------------------------------------------------------
130+ // JUnit XML helpers
131+ // ---------------------------------------------------------------------------
132+
55133function escXml ( s = '' ) {
56134 return String ( s )
57135 . replace ( / & / g, '&' )
@@ -60,42 +138,172 @@ function escXml (s = '') {
60138 . replace ( / " / g, '"' )
61139}
62140
141+ function toJUnit ( suites ) {
142+ const allCases = suites . flatMap ( s => s . cases )
143+ const total = allCases . length
144+ const failed = allCases . filter ( c => ! c . pass ) . length
145+
146+ const suiteXml = suites . map ( s => {
147+ const sTotal = s . cases . length
148+ const sFailed = s . cases . filter ( c => ! c . pass ) . length
149+ const casesXml = s . cases . map ( c => {
150+ if ( c . pass ) {
151+ return ` <testcase name="${ escXml ( c . name ) } " classname="${ escXml ( s . name ) } " time="0"/>`
152+ }
153+ return [
154+ ` <testcase name="${ escXml ( c . name ) } " classname="${ escXml ( s . name ) } " time="0">` ,
155+ ` <failure message="${ escXml ( c . error ) } ">${ escXml ( c . error ) } </failure>` ,
156+ ' </testcase>'
157+ ] . join ( '\n' )
158+ } ) . join ( '\n' )
159+
160+ return [
161+ ` <testsuite name="${ escXml ( s . name ) } " tests="${ sTotal } " failures="${ sFailed } " errors="0" skipped="0">` ,
162+ casesXml ,
163+ ' </testsuite>'
164+ ] . join ( '\n' )
165+ } ) . join ( '\n' )
166+
167+ return [
168+ '<?xml version="1.0" encoding="UTF-8"?>' ,
169+ `<testsuites name="bsv-conformance" tests="${ total } " failures="${ failed } " errors="0" skipped="0">` ,
170+ suiteXml ,
171+ '</testsuites>'
172+ ] . join ( '\n' )
173+ }
174+
175+ // ---------------------------------------------------------------------------
176+ // Main
177+ // ---------------------------------------------------------------------------
178+
63179async function run ( ) {
64- console . log ( `Loading vectors from ${ VECTORS_DIR } ` )
65- let vectorFiles
66- try {
67- vectorFiles = await loadVectors ( VECTORS_DIR )
68- } catch {
69- console . log ( 'No vectors found — nothing to run.' )
180+ const opts = parseArgs ( process . argv )
181+ const vectorsDir = opts . vectorsDir
182+
183+ console . log ( `BSV Conformance Runner` )
184+ console . log ( ` Vectors dir : ${ vectorsDir } ` )
185+ console . log ( ` Mode : ${ opts . validateOnly ? 'validate-only' : 'validate + report' } ` )
186+ if ( opts . reportPath ) console . log ( ` Report path : ${ opts . reportPath } ` )
187+ console . log ( )
188+
189+ // Discover all JSON files
190+ const jsonFiles = await findJsonFiles ( vectorsDir )
191+
192+ if ( jsonFiles . length === 0 ) {
193+ console . log ( 'No vector files found — nothing to validate.' )
70194 process . exit ( 0 )
71195 }
72196
73- const results = [ ]
197+ console . log ( `Found ${ jsonFiles . length } vector file(s)` )
74198
75- for ( const { path, data } of vectorFiles ) {
76- const suite = path . replace ( VECTORS_DIR + '/' , '' ) . replace ( / \. j s o n $ / , '' )
77- const cases = Array . isArray ( data ) ? data : data . vectors ?? [ ]
199+ let totalVectors = 0
200+ let totalParseErrors = 0
201+ const suites = [ ]
202+ const allErrors = [ ]
78203
79- for ( const vec of cases ) {
80- const name = vec . description ?? vec . name ?? JSON . stringify ( vec ) . slice ( 0 , 60 )
81- // Vectors are self-validating: each has `input` and `expected` fields.
82- // Domain-specific executors (imported per suite) do the actual check.
83- // For now, mark as pending until executor is wired.
84- results . push ( { suite, name, pass : true , pending : true } )
204+ for ( const filePath of jsonFiles ) {
205+ const relPath = filePath . replace ( vectorsDir + '/' , '' )
206+ let raw , parsed
207+
208+ // Parse JSON
209+ try {
210+ raw = await readFile ( filePath , 'utf-8' )
211+ parsed = JSON . parse ( raw )
212+ } catch ( err ) {
213+ const msg = `PARSE ERROR: ${ filePath } : ${ err . message } `
214+ allErrors . push ( msg )
215+ totalParseErrors ++
216+ console . log ( ` [FAIL] ${ relPath } — ${ err . message } ` )
217+ continue
218+ }
219+
220+ // Validate file structure
221+ const { errors : fileErrors , vectors } = validateFile ( filePath , parsed )
222+
223+ // Validate each vector
224+ const cases = [ ]
225+ for ( let i = 0 ; i < vectors . length ; i ++ ) {
226+ const vec = vectors [ i ]
227+ const vecErrors = validateVector ( filePath , vec , i )
228+ const vecName = vec . id ?? vec . description ?? `vector[${ i } ]`
229+
230+ const isFatal = vecErrors . some ( e => e . startsWith ( 'ERROR:' ) )
231+ cases . push ( {
232+ name : vecName ,
233+ pass : ! isFatal ,
234+ error : isFatal ? vecErrors . filter ( e => e . startsWith ( 'ERROR:' ) ) . join ( '; ' ) : null
235+ } )
236+ if ( isFatal ) allErrors . push ( ...vecErrors . filter ( e => e . startsWith ( 'ERROR:' ) ) )
237+ }
238+
239+ const fatalFileErrors = fileErrors . filter ( e => e . startsWith ( 'ERROR:' ) )
240+ const warnFileErrors = fileErrors . filter ( e => e . startsWith ( 'WARN:' ) )
241+
242+ const suiteName = relPath . replace ( / \. j s o n $ / , '' )
243+ const suitePass = fatalFileErrors . length === 0
244+
245+ // If the file itself has fatal errors, add a synthetic failing case
246+ if ( fatalFileErrors . length > 0 ) {
247+ cases . unshift ( {
248+ name : '_file_structure' ,
249+ pass : false ,
250+ error : fatalFileErrors . join ( '; ' )
251+ } )
252+ allErrors . push ( ...fatalFileErrors )
253+ totalParseErrors ++
85254 }
255+
256+ suites . push ( { name : suiteName , cases } )
257+ totalVectors += vectors . length
258+
259+ const status = ( fatalFileErrors . length === 0 && cases . every ( c => c . pass ) ) ? 'OK' : 'FAIL'
260+ const warnStr = warnFileErrors . length > 0 ? ` (${ warnFileErrors . length } warn)` : ''
261+ console . log ( ` [${ status } ] ${ relPath } — ${ vectors . length } vector(s)${ warnStr } ` )
262+
263+ for ( const w of warnFileErrors ) console . log ( ` ${ w } ` )
264+ for ( const e of fatalFileErrors ) console . log ( ` ${ e } ` )
86265 }
87266
88- await mkdir ( REPORT_DIR , { recursive : true } )
267+ console . log ( )
268+ console . log ( `Summary` )
269+ console . log ( ` Total vector files : ${ jsonFiles . length } ` )
270+ console . log ( ` Total vectors : ${ totalVectors } ` )
271+ console . log ( ` Parse/structure errors : ${ allErrors . filter ( e => e . startsWith ( 'ERROR:' ) ) . length } ` )
272+
273+ // Write reports
274+ const reportDir = opts . reportPath ? dirname ( opts . reportPath ) : REPORT_DIR
275+ const xmlPath = opts . reportPath ?? join ( REPORT_DIR , 'results.xml' )
276+ const jsonPath = join ( reportDir , 'report.json' )
277+
278+ if ( ! opts . validateOnly ) {
279+ await mkdir ( reportDir , { recursive : true } )
280+ await mkdir ( REPORT_DIR , { recursive : true } )
89281
90- const jsonReport = { timestamp : new Date ( ) . toISOString ( ) , results }
91- await writeFile ( join ( REPORT_DIR , 'report.json' ) , JSON . stringify ( jsonReport , null , 2 ) )
92- await writeFile ( join ( REPORT_DIR , 'report.xml' ) , toJUnit ( results ) )
282+ const xmlReport = toJUnit ( suites )
283+ await writeFile ( xmlPath , xmlReport )
284+ console . log ( ` JUnit XML : ${ xmlPath } ` )
93285
94- const passed = results . filter ( r => r . pass ) . length
95- const failed = results . filter ( r => ! r . pass ) . length
96- console . log ( `Conformance: ${ passed } passed, ${ failed } failed (${ results . filter ( r => r . pending ) . length } pending)` )
286+ const jsonReport = {
287+ timestamp : new Date ( ) . toISOString ( ) ,
288+ totalVectors,
289+ totalFiles : jsonFiles . length ,
290+ parseErrors : totalParseErrors ,
291+ suites
292+ }
293+ await writeFile ( jsonPath , JSON . stringify ( jsonReport , null , 2 ) )
294+ console . log ( ` JSON report : ${ jsonPath } ` )
295+ }
296+
297+ const hasErrors = allErrors . some ( e => e . startsWith ( 'ERROR:' ) )
298+ if ( hasErrors ) {
299+ console . log ( )
300+ console . log ( 'RESULT: FAIL — one or more errors found' )
301+ process . exit ( 1 )
302+ }
97303
98- if ( failed > 0 ) process . exit ( 1 )
304+ console . log ( )
305+ console . log ( 'RESULT: PASS — all vector files parsed cleanly' )
306+ process . exit ( 0 )
99307}
100308
101309run ( ) . catch ( err => { console . error ( err ) ; process . exit ( 1 ) } )
0 commit comments