11import { readFileSync , readdirSync , statSync } from "node:fs" ;
22import { join } from "node:path" ;
3- import type { Fixture , FixtureFile , FixtureFileEntry } from "./types.js" ;
3+ import type {
4+ Fixture ,
5+ FixtureFile ,
6+ FixtureFileEntry ,
7+ FixtureFileResponse ,
8+ FixtureResponse ,
9+ ResponseOverrides ,
10+ } from "./types.js" ;
411import {
512 isTextResponse ,
613 isToolCallResponse ,
14+ isContentWithToolCallsResponse ,
715 isErrorResponse ,
816 isEmbeddingResponse ,
17+ isImageResponse ,
18+ isAudioResponse ,
19+ isTranscriptionResponse ,
20+ isVideoResponse ,
921} from "./helpers.js" ;
1022import type { Logger } from "./logger.js" ;
1123
24+ /**
25+ * Auto-stringify object-valued `content` and `toolCalls[].arguments` fields.
26+ * This lets fixture authors write plain JSON objects instead of escaped strings.
27+ * All other fields (including ResponseOverrides) pass through unmodified.
28+ */
29+ export function normalizeResponse ( raw : FixtureFileResponse ) : FixtureResponse {
30+ // Shallow-clone so we don't mutate the parsed JSON input.
31+ const response = { ...raw } as Record < string , unknown > ;
32+
33+ // Auto-stringify object content (e.g. structured output)
34+ if ( typeof response . content === "object" && response . content !== null ) {
35+ response . content = JSON . stringify ( response . content ) ;
36+ }
37+
38+ // Auto-stringify object arguments in toolCalls
39+ if ( Array . isArray ( response . toolCalls ) ) {
40+ response . toolCalls = ( response . toolCalls as Array < Record < string , unknown > > ) . map ( ( tc ) => {
41+ if ( typeof tc . arguments === "object" && tc . arguments !== null ) {
42+ return { ...tc , arguments : JSON . stringify ( tc . arguments ) } ;
43+ }
44+ return tc ;
45+ } ) ;
46+ }
47+
48+ return response as unknown as FixtureResponse ;
49+ }
50+
1251export function entryToFixture ( entry : FixtureFileEntry ) : Fixture {
1352 return {
1453 match : {
@@ -21,7 +60,7 @@ export function entryToFixture(entry: FixtureFileEntry): Fixture {
2160 endpoint : entry . match . endpoint ,
2261 ...( entry . match . sequenceIndex !== undefined && { sequenceIndex : entry . match . sequenceIndex } ) ,
2362 } ,
24- response : entry . response ,
63+ response : normalizeResponse ( entry . response ) ,
2564 ...( entry . latency !== undefined && { latency : entry . latency } ) ,
2665 ...( entry . chunkSize !== undefined && { chunkSize : entry . chunkSize } ) ,
2766 ...( entry . truncateAfterChunks !== undefined && {
@@ -120,6 +159,68 @@ export interface ValidationResult {
120159 message : string ;
121160}
122161
162+ function validateReasoning (
163+ response : { reasoning ?: unknown } ,
164+ fixtureIndex : number ,
165+ results : ValidationResult [ ] ,
166+ ) : void {
167+ if ( response . reasoning !== undefined ) {
168+ if ( typeof response . reasoning !== "string" ) {
169+ results . push ( {
170+ severity : "error" ,
171+ fixtureIndex,
172+ message : "reasoning must be a string" ,
173+ } ) ;
174+ } else if ( response . reasoning === "" ) {
175+ results . push ( {
176+ severity : "warning" ,
177+ fixtureIndex,
178+ message : "reasoning is empty string — no reasoning events will be emitted" ,
179+ } ) ;
180+ }
181+ }
182+ }
183+
184+ function validateWebSearches (
185+ response : { webSearches ?: unknown } ,
186+ fixtureIndex : number ,
187+ results : ValidationResult [ ] ,
188+ ) : void {
189+ if ( response . webSearches !== undefined ) {
190+ if ( ! Array . isArray ( response . webSearches ) ) {
191+ results . push ( {
192+ severity : "error" ,
193+ fixtureIndex,
194+ message : "webSearches must be an array of strings" ,
195+ } ) ;
196+ } else if ( response . webSearches . length === 0 ) {
197+ results . push ( {
198+ severity : "warning" ,
199+ fixtureIndex,
200+ message : "webSearches is empty array — no web search events will be emitted" ,
201+ } ) ;
202+ } else {
203+ for ( let j = 0 ; j < response . webSearches . length ; j ++ ) {
204+ if ( typeof response . webSearches [ j ] !== "string" ) {
205+ results . push ( {
206+ severity : "error" ,
207+ fixtureIndex,
208+ message : `webSearches[${ j } ] is not a string` ,
209+ } ) ;
210+ break ;
211+ }
212+ if ( response . webSearches [ j ] === "" ) {
213+ results . push ( {
214+ severity : "warning" ,
215+ fixtureIndex,
216+ message : `webSearches[${ j } ] is empty string` ,
217+ } ) ;
218+ }
219+ }
220+ }
221+ }
222+ }
223+
123224export function validateFixtures ( fixtures : Fixture [ ] ) : ValidationResult [ ] {
124225 const results : ValidationResult [ ] = [ ] ;
125226
@@ -132,17 +233,24 @@ export function validateFixtures(fixtures: Fixture[]): ValidationResult[] {
132233 // --- Error checks ---
133234
134235 // Response type recognition
236+ // Note: isContentWithToolCallsResponse must be checked before isTextResponse
237+ // and isToolCallResponse since it is a structural superset of both.
135238 if (
239+ ! isContentWithToolCallsResponse ( response ) &&
136240 ! isTextResponse ( response ) &&
137241 ! isToolCallResponse ( response ) &&
138242 ! isErrorResponse ( response ) &&
139- ! isEmbeddingResponse ( response )
243+ ! isEmbeddingResponse ( response ) &&
244+ ! isImageResponse ( response ) &&
245+ ! isAudioResponse ( response ) &&
246+ ! isTranscriptionResponse ( response ) &&
247+ ! isVideoResponse ( response )
140248 ) {
141249 results . push ( {
142250 severity : "error" ,
143251 fixtureIndex : i ,
144252 message :
145- "response is not a recognized type (must have content, toolCalls, error, or embedding )" ,
253+ "response is not a recognized type (must have content, toolCalls, error, embedding, image, audio, transcription, or video )" ,
146254 } ) ;
147255 }
148256
@@ -155,54 +263,47 @@ export function validateFixtures(fixtures: Fixture[]): ValidationResult[] {
155263 message : "content is empty string" ,
156264 } ) ;
157265 }
158- if ( response . reasoning !== undefined ) {
159- if ( typeof response . reasoning !== "string" ) {
266+ validateReasoning ( response , i , results ) ;
267+ validateWebSearches ( response , i , results ) ;
268+ }
269+
270+ // ContentWithToolCalls response checks
271+ if ( isContentWithToolCallsResponse ( response ) ) {
272+ if ( response . content === "" ) {
273+ results . push ( {
274+ severity : "error" ,
275+ fixtureIndex : i ,
276+ message : "content is empty string" ,
277+ } ) ;
278+ }
279+ if ( response . toolCalls . length === 0 ) {
280+ results . push ( {
281+ severity : "warning" ,
282+ fixtureIndex : i ,
283+ message : "toolCalls array is empty — fixture will never produce tool calls" ,
284+ } ) ;
285+ }
286+ for ( let j = 0 ; j < response . toolCalls . length ; j ++ ) {
287+ const tc = response . toolCalls [ j ] ;
288+ if ( ! tc . name ) {
160289 results . push ( {
161290 severity : "error" ,
162291 fixtureIndex : i ,
163- message : "reasoning must be a string" ,
164- } ) ;
165- } else if ( response . reasoning === "" ) {
166- results . push ( {
167- severity : "warning" ,
168- fixtureIndex : i ,
169- message : "reasoning is empty string — no reasoning events will be emitted" ,
292+ message : `toolCalls[${ j } ].name is empty` ,
170293 } ) ;
171294 }
172- }
173- if ( response . webSearches !== undefined ) {
174- if ( ! Array . isArray ( response . webSearches ) ) {
295+ try {
296+ JSON . parse ( tc . arguments ) ;
297+ } catch {
175298 results . push ( {
176299 severity : "error" ,
177300 fixtureIndex : i ,
178- message : "webSearches must be an array of strings" ,
179- } ) ;
180- } else if ( response . webSearches . length === 0 ) {
181- results . push ( {
182- severity : "warning" ,
183- fixtureIndex : i ,
184- message : "webSearches is empty array — no web search events will be emitted" ,
301+ message : `toolCalls[${ j } ].arguments is not valid JSON: ${ tc . arguments } ` ,
185302 } ) ;
186- } else {
187- for ( let j = 0 ; j < response . webSearches . length ; j ++ ) {
188- if ( typeof response . webSearches [ j ] !== "string" ) {
189- results . push ( {
190- severity : "error" ,
191- fixtureIndex : i ,
192- message : `webSearches[${ j } ] is not a string` ,
193- } ) ;
194- break ;
195- }
196- if ( response . webSearches [ j ] === "" ) {
197- results . push ( {
198- severity : "warning" ,
199- fixtureIndex : i ,
200- message : `webSearches[${ j } ] is empty string` ,
201- } ) ;
202- }
203- }
204303 }
205304 }
305+ validateReasoning ( response , i , results ) ;
306+ validateWebSearches ( response , i , results ) ;
206307 }
207308
208309 // Tool call response checks
@@ -274,6 +375,78 @@ export function validateFixtures(fixtures: Fixture[]): ValidationResult[] {
274375 }
275376 }
276377
378+ // Validate ResponseOverrides fields
379+ if (
380+ isTextResponse ( response ) ||
381+ isToolCallResponse ( response ) ||
382+ isContentWithToolCallsResponse ( response )
383+ ) {
384+ const r = response as ResponseOverrides ;
385+ if ( r . id !== undefined && typeof r . id !== "string" ) {
386+ results . push ( {
387+ severity : "error" ,
388+ fixtureIndex : i ,
389+ message : `override "id" must be a string, got ${ typeof r . id } ` ,
390+ } ) ;
391+ }
392+ if ( r . created !== undefined && ( typeof r . created !== "number" || r . created < 0 ) ) {
393+ results . push ( {
394+ severity : "error" ,
395+ fixtureIndex : i ,
396+ message : `override "created" must be a non-negative number` ,
397+ } ) ;
398+ }
399+ if ( r . model !== undefined && typeof r . model !== "string" ) {
400+ results . push ( {
401+ severity : "error" ,
402+ fixtureIndex : i ,
403+ message : `override "model" must be a string, got ${ typeof r . model } ` ,
404+ } ) ;
405+ }
406+ if ( r . finishReason !== undefined && typeof r . finishReason !== "string" ) {
407+ results . push ( {
408+ severity : "error" ,
409+ fixtureIndex : i ,
410+ message : `override "finishReason" must be a string, got ${ typeof r . finishReason } ` ,
411+ } ) ;
412+ }
413+ if ( r . role !== undefined && typeof r . role !== "string" ) {
414+ results . push ( {
415+ severity : "error" ,
416+ fixtureIndex : i ,
417+ message : `override "role" must be a string, got ${ typeof r . role } ` ,
418+ } ) ;
419+ }
420+ if ( r . systemFingerprint !== undefined && typeof r . systemFingerprint !== "string" ) {
421+ results . push ( {
422+ severity : "error" ,
423+ fixtureIndex : i ,
424+ message : `override "systemFingerprint" must be a string, got ${ typeof r . systemFingerprint } ` ,
425+ } ) ;
426+ }
427+ if ( r . usage !== undefined ) {
428+ if ( typeof r . usage !== "object" || r . usage === null || Array . isArray ( r . usage ) ) {
429+ results . push ( {
430+ severity : "error" ,
431+ fixtureIndex : i ,
432+ message : `override "usage" must be an object` ,
433+ } ) ;
434+ } else {
435+ // Check all known usage fields are numbers if present
436+ for ( const key of Object . keys ( r . usage ) ) {
437+ const val = ( r . usage as Record < string , unknown > ) [ key ] ;
438+ if ( val !== undefined && typeof val !== "number" ) {
439+ results . push ( {
440+ severity : "error" ,
441+ fixtureIndex : i ,
442+ message : `override "usage.${ key } " must be a number, got ${ typeof val } ` ,
443+ } ) ;
444+ }
445+ }
446+ }
447+ }
448+ }
449+
277450 // Numeric sanity checks
278451 if ( f . latency !== undefined && f . latency < 0 ) {
279452 results . push ( {
0 commit comments