@@ -268,6 +268,46 @@ describe('runConfigure', () => {
268268 expect ( capture . stderr . join ( '\n' ) ) . toContain ( 'profile NOT updated' ) ;
269269 } ) ;
270270
271+ it ( 'key-rejected error preserves the typed ApiError envelope (JSON contract)' , async ( ) => {
272+ const { deps } = makeCapture ( ) ;
273+ const rejectedFetch : AuthDeps [ 'fetchImpl' ] = vi . fn (
274+ async ( ) =>
275+ new Response (
276+ JSON . stringify ( {
277+ error : {
278+ code : 'AUTH_INVALID' ,
279+ message : 'API key is invalid or revoked.' ,
280+ nextAction : 'Rotate your key.' ,
281+ requestId : 'req_reject' ,
282+ details : { reason : 'malformed' } ,
283+ } ,
284+ } ) ,
285+ { status : 401 , headers : { 'content-type' : 'application/json' } } ,
286+ ) ,
287+ ) as unknown as AuthDeps [ 'fetchImpl' ] ;
288+
289+ // The thrown error must be an ApiError (with code, nextAction, requestId)
290+ // — not a CLIError wrapper that drops those fields. Under --output json,
291+ // index.ts renders ApiError as the full typed envelope; CLIError would
292+ // render only {"error":"...string..."}, violating the JSON contract.
293+ await expect (
294+ runConfigure (
295+ { profile : 'default' , output : 'json' , debug : false , fromEnv : true } ,
296+ {
297+ ...deps ,
298+ env : { TESTSPRITE_API_KEY : 'sk-bad' } ,
299+ credentialsPath,
300+ fetchImpl : rejectedFetch ,
301+ } ,
302+ ) ,
303+ ) . rejects . toMatchObject ( {
304+ code : 'AUTH_INVALID' ,
305+ exitCode : 3 ,
306+ nextAction : 'Rotate your key.' ,
307+ requestId : 'req_reject' ,
308+ } ) ;
309+ } ) ;
310+
271311 // The old "run `testsprite agent install`" self-bootstrap tip was removed with
272312 // the setup consolidation — runConfigure now runs ONLY as part of `setup`,
273313 // which installs the skill itself. These guard that the tip stays gone.
0 commit comments