@curl-runner/cli 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +24 -7
- package/package.json +1 -1
- package/src/ci-exit.test.ts +215 -0
- package/src/cli.ts +122 -2
- package/src/executor/request-executor.ts +53 -0
- package/src/types/config.ts +83 -0
- package/src/utils/curl-builder.test.ts +165 -0
- package/src/utils/curl-builder.ts +35 -2
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
|
-
var
|
|
4
|
-
`).filter((w)=>w.includes(":"));for(let w of J){let[
|
|
5
|
-
`);if(
|
|
6
|
-
`),w=this.shouldShowRequestDetails()?1/0:10,
|
|
7
|
-
`)})}if(this.shouldShowMetrics()&&$.metrics&&K==="detailed"){let D=$.metrics,J=[];if(J.push({label:"Request Duration",value:this.formatDuration(D.duration),color:"cyan"}),D.size!==void 0)J.push({label:"Response Size",value:this.formatSize(D.size),color:"cyan"});if(D.dnsLookup)J.push({label:"DNS Lookup",value:this.formatDuration(D.dnsLookup),color:"cyan"});if(D.tcpConnection)J.push({label:"TCP Connection",value:this.formatDuration(D.tcpConnection),color:"cyan"});if(D.tlsHandshake)J.push({label:"TLS Handshake",value:this.formatDuration(D.tlsHandshake),color:"cyan"});if(D.firstByte)J.push({label:"Time to First Byte",value:this.formatDuration(D.firstByte),color:"cyan"});z.push({label:"Metrics",children:J})}if(W.render(z),$.error)console.log(),this.logValidationErrors($.error);console.log()}logSummary($,K=!1){if(this.config.format==="raw")return;if(this.config.format==="json"){let D={summary:{total:$.total,successful:$.successful,failed:$.failed,duration:$.duration},results:$.results.map((J)=>({request:{name:J.request.name,url:J.request.url,method:J.request.method||"GET"},success:J.success,status:J.status,...this.shouldShowHeaders()&&J.headers?{headers:J.headers}:{},...this.shouldShowBody()&&J.body?{body:J.body}:{},...J.error?{error:J.error}:{},...this.shouldShowMetrics()&&J.metrics?{metrics:J.metrics}:{}}))};console.log(JSON.stringify(D,null,2));return}if(!this.shouldShowOutput())return;let Q=this.config.prettyLevel||"minimal";if(K)console.log();if(Q==="minimal"){let D=$.failed===0?"green":"red",J=$.failed===0?`${$.total} request${$.total===1?"":"s"} completed successfully`:`${$.successful}/${$.total} request${$.total===1?"":"s"} completed, ${$.failed} failed`;console.log(`${K?"\u25C6 Global Summary":"Summary"}: ${this.color(J,D)}`);return}let X=($.successful/$.total*100).toFixed(1),Z=$.failed===0?"green":"red",z=$.failed===0?`${$.total} request${$.total===1?"":"s"} completed successfully`:`${$.successful}/${$.total} request${$.total===1?"":"s"} completed, ${$.failed} failed`,W=K?"\u25C6 Global Summary":"Summary";if(console.log(),console.log(`${W}: ${this.color(z,Z)} (${this.color(this.formatDuration($.duration),"cyan")})`),$.failed>0&&this.shouldShowRequestDetails())$.results.filter((D)=>!D.success).forEach((D)=>{let J=D.request.name||D.request.url;console.log(` ${this.color("\u2022","red")} ${J}: ${D.error}`)})}logError($){console.error(this.color(`\u2717 ${$}`,"red"))}logWarning($){console.warn(this.color(`\u26A0 ${$}`,"yellow"))}logInfo($){console.log(this.color(`\u2139 ${$}`,"blue"))}logSuccess($){console.log(this.color(`\u2713 ${$}`,"green"))}logFileHeader($,K){if(!this.shouldShowOutput()||this.config.format!=="pretty")return;let Q=$.replace(/.*\//,"").replace(".yaml","");console.log(),console.log(this.color(`\u25B6 ${Q}.yaml`,"bright")+this.color(` (${K} request${K===1?"":"s"})`,"dim"))}}function g($,K){let Q=K.split("."),X=$;for(let Z of Q){if(X===null||X===void 0)return;if(typeof X!=="object")return;let z=Z.match(/^(\w+)\[(\d+)\]$/);if(z){let[,W,D]=z,J=Number.parseInt(D,10);if(X=X[W],Array.isArray(X))X=X[J];else return}else if(/^\d+$/.test(Z)&&Array.isArray(X))X=X[Number.parseInt(Z,10)];else X=X[Z]}return X}function p($){if($===void 0||$===null)return"";if(typeof $==="string")return $;if(typeof $==="number"||typeof $==="boolean")return String($);return JSON.stringify($)}function N($,K){let Q={},X={status:$.status,headers:$.headers||{},body:$.body,metrics:$.metrics};for(let[Z,z]of Object.entries(K)){let W=g(X,z);Q[Z]=p(W)}return Q}function P(){return{}}class B{logger;globalConfig;constructor($={}){this.globalConfig=$,this.logger=new Y($.output)}mergeOutputConfig($){return{...this.globalConfig.output,...$.sourceOutputConfig}}async executeRequest($,K=0){let Q=performance.now(),X=this.mergeOutputConfig($),Z=new Y(X);Z.logRequestStart($,K);let z=M.buildCommand($);Z.logCommand(z);let W=0,D,J=($.retry?.count||0)+1;while(W<J){if(W>0){if(Z.logRetry(W,J-1),$.retry?.delay)await Bun.sleep($.retry.delay)}let O=await M.executeCurl(z);if(O.success){let U=O.body;try{if(O.headers?.["content-type"]?.includes("application/json")||U&&(U.trim().startsWith("{")||U.trim().startsWith("[")))U=JSON.parse(U)}catch(I){}let G={request:$,success:!0,status:O.status,headers:O.headers,body:U,metrics:{...O.metrics,duration:performance.now()-Q}};if($.expect){let I=this.validateResponse(G,$.expect);if(!I.success)G.success=!1,G.error=I.error}return Z.logRequestComplete(G),G}D=O.error,W++}let w={request:$,success:!1,error:D,metrics:{duration:performance.now()-Q}};return Z.logRequestComplete(w),w}validateResponse($,K){if(!K)return{success:!0};let Q=[];if(K.status!==void 0){let Z=Array.isArray(K.status)?K.status:[K.status];if(!Z.includes($.status||0))Q.push(`Expected status ${Z.join(" or ")}, got ${$.status}`)}if(K.headers)for(let[Z,z]of Object.entries(K.headers)){let W=$.headers?.[Z]||$.headers?.[Z.toLowerCase()];if(W!==z)Q.push(`Expected header ${Z}="${z}", got "${W}"`)}if(K.body!==void 0){let Z=this.validateBodyProperties($.body,K.body,"");if(Z.length>0)Q.push(...Z)}if(K.responseTime!==void 0&&$.metrics){let Z=$.metrics.duration;if(!this.validateRangePattern(Z,K.responseTime))Q.push(`Expected response time to match ${K.responseTime}ms, got ${Z.toFixed(2)}ms`)}let X=Q.length>0;if(K.failure===!0){if(X)return{success:!1,error:Q.join("; ")};let Z=$.status||0;if(Z>=400)return{success:!0};else return{success:!1,error:`Expected request to fail (4xx/5xx) but got status ${Z}`}}else if(X)return{success:!1,error:Q.join("; ")};else return{success:!0}}validateBodyProperties($,K,Q){let X=[];if(typeof K!=="object"||K===null){let Z=this.validateValue($,K,Q||"body");if(!Z.isValid)X.push(Z.error);return X}if(Array.isArray(K)){let Z=this.validateValue($,K,Q||"body");if(!Z.isValid)X.push(Z.error);return X}for(let[Z,z]of Object.entries(K)){let W=Q?`${Q}.${Z}`:Z,D;if(Array.isArray($)&&this.isArraySelector(Z))D=this.getArrayValue($,Z);else D=$?.[Z];if(typeof z==="object"&&z!==null&&!Array.isArray(z)){let J=this.validateBodyProperties(D,z,W);X.push(...J)}else{let J=this.validateValue(D,z,W);if(!J.isValid)X.push(J.error)}}return X}validateValue($,K,Q){if(K==="*")return{isValid:!0};if(Array.isArray(K)){if(!K.some((Z)=>{if(Z==="*")return!0;if(typeof Z==="string"&&this.isRegexPattern(Z))return this.validateRegexPattern($,Z);if(typeof Z==="string"&&this.isRangePattern(Z))return this.validateRangePattern($,Z);return $===Z}))return{isValid:!1,error:`Expected ${Q} to match one of ${JSON.stringify(K)}, got ${JSON.stringify($)}`};return{isValid:!0}}if(typeof K==="string"&&this.isRegexPattern(K)){if(!this.validateRegexPattern($,K))return{isValid:!1,error:`Expected ${Q} to match pattern ${K}, got ${JSON.stringify($)}`};return{isValid:!0}}if(typeof K==="string"&&this.isRangePattern(K)){if(!this.validateRangePattern($,K))return{isValid:!1,error:`Expected ${Q} to match range ${K}, got ${JSON.stringify($)}`};return{isValid:!0}}if(K==="null"||K===null){if($!==null)return{isValid:!1,error:`Expected ${Q} to be null, got ${JSON.stringify($)}`};return{isValid:!0}}if($!==K)return{isValid:!1,error:`Expected ${Q} to be ${JSON.stringify(K)}, got ${JSON.stringify($)}`};return{isValid:!0}}isRegexPattern($){return $.startsWith("^")||$.endsWith("$")||$.includes("\\d")||$.includes("\\w")||$.includes("\\s")||$.includes("[")||$.includes("*")||$.includes("+")||$.includes("?")}validateRegexPattern($,K){let Q=String($);try{return new RegExp(K).test(Q)}catch{return!1}}isRangePattern($){return/^(>=?|<=?|>|<)\s*[\d.-]+(\s*,\s*(>=?|<=?|>|<)\s*[\d.-]+)*$/.test($)}validateRangePattern($,K){let Q=Number($);if(Number.isNaN(Q))return!1;let X=K.match(/^([\d.-]+)\s*-\s*([\d.-]+)$/);if(X){let z=Number(X[1]),W=Number(X[2]);return Q>=z&&Q<=W}return K.split(",").map((z)=>z.trim()).every((z)=>{let W=z.match(/^(>=?|<=?|>|<)\s*([\d.-]+)$/);if(!W)return!1;let D=W[1],J=Number(W[2]);switch(D){case">":return Q>J;case">=":return Q>=J;case"<":return Q<J;case"<=":return Q<=J;default:return!1}})}isArraySelector($){return/^\[.*\]$/.test($)||$==="*"||$.startsWith("slice(")}getArrayValue($,K){if(K==="*")return $;if(K.startsWith("[")&&K.endsWith("]")){let Q=K.slice(1,-1);if(Q==="*")return $;let X=Number(Q);if(!Number.isNaN(X))return X>=0?$[X]:$[$.length+X]}if(K.startsWith("slice(")){let Q=K.match(/slice\((\d+)(?:,(\d+))?\)/);if(Q){let X=Number(Q[1]),Z=Q[2]?Number(Q[2]):void 0;return $.slice(X,Z)}}return}async executeSequential($){let K=performance.now(),Q=[],X=P();for(let Z=0;Z<$.length;Z++){let z=this.interpolateStoreVariables($[Z],X),W=await this.executeRequest(z,Z+1);if(Q.push(W),W.success&&z.store){let D=N(W,z.store);Object.assign(X,D),this.logStoredValues(D)}if(!W.success&&!this.globalConfig.continueOnError){this.logger.logError("Stopping execution due to error");break}}return this.createSummary(Q,performance.now()-K)}interpolateStoreVariables($,K){if(Object.keys(K).length===0)return $;return H.interpolateVariables($,{},K)}logStoredValues($){if(Object.keys($).length===0)return;let K=Object.entries($);for(let[Q,X]of K){let Z=X.length>50?`${X.substring(0,50)}...`:X;this.logger.logInfo(`Stored: ${Q} = "${Z}"`)}}async executeParallel($){let K=performance.now(),Q=$.map((Z,z)=>this.executeRequest(Z,z+1)),X=await Promise.all(Q);return this.createSummary(X,performance.now()-K)}async execute($){this.logger.logExecutionStart($.length,this.globalConfig.execution||"sequential");let K=this.globalConfig.execution==="parallel"?await this.executeParallel($):await this.executeSequential($);if(this.logger.logSummary(K),this.globalConfig.output?.saveToFile)await this.saveSummaryToFile(K);return K}createSummary($,K){let Q=$.filter((Z)=>Z.success).length,X=$.filter((Z)=>!Z.success).length;return{total:$.length,successful:Q,failed:X,duration:K,results:$}}async saveSummaryToFile($){let K=this.globalConfig.output?.saveToFile;if(!K)return;let Q=JSON.stringify($,null,2);await Bun.write(K,Q),this.logger.logInfo(`Results saved to ${K}`)}}function A(){if(typeof BUILD_VERSION<"u")return BUILD_VERSION;if(process.env.CURL_RUNNER_VERSION)return process.env.CURL_RUNNER_VERSION;try{let $=["../package.json","./package.json","../../package.json"];for(let K of $)try{let Q=S(K);if(Q.name==="@curl-runner/cli"&&Q.version)return Q.version}catch{}return"0.0.0"}catch{return"0.0.0"}}var h={reset:"\x1B[0m",bright:"\x1B[1m",dim:"\x1B[2m",underscore:"\x1B[4m",black:"\x1B[30m",red:"\x1B[31m",green:"\x1B[32m",yellow:"\x1B[33m",blue:"\x1B[34m",magenta:"\x1B[35m",cyan:"\x1B[36m",white:"\x1B[37m",bgBlack:"\x1B[40m",bgRed:"\x1B[41m",bgGreen:"\x1B[42m",bgYellow:"\x1B[43m",bgBlue:"\x1B[44m",bgMagenta:"\x1B[45m",bgCyan:"\x1B[46m",bgWhite:"\x1B[47m"};function F($,K){return`${h[K]}${$}${h.reset}`}var b=`${process.env.HOME}/.curl-runner-version-cache.json`,d=86400000,m="https://registry.npmjs.org/@curl-runner/cli/latest";class L{async checkForUpdates($=!1){try{if(process.env.CI)return;let K=A();if(K==="0.0.0")return;if(!$){let X=await this.getCachedVersion();if(X&&Date.now()-X.lastCheck<d){this.compareVersions(K,X.latestVersion);return}}let Q=await this.fetchLatestVersion();if(Q)await this.setCachedVersion(Q),this.compareVersions(K,Q)}catch{}}async fetchLatestVersion(){try{let $=await fetch(m,{signal:AbortSignal.timeout(3000)});if(!$.ok)return null;return(await $.json()).version}catch{return null}}compareVersions($,K){if(this.isNewerVersion($,K))console.log(),console.log(F("\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E","yellow")),console.log(F("\u2502","yellow")+" "+F("\u2502","yellow")),console.log(F("\u2502","yellow")+" "+F("\uD83D\uDCE6 New version available!","bright")+` ${F($,"red")} \u2192 ${F(K,"green")} `+F("\u2502","yellow")),console.log(F("\u2502","yellow")+" "+F("\u2502","yellow")),console.log(F("\u2502","yellow")+" Update with: "+F("npm install -g @curl-runner/cli","cyan")+" "+F("\u2502","yellow")),console.log(F("\u2502","yellow")+" or: "+F("bun install -g @curl-runner/cli","cyan")+" "+F("\u2502","yellow")),console.log(F("\u2502","yellow")+" "+F("\u2502","yellow")),console.log(F("\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F","yellow")),console.log()}isNewerVersion($,K){try{let Q=$.replace(/^v/,""),X=K.replace(/^v/,""),Z=Q.split(".").map(Number),z=X.split(".").map(Number);for(let W=0;W<Math.max(Z.length,z.length);W++){let D=Z[W]||0,J=z[W]||0;if(J>D)return!0;if(J<D)return!1}return!1}catch{return!1}}async getCachedVersion(){try{let $=Bun.file(b);if(await $.exists())return JSON.parse(await $.text())}catch{}return null}async setCachedVersion($){try{let K={lastCheck:Date.now(),latestVersion:$};await Bun.write(b,JSON.stringify(K))}catch{}}}class V{logger=new Y;async loadConfigFile(){let $=["curl-runner.yaml","curl-runner.yml",".curl-runner.yaml",".curl-runner.yml"];for(let K of $)try{if(await Bun.file(K).exists()){let X=await H.parseFile(K),Z=X.global||X;return this.logger.logInfo(`Loaded configuration from ${K}`),Z}}catch(Q){this.logger.logWarning(`Failed to load configuration from ${K}: ${Q}`)}return{}}loadEnvironmentVariables(){let $={};if(process.env.CURL_RUNNER_TIMEOUT)$.defaults={...$.defaults,timeout:Number.parseInt(process.env.CURL_RUNNER_TIMEOUT,10)};if(process.env.CURL_RUNNER_RETRIES)$.defaults={...$.defaults,retry:{...$.defaults?.retry,count:Number.parseInt(process.env.CURL_RUNNER_RETRIES,10)}};if(process.env.CURL_RUNNER_RETRY_DELAY)$.defaults={...$.defaults,retry:{...$.defaults?.retry,delay:Number.parseInt(process.env.CURL_RUNNER_RETRY_DELAY,10)}};if(process.env.CURL_RUNNER_VERBOSE)$.output={...$.output,verbose:process.env.CURL_RUNNER_VERBOSE.toLowerCase()==="true"};if(process.env.CURL_RUNNER_EXECUTION)$.execution=process.env.CURL_RUNNER_EXECUTION;if(process.env.CURL_RUNNER_CONTINUE_ON_ERROR)$.continueOnError=process.env.CURL_RUNNER_CONTINUE_ON_ERROR.toLowerCase()==="true";if(process.env.CURL_RUNNER_OUTPUT_FORMAT){let K=process.env.CURL_RUNNER_OUTPUT_FORMAT;if(["json","pretty","raw"].includes(K))$.output={...$.output,format:K}}if(process.env.CURL_RUNNER_PRETTY_LEVEL){let K=process.env.CURL_RUNNER_PRETTY_LEVEL;if(["minimal","standard","detailed"].includes(K))$.output={...$.output,prettyLevel:K}}if(process.env.CURL_RUNNER_OUTPUT_FILE)$.output={...$.output,saveToFile:process.env.CURL_RUNNER_OUTPUT_FILE};return $}async run($){try{let{files:K,options:Q}=this.parseArguments($);if(!Q.version&&!Q.help)new L().checkForUpdates().catch(()=>{});if(Q.help){this.showHelp();return}if(Q.version){console.log(`curl-runner v${A()}`);return}let X=this.loadEnvironmentVariables(),Z=await this.loadConfigFile(),z=await this.findYamlFiles(K,Q);if(z.length===0)this.logger.logError("No YAML files found"),process.exit(1);this.logger.logInfo(`Found ${z.length} YAML file(s)`);let W=this.mergeGlobalConfigs(X,Z),D=[],J=[];for(let U of z){this.logger.logInfo(`Processing: ${U}`);let{requests:G,config:I}=await this.processYamlFile(U),E=I?.output||{},_=G.map((T)=>({...T,sourceOutputConfig:E,sourceFile:U}));if(I){let{...T}=I;W=this.mergeGlobalConfigs(W,T)}J.push({file:U,requests:_,config:I}),D.push(..._)}if(Q.execution)W.execution=Q.execution;if(Q.continueOnError!==void 0)W.continueOnError=Q.continueOnError;if(Q.verbose!==void 0)W.output={...W.output,verbose:Q.verbose};if(Q.quiet!==void 0)W.output={...W.output,verbose:!1};if(Q.output)W.output={...W.output,saveToFile:Q.output};if(Q.outputFormat)W.output={...W.output,format:Q.outputFormat};if(Q.prettyLevel)W.output={...W.output,prettyLevel:Q.prettyLevel};if(Q.showHeaders!==void 0)W.output={...W.output,showHeaders:Q.showHeaders};if(Q.showBody!==void 0)W.output={...W.output,showBody:Q.showBody};if(Q.showMetrics!==void 0)W.output={...W.output,showMetrics:Q.showMetrics};if(Q.timeout)W.defaults={...W.defaults,timeout:Q.timeout};if(Q.retries||Q.noRetry){let U=Q.noRetry?0:Q.retries||0;W.defaults={...W.defaults,retry:{...W.defaults?.retry,count:U}}}if(Q.retryDelay)W.defaults={...W.defaults,retry:{...W.defaults?.retry,delay:Q.retryDelay}};if(D.length===0)this.logger.logError("No requests found in YAML files"),process.exit(1);let w=new B(W),O;if(J.length>1){let U=[],G=0;for(let _=0;_<J.length;_++){let T=J[_];this.logger.logFileHeader(T.file,T.requests.length);let R=await w.execute(T.requests);if(U.push(...R.results),G+=R.duration,_<J.length-1)console.log()}let I=U.filter((_)=>_.success).length,E=U.filter((_)=>!_.success).length;O={total:U.length,successful:I,failed:E,duration:G,results:U},w.logger.logSummary(O,!0)}else O=await w.execute(D);process.exit(O.failed>0&&!W.continueOnError?1:0)}catch(K){this.logger.logError(K instanceof Error?K.message:String(K)),process.exit(1)}}parseArguments($){let K={},Q=[];for(let X=0;X<$.length;X++){let Z=$[X];if(Z.startsWith("--")){let z=Z.slice(2),W=$[X+1];if(z==="help"||z==="version")K[z]=!0;else if(z==="no-retry")K.noRetry=!0;else if(z==="quiet")K.quiet=!0;else if(z==="show-headers")K.showHeaders=!0;else if(z==="show-body")K.showBody=!0;else if(z==="show-metrics")K.showMetrics=!0;else if(W&&!W.startsWith("--")){if(z==="continue-on-error")K.continueOnError=W==="true";else if(z==="verbose")K.verbose=W==="true";else if(z==="timeout")K.timeout=Number.parseInt(W,10);else if(z==="retries")K.retries=Number.parseInt(W,10);else if(z==="retry-delay")K.retryDelay=Number.parseInt(W,10);else if(z==="output-format"){if(["json","pretty","raw"].includes(W))K.outputFormat=W}else if(z==="pretty-level"){if(["minimal","standard","detailed"].includes(W))K.prettyLevel=W}else K[z]=W;X++}else K[z]=!0}else if(Z.startsWith("-")){let z=Z.slice(1);for(let W of z)switch(W){case"h":K.help=!0;break;case"v":K.verbose=!0;break;case"p":K.execution="parallel";break;case"c":K.continueOnError=!0;break;case"q":K.quiet=!0;break;case"o":{let D=$[X+1];if(D&&!D.startsWith("-"))K.output=D,X++;break}}}else Q.push(Z)}return{files:Q,options:K}}async findYamlFiles($,K){let Q=new Set,X=[];if($.length===0)X=K.all?["**/*.yaml","**/*.yml"]:["*.yaml","*.yml"];else for(let Z of $)try{let W=await(await import("fs/promises")).stat(Z);if(W.isDirectory()){if(X.push(`${Z}/*.yaml`,`${Z}/*.yml`),K.all)X.push(`${Z}/**/*.yaml`,`${Z}/**/*.yml`)}else if(W.isFile())X.push(Z)}catch{X.push(Z)}for(let Z of X){let z=new c(Z);for await(let W of z.scan("."))if(W.endsWith(".yaml")||W.endsWith(".yml"))Q.add(W)}return Array.from(Q).sort()}async processYamlFile($){let K=await H.parseFile($),Q=[],X;if(K.global)X=K.global;let Z={...K.global?.variables,...K.collection?.variables},z={...K.global?.defaults,...K.collection?.defaults};if(K.request){let W=this.prepareRequest(K.request,Z,z);Q.push(W)}if(K.requests)for(let W of K.requests){let D=this.prepareRequest(W,Z,z);Q.push(D)}if(K.collection?.requests)for(let W of K.collection.requests){let D=this.prepareRequest(W,Z,z);Q.push(D)}return{requests:Q,config:X}}prepareRequest($,K,Q){let X=H.interpolateVariables($,K);return H.mergeConfigs(Q,X)}mergeGlobalConfigs($,K){return{...$,...K,variables:{...$.variables,...K.variables},output:{...$.output,...K.output},defaults:{...$.defaults,...K.defaults}}}showHelp(){console.log(`
|
|
3
|
+
var x=Object.create;var{getPrototypeOf:y,defineProperty:q,getOwnPropertyNames:f}=Object;var p=Object.prototype.hasOwnProperty;var g=($,K,Q)=>{Q=$!=null?x(y($)):{};let Z=K||!$||!$.__esModule?q(Q,"default",{value:$,enumerable:!0}):Q;for(let X of f($))if(!p.call(Z,X))q(Z,X,{get:()=>$[X],enumerable:!0});return Z};var E=import.meta.require;var{Glob:u}=globalThis.Bun;var{YAML:N}=globalThis.Bun;class G{static async parseFile($){let Q=await Bun.file($).text();return N.parse(Q)}static parse($){return N.parse($)}static interpolateVariables($,K,Q){if(typeof $==="string"){let Z=$.match(/^\$\{([^}]+)\}$/);if(Z){let X=Z[1],z=G.resolveVariable(X,K,Q);return z!==null?z:$}return $.replace(/\$\{([^}]+)\}/g,(X,z)=>{let W=G.resolveVariable(z,K,Q);return W!==null?W:X})}if(Array.isArray($))return $.map((Z)=>G.interpolateVariables(Z,K,Q));if($&&typeof $==="object"){let Z={};for(let[X,z]of Object.entries($))Z[X]=G.interpolateVariables(z,K,Q);return Z}return $}static resolveVariable($,K,Q){if($.startsWith("store.")&&Q){let X=$.slice(6);if(X in Q)return Q[X];return null}let Z=G.resolveDynamicVariable($);if(Z!==null)return Z;if($ in K)return K[$];return null}static resolveDynamicVariable($){if($==="UUID")return crypto.randomUUID();if($==="CURRENT_TIME"||$==="TIMESTAMP")return Date.now().toString();if($.startsWith("DATE:")){let K=$.slice(5);return G.formatDate(new Date,K)}if($.startsWith("TIME:")){let K=$.slice(5);return G.formatTime(new Date,K)}return null}static formatDate($,K){let Q=$.getFullYear(),Z=String($.getMonth()+1).padStart(2,"0"),X=String($.getDate()).padStart(2,"0");return K.replace("YYYY",Q.toString()).replace("MM",Z).replace("DD",X)}static formatTime($,K){let Q=String($.getHours()).padStart(2,"0"),Z=String($.getMinutes()).padStart(2,"0"),X=String($.getSeconds()).padStart(2,"0");return K.replace("HH",Q).replace("mm",Z).replace("ss",X)}static mergeConfigs($,K){return{...$,...K,headers:{...$.headers,...K.headers},params:{...$.params,...K.params},variables:{...$.variables,...K.variables}}}}function d($){return typeof $==="object"&&$!==null&&"file"in $}function P($){return $.replace(/'/g,"'\\''")}class j{static buildCommand($){let K=["curl"];if(K.push("-X",$.method||"GET"),K.push("-w",'"\\n__CURL_METRICS_START__%{json}__CURL_METRICS_END__"'),$.headers)for(let[Z,X]of Object.entries($.headers))K.push("-H",`"${Z}: ${X}"`);if($.auth){if($.auth.type==="basic"&&$.auth.username&&$.auth.password)K.push("-u",`"${$.auth.username}:${$.auth.password}"`);else if($.auth.type==="bearer"&&$.auth.token)K.push("-H",`"Authorization: Bearer ${$.auth.token}"`)}if($.formData)for(let[Z,X]of Object.entries($.formData))if(d(X)){let z=`@${X.file}`;if(X.filename)z+=`;filename=${X.filename}`;if(X.contentType)z+=`;type=${X.contentType}`;K.push("-F",`'${Z}=${P(z)}'`)}else{let z=String(X);K.push("-F",`'${Z}=${P(z)}'`)}else if($.body){let Z=typeof $.body==="string"?$.body:JSON.stringify($.body);if(K.push("-d",`'${Z.replace(/'/g,"'\\''")}'`),!$.headers?.["Content-Type"])K.push("-H",'"Content-Type: application/json"')}if($.timeout)K.push("--max-time",$.timeout.toString());if($.followRedirects!==!1){if(K.push("-L"),$.maxRedirects)K.push("--max-redirs",$.maxRedirects.toString())}if($.proxy)K.push("-x",$.proxy);if($.insecure)K.push("-k");if($.output)K.push("-o",$.output);K.push("-s","-S");let Q=$.url;if($.params&&Object.keys($.params).length>0){let Z=new URLSearchParams($.params).toString();Q+=(Q.includes("?")?"&":"?")+Z}return K.push(`"${Q}"`),K.join(" ")}static async executeCurl($){try{let K=Bun.spawn(["sh","-c",$],{stdout:"pipe",stderr:"pipe"}),Q=await new Response(K.stdout).text(),Z=await new Response(K.stderr).text();if(await K.exited,K.exitCode!==0&&!Q)return{success:!1,error:Z||`Command failed with exit code ${K.exitCode}`};let X=Q,z={},W=Q.match(/__CURL_METRICS_START__(.+?)__CURL_METRICS_END__/);if(W){X=Q.replace(/__CURL_METRICS_START__.+?__CURL_METRICS_END__/,"").trim();try{z=JSON.parse(W[1])}catch(J){}}let U={};if(z.response_code){let J=Z.split(`
|
|
4
|
+
`).filter((w)=>w.includes(":"));for(let w of J){let[_,...H]=w.split(":");if(_&&H.length>0)U[_.trim()]=H.join(":").trim()}}return{success:!0,status:z.response_code||z.http_code,headers:U,body:X,metrics:{duration:(z.time_total||0)*1000,size:z.size_download,dnsLookup:(z.time_namelookup||0)*1000,tcpConnection:(z.time_connect||0)*1000,tlsHandshake:(z.time_appconnect||0)*1000,firstByte:(z.time_starttransfer||0)*1000,download:(z.time_total||0)*1000}}}catch(K){return{success:!1,error:K instanceof Error?K.message:String(K)}}}}class L{colors;constructor($){this.colors=$}color($,K){if(!K||!this.colors[K])return $;return`${this.colors[K]}${$}${this.colors.reset}`}render($,K=" "){$.forEach((Q,Z)=>{let X=Z===$.length-1,z=X?`${K}\u2514\u2500`:`${K}\u251C\u2500`;if(Q.label&&Q.value){let W=Q.color?this.color(Q.value,Q.color):Q.value,U=W.split(`
|
|
5
|
+
`);if(U.length===1)console.log(`${z} ${Q.label}: ${W}`);else{console.log(`${z} ${Q.label}:`);let J=X?`${K} `:`${K}\u2502 `;U.forEach((w)=>{console.log(`${J}${w}`)})}}else if(Q.label&&!Q.value)console.log(`${z} ${Q.label}:`);else if(!Q.label&&Q.value){let W=X?`${K} `:`${K}\u2502 `;console.log(`${W}${Q.value}`)}if(Q.children&&Q.children.length>0){let W=X?`${K} `:`${K}\u2502 `;this.render(Q.children,W)}})}}class M{config;colors={reset:"\x1B[0m",bright:"\x1B[1m",dim:"\x1B[2m",underscore:"\x1B[4m",black:"\x1B[30m",red:"\x1B[31m",green:"\x1B[32m",yellow:"\x1B[33m",blue:"\x1B[34m",magenta:"\x1B[35m",cyan:"\x1B[36m",white:"\x1B[37m",bgBlack:"\x1B[40m",bgRed:"\x1B[41m",bgGreen:"\x1B[42m",bgYellow:"\x1B[43m",bgBlue:"\x1B[44m",bgMagenta:"\x1B[45m",bgCyan:"\x1B[46m",bgWhite:"\x1B[47m"};constructor($={}){this.config={verbose:!1,showHeaders:!1,showBody:!0,showMetrics:!1,format:"pretty",prettyLevel:"minimal",...$}}color($,K){return`${this.colors[K]}${$}${this.colors.reset}`}getShortFilename($){return $.replace(/.*\//,"").replace(".yaml","")}shouldShowOutput(){if(this.config.format==="raw")return!1;if(this.config.format==="pretty")return!0;return this.config.verbose!==!1}shouldShowHeaders(){if(this.config.format!=="pretty")return this.config.showHeaders||!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.showHeaders||!1;case"detailed":return!0;default:return this.config.showHeaders||!1}}shouldShowBody(){if(this.config.format!=="pretty")return this.config.showBody!==!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.showBody!==!1;case"detailed":return!0;default:return this.config.showBody!==!1}}shouldShowMetrics(){if(this.config.format!=="pretty")return this.config.showMetrics||!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.showMetrics||!1;case"detailed":return!0;default:return this.config.showMetrics||!1}}shouldShowRequestDetails(){if(this.config.format!=="pretty")return this.config.verbose||!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.verbose||!1;case"detailed":return!0;default:return this.config.verbose||!1}}shouldShowSeparators(){if(this.config.format!=="pretty")return!0;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return!0;case"detailed":return!0;default:return!0}}colorStatusCode($){return this.color($,"yellow")}logValidationErrors($){let K=$.split("; ");if(K.length===1){let Q=K[0].trim(),Z=Q.match(/^Expected status (.+?), got (.+)$/);if(Z){let[,X,z]=Z,W=this.colorStatusCode(X.replace(" or ","|")),U=this.color(z,"red");console.log(` ${this.color("\u2717","red")} ${this.color("Error:","red")} Expected ${this.color("status","yellow")} ${W}, got ${U}`)}else console.log(` ${this.color("\u2717","red")} ${this.color("Error:","red")} ${Q}`)}else{console.log(` ${this.color("\u2717","red")} ${this.color("Validation Errors:","red")}`);for(let Q of K){let Z=Q.trim();if(Z)if(Z.startsWith("Expected ")){let X=Z.match(/^Expected status (.+?), got (.+)$/);if(X){let[,z,W]=X,U=this.colorStatusCode(z.replace(" or ","|")),J=this.color(W,"red");console.log(` ${this.color("\u2022","red")} ${this.color("status","yellow")}: expected ${U}, got ${J}`)}else{let z=Z.match(/^Expected (.+?) to be (.+?), got (.+)$/);if(z){let[,W,U,J]=z;console.log(` ${this.color("\u2022","red")} ${this.color(W,"yellow")}: expected ${this.color(U,"green")}, got ${this.color(J,"red")}`)}else console.log(` ${this.color("\u2022","red")} ${Z}`)}}else console.log(` ${this.color("\u2022","red")} ${Z}`)}}}formatJson($){if(this.config.format==="raw")return typeof $==="string"?$:JSON.stringify($);if(this.config.format==="json")return JSON.stringify($);return JSON.stringify($,null,2)}formatDuration($){if($<1000)return`${$.toFixed(0)}ms`;return`${($/1000).toFixed(2)}s`}formatSize($){if(!$)return"0 B";let K=["B","KB","MB","GB"],Q=Math.floor(Math.log($)/Math.log(1024));return`${($/1024**Q).toFixed(2)} ${K[Q]}`}logExecutionStart($,K){if(!this.shouldShowOutput())return;if(this.shouldShowSeparators())console.log(),console.log(this.color(`Executing ${$} request(s) in ${K} mode`,"dim")),console.log();else console.log()}logRequestStart($,K){return}logCommand($){if(this.shouldShowRequestDetails())console.log(this.color(" Command:","dim")),console.log(this.color(` ${$}`,"dim"))}logRetry($,K){console.log(this.color(` \u21BB Retry ${$}/${K}...`,"yellow"))}logRequestComplete($){if(this.config.format==="raw"){if($.success&&this.config.showBody&&$.body){let U=this.formatJson($.body);console.log(U)}return}if(this.config.format==="json"){let U={request:{name:$.request.name,url:$.request.url,method:$.request.method||"GET"},success:$.success,status:$.status,...this.shouldShowHeaders()&&$.headers?{headers:$.headers}:{},...this.shouldShowBody()&&$.body?{body:$.body}:{},...$.error?{error:$.error}:{},...this.shouldShowMetrics()&&$.metrics?{metrics:$.metrics}:{}};console.log(JSON.stringify(U,null,2));return}if(!this.shouldShowOutput())return;let K=this.config.prettyLevel||"minimal",Q=$.success?"green":"red",Z=$.success?"\u2713":"x",X=$.request.name||"Request";if(K==="minimal"){let U=$.request.sourceFile?this.getShortFilename($.request.sourceFile):"inline";console.log(`${this.color(Z,Q)} ${this.color(X,"bright")} [${U}]`);let J=[],w=new L(this.colors);J.push({label:$.request.method||"GET",value:$.request.url,color:"blue"});let _=$.status?`${$.status}`:"ERROR";if(J.push({label:`${Z} Status`,value:_,color:Q}),$.metrics){let H=`${this.formatDuration($.metrics.duration)} | ${this.formatSize($.metrics.size)}`;J.push({label:"Duration",value:H,color:"cyan"})}if(w.render(J),$.error)console.log(),this.logValidationErrors($.error);console.log();return}console.log(`${this.color(Z,Q)} ${this.color(X,"bright")}`);let z=[],W=new L(this.colors);if(z.push({label:"URL",value:$.request.url,color:"blue"}),z.push({label:"Method",value:$.request.method||"GET",color:"yellow"}),z.push({label:"Status",value:String($.status||"ERROR"),color:Q}),$.metrics)z.push({label:"Duration",value:this.formatDuration($.metrics.duration),color:"cyan"});if(this.shouldShowHeaders()&&$.headers&&Object.keys($.headers).length>0){let U=Object.entries($.headers).map(([J,w])=>({label:this.color(J,"dim"),value:String(w)}));z.push({label:"Headers",children:U})}if(this.shouldShowBody()&&$.body){let J=this.formatJson($.body).split(`
|
|
6
|
+
`),w=this.shouldShowRequestDetails()?1/0:10,_=J.slice(0,w);if(J.length>w)_.push(this.color(`... (${J.length-w} more lines)`,"dim"));z.push({label:"Response Body",value:_.join(`
|
|
7
|
+
`)})}if(this.shouldShowMetrics()&&$.metrics&&K==="detailed"){let U=$.metrics,J=[];if(J.push({label:"Request Duration",value:this.formatDuration(U.duration),color:"cyan"}),U.size!==void 0)J.push({label:"Response Size",value:this.formatSize(U.size),color:"cyan"});if(U.dnsLookup)J.push({label:"DNS Lookup",value:this.formatDuration(U.dnsLookup),color:"cyan"});if(U.tcpConnection)J.push({label:"TCP Connection",value:this.formatDuration(U.tcpConnection),color:"cyan"});if(U.tlsHandshake)J.push({label:"TLS Handshake",value:this.formatDuration(U.tlsHandshake),color:"cyan"});if(U.firstByte)J.push({label:"Time to First Byte",value:this.formatDuration(U.firstByte),color:"cyan"});z.push({label:"Metrics",children:J})}if(W.render(z),$.error)console.log(),this.logValidationErrors($.error);console.log()}logSummary($,K=!1){if(this.config.format==="raw")return;if(this.config.format==="json"){let U={summary:{total:$.total,successful:$.successful,failed:$.failed,duration:$.duration},results:$.results.map((J)=>({request:{name:J.request.name,url:J.request.url,method:J.request.method||"GET"},success:J.success,status:J.status,...this.shouldShowHeaders()&&J.headers?{headers:J.headers}:{},...this.shouldShowBody()&&J.body?{body:J.body}:{},...J.error?{error:J.error}:{},...this.shouldShowMetrics()&&J.metrics?{metrics:J.metrics}:{}}))};console.log(JSON.stringify(U,null,2));return}if(!this.shouldShowOutput())return;let Q=this.config.prettyLevel||"minimal";if(K)console.log();if(Q==="minimal"){let U=$.failed===0?"green":"red",J=$.failed===0?`${$.total} request${$.total===1?"":"s"} completed successfully`:`${$.successful}/${$.total} request${$.total===1?"":"s"} completed, ${$.failed} failed`;console.log(`${K?"\u25C6 Global Summary":"Summary"}: ${this.color(J,U)}`);return}let Z=($.successful/$.total*100).toFixed(1),X=$.failed===0?"green":"red",z=$.failed===0?`${$.total} request${$.total===1?"":"s"} completed successfully`:`${$.successful}/${$.total} request${$.total===1?"":"s"} completed, ${$.failed} failed`,W=K?"\u25C6 Global Summary":"Summary";if(console.log(),console.log(`${W}: ${this.color(z,X)} (${this.color(this.formatDuration($.duration),"cyan")})`),$.failed>0&&this.shouldShowRequestDetails())$.results.filter((U)=>!U.success).forEach((U)=>{let J=U.request.name||U.request.url;console.log(` ${this.color("\u2022","red")} ${J}: ${U.error}`)})}logError($){console.error(this.color(`\u2717 ${$}`,"red"))}logWarning($){console.warn(this.color(`\u26A0 ${$}`,"yellow"))}logInfo($){console.log(this.color(`\u2139 ${$}`,"blue"))}logSuccess($){console.log(this.color(`\u2713 ${$}`,"green"))}logFileHeader($,K){if(!this.shouldShowOutput()||this.config.format!=="pretty")return;let Q=$.replace(/.*\//,"").replace(".yaml","");console.log(),console.log(this.color(`\u25B6 ${Q}.yaml`,"bright")+this.color(` (${K} request${K===1?"":"s"})`,"dim"))}}function m($,K){let Q=K.split("."),Z=$;for(let X of Q){if(Z===null||Z===void 0)return;if(typeof Z!=="object")return;let z=X.match(/^(\w+)\[(\d+)\]$/);if(z){let[,W,U]=z,J=Number.parseInt(U,10);if(Z=Z[W],Array.isArray(Z))Z=Z[J];else return}else if(/^\d+$/.test(X)&&Array.isArray(Z))Z=Z[Number.parseInt(X,10)];else Z=Z[X]}return Z}function c($){if($===void 0||$===null)return"";if(typeof $==="string")return $;if(typeof $==="number"||typeof $==="boolean")return String($);return JSON.stringify($)}function h($,K){let Q={},Z={status:$.status,headers:$.headers||{},body:$.body,metrics:$.metrics};for(let[X,z]of Object.entries(K)){let W=m(Z,z);Q[X]=c(W)}return Q}function b(){return{}}class S{logger;globalConfig;constructor($={}){this.globalConfig=$,this.logger=new M($.output)}mergeOutputConfig($){return{...this.globalConfig.output,...$.sourceOutputConfig}}isFileAttachment($){return typeof $==="object"&&$!==null&&"file"in $}async validateFileAttachments($){if(!$.formData)return;let K=[];for(let[Q,Z]of Object.entries($.formData))if(this.isFileAttachment(Z)){let X=Z.file;if(!await Bun.file(X).exists())K.push(`${Q}: ${X}`)}if(K.length>0)return`File(s) not found: ${K.join(", ")}`;return}async executeRequest($,K=0){let Q=performance.now(),Z=this.mergeOutputConfig($),X=new M(Z);X.logRequestStart($,K);let z=await this.validateFileAttachments($);if(z){let H={request:$,success:!1,error:z,metrics:{duration:performance.now()-Q}};return X.logRequestComplete(H),H}let W=j.buildCommand($);X.logCommand(W);let U=0,J,w=($.retry?.count||0)+1;while(U<w){if(U>0){if(X.logRetry(U,w-1),$.retry?.delay)await Bun.sleep($.retry.delay)}let H=await j.executeCurl(W);if(H.success){let D=H.body;try{if(H.headers?.["content-type"]?.includes("application/json")||D&&(D.trim().startsWith("{")||D.trim().startsWith("[")))D=JSON.parse(D)}catch(T){}let I={request:$,success:!0,status:H.status,headers:H.headers,body:D,metrics:{...H.metrics,duration:performance.now()-Q}};if($.expect){let T=this.validateResponse(I,$.expect);if(!T.success)I.success=!1,I.error=T.error}return X.logRequestComplete(I),I}J=H.error,U++}let _={request:$,success:!1,error:J,metrics:{duration:performance.now()-Q}};return X.logRequestComplete(_),_}validateResponse($,K){if(!K)return{success:!0};let Q=[];if(K.status!==void 0){let X=Array.isArray(K.status)?K.status:[K.status];if(!X.includes($.status||0))Q.push(`Expected status ${X.join(" or ")}, got ${$.status}`)}if(K.headers)for(let[X,z]of Object.entries(K.headers)){let W=$.headers?.[X]||$.headers?.[X.toLowerCase()];if(W!==z)Q.push(`Expected header ${X}="${z}", got "${W}"`)}if(K.body!==void 0){let X=this.validateBodyProperties($.body,K.body,"");if(X.length>0)Q.push(...X)}if(K.responseTime!==void 0&&$.metrics){let X=$.metrics.duration;if(!this.validateRangePattern(X,K.responseTime))Q.push(`Expected response time to match ${K.responseTime}ms, got ${X.toFixed(2)}ms`)}let Z=Q.length>0;if(K.failure===!0){if(Z)return{success:!1,error:Q.join("; ")};let X=$.status||0;if(X>=400)return{success:!0};else return{success:!1,error:`Expected request to fail (4xx/5xx) but got status ${X}`}}else if(Z)return{success:!1,error:Q.join("; ")};else return{success:!0}}validateBodyProperties($,K,Q){let Z=[];if(typeof K!=="object"||K===null){let X=this.validateValue($,K,Q||"body");if(!X.isValid)Z.push(X.error);return Z}if(Array.isArray(K)){let X=this.validateValue($,K,Q||"body");if(!X.isValid)Z.push(X.error);return Z}for(let[X,z]of Object.entries(K)){let W=Q?`${Q}.${X}`:X,U;if(Array.isArray($)&&this.isArraySelector(X))U=this.getArrayValue($,X);else U=$?.[X];if(typeof z==="object"&&z!==null&&!Array.isArray(z)){let J=this.validateBodyProperties(U,z,W);Z.push(...J)}else{let J=this.validateValue(U,z,W);if(!J.isValid)Z.push(J.error)}}return Z}validateValue($,K,Q){if(K==="*")return{isValid:!0};if(Array.isArray(K)){if(!K.some((X)=>{if(X==="*")return!0;if(typeof X==="string"&&this.isRegexPattern(X))return this.validateRegexPattern($,X);if(typeof X==="string"&&this.isRangePattern(X))return this.validateRangePattern($,X);return $===X}))return{isValid:!1,error:`Expected ${Q} to match one of ${JSON.stringify(K)}, got ${JSON.stringify($)}`};return{isValid:!0}}if(typeof K==="string"&&this.isRegexPattern(K)){if(!this.validateRegexPattern($,K))return{isValid:!1,error:`Expected ${Q} to match pattern ${K}, got ${JSON.stringify($)}`};return{isValid:!0}}if(typeof K==="string"&&this.isRangePattern(K)){if(!this.validateRangePattern($,K))return{isValid:!1,error:`Expected ${Q} to match range ${K}, got ${JSON.stringify($)}`};return{isValid:!0}}if(K==="null"||K===null){if($!==null)return{isValid:!1,error:`Expected ${Q} to be null, got ${JSON.stringify($)}`};return{isValid:!0}}if($!==K)return{isValid:!1,error:`Expected ${Q} to be ${JSON.stringify(K)}, got ${JSON.stringify($)}`};return{isValid:!0}}isRegexPattern($){return $.startsWith("^")||$.endsWith("$")||$.includes("\\d")||$.includes("\\w")||$.includes("\\s")||$.includes("[")||$.includes("*")||$.includes("+")||$.includes("?")}validateRegexPattern($,K){let Q=String($);try{return new RegExp(K).test(Q)}catch{return!1}}isRangePattern($){return/^(>=?|<=?|>|<)\s*[\d.-]+(\s*,\s*(>=?|<=?|>|<)\s*[\d.-]+)*$/.test($)}validateRangePattern($,K){let Q=Number($);if(Number.isNaN(Q))return!1;let Z=K.match(/^([\d.-]+)\s*-\s*([\d.-]+)$/);if(Z){let z=Number(Z[1]),W=Number(Z[2]);return Q>=z&&Q<=W}return K.split(",").map((z)=>z.trim()).every((z)=>{let W=z.match(/^(>=?|<=?|>|<)\s*([\d.-]+)$/);if(!W)return!1;let U=W[1],J=Number(W[2]);switch(U){case">":return Q>J;case">=":return Q>=J;case"<":return Q<J;case"<=":return Q<=J;default:return!1}})}isArraySelector($){return/^\[.*\]$/.test($)||$==="*"||$.startsWith("slice(")}getArrayValue($,K){if(K==="*")return $;if(K.startsWith("[")&&K.endsWith("]")){let Q=K.slice(1,-1);if(Q==="*")return $;let Z=Number(Q);if(!Number.isNaN(Z))return Z>=0?$[Z]:$[$.length+Z]}if(K.startsWith("slice(")){let Q=K.match(/slice\((\d+)(?:,(\d+))?\)/);if(Q){let Z=Number(Q[1]),X=Q[2]?Number(Q[2]):void 0;return $.slice(Z,X)}}return}async executeSequential($){let K=performance.now(),Q=[],Z=b();for(let X=0;X<$.length;X++){let z=this.interpolateStoreVariables($[X],Z),W=await this.executeRequest(z,X+1);if(Q.push(W),W.success&&z.store){let U=h(W,z.store);Object.assign(Z,U),this.logStoredValues(U)}if(!W.success&&!this.globalConfig.continueOnError){this.logger.logError("Stopping execution due to error");break}}return this.createSummary(Q,performance.now()-K)}interpolateStoreVariables($,K){if(Object.keys(K).length===0)return $;return G.interpolateVariables($,{},K)}logStoredValues($){if(Object.keys($).length===0)return;let K=Object.entries($);for(let[Q,Z]of K){let X=Z.length>50?`${Z.substring(0,50)}...`:Z;this.logger.logInfo(`Stored: ${Q} = "${X}"`)}}async executeParallel($){let K=performance.now(),Q=$.map((X,z)=>this.executeRequest(X,z+1)),Z=await Promise.all(Q);return this.createSummary(Z,performance.now()-K)}async execute($){this.logger.logExecutionStart($.length,this.globalConfig.execution||"sequential");let K=this.globalConfig.execution==="parallel"?await this.executeParallel($):await this.executeSequential($);if(this.logger.logSummary(K),this.globalConfig.output?.saveToFile)await this.saveSummaryToFile(K);return K}createSummary($,K){let Q=$.filter((X)=>X.success).length,Z=$.filter((X)=>!X.success).length;return{total:$.length,successful:Q,failed:Z,duration:K,results:$}}async saveSummaryToFile($){let K=this.globalConfig.output?.saveToFile;if(!K)return;let Q=JSON.stringify($,null,2);await Bun.write(K,Q),this.logger.logInfo(`Results saved to ${K}`)}}function B(){if(typeof BUILD_VERSION<"u")return BUILD_VERSION;if(process.env.CURL_RUNNER_VERSION)return process.env.CURL_RUNNER_VERSION;try{let $=["../package.json","./package.json","../../package.json"];for(let K of $)try{let Q=E(K);if(Q.name==="@curl-runner/cli"&&Q.version)return Q.version}catch{}return"0.0.0"}catch{return"0.0.0"}}var V={reset:"\x1B[0m",bright:"\x1B[1m",dim:"\x1B[2m",underscore:"\x1B[4m",black:"\x1B[30m",red:"\x1B[31m",green:"\x1B[32m",yellow:"\x1B[33m",blue:"\x1B[34m",magenta:"\x1B[35m",cyan:"\x1B[36m",white:"\x1B[37m",bgBlack:"\x1B[40m",bgRed:"\x1B[41m",bgGreen:"\x1B[42m",bgYellow:"\x1B[43m",bgBlue:"\x1B[44m",bgMagenta:"\x1B[45m",bgCyan:"\x1B[46m",bgWhite:"\x1B[47m"};function O($,K){return`${V[K]}${$}${V.reset}`}var C=`${process.env.HOME}/.curl-runner-version-cache.json`,i=86400000,n="https://registry.npmjs.org/@curl-runner/cli/latest";class R{async checkForUpdates($=!1){try{if(process.env.CI)return;let K=B();if(K==="0.0.0")return;if(!$){let Z=await this.getCachedVersion();if(Z&&Date.now()-Z.lastCheck<i){this.compareVersions(K,Z.latestVersion);return}}let Q=await this.fetchLatestVersion();if(Q)await this.setCachedVersion(Q),this.compareVersions(K,Q)}catch{}}async fetchLatestVersion(){try{let $=await fetch(n,{signal:AbortSignal.timeout(3000)});if(!$.ok)return null;return(await $.json()).version}catch{return null}}compareVersions($,K){if(this.isNewerVersion($,K))console.log(),console.log(O("\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E","yellow")),console.log(O("\u2502","yellow")+" "+O("\u2502","yellow")),console.log(O("\u2502","yellow")+" "+O("\uD83D\uDCE6 New version available!","bright")+` ${O($,"red")} \u2192 ${O(K,"green")} `+O("\u2502","yellow")),console.log(O("\u2502","yellow")+" "+O("\u2502","yellow")),console.log(O("\u2502","yellow")+" Update with: "+O("npm install -g @curl-runner/cli","cyan")+" "+O("\u2502","yellow")),console.log(O("\u2502","yellow")+" or: "+O("bun install -g @curl-runner/cli","cyan")+" "+O("\u2502","yellow")),console.log(O("\u2502","yellow")+" "+O("\u2502","yellow")),console.log(O("\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F","yellow")),console.log()}isNewerVersion($,K){try{let Q=$.replace(/^v/,""),Z=K.replace(/^v/,""),X=Q.split(".").map(Number),z=Z.split(".").map(Number);for(let W=0;W<Math.max(X.length,z.length);W++){let U=X[W]||0,J=z[W]||0;if(J>U)return!0;if(J<U)return!1}return!1}catch{return!1}}async getCachedVersion(){try{let $=Bun.file(C);if(await $.exists())return JSON.parse(await $.text())}catch{}return null}async setCachedVersion($){try{let K={lastCheck:Date.now(),latestVersion:$};await Bun.write(C,JSON.stringify(K))}catch{}}}class v{logger=new M;async loadConfigFile(){let $=["curl-runner.yaml","curl-runner.yml",".curl-runner.yaml",".curl-runner.yml"];for(let K of $)try{if(await Bun.file(K).exists()){let Z=await G.parseFile(K),X=Z.global||Z;return this.logger.logInfo(`Loaded configuration from ${K}`),X}}catch(Q){this.logger.logWarning(`Failed to load configuration from ${K}: ${Q}`)}return{}}loadEnvironmentVariables(){let $={};if(process.env.CURL_RUNNER_TIMEOUT)$.defaults={...$.defaults,timeout:Number.parseInt(process.env.CURL_RUNNER_TIMEOUT,10)};if(process.env.CURL_RUNNER_RETRIES)$.defaults={...$.defaults,retry:{...$.defaults?.retry,count:Number.parseInt(process.env.CURL_RUNNER_RETRIES,10)}};if(process.env.CURL_RUNNER_RETRY_DELAY)$.defaults={...$.defaults,retry:{...$.defaults?.retry,delay:Number.parseInt(process.env.CURL_RUNNER_RETRY_DELAY,10)}};if(process.env.CURL_RUNNER_VERBOSE)$.output={...$.output,verbose:process.env.CURL_RUNNER_VERBOSE.toLowerCase()==="true"};if(process.env.CURL_RUNNER_EXECUTION)$.execution=process.env.CURL_RUNNER_EXECUTION;if(process.env.CURL_RUNNER_CONTINUE_ON_ERROR)$.continueOnError=process.env.CURL_RUNNER_CONTINUE_ON_ERROR.toLowerCase()==="true";if(process.env.CURL_RUNNER_OUTPUT_FORMAT){let K=process.env.CURL_RUNNER_OUTPUT_FORMAT;if(["json","pretty","raw"].includes(K))$.output={...$.output,format:K}}if(process.env.CURL_RUNNER_PRETTY_LEVEL){let K=process.env.CURL_RUNNER_PRETTY_LEVEL;if(["minimal","standard","detailed"].includes(K))$.output={...$.output,prettyLevel:K}}if(process.env.CURL_RUNNER_OUTPUT_FILE)$.output={...$.output,saveToFile:process.env.CURL_RUNNER_OUTPUT_FILE};if(process.env.CURL_RUNNER_STRICT_EXIT)$.ci={...$.ci,strictExit:process.env.CURL_RUNNER_STRICT_EXIT.toLowerCase()==="true"};if(process.env.CURL_RUNNER_FAIL_ON)$.ci={...$.ci,failOn:Number.parseInt(process.env.CURL_RUNNER_FAIL_ON,10)};if(process.env.CURL_RUNNER_FAIL_ON_PERCENTAGE){let K=Number.parseFloat(process.env.CURL_RUNNER_FAIL_ON_PERCENTAGE);if(K>=0&&K<=100)$.ci={...$.ci,failOnPercentage:K}}return $}async run($){try{let{files:K,options:Q}=this.parseArguments($);if(!Q.version&&!Q.help)new R().checkForUpdates().catch(()=>{});if(Q.help){this.showHelp();return}if(Q.version){console.log(`curl-runner v${B()}`);return}let Z=this.loadEnvironmentVariables(),X=await this.loadConfigFile(),z=await this.findYamlFiles(K,Q);if(z.length===0)this.logger.logError("No YAML files found"),process.exit(1);this.logger.logInfo(`Found ${z.length} YAML file(s)`);let W=this.mergeGlobalConfigs(Z,X),U=[],J=[];for(let D of z){this.logger.logInfo(`Processing: ${D}`);let{requests:I,config:T}=await this.processYamlFile(D),A=T?.output||{},F=I.map((Y)=>({...Y,sourceOutputConfig:A,sourceFile:D}));if(T){let{...Y}=T;W=this.mergeGlobalConfigs(W,Y)}J.push({file:D,requests:F,config:T}),U.push(...F)}if(Q.execution)W.execution=Q.execution;if(Q.continueOnError!==void 0)W.continueOnError=Q.continueOnError;if(Q.verbose!==void 0)W.output={...W.output,verbose:Q.verbose};if(Q.quiet!==void 0)W.output={...W.output,verbose:!1};if(Q.output)W.output={...W.output,saveToFile:Q.output};if(Q.outputFormat)W.output={...W.output,format:Q.outputFormat};if(Q.prettyLevel)W.output={...W.output,prettyLevel:Q.prettyLevel};if(Q.showHeaders!==void 0)W.output={...W.output,showHeaders:Q.showHeaders};if(Q.showBody!==void 0)W.output={...W.output,showBody:Q.showBody};if(Q.showMetrics!==void 0)W.output={...W.output,showMetrics:Q.showMetrics};if(Q.timeout)W.defaults={...W.defaults,timeout:Q.timeout};if(Q.retries||Q.noRetry){let D=Q.noRetry?0:Q.retries||0;W.defaults={...W.defaults,retry:{...W.defaults?.retry,count:D}}}if(Q.retryDelay)W.defaults={...W.defaults,retry:{...W.defaults?.retry,delay:Q.retryDelay}};if(Q.strictExit!==void 0)W.ci={...W.ci,strictExit:Q.strictExit};if(Q.failOn!==void 0)W.ci={...W.ci,failOn:Q.failOn};if(Q.failOnPercentage!==void 0)W.ci={...W.ci,failOnPercentage:Q.failOnPercentage};if(U.length===0)this.logger.logError("No requests found in YAML files"),process.exit(1);let w=new S(W),_;if(J.length>1){let D=[],I=0;for(let F=0;F<J.length;F++){let Y=J[F];this.logger.logFileHeader(Y.file,Y.requests.length);let k=await w.execute(Y.requests);if(D.push(...k.results),I+=k.duration,F<J.length-1)console.log()}let T=D.filter((F)=>F.success).length,A=D.filter((F)=>!F.success).length;_={total:D.length,successful:T,failed:A,duration:I,results:D},w.logger.logSummary(_,!0)}else _=await w.execute(U);let H=this.determineExitCode(_,W);process.exit(H)}catch(K){this.logger.logError(K instanceof Error?K.message:String(K)),process.exit(1)}}parseArguments($){let K={},Q=[];for(let Z=0;Z<$.length;Z++){let X=$[Z];if(X.startsWith("--")){let z=X.slice(2),W=$[Z+1];if(z==="help"||z==="version")K[z]=!0;else if(z==="no-retry")K.noRetry=!0;else if(z==="quiet")K.quiet=!0;else if(z==="show-headers")K.showHeaders=!0;else if(z==="show-body")K.showBody=!0;else if(z==="show-metrics")K.showMetrics=!0;else if(z==="strict-exit")K.strictExit=!0;else if(W&&!W.startsWith("--")){if(z==="continue-on-error")K.continueOnError=W==="true";else if(z==="verbose")K.verbose=W==="true";else if(z==="timeout")K.timeout=Number.parseInt(W,10);else if(z==="retries")K.retries=Number.parseInt(W,10);else if(z==="retry-delay")K.retryDelay=Number.parseInt(W,10);else if(z==="fail-on")K.failOn=Number.parseInt(W,10);else if(z==="fail-on-percentage"){let U=Number.parseFloat(W);if(U>=0&&U<=100)K.failOnPercentage=U}else if(z==="output-format"){if(["json","pretty","raw"].includes(W))K.outputFormat=W}else if(z==="pretty-level"){if(["minimal","standard","detailed"].includes(W))K.prettyLevel=W}else K[z]=W;Z++}else K[z]=!0}else if(X.startsWith("-")){let z=X.slice(1);for(let W of z)switch(W){case"h":K.help=!0;break;case"v":K.verbose=!0;break;case"p":K.execution="parallel";break;case"c":K.continueOnError=!0;break;case"q":K.quiet=!0;break;case"o":{let U=$[Z+1];if(U&&!U.startsWith("-"))K.output=U,Z++;break}}}else Q.push(X)}return{files:Q,options:K}}async findYamlFiles($,K){let Q=new Set,Z=[];if($.length===0)Z=K.all?["**/*.yaml","**/*.yml"]:["*.yaml","*.yml"];else for(let X of $)try{let W=await(await import("fs/promises")).stat(X);if(W.isDirectory()){if(Z.push(`${X}/*.yaml`,`${X}/*.yml`),K.all)Z.push(`${X}/**/*.yaml`,`${X}/**/*.yml`)}else if(W.isFile())Z.push(X)}catch{Z.push(X)}for(let X of Z){let z=new u(X);for await(let W of z.scan("."))if(W.endsWith(".yaml")||W.endsWith(".yml"))Q.add(W)}return Array.from(Q).sort()}async processYamlFile($){let K=await G.parseFile($),Q=[],Z;if(K.global)Z=K.global;let X={...K.global?.variables,...K.collection?.variables},z={...K.global?.defaults,...K.collection?.defaults};if(K.request){let W=this.prepareRequest(K.request,X,z);Q.push(W)}if(K.requests)for(let W of K.requests){let U=this.prepareRequest(W,X,z);Q.push(U)}if(K.collection?.requests)for(let W of K.collection.requests){let U=this.prepareRequest(W,X,z);Q.push(U)}return{requests:Q,config:Z}}prepareRequest($,K,Q){let Z=G.interpolateVariables($,K);return G.mergeConfigs(Q,Z)}mergeGlobalConfigs($,K){return{...$,...K,variables:{...$.variables,...K.variables},output:{...$.output,...K.output},defaults:{...$.defaults,...K.defaults},ci:{...$.ci,...K.ci}}}determineExitCode($,K){let{failed:Q,total:Z}=$,X=K.ci;if(Q===0)return 0;if(X){if(X.strictExit)return 1;if(X.failOn!==void 0&&Q>X.failOn)return 1;if(X.failOnPercentage!==void 0&&Z>0){if(Q/Z*100>X.failOnPercentage)return 1}if(X.failOn!==void 0||X.failOnPercentage!==void 0)return 0}return!K.continueOnError?1:0}showHelp(){console.log(`
|
|
8
8
|
${this.logger.color("\uD83D\uDE80 CURL RUNNER","bright")}
|
|
9
9
|
|
|
10
10
|
${this.logger.color("USAGE:","yellow")}
|
|
@@ -29,6 +29,11 @@ ${this.logger.color("OPTIONS:","yellow")}
|
|
|
29
29
|
--show-metrics Include performance metrics in output
|
|
30
30
|
--version Show version
|
|
31
31
|
|
|
32
|
+
${this.logger.color("CI/CD OPTIONS:","yellow")}
|
|
33
|
+
--strict-exit Exit with code 1 if any validation fails (for CI/CD)
|
|
34
|
+
--fail-on <count> Exit with code 1 if failures exceed this count
|
|
35
|
+
--fail-on-percentage <pct> Exit with code 1 if failure percentage exceeds this value
|
|
36
|
+
|
|
32
37
|
${this.logger.color("EXAMPLES:","yellow")}
|
|
33
38
|
# Run all YAML files in current directory
|
|
34
39
|
curl-runner
|
|
@@ -57,6 +62,18 @@ ${this.logger.color("EXAMPLES:","yellow")}
|
|
|
57
62
|
# Run with detailed pretty output (show all information)
|
|
58
63
|
curl-runner --output-format pretty --pretty-level detailed test.yaml
|
|
59
64
|
|
|
65
|
+
# CI/CD: Fail if any validation fails (strict mode)
|
|
66
|
+
curl-runner tests/ --strict-exit
|
|
67
|
+
|
|
68
|
+
# CI/CD: Run all tests but fail if any validation fails
|
|
69
|
+
curl-runner tests/ --continue-on-error --strict-exit
|
|
70
|
+
|
|
71
|
+
# CI/CD: Allow up to 2 failures
|
|
72
|
+
curl-runner tests/ --fail-on 2
|
|
73
|
+
|
|
74
|
+
# CI/CD: Allow up to 10% failures
|
|
75
|
+
curl-runner tests/ --fail-on-percentage 10
|
|
76
|
+
|
|
60
77
|
${this.logger.color("YAML STRUCTURE:","yellow")}
|
|
61
78
|
Single request:
|
|
62
79
|
request:
|
|
@@ -79,7 +96,7 @@ ${this.logger.color("YAML STRUCTURE:","yellow")}
|
|
|
79
96
|
requests:
|
|
80
97
|
- url: \${BASE_URL}/users
|
|
81
98
|
method: GET
|
|
82
|
-
`)}}var
|
|
99
|
+
`)}}var l=new v;l.run(process.argv.slice(2));
|
|
83
100
|
|
|
84
|
-
//# debugId=
|
|
101
|
+
//# debugId=EA3EA83C7A39EBEE64756E2164756E21
|
|
85
102
|
//# sourceMappingURL=cli.js.map
|
package/package.json
CHANGED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ExecutionSummary, GlobalConfig } from './types/config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Determines the appropriate exit code based on execution results and CI configuration.
|
|
6
|
+
* This is a standalone function for testing purposes.
|
|
7
|
+
*/
|
|
8
|
+
function determineExitCode(summary: ExecutionSummary, config: GlobalConfig): number {
|
|
9
|
+
const { failed, total } = summary;
|
|
10
|
+
const ci = config.ci;
|
|
11
|
+
|
|
12
|
+
// If no failures, always exit with 0
|
|
13
|
+
if (failed === 0) {
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Check CI exit code options
|
|
18
|
+
if (ci) {
|
|
19
|
+
// strictExit: exit 1 if ANY failures occur
|
|
20
|
+
if (ci.strictExit) {
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// failOn: exit 1 if failures exceed the threshold
|
|
25
|
+
if (ci.failOn !== undefined && failed > ci.failOn) {
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// failOnPercentage: exit 1 if failure percentage exceeds the threshold
|
|
30
|
+
if (ci.failOnPercentage !== undefined && total > 0) {
|
|
31
|
+
const failurePercentage = (failed / total) * 100;
|
|
32
|
+
if (failurePercentage > ci.failOnPercentage) {
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// If any CI option is set but thresholds not exceeded, exit 0
|
|
38
|
+
if (ci.failOn !== undefined || ci.failOnPercentage !== undefined) {
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Default behavior: exit 1 if failures AND continueOnError is false
|
|
44
|
+
return !config.continueOnError ? 1 : 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates a mock execution summary for testing
|
|
49
|
+
*/
|
|
50
|
+
function createSummary(total: number, failed: number): ExecutionSummary {
|
|
51
|
+
return {
|
|
52
|
+
total,
|
|
53
|
+
successful: total - failed,
|
|
54
|
+
failed,
|
|
55
|
+
duration: 1000,
|
|
56
|
+
results: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('CI Exit Code', () => {
|
|
61
|
+
describe('default behavior (no CI options)', () => {
|
|
62
|
+
test('should exit 0 when no failures', () => {
|
|
63
|
+
const summary = createSummary(10, 0);
|
|
64
|
+
const config: GlobalConfig = {};
|
|
65
|
+
expect(determineExitCode(summary, config)).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('should exit 1 when failures exist and continueOnError is false', () => {
|
|
69
|
+
const summary = createSummary(10, 2);
|
|
70
|
+
const config: GlobalConfig = {};
|
|
71
|
+
expect(determineExitCode(summary, config)).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('should exit 0 when failures exist and continueOnError is true', () => {
|
|
75
|
+
const summary = createSummary(10, 2);
|
|
76
|
+
const config: GlobalConfig = { continueOnError: true };
|
|
77
|
+
expect(determineExitCode(summary, config)).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('--strict-exit flag', () => {
|
|
82
|
+
test('should exit 1 when strictExit is true and any failures exist', () => {
|
|
83
|
+
const summary = createSummary(10, 1);
|
|
84
|
+
const config: GlobalConfig = { ci: { strictExit: true } };
|
|
85
|
+
expect(determineExitCode(summary, config)).toBe(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('should exit 0 when strictExit is true but no failures', () => {
|
|
89
|
+
const summary = createSummary(10, 0);
|
|
90
|
+
const config: GlobalConfig = { ci: { strictExit: true } };
|
|
91
|
+
expect(determineExitCode(summary, config)).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('should exit 1 when strictExit is true even with continueOnError', () => {
|
|
95
|
+
const summary = createSummary(10, 1);
|
|
96
|
+
const config: GlobalConfig = {
|
|
97
|
+
continueOnError: true,
|
|
98
|
+
ci: { strictExit: true },
|
|
99
|
+
};
|
|
100
|
+
expect(determineExitCode(summary, config)).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('--fail-on threshold', () => {
|
|
105
|
+
test('should exit 0 when failures are at or below threshold', () => {
|
|
106
|
+
const summary = createSummary(10, 2);
|
|
107
|
+
const config: GlobalConfig = { ci: { failOn: 2 } };
|
|
108
|
+
expect(determineExitCode(summary, config)).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('should exit 1 when failures exceed threshold', () => {
|
|
112
|
+
const summary = createSummary(10, 3);
|
|
113
|
+
const config: GlobalConfig = { ci: { failOn: 2 } };
|
|
114
|
+
expect(determineExitCode(summary, config)).toBe(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('should exit 0 when failOn is 0 and no failures', () => {
|
|
118
|
+
const summary = createSummary(10, 0);
|
|
119
|
+
const config: GlobalConfig = { ci: { failOn: 0 } };
|
|
120
|
+
expect(determineExitCode(summary, config)).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('should exit 1 when failOn is 0 and any failures exist', () => {
|
|
124
|
+
const summary = createSummary(10, 1);
|
|
125
|
+
const config: GlobalConfig = { ci: { failOn: 0 } };
|
|
126
|
+
expect(determineExitCode(summary, config)).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('--fail-on-percentage threshold', () => {
|
|
131
|
+
test('should exit 0 when failure percentage is at or below threshold', () => {
|
|
132
|
+
const summary = createSummary(100, 10);
|
|
133
|
+
const config: GlobalConfig = { ci: { failOnPercentage: 10 } };
|
|
134
|
+
expect(determineExitCode(summary, config)).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('should exit 1 when failure percentage exceeds threshold', () => {
|
|
138
|
+
const summary = createSummary(100, 11);
|
|
139
|
+
const config: GlobalConfig = { ci: { failOnPercentage: 10 } };
|
|
140
|
+
expect(determineExitCode(summary, config)).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('should exit 0 when failOnPercentage is 50 and failures are 50%', () => {
|
|
144
|
+
const summary = createSummary(10, 5);
|
|
145
|
+
const config: GlobalConfig = { ci: { failOnPercentage: 50 } };
|
|
146
|
+
expect(determineExitCode(summary, config)).toBe(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('should exit 1 when failOnPercentage is 50 and failures are 51%', () => {
|
|
150
|
+
const summary = createSummary(100, 51);
|
|
151
|
+
const config: GlobalConfig = { ci: { failOnPercentage: 50 } };
|
|
152
|
+
expect(determineExitCode(summary, config)).toBe(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('should handle edge case with 0 total requests', () => {
|
|
156
|
+
const summary = createSummary(0, 0);
|
|
157
|
+
const config: GlobalConfig = { ci: { failOnPercentage: 10 } };
|
|
158
|
+
expect(determineExitCode(summary, config)).toBe(0);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('combined options', () => {
|
|
163
|
+
test('strictExit takes precedence over failOn', () => {
|
|
164
|
+
const summary = createSummary(10, 1);
|
|
165
|
+
const config: GlobalConfig = {
|
|
166
|
+
ci: { strictExit: true, failOn: 5 },
|
|
167
|
+
};
|
|
168
|
+
expect(determineExitCode(summary, config)).toBe(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('strictExit takes precedence over failOnPercentage', () => {
|
|
172
|
+
const summary = createSummary(100, 1);
|
|
173
|
+
const config: GlobalConfig = {
|
|
174
|
+
ci: { strictExit: true, failOnPercentage: 50 },
|
|
175
|
+
};
|
|
176
|
+
expect(determineExitCode(summary, config)).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('failOn checked before failOnPercentage', () => {
|
|
180
|
+
const summary = createSummary(100, 6); // 6% failure
|
|
181
|
+
const config: GlobalConfig = {
|
|
182
|
+
ci: { failOn: 5, failOnPercentage: 10 }, // Would pass percentage but fail count
|
|
183
|
+
};
|
|
184
|
+
expect(determineExitCode(summary, config)).toBe(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('should exit 0 when all thresholds pass', () => {
|
|
188
|
+
const summary = createSummary(100, 5); // 5% failure
|
|
189
|
+
const config: GlobalConfig = {
|
|
190
|
+
ci: { failOn: 5, failOnPercentage: 10 },
|
|
191
|
+
};
|
|
192
|
+
expect(determineExitCode(summary, config)).toBe(0);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('CI option with continueOnError', () => {
|
|
197
|
+
test('should still respect CI thresholds even with continueOnError', () => {
|
|
198
|
+
const summary = createSummary(10, 3);
|
|
199
|
+
const config: GlobalConfig = {
|
|
200
|
+
continueOnError: true,
|
|
201
|
+
ci: { failOn: 2 },
|
|
202
|
+
};
|
|
203
|
+
expect(determineExitCode(summary, config)).toBe(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('should exit 0 when threshold not exceeded with continueOnError', () => {
|
|
207
|
+
const summary = createSummary(10, 2);
|
|
208
|
+
const config: GlobalConfig = {
|
|
209
|
+
continueOnError: true,
|
|
210
|
+
ci: { failOn: 2 },
|
|
211
|
+
};
|
|
212
|
+
expect(determineExitCode(summary, config)).toBe(0);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Glob } from 'bun';
|
|
4
4
|
import { RequestExecutor } from './executor/request-executor';
|
|
5
5
|
import { YamlParser } from './parser/yaml';
|
|
6
|
-
import type { GlobalConfig, RequestConfig } from './types/config';
|
|
6
|
+
import type { ExecutionSummary, GlobalConfig, RequestConfig } from './types/config';
|
|
7
7
|
import { Logger } from './utils/logger';
|
|
8
8
|
import { VersionChecker } from './utils/version-checker';
|
|
9
9
|
import { getVersion } from './version';
|
|
@@ -105,6 +105,31 @@ class CurlRunnerCLI {
|
|
|
105
105
|
envConfig.output = { ...envConfig.output, saveToFile: process.env.CURL_RUNNER_OUTPUT_FILE };
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// CI exit code configuration
|
|
109
|
+
if (process.env.CURL_RUNNER_STRICT_EXIT) {
|
|
110
|
+
envConfig.ci = {
|
|
111
|
+
...envConfig.ci,
|
|
112
|
+
strictExit: process.env.CURL_RUNNER_STRICT_EXIT.toLowerCase() === 'true',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (process.env.CURL_RUNNER_FAIL_ON) {
|
|
117
|
+
envConfig.ci = {
|
|
118
|
+
...envConfig.ci,
|
|
119
|
+
failOn: Number.parseInt(process.env.CURL_RUNNER_FAIL_ON, 10),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.env.CURL_RUNNER_FAIL_ON_PERCENTAGE) {
|
|
124
|
+
const percentage = Number.parseFloat(process.env.CURL_RUNNER_FAIL_ON_PERCENTAGE);
|
|
125
|
+
if (percentage >= 0 && percentage <= 100) {
|
|
126
|
+
envConfig.ci = {
|
|
127
|
+
...envConfig.ci,
|
|
128
|
+
failOnPercentage: percentage,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
108
133
|
return envConfig;
|
|
109
134
|
}
|
|
110
135
|
|
|
@@ -233,6 +258,20 @@ class CurlRunnerCLI {
|
|
|
233
258
|
};
|
|
234
259
|
}
|
|
235
260
|
|
|
261
|
+
// Apply CI exit code options
|
|
262
|
+
if (options.strictExit !== undefined) {
|
|
263
|
+
globalConfig.ci = { ...globalConfig.ci, strictExit: options.strictExit as boolean };
|
|
264
|
+
}
|
|
265
|
+
if (options.failOn !== undefined) {
|
|
266
|
+
globalConfig.ci = { ...globalConfig.ci, failOn: options.failOn as number };
|
|
267
|
+
}
|
|
268
|
+
if (options.failOnPercentage !== undefined) {
|
|
269
|
+
globalConfig.ci = {
|
|
270
|
+
...globalConfig.ci,
|
|
271
|
+
failOnPercentage: options.failOnPercentage as number,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
236
275
|
if (allRequests.length === 0) {
|
|
237
276
|
this.logger.logError('No requests found in YAML files');
|
|
238
277
|
process.exit(1);
|
|
@@ -283,7 +322,9 @@ class CurlRunnerCLI {
|
|
|
283
322
|
summary = await executor.execute(allRequests);
|
|
284
323
|
}
|
|
285
324
|
|
|
286
|
-
|
|
325
|
+
// Determine exit code based on CI configuration
|
|
326
|
+
const exitCode = this.determineExitCode(summary, globalConfig);
|
|
327
|
+
process.exit(exitCode);
|
|
287
328
|
} catch (error) {
|
|
288
329
|
this.logger.logError(error instanceof Error ? error.message : String(error));
|
|
289
330
|
process.exit(1);
|
|
@@ -313,6 +354,8 @@ class CurlRunnerCLI {
|
|
|
313
354
|
options.showBody = true;
|
|
314
355
|
} else if (key === 'show-metrics') {
|
|
315
356
|
options.showMetrics = true;
|
|
357
|
+
} else if (key === 'strict-exit') {
|
|
358
|
+
options.strictExit = true;
|
|
316
359
|
} else if (nextArg && !nextArg.startsWith('--')) {
|
|
317
360
|
if (key === 'continue-on-error') {
|
|
318
361
|
options.continueOnError = nextArg === 'true';
|
|
@@ -324,6 +367,13 @@ class CurlRunnerCLI {
|
|
|
324
367
|
options.retries = Number.parseInt(nextArg, 10);
|
|
325
368
|
} else if (key === 'retry-delay') {
|
|
326
369
|
options.retryDelay = Number.parseInt(nextArg, 10);
|
|
370
|
+
} else if (key === 'fail-on') {
|
|
371
|
+
options.failOn = Number.parseInt(nextArg, 10);
|
|
372
|
+
} else if (key === 'fail-on-percentage') {
|
|
373
|
+
const percentage = Number.parseFloat(nextArg);
|
|
374
|
+
if (percentage >= 0 && percentage <= 100) {
|
|
375
|
+
options.failOnPercentage = percentage;
|
|
376
|
+
}
|
|
327
377
|
} else if (key === 'output-format') {
|
|
328
378
|
if (['json', 'pretty', 'raw'].includes(nextArg)) {
|
|
329
379
|
options.outputFormat = nextArg;
|
|
@@ -485,9 +535,62 @@ class CurlRunnerCLI {
|
|
|
485
535
|
variables: { ...base.variables, ...override.variables },
|
|
486
536
|
output: { ...base.output, ...override.output },
|
|
487
537
|
defaults: { ...base.defaults, ...override.defaults },
|
|
538
|
+
ci: { ...base.ci, ...override.ci },
|
|
488
539
|
};
|
|
489
540
|
}
|
|
490
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Determines the appropriate exit code based on execution results and CI configuration.
|
|
544
|
+
*
|
|
545
|
+
* Exit code logic:
|
|
546
|
+
* - If strictExit is enabled: exit 1 if ANY failures occur
|
|
547
|
+
* - If failOn is set: exit 1 if failures exceed the threshold
|
|
548
|
+
* - If failOnPercentage is set: exit 1 if failure percentage exceeds the threshold
|
|
549
|
+
* - Default behavior: exit 1 only if failures exist AND continueOnError is false
|
|
550
|
+
*
|
|
551
|
+
* @param summary - The execution summary containing success/failure counts
|
|
552
|
+
* @param config - Global configuration including CI exit options
|
|
553
|
+
* @returns 0 for success, 1 for failure
|
|
554
|
+
*/
|
|
555
|
+
private determineExitCode(summary: ExecutionSummary, config: GlobalConfig): number {
|
|
556
|
+
const { failed, total } = summary;
|
|
557
|
+
const ci = config.ci;
|
|
558
|
+
|
|
559
|
+
// If no failures, always exit with 0
|
|
560
|
+
if (failed === 0) {
|
|
561
|
+
return 0;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Check CI exit code options
|
|
565
|
+
if (ci) {
|
|
566
|
+
// strictExit: exit 1 if ANY failures occur
|
|
567
|
+
if (ci.strictExit) {
|
|
568
|
+
return 1;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// failOn: exit 1 if failures exceed the threshold
|
|
572
|
+
if (ci.failOn !== undefined && failed > ci.failOn) {
|
|
573
|
+
return 1;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// failOnPercentage: exit 1 if failure percentage exceeds the threshold
|
|
577
|
+
if (ci.failOnPercentage !== undefined && total > 0) {
|
|
578
|
+
const failurePercentage = (failed / total) * 100;
|
|
579
|
+
if (failurePercentage > ci.failOnPercentage) {
|
|
580
|
+
return 1;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// If any CI option is set but thresholds not exceeded, exit 0
|
|
585
|
+
if (ci.failOn !== undefined || ci.failOnPercentage !== undefined) {
|
|
586
|
+
return 0;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Default behavior: exit 1 if failures AND continueOnError is false
|
|
591
|
+
return !config.continueOnError ? 1 : 0;
|
|
592
|
+
}
|
|
593
|
+
|
|
491
594
|
private showHelp(): void {
|
|
492
595
|
console.log(`
|
|
493
596
|
${this.logger.color('🚀 CURL RUNNER', 'bright')}
|
|
@@ -514,6 +617,11 @@ ${this.logger.color('OPTIONS:', 'yellow')}
|
|
|
514
617
|
--show-metrics Include performance metrics in output
|
|
515
618
|
--version Show version
|
|
516
619
|
|
|
620
|
+
${this.logger.color('CI/CD OPTIONS:', 'yellow')}
|
|
621
|
+
--strict-exit Exit with code 1 if any validation fails (for CI/CD)
|
|
622
|
+
--fail-on <count> Exit with code 1 if failures exceed this count
|
|
623
|
+
--fail-on-percentage <pct> Exit with code 1 if failure percentage exceeds this value
|
|
624
|
+
|
|
517
625
|
${this.logger.color('EXAMPLES:', 'yellow')}
|
|
518
626
|
# Run all YAML files in current directory
|
|
519
627
|
curl-runner
|
|
@@ -542,6 +650,18 @@ ${this.logger.color('EXAMPLES:', 'yellow')}
|
|
|
542
650
|
# Run with detailed pretty output (show all information)
|
|
543
651
|
curl-runner --output-format pretty --pretty-level detailed test.yaml
|
|
544
652
|
|
|
653
|
+
# CI/CD: Fail if any validation fails (strict mode)
|
|
654
|
+
curl-runner tests/ --strict-exit
|
|
655
|
+
|
|
656
|
+
# CI/CD: Run all tests but fail if any validation fails
|
|
657
|
+
curl-runner tests/ --continue-on-error --strict-exit
|
|
658
|
+
|
|
659
|
+
# CI/CD: Allow up to 2 failures
|
|
660
|
+
curl-runner tests/ --fail-on 2
|
|
661
|
+
|
|
662
|
+
# CI/CD: Allow up to 10% failures
|
|
663
|
+
curl-runner tests/ --fail-on-percentage 10
|
|
664
|
+
|
|
545
665
|
${this.logger.color('YAML STRUCTURE:', 'yellow')}
|
|
546
666
|
Single request:
|
|
547
667
|
request:
|
|
@@ -2,6 +2,8 @@ import { YamlParser } from '../parser/yaml';
|
|
|
2
2
|
import type {
|
|
3
3
|
ExecutionResult,
|
|
4
4
|
ExecutionSummary,
|
|
5
|
+
FileAttachment,
|
|
6
|
+
FormFieldValue,
|
|
5
7
|
GlobalConfig,
|
|
6
8
|
JsonValue,
|
|
7
9
|
RequestConfig,
|
|
@@ -28,6 +30,42 @@ export class RequestExecutor {
|
|
|
28
30
|
};
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Checks if a form field value is a file attachment.
|
|
35
|
+
*/
|
|
36
|
+
private isFileAttachment(value: FormFieldValue): value is FileAttachment {
|
|
37
|
+
return typeof value === 'object' && value !== null && 'file' in value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validates that all file attachments in formData exist.
|
|
42
|
+
* Returns an error message if any file is missing, or undefined if all files exist.
|
|
43
|
+
*/
|
|
44
|
+
private async validateFileAttachments(config: RequestConfig): Promise<string | undefined> {
|
|
45
|
+
if (!config.formData) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const missingFiles: string[] = [];
|
|
50
|
+
|
|
51
|
+
for (const [fieldName, fieldValue] of Object.entries(config.formData)) {
|
|
52
|
+
if (this.isFileAttachment(fieldValue)) {
|
|
53
|
+
const filePath = fieldValue.file;
|
|
54
|
+
const file = Bun.file(filePath);
|
|
55
|
+
const exists = await file.exists();
|
|
56
|
+
if (!exists) {
|
|
57
|
+
missingFiles.push(`${fieldName}: ${filePath}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (missingFiles.length > 0) {
|
|
63
|
+
return `File(s) not found: ${missingFiles.join(', ')}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
31
69
|
async executeRequest(config: RequestConfig, index: number = 0): Promise<ExecutionResult> {
|
|
32
70
|
const startTime = performance.now();
|
|
33
71
|
|
|
@@ -37,6 +75,21 @@ export class RequestExecutor {
|
|
|
37
75
|
|
|
38
76
|
requestLogger.logRequestStart(config, index);
|
|
39
77
|
|
|
78
|
+
// Validate file attachments exist before executing
|
|
79
|
+
const fileError = await this.validateFileAttachments(config);
|
|
80
|
+
if (fileError) {
|
|
81
|
+
const failedResult: ExecutionResult = {
|
|
82
|
+
request: config,
|
|
83
|
+
success: false,
|
|
84
|
+
error: fileError,
|
|
85
|
+
metrics: {
|
|
86
|
+
duration: performance.now() - startTime,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
requestLogger.logRequestComplete(failedResult);
|
|
90
|
+
return failedResult;
|
|
91
|
+
}
|
|
92
|
+
|
|
40
93
|
const command = CurlBuilder.buildCommand(config);
|
|
41
94
|
requestLogger.logCommand(command);
|
|
42
95
|
|
package/src/types/config.ts
CHANGED
|
@@ -4,6 +4,47 @@ export interface JsonObject {
|
|
|
4
4
|
}
|
|
5
5
|
export interface JsonArray extends Array<JsonValue> {}
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Configuration for a file attachment in a form data request.
|
|
9
|
+
*
|
|
10
|
+
* Examples:
|
|
11
|
+
* - `{ file: "./image.png" }` - Simple file attachment
|
|
12
|
+
* - `{ file: "./doc.pdf", filename: "document.pdf" }` - With custom filename
|
|
13
|
+
* - `{ file: "./data.json", contentType: "application/json" }` - With explicit content type
|
|
14
|
+
*/
|
|
15
|
+
export interface FileAttachment {
|
|
16
|
+
/** Path to the file (relative to YAML file or absolute) */
|
|
17
|
+
file: string;
|
|
18
|
+
/** Custom filename to send (defaults to actual filename) */
|
|
19
|
+
filename?: string;
|
|
20
|
+
/** Explicit content type (curl will auto-detect if not specified) */
|
|
21
|
+
contentType?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A form field value can be a string, number, boolean, or a file attachment.
|
|
26
|
+
*/
|
|
27
|
+
export type FormFieldValue = string | number | boolean | FileAttachment;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Configuration for form data (multipart/form-data) requests.
|
|
31
|
+
* Each key is a form field name, and the value can be a simple value or a file attachment.
|
|
32
|
+
*
|
|
33
|
+
* Examples:
|
|
34
|
+
* ```yaml
|
|
35
|
+
* formData:
|
|
36
|
+
* name: "John Doe"
|
|
37
|
+
* age: 30
|
|
38
|
+
* avatar:
|
|
39
|
+
* file: "./avatar.png"
|
|
40
|
+
* document:
|
|
41
|
+
* file: "./report.pdf"
|
|
42
|
+
* filename: "quarterly-report.pdf"
|
|
43
|
+
* contentType: "application/pdf"
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export type FormDataConfig = Record<string, FormFieldValue>;
|
|
47
|
+
|
|
7
48
|
/**
|
|
8
49
|
* Configuration for storing response values as variables for subsequent requests.
|
|
9
50
|
* Maps a variable name to a JSON path in the response.
|
|
@@ -24,6 +65,18 @@ export interface RequestConfig {
|
|
|
24
65
|
params?: Record<string, string>;
|
|
25
66
|
sourceFile?: string; // Source YAML file for better output organization
|
|
26
67
|
body?: JsonValue;
|
|
68
|
+
/**
|
|
69
|
+
* Form data for multipart/form-data requests.
|
|
70
|
+
* Use this for file uploads or when you need to send form fields.
|
|
71
|
+
* Cannot be used together with 'body'.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* formData:
|
|
75
|
+
* username: "john"
|
|
76
|
+
* avatar:
|
|
77
|
+
* file: "./avatar.png"
|
|
78
|
+
*/
|
|
79
|
+
formData?: FormDataConfig;
|
|
27
80
|
timeout?: number;
|
|
28
81
|
followRedirects?: boolean;
|
|
29
82
|
maxRedirects?: number;
|
|
@@ -79,9 +132,39 @@ export interface CollectionConfig {
|
|
|
79
132
|
requests: RequestConfig[];
|
|
80
133
|
}
|
|
81
134
|
|
|
135
|
+
/**
|
|
136
|
+
* CI exit code configuration options.
|
|
137
|
+
* These options control how curl-runner exits in CI/CD pipelines.
|
|
138
|
+
*/
|
|
139
|
+
export interface CIExitConfig {
|
|
140
|
+
/**
|
|
141
|
+
* When true, exit with code 1 if any validation failures occur,
|
|
142
|
+
* regardless of the continueOnError setting.
|
|
143
|
+
* This is useful for CI/CD pipelines that need strict validation.
|
|
144
|
+
*/
|
|
145
|
+
strictExit?: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* Maximum number of failures allowed before exiting with code 1.
|
|
148
|
+
* If set to 0, any failure will cause a non-zero exit.
|
|
149
|
+
* If undefined and strictExit is true, any failure causes non-zero exit.
|
|
150
|
+
*/
|
|
151
|
+
failOn?: number;
|
|
152
|
+
/**
|
|
153
|
+
* Maximum percentage of failures allowed before exiting with code 1.
|
|
154
|
+
* Value should be between 0 and 100.
|
|
155
|
+
* If set to 10, up to 10% of requests can fail without causing a non-zero exit.
|
|
156
|
+
*/
|
|
157
|
+
failOnPercentage?: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
82
160
|
export interface GlobalConfig {
|
|
83
161
|
execution?: 'sequential' | 'parallel';
|
|
84
162
|
continueOnError?: boolean;
|
|
163
|
+
/**
|
|
164
|
+
* CI/CD exit code configuration.
|
|
165
|
+
* Controls when curl-runner should exit with non-zero status codes.
|
|
166
|
+
*/
|
|
167
|
+
ci?: CIExitConfig;
|
|
85
168
|
variables?: Record<string, string>;
|
|
86
169
|
output?: {
|
|
87
170
|
verbose?: boolean;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { CurlBuilder } from './curl-builder';
|
|
3
|
+
|
|
4
|
+
describe('CurlBuilder', () => {
|
|
5
|
+
describe('buildCommand', () => {
|
|
6
|
+
test('should build basic GET request', () => {
|
|
7
|
+
const command = CurlBuilder.buildCommand({
|
|
8
|
+
url: 'https://example.com/api',
|
|
9
|
+
method: 'GET',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(command).toContain('curl');
|
|
13
|
+
expect(command).toContain('-X GET');
|
|
14
|
+
expect(command).toContain('"https://example.com/api"');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('should build POST request with JSON body', () => {
|
|
18
|
+
const command = CurlBuilder.buildCommand({
|
|
19
|
+
url: 'https://example.com/api',
|
|
20
|
+
method: 'POST',
|
|
21
|
+
body: { name: 'test' },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(command).toContain('-X POST');
|
|
25
|
+
expect(command).toContain('-d \'{"name":"test"}\'');
|
|
26
|
+
expect(command).toContain('Content-Type: application/json');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('should build POST request with form data', () => {
|
|
30
|
+
const command = CurlBuilder.buildCommand({
|
|
31
|
+
url: 'https://example.com/upload',
|
|
32
|
+
method: 'POST',
|
|
33
|
+
formData: {
|
|
34
|
+
username: 'john',
|
|
35
|
+
age: 30,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(command).toContain('-X POST');
|
|
40
|
+
expect(command).toContain("-F 'username=john'");
|
|
41
|
+
expect(command).toContain("-F 'age=30'");
|
|
42
|
+
expect(command).not.toContain('-d');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('should build POST request with file attachment', () => {
|
|
46
|
+
const command = CurlBuilder.buildCommand({
|
|
47
|
+
url: 'https://example.com/upload',
|
|
48
|
+
method: 'POST',
|
|
49
|
+
formData: {
|
|
50
|
+
document: {
|
|
51
|
+
file: './test.pdf',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(command).toContain("-F 'document=@./test.pdf'");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('should build POST request with file attachment and custom filename', () => {
|
|
60
|
+
const command = CurlBuilder.buildCommand({
|
|
61
|
+
url: 'https://example.com/upload',
|
|
62
|
+
method: 'POST',
|
|
63
|
+
formData: {
|
|
64
|
+
document: {
|
|
65
|
+
file: './test.pdf',
|
|
66
|
+
filename: 'report.pdf',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(command).toContain("-F 'document=@./test.pdf;filename=report.pdf'");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('should build POST request with file attachment and content type', () => {
|
|
75
|
+
const command = CurlBuilder.buildCommand({
|
|
76
|
+
url: 'https://example.com/upload',
|
|
77
|
+
method: 'POST',
|
|
78
|
+
formData: {
|
|
79
|
+
data: {
|
|
80
|
+
file: './data.json',
|
|
81
|
+
contentType: 'application/json',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(command).toContain("-F 'data=@./data.json;type=application/json'");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('should build POST request with file attachment including all options', () => {
|
|
90
|
+
const command = CurlBuilder.buildCommand({
|
|
91
|
+
url: 'https://example.com/upload',
|
|
92
|
+
method: 'POST',
|
|
93
|
+
formData: {
|
|
94
|
+
document: {
|
|
95
|
+
file: './report.pdf',
|
|
96
|
+
filename: 'quarterly-report.pdf',
|
|
97
|
+
contentType: 'application/pdf',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(command).toContain(
|
|
103
|
+
"-F 'document=@./report.pdf;filename=quarterly-report.pdf;type=application/pdf'",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('should build POST request with mixed form data and files', () => {
|
|
108
|
+
const command = CurlBuilder.buildCommand({
|
|
109
|
+
url: 'https://example.com/upload',
|
|
110
|
+
method: 'POST',
|
|
111
|
+
formData: {
|
|
112
|
+
title: 'My Document',
|
|
113
|
+
description: 'Test upload',
|
|
114
|
+
file: {
|
|
115
|
+
file: './document.pdf',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(command).toContain("-F 'title=My Document'");
|
|
121
|
+
expect(command).toContain("-F 'description=Test upload'");
|
|
122
|
+
expect(command).toContain("-F 'file=@./document.pdf'");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('should escape single quotes in form field values', () => {
|
|
126
|
+
const command = CurlBuilder.buildCommand({
|
|
127
|
+
url: 'https://example.com/upload',
|
|
128
|
+
method: 'POST',
|
|
129
|
+
formData: {
|
|
130
|
+
message: "It's a test",
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(command).toContain("-F 'message=It'\\''s a test'");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('should prefer formData over body when both are present', () => {
|
|
138
|
+
const command = CurlBuilder.buildCommand({
|
|
139
|
+
url: 'https://example.com/api',
|
|
140
|
+
method: 'POST',
|
|
141
|
+
formData: {
|
|
142
|
+
field: 'value',
|
|
143
|
+
},
|
|
144
|
+
body: { name: 'test' },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(command).toContain("-F 'field=value'");
|
|
148
|
+
expect(command).not.toContain('-d');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('should handle boolean form field values', () => {
|
|
152
|
+
const command = CurlBuilder.buildCommand({
|
|
153
|
+
url: 'https://example.com/api',
|
|
154
|
+
method: 'POST',
|
|
155
|
+
formData: {
|
|
156
|
+
active: true,
|
|
157
|
+
disabled: false,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(command).toContain("-F 'active=true'");
|
|
162
|
+
expect(command).toContain("-F 'disabled=false'");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RequestConfig } from '../types/config';
|
|
1
|
+
import type { FileAttachment, FormFieldValue, RequestConfig } from '../types/config';
|
|
2
2
|
|
|
3
3
|
interface CurlMetrics {
|
|
4
4
|
response_code?: number;
|
|
@@ -11,6 +11,20 @@ interface CurlMetrics {
|
|
|
11
11
|
time_starttransfer?: number;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Checks if a form field value is a file attachment.
|
|
16
|
+
*/
|
|
17
|
+
function isFileAttachment(value: FormFieldValue): value is FileAttachment {
|
|
18
|
+
return typeof value === 'object' && value !== null && 'file' in value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Escapes a string value for use in curl -F flag.
|
|
23
|
+
*/
|
|
24
|
+
function escapeFormValue(value: string): string {
|
|
25
|
+
return value.replace(/'/g, "'\\''");
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
// Using class for organization, but could be refactored to functions
|
|
15
29
|
export class CurlBuilder {
|
|
16
30
|
static buildCommand(config: RequestConfig): string {
|
|
@@ -34,7 +48,26 @@ export class CurlBuilder {
|
|
|
34
48
|
}
|
|
35
49
|
}
|
|
36
50
|
|
|
37
|
-
if (config.
|
|
51
|
+
if (config.formData) {
|
|
52
|
+
// Use -F flags for multipart/form-data
|
|
53
|
+
for (const [fieldName, fieldValue] of Object.entries(config.formData)) {
|
|
54
|
+
if (isFileAttachment(fieldValue)) {
|
|
55
|
+
// File attachment: -F "field=@filepath;filename=name;type=mimetype"
|
|
56
|
+
let fileSpec = `@${fieldValue.file}`;
|
|
57
|
+
if (fieldValue.filename) {
|
|
58
|
+
fileSpec += `;filename=${fieldValue.filename}`;
|
|
59
|
+
}
|
|
60
|
+
if (fieldValue.contentType) {
|
|
61
|
+
fileSpec += `;type=${fieldValue.contentType}`;
|
|
62
|
+
}
|
|
63
|
+
parts.push('-F', `'${fieldName}=${escapeFormValue(fileSpec)}'`);
|
|
64
|
+
} else {
|
|
65
|
+
// Regular form field: -F "field=value"
|
|
66
|
+
const strValue = String(fieldValue);
|
|
67
|
+
parts.push('-F', `'${fieldName}=${escapeFormValue(strValue)}'`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} else if (config.body) {
|
|
38
71
|
const bodyStr = typeof config.body === 'string' ? config.body : JSON.stringify(config.body);
|
|
39
72
|
parts.push('-d', `'${bodyStr.replace(/'/g, "'\\''")}'`);
|
|
40
73
|
|