11import { randomUUID } from 'node:crypto' ;
2+ import * as v from 'valibot' ;
23import type { ErrorCode } from './errors.js' ;
34import { ApiError , InterruptError , RequestTimeoutError , TransportError } from './errors.js' ;
45import { VERSION } from '../version.js' ;
6+ import {
7+ BATCH_RERUN_RESPONSE_SCHEMA ,
8+ BATCH_RUN_FRESH_RESPONSE_SCHEMA ,
9+ LIST_RUNS_RESPONSE_SCHEMA ,
10+ RERUN_RESPONSE_SCHEMA ,
11+ RUN_RESPONSE_SCHEMA ,
12+ TRIGGER_RUN_RESPONSE_SCHEMA ,
13+ } from './response-schemas.js' ;
514import type {
615 TriggerRunBody ,
716 TriggerRunResponse ,
@@ -108,10 +117,26 @@ export interface HttpClientOptions {
108117 shutdownSignal ?: AbortSignal ;
109118}
110119
111- export interface RequestOptions {
120+ export interface RequestOptions < T = unknown > {
112121 query ?: Record < string , string | number | boolean | undefined > ;
113122 signal ?: AbortSignal ;
114123 requestId ?: string ;
124+ /**
125+ * Optional valibot schema for the parsed 2xx response body (issue #102).
126+ *
127+ * When present, `requestWithMeta` runs `v.safeParse` on the OK-path JSON:
128+ * success returns the parsed output (unknown extra keys preserved via
129+ * `looseObject`); failure throws an INTERNAL `ApiError` envelope naming the
130+ * request path and the first {@link MAX_SCHEMA_ISSUES_IN_DETAILS} mismatched
131+ * field paths (never the body itself). When absent, behavior is unchanged:
132+ * the body is returned via the historical blind `as T` cast.
133+ *
134+ * Wired by the typed run helpers only (`triggerRun`, `triggerRunWithMeta`,
135+ * `triggerRerun`, `triggerBatchRerun`, `triggerBatchRunFresh`, `getRun`,
136+ * `listTestRuns`); generic `get`/`post`/... callers stay opt-in.
137+ * sourceRef: response-schemas.ts.
138+ */
139+ schema ?: v . GenericSchema < unknown , T > ;
115140 /**
116141 * Optional JSON body for non-GET requests. Serialized with
117142 * `JSON.stringify`; `Content-Type: application/json` is auto-attached
@@ -168,6 +193,11 @@ const MAX_RATE_LIMITED_DELAY_MS = 60_000;
168193const CONFLICT_DELAY_MS = 1000 ;
169194const INTERNAL_DELAY_MS = 500 ;
170195
196+ // Cap on how many valibot issues a shape-mismatch INTERNAL envelope carries in
197+ // `details.issues` (path + message each). Keeps the envelope readable and
198+ // guarantees the response body itself is never echoed back to the operator.
199+ const MAX_SCHEMA_ISSUES_IN_DETAILS = 3 ;
200+
171201/**
172202 * Result of a successful HTTP request, including the parsed body and the
173203 * `x-request-id` that was sent (useful for surfacing in happy-path output).
@@ -222,23 +252,23 @@ export class HttpClient {
222252 }
223253 }
224254
225- async get < T > ( path : string , options : RequestOptions = { } ) : Promise < T > {
255+ async get < T > ( path : string , options : RequestOptions < T > = { } ) : Promise < T > {
226256 return this . requestWithMeta < T > ( 'GET' , path , options ) . then ( r => r . body ) ;
227257 }
228258
229- async post < T > ( path : string , options : RequestOptions = { } ) : Promise < T > {
259+ async post < T > ( path : string , options : RequestOptions < T > = { } ) : Promise < T > {
230260 return this . requestWithMeta < T > ( 'POST' , path , options ) . then ( r => r . body ) ;
231261 }
232262
233- async put < T > ( path : string , options : RequestOptions = { } ) : Promise < T > {
263+ async put < T > ( path : string , options : RequestOptions < T > = { } ) : Promise < T > {
234264 return this . requestWithMeta < T > ( 'PUT' , path , options ) . then ( r => r . body ) ;
235265 }
236266
237- async patch < T > ( path : string , options : RequestOptions = { } ) : Promise < T > {
267+ async patch < T > ( path : string , options : RequestOptions < T > = { } ) : Promise < T > {
238268 return this . requestWithMeta < T > ( 'PATCH' , path , options ) . then ( r => r . body ) ;
239269 }
240270
241- async delete < T > ( path : string , options : RequestOptions = { } ) : Promise < T > {
271+ async delete < T > ( path : string , options : RequestOptions < T > = { } ) : Promise < T > {
242272 return this . requestWithMeta < T > ( 'DELETE' , path , options ) . then ( r => r . body ) ;
243273 }
244274
@@ -247,23 +277,26 @@ export class HttpClient {
247277 * `requestId` and `status`, so callers can surface the requestId in
248278 * happy-path output (dogfood item 1).
249279 */
250- async getWithMeta < T > ( path : string , options : RequestOptions = { } ) : Promise < RequestResult < T > > {
280+ async getWithMeta < T > ( path : string , options : RequestOptions < T > = { } ) : Promise < RequestResult < T > > {
251281 return this . requestWithMeta < T > ( 'GET' , path , options ) ;
252282 }
253283
254- async postWithMeta < T > ( path : string , options : RequestOptions = { } ) : Promise < RequestResult < T > > {
284+ async postWithMeta < T > ( path : string , options : RequestOptions < T > = { } ) : Promise < RequestResult < T > > {
255285 return this . requestWithMeta < T > ( 'POST' , path , options ) ;
256286 }
257287
258- async putWithMeta < T > ( path : string , options : RequestOptions = { } ) : Promise < RequestResult < T > > {
288+ async putWithMeta < T > ( path : string , options : RequestOptions < T > = { } ) : Promise < RequestResult < T > > {
259289 return this . requestWithMeta < T > ( 'PUT' , path , options ) ;
260290 }
261291
262- async patchWithMeta < T > ( path : string , options : RequestOptions = { } ) : Promise < RequestResult < T > > {
292+ async patchWithMeta < T > ( path : string , options : RequestOptions < T > = { } ) : Promise < RequestResult < T > > {
263293 return this . requestWithMeta < T > ( 'PATCH' , path , options ) ;
264294 }
265295
266- async deleteWithMeta < T > ( path : string , options : RequestOptions = { } ) : Promise < RequestResult < T > > {
296+ async deleteWithMeta < T > (
297+ path : string ,
298+ options : RequestOptions < T > = { } ,
299+ ) : Promise < RequestResult < T > > {
267300 return this . requestWithMeta < T > ( 'DELETE' , path , options ) ;
268301 }
269302
@@ -282,6 +315,7 @@ export class HttpClient {
282315 body,
283316 headers : { 'idempotency-key' : options . idempotencyKey } ,
284317 signal : options . signal ,
318+ schema : TRIGGER_RUN_RESPONSE_SCHEMA ,
285319 // 409 on POST /runs means "another run is already in flight" — a
286320 // persistent condition, not a transient snapshot conflict. Retrying
287321 // would enqueue a second run once the first finishes.
@@ -310,6 +344,7 @@ export class HttpClient {
310344 body,
311345 headers : { 'idempotency-key' : options . idempotencyKey } ,
312346 signal : options . signal ,
347+ schema : TRIGGER_RUN_RESPONSE_SCHEMA ,
313348 retryOnConflict : false ,
314349 // Default true: single `test run` / `test create --run` retain 429 retry.
315350 // Batch call site passes false to keep outer-loop as sole rate-limit owner.
@@ -334,6 +369,7 @@ export class HttpClient {
334369 body,
335370 headers : { 'idempotency-key' : options . idempotencyKey } ,
336371 signal : options . signal ,
372+ schema : RERUN_RESPONSE_SCHEMA ,
337373 retryOnConflict : false ,
338374 } ) . then ( r => r . body ) ;
339375 }
@@ -353,6 +389,7 @@ export class HttpClient {
353389 body,
354390 headers : { 'idempotency-key' : options . idempotencyKey } ,
355391 signal : options . signal ,
392+ schema : BATCH_RERUN_RESPONSE_SCHEMA ,
356393 retryOnConflict : false ,
357394 } ) . then ( r => r . body ) ;
358395 }
@@ -373,6 +410,7 @@ export class HttpClient {
373410 body,
374411 headers : { 'idempotency-key' : options . idempotencyKey } ,
375412 signal : options . signal ,
413+ schema : BATCH_RUN_FRESH_RESPONSE_SCHEMA ,
376414 retryOnConflict : false ,
377415 } ) . then ( r => r . body ) ;
378416 }
@@ -391,7 +429,10 @@ export class HttpClient {
391429 if ( query . pageSize !== undefined ) q . pageSize = query . pageSize ;
392430 if ( query . source !== undefined ) q . source = query . source ;
393431 if ( query . since !== undefined ) q . since = query . since ;
394- return this . get < ListRunsResponse > ( `/tests/${ encodeURIComponent ( testId ) } /runs` , { query : q } ) ;
432+ return this . get < ListRunsResponse > ( `/tests/${ encodeURIComponent ( testId ) } /runs` , {
433+ query : q ,
434+ schema : LIST_RUNS_RESPONSE_SCHEMA ,
435+ } ) ;
395436 }
396437
397438 /**
@@ -421,6 +462,7 @@ export class HttpClient {
421462 return this . get < RunResponse > ( `/runs/${ encodeURIComponent ( runId ) } ` , {
422463 query : Object . keys ( query ) . length > 0 ? query : undefined ,
423464 signal : options ?. signal ,
465+ schema : RUN_RESPONSE_SCHEMA ,
424466 } ) ;
425467 }
426468
@@ -481,7 +523,7 @@ export class HttpClient {
481523 async requestWithMeta < T > (
482524 method : string ,
483525 path : string ,
484- options : RequestOptions = { } ,
526+ options : RequestOptions < T > = { } ,
485527 ) : Promise < RequestResult < T > > {
486528 if ( ! this . apiKey ) throw ApiError . authRequired ( ) ;
487529
@@ -594,8 +636,9 @@ export class HttpClient {
594636 requestId,
595637 durationMs,
596638 } ) ;
639+ let raw : unknown ;
597640 try {
598- return { body : ( await response . json ( ) ) as T , requestId , status : response . status } ;
641+ raw = await response . json ( ) ;
599642 } catch ( err ) {
600643 // Interrupt passthrough (see the fetch catch above).
601644 if ( err instanceof InterruptError ) throw err ;
@@ -609,6 +652,34 @@ export class HttpClient {
609652 // and break the --output json envelope contract.
610653 throw malformedResponseError ( response , requestId , err ) ;
611654 }
655+ if ( options . schema !== undefined ) {
656+ const parsed = v . safeParse ( options . schema , raw ) ;
657+ if ( ! parsed . success ) {
658+ const issues = parsed . issues . slice ( 0 , MAX_SCHEMA_ISSUES_IN_DETAILS ) . map ( issue => ( {
659+ path : v . getDotPath ( issue ) ?? '(root)' ,
660+ message : issue . message ,
661+ } ) ) ;
662+ // Shape drift is a server-side contract break: surface a typed
663+ // INTERNAL envelope (requestId + the first mismatched paths,
664+ // never the body) instead of letting a blind cast poison
665+ // downstream output with undefined fields or a raw TypeError.
666+ throw ApiError . fromEnvelope (
667+ {
668+ error : {
669+ code : 'INTERNAL' ,
670+ message : `Response shape mismatch from ${ shortPath ( path ) } .` ,
671+ nextAction :
672+ 'Retry; if it persists, report this requestId (the server returned an unexpected shape).' ,
673+ requestId,
674+ details : { issues } ,
675+ } ,
676+ } ,
677+ response . status ,
678+ ) ;
679+ }
680+ return { body : parsed . output as T , requestId, status : response . status } ;
681+ }
682+ return { body : raw as T , requestId, status : response . status } ;
612683 }
613684
614685 let rawBody : unknown ;
@@ -790,7 +861,7 @@ export class HttpClient {
790861 * `client.request(...)` directly. New callers should use
791862 * `requestWithMeta` or the typed helpers (`get`, `post`, etc.).
792863 */
793- async request < T > ( method : string , path : string , options : RequestOptions = { } ) : Promise < T > {
864+ async request < T > ( method : string , path : string , options : RequestOptions < T > = { } ) : Promise < T > {
794865 return this . requestWithMeta < T > ( method , path , options ) . then ( r => r . body ) ;
795866 }
796867}
0 commit comments