1- import { join } from 'node:path' ;
2- import { PassThrough } from 'node:stream' ;
3- import { describe , expect , it } from 'vitest' ;
4- import { AnyApiClient } from '../src/api.js' ;
5- import { runCommand } from '../src/commands.js' ;
6- import { ApiError } from '../src/errors.js' ;
1+ import { join } from "node:path" ;
2+ import { PassThrough } from "node:stream" ;
3+ import { Command } from "commander" ;
4+ import { describe , expect , it } from "vitest" ;
5+ import { AnyApiClient } from "../src/api.js" ;
6+ import { runCommand } from "../src/commands.js" ;
7+ import { ApiError } from "../src/errors.js" ;
78import {
89 buildRunOutputPath ,
910 formatIdempotencyError ,
1011 formatTrialCapMessage ,
1112 isTrialCapReached ,
13+ normalizeRunCLIOptions ,
1214 parseRunInput ,
1315 prepareRunIdempotency ,
14- } from ' ../src/run.js' ;
15- import type { CommandContext } from ' ../src/io.js' ;
16- import type { FetchLike } from ' ../src/types.js' ;
16+ } from " ../src/run.js" ;
17+ import type { CommandContext } from " ../src/io.js" ;
18+ import type { FetchLike } from " ../src/types.js" ;
1719
18- describe ( 'run idempotency' , ( ) => {
19- it ( 'preserves customer output fields without recursive rewriting' , async ( ) => {
20+ describe ( "run idempotency" , ( ) => {
21+ it ( "maps Commander negated --no-wait into the transport option" , ( ) => {
22+ const command = new Command ( ) . exitOverride ( ) . option ( "--no-wait" ) ;
23+ command . parse ( [ "node" , "test" , "--no-wait" ] ) ;
24+ expect ( normalizeRunCLIOptions ( command . opts ( ) ) . noWait ) . toBe ( true ) ;
25+ } ) ;
26+
27+ it ( "preserves customer output fields without recursive rewriting" , async ( ) => {
2028 const responseBody = {
2129 output : {
2230 found : true ,
2331 data : {
2432 creditScore : 812 ,
25- provider : ' source named by the customer API' ,
26- providers : [ ' first source' , ' second source' ] ,
27- nested : { provider : { name : ' structured provider value' } } ,
33+ provider : " source named by the customer API" ,
34+ providers : [ " first source" , " second source" ] ,
35+ nested : { provider : { name : " structured provider value" } } ,
2836 } ,
2937 } ,
30- provider : ' AnyAPI' ,
38+ provider : " AnyAPI" ,
3139 costUsd : 0.01 ,
3240 items : 1 ,
3341 } ;
3442 const ctx = commandContext ( async ( ) => Response . json ( responseBody ) ) ;
3543
36- await runCommand ( ctx , { apiKey : ' aa_live_test' } , ' finance.profile' , {
37- input : '{}' ,
44+ await runCommand ( ctx , { apiKey : " aa_live_test" } , " finance.profile" , {
45+ input : "{}" ,
3846 json : true ,
3947 } ) ;
4048
4149 const stdout = ctx . stdout . read ( ) ?. toString ( ) . trim ( ) ;
4250 expect ( JSON . parse ( stdout ) ) . toEqual ( responseBody ) ;
4351 } ) ;
4452
45- it ( ' preserves balance response fields without recursive rewriting' , async ( ) => {
53+ it ( " preserves balance response fields without recursive rewriting" , async ( ) => {
4654 const responseBody = {
4755 balanceUsd : 1.25 ,
4856 creditScore : 812 ,
49- provider : ' account-data-source' ,
50- providers : [ ' account-data-source' ] ,
57+ provider : " account-data-source" ,
58+ providers : [ " account-data-source" ] ,
5159 } ;
5260 const client = new AnyApiClient ( {
53- apiKey : ' aa_live_test' ,
61+ apiKey : " aa_live_test" ,
5462 fetchImpl : async ( ) => Response . json ( responseBody ) ,
55- restBaseUrl : ' https://example.test/v1' ,
63+ restBaseUrl : " https://example.test/v1" ,
5664 } ) ;
5765
5866 await expect ( client . balance ( ) ) . resolves . toEqual ( responseBody ) ;
5967 } ) ;
6068
61- it ( ' passes the command flag through to the idempotency key header' , async ( ) => {
69+ it ( " passes the command flag through to the idempotency key header" , async ( ) => {
6270 let requestInit : RequestInit | undefined ;
6371 const fetchImpl : FetchLike = async ( _input , init ) => {
6472 requestInit = init ;
65- return Response . json ( { output : { } , provider : 'AnyAPI' , costUsd : 0.01 , items : 1 } ) ;
73+ return Response . json ( {
74+ output : { } ,
75+ provider : "AnyAPI" ,
76+ costUsd : 0.01 ,
77+ items : 1 ,
78+ } ) ;
6679 } ;
6780
68- await runCommand ( commandContext ( fetchImpl ) , { apiKey : 'aa_live_test' } , 'reddit.search' , {
69- input : '{"query":"anyapi"}' ,
70- idempotencyKey : 'k1' ,
71- json : true ,
72- } ) ;
81+ await runCommand (
82+ commandContext ( fetchImpl ) ,
83+ { apiKey : "aa_live_test" } ,
84+ "reddit.search" ,
85+ {
86+ input : '{"query":"anyapi"}' ,
87+ idempotencyKey : "k1" ,
88+ json : true ,
89+ } ,
90+ ) ;
7391
74- expect ( new Headers ( requestInit ?. headers ) . get ( ' Idempotency-Key' ) ) . toBe ( 'k1' ) ;
92+ expect ( new Headers ( requestInit ?. headers ) . get ( " Idempotency-Key" ) ) . toBe ( "k1" ) ;
7593 } ) ;
7694
77- it ( 'omits the idempotency key header when the flag is absent' , async ( ) => {
95+ it ( "generates a fresh idempotency key when the flag is absent" , async ( ) => {
7896 let requestInit : RequestInit | undefined ;
7997 const fetchImpl : FetchLike = async ( _input , init ) => {
8098 requestInit = init ;
81- return Response . json ( { output : { } , provider : 'AnyAPI' , costUsd : 0.01 , items : 1 } ) ;
99+ return Response . json ( {
100+ output : { } ,
101+ provider : "AnyAPI" ,
102+ costUsd : 0.01 ,
103+ items : 1 ,
104+ } ) ;
82105 } ;
83106
84- await runCommand ( commandContext ( fetchImpl ) , { apiKey : 'aa_live_test' } , 'reddit.search' , {
85- input : '{"query":"anyapi"}' ,
86- json : true ,
87- } ) ;
107+ await runCommand (
108+ commandContext ( fetchImpl ) ,
109+ { apiKey : "aa_live_test" } ,
110+ "reddit.search" ,
111+ {
112+ input : '{"query":"anyapi"}' ,
113+ json : true ,
114+ } ,
115+ ) ;
88116
89- expect ( new Headers ( requestInit ?. headers ) . has ( 'Idempotency-Key' ) ) . toBe ( false ) ;
117+ expect ( new Headers ( requestInit ?. headers ) . get ( "Idempotency-Key" ) ) . toMatch (
118+ / ^ a n y a p i - a u t o - / ,
119+ ) ;
90120 } ) ;
91121
92- it ( ' derives the same auto key and request body from equivalent JSON input' , async ( ) => {
122+ it ( " derives a fresh auto key while keeping equivalent request bodies stable" , async ( ) => {
93123 const firstInput = await parseRunInput ( {
94124 input : '{"query":"anyapi","filters":{"sort":"new","limit":5}}' ,
95125 } ) ;
96126 const secondInput = await parseRunInput ( {
97127 input : '{ "filters": { "limit": 5, "sort": "new" }, "query": "anyapi" }' ,
98128 } ) ;
99- const date = new Date ( ' 2026-07-27T12:00:00Z' ) ;
129+ const date = new Date ( " 2026-07-27T12:00:00Z" ) ;
100130
101- const first = prepareRunIdempotency ( 'reddit.search' , firstInput , 'auto' , date ) ;
102- const second = prepareRunIdempotency ( 'reddit.search' , secondInput , 'auto' , date ) ;
131+ const first = prepareRunIdempotency (
132+ "reddit.search" ,
133+ firstInput ,
134+ "auto" ,
135+ date ,
136+ ) ;
137+ const second = prepareRunIdempotency (
138+ "reddit.search" ,
139+ secondInput ,
140+ "auto" ,
141+ date ,
142+ ) ;
103143
104- expect ( second . idempotencyKey ) . toBe ( first . idempotencyKey ) ;
144+ expect ( second . idempotencyKey ) . not . toBe ( first . idempotencyKey ) ;
105145 expect ( JSON . stringify ( second . input ) ) . toBe ( JSON . stringify ( first . input ) ) ;
106- expect ( first . idempotencyKey ) . toMatch ( / ^ a n y a p i - a u t o - [ a - f 0 - 9 ] { 64 } $ / ) ;
146+ expect ( first . idempotencyKey ) . toMatch ( / ^ a n y a p i - a u t o - [ a - f 0 - 9 - ] { 36 } $ / ) ;
147+ expect ( second . idempotencyKey ) . toMatch ( / ^ a n y a p i - a u t o - [ a - f 0 - 9 - ] { 36 } $ / ) ;
107148 } ) ;
108149
109- it ( 'rejects explicit keys outside the gateway wire format' , ( ) => {
110- expect ( ( ) => prepareRunIdempotency ( 'reddit.search' , { } , '' ) ) . toThrow ( '1 to 255 visible ASCII' ) ;
111- expect ( ( ) => prepareRunIdempotency ( 'reddit.search' , { } , 'contains space' ) ) . toThrow ( '1 to 255 visible ASCII' ) ;
112- expect ( ( ) => prepareRunIdempotency ( 'reddit.search' , { } , 'ends-with-newline\n' ) ) . toThrow ( '1 to 255 visible ASCII' ) ;
113- expect ( ( ) => prepareRunIdempotency ( 'reddit.search' , { } , 'x' . repeat ( 256 ) ) ) . toThrow ( '1 to 255 visible ASCII' ) ;
114- expect ( prepareRunIdempotency ( 'reddit.search' , { } , 'x' . repeat ( 255 ) ) . idempotencyKey ) . toHaveLength ( 255 ) ;
150+ it ( "rejects explicit keys outside the gateway wire format" , ( ) => {
151+ expect ( ( ) => prepareRunIdempotency ( "reddit.search" , { } , "" ) ) . toThrow (
152+ "1 to 255 visible ASCII" ,
153+ ) ;
154+ expect ( ( ) =>
155+ prepareRunIdempotency ( "reddit.search" , { } , "contains space" ) ,
156+ ) . toThrow ( "1 to 255 visible ASCII" ) ;
157+ expect ( ( ) =>
158+ prepareRunIdempotency ( "reddit.search" , { } , "ends-with-newline\n" ) ,
159+ ) . toThrow ( "1 to 255 visible ASCII" ) ;
160+ expect ( ( ) =>
161+ prepareRunIdempotency ( "reddit.search" , { } , "x" . repeat ( 256 ) ) ,
162+ ) . toThrow ( "1 to 255 visible ASCII" ) ;
163+ expect (
164+ prepareRunIdempotency ( "reddit.search" , { } , "x" . repeat ( 255 ) )
165+ . idempotencyKey ,
166+ ) . toHaveLength ( 255 ) ;
115167 } ) ;
116168} ) ;
117169
118170function commandContext ( fetchImpl : FetchLike ) : CommandContext {
119171 return {
120- cwd : ' /tmp' ,
121- homeDir : ' /tmp' ,
172+ cwd : " /tmp" ,
173+ homeDir : " /tmp" ,
122174 env : { } ,
123175 stdin : new PassThrough ( ) ,
124176 stdout : new PassThrough ( ) ,
@@ -127,68 +179,91 @@ function commandContext(fetchImpl: FetchLike): CommandContext {
127179 } ;
128180}
129181
130- describe ( 'run output paths' , ( ) => {
131- it ( 'uses sku and a file-safe ISO timestamp under .anyapi' , ( ) => {
132- const path = buildRunOutputPath ( 'reddit.search' , new Date ( '2026-07-05T19:20:30.456Z' ) , '/tmp/project' ) ;
133- expect ( path ) . toBe ( join ( '/tmp/project' , '.anyapi' , 'reddit.search-2026-07-05T19-20-30-456Z.json' ) ) ;
182+ describe ( "run output paths" , ( ) => {
183+ it ( "uses sku and a file-safe ISO timestamp under .anyapi" , ( ) => {
184+ const path = buildRunOutputPath (
185+ "reddit.search" ,
186+ new Date ( "2026-07-05T19:20:30.456Z" ) ,
187+ "/tmp/project" ,
188+ ) ;
189+ expect ( path ) . toBe (
190+ join (
191+ "/tmp/project" ,
192+ ".anyapi" ,
193+ "reddit.search-2026-07-05T19-20-30-456Z.json" ,
194+ ) ,
195+ ) ;
134196 } ) ;
135197
136- it ( 'replaces unsafe sku characters' , ( ) => {
137- const path = buildRunOutputPath ( 'web/scrape test' , new Date ( '2026-07-05T19:20:30.456Z' ) , '/tmp/project' ) ;
138- expect ( path . endsWith ( 'web_scrape_test-2026-07-05T19-20-30-456Z.json' ) ) . toBe ( true ) ;
198+ it ( "replaces unsafe sku characters" , ( ) => {
199+ const path = buildRunOutputPath (
200+ "web/scrape test" ,
201+ new Date ( "2026-07-05T19:20:30.456Z" ) ,
202+ "/tmp/project" ,
203+ ) ;
204+ expect ( path . endsWith ( "web_scrape_test-2026-07-05T19-20-30-456Z.json" ) ) . toBe (
205+ true ,
206+ ) ;
139207 } ) ;
140208} ) ;
141209
142- describe ( ' 402 handling' , ( ) => {
143- it ( ' detects trial cap errors and relays the server upgrade guidance' , async ( ) => {
210+ describe ( " 402 handling" , ( ) => {
211+ it ( " detects trial cap errors and relays the server upgrade guidance" , async ( ) => {
144212 const fetchImpl : FetchLike = async ( ) =>
145213 new Response (
146- JSON . stringify ( { error : 'trial_cap_reached' , message : 'Trial budget used up; run anyapi connect.' } ) ,
147- { status : 402 , headers : { 'Content-Type' : 'application/json' } } ,
214+ JSON . stringify ( {
215+ error : "trial_cap_reached" ,
216+ message : "Trial budget used up; run anyapi connect." ,
217+ } ) ,
218+ { status : 402 , headers : { "Content-Type" : "application/json" } } ,
148219 ) ;
149220 const client = new AnyApiClient ( {
150- apiKey : ' aa_live_test' ,
221+ apiKey : " aa_live_test" ,
151222 fetchImpl,
152- restBaseUrl : ' https://example.test/v1' ,
223+ restBaseUrl : " https://example.test/v1" ,
153224 } ) ;
154225
155226 try {
156- await client . run ( ' reddit.search' , { query : ' anyapi' } ) ;
157- throw new Error ( ' Expected run to fail' ) ;
227+ await client . run ( " reddit.search" , { query : " anyapi" } ) ;
228+ throw new Error ( " Expected run to fail" ) ;
158229 } catch ( error ) {
159230 expect ( error ) . toBeInstanceOf ( ApiError ) ;
160231 expect ( isTrialCapReached ( error ) ) . toBe ( true ) ;
161- expect ( formatTrialCapMessage ( error ) ) . toBe ( 'Trial budget used up; run anyapi connect.' ) ;
232+ expect ( formatTrialCapMessage ( error ) ) . toBe (
233+ "Trial budget used up; run anyapi connect." ,
234+ ) ;
162235 }
163236 } ) ;
164237
165- it ( 'falls back to a connect nudge when the 402 body has no message' , ( ) => {
166- const error = new ApiError ( 'trial_cap_reached' , 402 , { error : 'trial_cap_reached' } ) ;
238+ it ( "falls back to a connect nudge when the 402 body has no message" , ( ) => {
239+ const error = new ApiError ( "trial_cap_reached" , 402 , {
240+ error : "trial_cap_reached" ,
241+ } ) ;
167242 expect ( isTrialCapReached ( error ) ) . toBe ( true ) ;
168- expect ( formatTrialCapMessage ( error ) ) . toContain ( ' anyapi connect' ) ;
243+ expect ( formatTrialCapMessage ( error ) ) . toContain ( " anyapi connect" ) ;
169244 } ) ;
170245} ) ;
171246
172- describe ( ' 409 idempotency handling' , ( ) => {
173- it ( ' explains when the key belongs to a different request using the error code' , ( ) => {
174- const error = new ApiError ( ' Original request is still running.' , 409 , {
175- error : ' Original request is still running.' ,
176- code : ' idempotency_conflict' ,
247+ describe ( " 409 idempotency handling" , ( ) => {
248+ it ( " explains when the key belongs to a different request using the error code" , ( ) => {
249+ const error = new ApiError ( " Original request is still running." , 409 , {
250+ error : " Original request is still running." ,
251+ code : " idempotency_conflict" ,
177252 } ) ;
178253
179254 expect ( formatIdempotencyError ( error ) ) . toBe (
180- ' This idempotency key was already used for a different request. Use a new key, or retry with the original SKU and input.' ,
255+ " This idempotency key was already used for a different request. Use a new key, or retry with the original SKU and input." ,
181256 ) ;
182257 } ) ;
183258
184- it ( ' explains when the original request is still running using the error code' , ( ) => {
185- const error = new ApiError ( ' This key belongs to another request.' , 409 , {
186- error : ' This key belongs to another request.' ,
187- code : ' idempotency_in_progress' ,
259+ it ( " explains when the original request is still running using the error code" , ( ) => {
260+ const error = new ApiError ( " This key belongs to another request." , 409 , {
261+ error : " This key belongs to another request." ,
262+ code : " idempotency_in_progress" ,
188263 } ) ;
189264
190265 expect ( formatIdempotencyError ( error ) ) . toBe (
191- ' The original request for this idempotency key is still running. Retry shortly with the same key.' ,
266+ " The original request for this idempotency key is still running. Retry shortly with the same key." ,
192267 ) ;
193268 } ) ;
194269} ) ;
0 commit comments