@@ -312,6 +312,46 @@ describe('runConfigure', () => {
312312 expect ( capture . stderr . join ( '\n' ) ) . toContain ( 'profile NOT updated' ) ;
313313 } ) ;
314314
315+ it ( 'key-rejected error preserves the typed ApiError envelope (JSON contract)' , async ( ) => {
316+ const { deps } = makeCapture ( ) ;
317+ const rejectedFetch : AuthDeps [ 'fetchImpl' ] = vi . fn (
318+ async ( ) =>
319+ new Response (
320+ JSON . stringify ( {
321+ error : {
322+ code : 'AUTH_INVALID' ,
323+ message : 'API key is invalid or revoked.' ,
324+ nextAction : 'Rotate your key.' ,
325+ requestId : 'req_reject' ,
326+ details : { reason : 'malformed' } ,
327+ } ,
328+ } ) ,
329+ { status : 401 , headers : { 'content-type' : 'application/json' } } ,
330+ ) ,
331+ ) as unknown as AuthDeps [ 'fetchImpl' ] ;
332+
333+ // The thrown error must be an ApiError (with code, nextAction, requestId)
334+ // — not a CLIError wrapper that drops those fields. Under --output json,
335+ // index.ts renders ApiError as the full typed envelope; CLIError would
336+ // render only {"error":"...string..."}, violating the JSON contract.
337+ await expect (
338+ runConfigure (
339+ { profile : 'default' , output : 'json' , debug : false , fromEnv : true } ,
340+ {
341+ ...deps ,
342+ env : { TESTSPRITE_API_KEY : 'sk-bad' } ,
343+ credentialsPath,
344+ fetchImpl : rejectedFetch ,
345+ } ,
346+ ) ,
347+ ) . rejects . toMatchObject ( {
348+ code : 'AUTH_INVALID' ,
349+ exitCode : 3 ,
350+ nextAction : 'Rotate your key.' ,
351+ requestId : 'req_reject' ,
352+ } ) ;
353+ } ) ;
354+
315355 // The old "run `testsprite agent install`" self-bootstrap tip was removed with
316356 // the setup consolidation — runConfigure now runs ONLY as part of `setup`,
317357 // which installs the skill itself. These guard that the tip stays gone.
0 commit comments