├── .gitignore ├── .npmignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── index.ts ├── loadtesting.ts ├── matcher.ts ├── steps │ ├── delay.ts │ ├── graphql.ts │ ├── grpc.ts │ ├── http.ts │ ├── plugin.ts │ ├── sse.ts │ └── trpc.ts └── utils │ ├── auth.ts │ ├── files.ts │ ├── runner.ts │ ├── schema.ts │ └── testdata.ts ├── tests ├── auth.yml ├── basic.yml ├── example.json ├── filelist.yml ├── helloworld.proto ├── loadtesting.ts ├── multipart.yml ├── plugin.js ├── plugin.yml ├── sse.js ├── sse.yml └── test.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | tests/ 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Step CI Runner 2 | 3 | Step CI Test Runner 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm install @stepci/runner 9 | ``` 10 | 11 | ## Usage 12 | 13 | ### Run workflow from file 14 | 15 | ```js 16 | import { runFromFile } from '@stepci/runner' 17 | runFromFile('./examples/status.yml').then(console.log) 18 | ``` 19 | 20 | ### Run workflow from config 21 | 22 | ```js 23 | import { run } from '@stepci/runner' 24 | 25 | // Example workflow 26 | const workflow = { 27 | version: "1.0", 28 | name: "Status Test", 29 | env: { 30 | host: "example.com" 31 | }, 32 | tests: { 33 | example: { 34 | steps: [{ 35 | name: "GET request", 36 | http: { 37 | url: "https://${{env.host}}", 38 | method: "GET", 39 | check: { 40 | status: "/^20/" 41 | } 42 | } 43 | }] 44 | } 45 | } 46 | } 47 | 48 | run(workflow).then(console.log) 49 | ``` 50 | 51 | ### Events 52 | 53 | If you supply an `EventEmitter` as argument, you can subscribe to following events: 54 | 55 | - `step:http_request`, when a http request is made 56 | - `step:http_response`, when a http response is received 57 | - `step:grpc_request`, when a grpc request is made 58 | - `step:grpc_response`, when a grpc is received 59 | - `step:result`, when step finishes 60 | - `step:error`, when step errors 61 | - `test:result`, when test finishes 62 | - `workflow:result`, when workflow finishes 63 | - `loadtest:result`, when loadtest finishes 64 | 65 | **Example: Events** 66 | 67 | ```js 68 | import { run } from '@stepci/runner' 69 | import { EventEmitter } from 'node:events' 70 | 71 | // Example workflow 72 | const workflow = { 73 | version: "1.0", 74 | name: "Status Test", 75 | env: { 76 | host: "example.com" 77 | }, 78 | tests: { 79 | example: { 80 | steps: [{ 81 | name: "GET request", 82 | http: { 83 | url: "https://${{env.host}}", 84 | method: "GET", 85 | check: { 86 | status: "/^20/" 87 | } 88 | } 89 | }] 90 | } 91 | } 92 | } 93 | 94 | const ee = new EventEmitter() 95 | ee.on('done', console.log) 96 | run(workflow, { ee }) 97 | ``` 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stepci/runner", 3 | "version": "2.0.7", 4 | "description": "Step CI Runner", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc -p tsconfig.json", 8 | "build:watch": "tsc -w -p tsconfig.json", 9 | "test": "ts-node ./tests/test.ts", 10 | "test:loadtest": "ts-node ./tests/loadtesting.ts" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/StepCI/runner.git" 15 | }, 16 | "keywords": [ 17 | "http", 18 | "testing", 19 | "api", 20 | "framework" 21 | ], 22 | "author": "Mish Ushakov ", 23 | "license": "MPL-2.0", 24 | "bugs": { 25 | "url": "https://github.com/StepCI/runner/issues" 26 | }, 27 | "homepage": "https://github.com/StepCI/runner#readme", 28 | "dependencies": { 29 | "@apidevtools/json-schema-ref-parser": "9.1.0", 30 | "@fast-csv/parse": "^4.3.6", 31 | "@tgwf/co2": "^0.11.3", 32 | "@xmldom/xmldom": "^0.8.6", 33 | "ajv": "^8.11.0", 34 | "ajv-formats": "^2.1.1", 35 | "cheerio": "^1.0.0-rc.12", 36 | "cool-grpc": "^1.2.3", 37 | "deep-equal": "^2.0.5", 38 | "eventsource": "^2.0.2", 39 | "filtrex": "^3.0.0", 40 | "flat": "^5.0.2", 41 | "form-data": "^4.0.0", 42 | "got": "^11.8.3", 43 | "js-yaml": "^4.1.0", 44 | "jsonpath-plus": "^10.3.0", 45 | "liquidless": "^1.2.0", 46 | "liquidless-faker": "^1.0.1", 47 | "liquidless-naughtystrings": "^1.0.0", 48 | "p-limit": "^3.1.0", 49 | "parse-duration": "^1.1.2", 50 | "phasic": "^1.0.2", 51 | "proxy-agent": "^6.3.1", 52 | "simple-statistics": "^7.8.0", 53 | "tough-cookie": "^4.1.2", 54 | "xpath": "^0.0.32" 55 | }, 56 | "devDependencies": { 57 | "@tsconfig/node16": "^1.0.3", 58 | "@types/deep-equal": "^1.0.1", 59 | "@types/eventsource": "^1.1.11", 60 | "@types/flat": "^5.0.2", 61 | "@types/js-yaml": "^4.0.5", 62 | "@types/node": "^18.7.18", 63 | "@types/tough-cookie": "^4.0.2", 64 | "@types/xmldom": "^0.1.31", 65 | "ts-node": "^10.9.1", 66 | "typescript": "^4.8.3" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { CookieJar, Cookie } from 'tough-cookie' 2 | import { renderObject as liquidlessRenderObject } from 'liquidless' 3 | import { fake } from 'liquidless-faker' 4 | import { naughtystring } from 'liquidless-naughtystrings' 5 | import { EventEmitter } from 'node:events' 6 | import fs from 'fs' 7 | import yaml from 'js-yaml' 8 | import $RefParser from '@apidevtools/json-schema-ref-parser' 9 | import Ajv from 'ajv' 10 | import addFormats from 'ajv-formats' 11 | import pLimit from 'p-limit' 12 | import path from 'node:path' 13 | import { Phase } from 'phasic' 14 | import { Matcher, CheckResult, CheckResults } from './matcher' 15 | import { LoadTestCheck } from './loadtesting' 16 | import { parseCSV, TestData } from './utils/testdata' 17 | import { CapturesStorage, checkCondition, didChecksPass } from './utils/runner' 18 | import { CredentialsStorage } from './utils/auth' 19 | import runHTTPStep, { HTTPStep, HTTPStepRequest, HTTPStepResponse } from './steps/http' 20 | import runGRPCStep, { gRPCStep, gRPCStepRequest, gRPCStepResponse } from './steps/grpc' 21 | import runSSEStep, { SSEStep, SSEStepRequest, SSEStepResponse } from './steps/sse' 22 | import runDelayStep from './steps/delay' 23 | import runPluginStep, { PluginStep } from './steps/plugin' 24 | import runTRPCStep, { tRPCStep } from './steps/trpc' 25 | import runGraphQLStep, { GraphQLStep } from './steps/graphql' 26 | import parseDuration from 'parse-duration' 27 | import { addCustomSchemas } from './utils/schema' 28 | 29 | export type Workflow = { 30 | version: string 31 | name: string 32 | env?: WorkflowEnv 33 | /** 34 | * @deprecated Import files using `$refs` instead. 35 | */ 36 | include?: string[] 37 | before?: Test 38 | tests: Tests 39 | after?: Test 40 | components?: WorkflowComponents 41 | config?: WorkflowConfig 42 | } 43 | 44 | export type WorkflowEnv = { 45 | [key: string]: string 46 | } 47 | 48 | export type WorkflowComponents = { 49 | schemas?: { 50 | [key: string]: any 51 | } 52 | credentials?: CredentialsStorage 53 | } 54 | 55 | export type WorkflowConfig = { 56 | loadTest?: { 57 | phases: Phase[] 58 | check?: LoadTestCheck 59 | }, 60 | continueOnFail?: boolean, 61 | http?: { 62 | baseURL?: string 63 | rejectUnauthorized?: boolean 64 | http2?: boolean 65 | } 66 | grpc?: { 67 | proto: string | string[] 68 | } 69 | concurrency?: number 70 | } 71 | 72 | export type WorkflowOptions = { 73 | path?: string 74 | secrets?: WorkflowOptionsSecrets 75 | ee?: EventEmitter 76 | env?: WorkflowEnv 77 | concurrency?: number 78 | } 79 | 80 | type WorkflowOptionsSecrets = { 81 | [key: string]: string 82 | } 83 | 84 | export type WorkflowResult = { 85 | workflow: Workflow 86 | result: { 87 | tests: TestResult[] 88 | passed: boolean 89 | timestamp: Date 90 | duration: number 91 | bytesSent: number 92 | bytesReceived: number 93 | co2: number 94 | } 95 | path?: string 96 | } 97 | 98 | export type Test = { 99 | name?: string 100 | env?: object 101 | steps: Step[] 102 | testdata?: TestData 103 | } 104 | 105 | export type Tests = { 106 | [key: string]: Test 107 | } 108 | 109 | export type Step = { 110 | id?: string 111 | name?: string 112 | retries?: { 113 | count: number 114 | interval?: string | number 115 | } 116 | if?: string 117 | http?: HTTPStep 118 | trpc?: tRPCStep 119 | graphql?: GraphQLStep 120 | grpc?: gRPCStep 121 | sse?: SSEStep 122 | delay?: string 123 | plugin?: PluginStep 124 | } 125 | 126 | export type StepCheckValue = { 127 | [key: string]: string 128 | } 129 | 130 | export type StepCheckJSONPath = { 131 | [key: string]: any 132 | } 133 | 134 | export type StepCheckPerformance = { 135 | [key: string]: number 136 | } 137 | 138 | export type StepCheckCaptures = { 139 | [key: string]: any 140 | } 141 | 142 | export type StepCheckMatcher = { 143 | [key: string]: Matcher[] 144 | } 145 | 146 | export type TestResult = { 147 | id: string 148 | name?: string 149 | steps: StepResult[] 150 | passed: boolean 151 | timestamp: Date 152 | duration: number 153 | co2: number 154 | bytesSent: number 155 | bytesReceived: number 156 | } 157 | 158 | export type StepResult = { 159 | id?: string 160 | testId: string 161 | name?: string 162 | retries?: number 163 | captures?: CapturesStorage 164 | cookies?: Cookie.Serialized[] 165 | errored: boolean 166 | errorMessage?: string 167 | passed: boolean 168 | skipped: boolean 169 | timestamp: Date 170 | responseTime: number 171 | duration: number 172 | co2: number 173 | bytesSent: number 174 | bytesReceived: number 175 | } & StepRunResult 176 | 177 | export type StepRunResult = { 178 | type?: string 179 | checks?: StepCheckResult 180 | request?: HTTPStepRequest | gRPCStepRequest | SSEStepRequest | any 181 | response?: HTTPStepResponse | gRPCStepResponse | SSEStepResponse | any 182 | } 183 | 184 | export type StepCheckResult = { 185 | [key: string]: CheckResult | CheckResults 186 | } 187 | 188 | const templateDelimiters = ['${{', '}}'] 189 | 190 | function renderObject( 191 | object: object, 192 | props: object, 193 | ): T { 194 | return liquidlessRenderObject(object, props, { 195 | filters: { 196 | fake, 197 | naughtystring 198 | }, 199 | delimiters: templateDelimiters 200 | }) 201 | } 202 | 203 | // Run from test file 204 | export async function runFromYAML(yamlString: string, options?: WorkflowOptions): Promise { 205 | const workflow = yaml.load(yamlString) 206 | const dereffed = await $RefParser.dereference(workflow as any, { 207 | dereference: { 208 | circular: 'ignore' 209 | } 210 | }) as unknown as Workflow 211 | return run(dereffed, options) 212 | } 213 | 214 | // Run from test file 215 | export async function runFromFile(path: string, options?: WorkflowOptions): Promise { 216 | const testFile = await fs.promises.readFile(path) 217 | return runFromYAML(testFile.toString(), { ...options, path }) 218 | } 219 | 220 | // Run workflow 221 | export async function run(workflow: Workflow, options?: WorkflowOptions): Promise { 222 | const timestamp = new Date() 223 | const schemaValidator = new Ajv({ strictSchema: false }) 224 | addFormats(schemaValidator) 225 | 226 | // Templating for env, components, config 227 | let env = { ...workflow.env, ...options?.env } 228 | if (workflow.env) { 229 | env = renderObject(env, { env, secrets: options?.secrets }) 230 | } 231 | 232 | if (workflow.components) { 233 | workflow.components = renderObject(workflow.components, { env, secrets: options?.secrets }) 234 | } 235 | 236 | if (workflow.components?.schemas) { 237 | addCustomSchemas(schemaValidator, workflow.components.schemas) 238 | } 239 | 240 | if (workflow.config) { 241 | workflow.config = renderObject(workflow.config, { env, secrets: options?.secrets }) 242 | } 243 | 244 | if (workflow.include) { 245 | for (const workflowPath of workflow.include) { 246 | const testFile = await fs.promises.readFile(path.join(path.dirname(options?.path || __dirname), workflowPath)) 247 | const test = yaml.load(testFile.toString()) as Workflow 248 | workflow.tests = { ...workflow.tests, ...test.tests } 249 | } 250 | } 251 | 252 | const concurrency = options?.concurrency || workflow.config?.concurrency || Object.keys(workflow.tests).length 253 | const limit = pLimit(concurrency <= 0 ? 1 : concurrency) 254 | 255 | const testResults: TestResult[] = [] 256 | const captures: CapturesStorage = {} 257 | 258 | // Run `before` section 259 | if (workflow.before) { 260 | const beforeResult = await runTest('before', workflow.before, schemaValidator, options, workflow.config, env, captures) 261 | testResults.push(beforeResult) 262 | } 263 | 264 | // Run `tests` section 265 | const input: Promise[] = [] 266 | Object.entries(workflow.tests).map(([id, test]) => input.push(limit(() => runTest(id, test, schemaValidator, options, workflow.config, env, { ...captures })))) 267 | testResults.push(...await Promise.all(input)) 268 | 269 | // Run `after` section 270 | if (workflow.after) { 271 | const afterResult = await runTest('after', workflow.after, schemaValidator, options, workflow.config, env, captures) 272 | testResults.push(afterResult) 273 | } 274 | 275 | const workflowResult: WorkflowResult = { 276 | workflow, 277 | result: { 278 | tests: testResults, 279 | timestamp, 280 | passed: testResults.every(test => test.passed), 281 | duration: Date.now() - timestamp.valueOf(), 282 | co2: testResults.map(test => test.co2).reduce((a, b) => a + b), 283 | bytesSent: testResults.map(test => test.bytesSent).reduce((a, b) => a + b), 284 | bytesReceived: testResults.map(test => test.bytesReceived).reduce((a, b) => a + b), 285 | }, 286 | path: options?.path 287 | } 288 | 289 | options?.ee?.emit('workflow:result', workflowResult) 290 | return workflowResult 291 | } 292 | 293 | async function runTest(id: string, test: Test, schemaValidator: Ajv, options?: WorkflowOptions, config?: WorkflowConfig, env?: object, capturesStorage?: CapturesStorage): Promise { 294 | const testResult: TestResult = { 295 | id, 296 | name: test.name, 297 | steps: [], 298 | passed: true, 299 | timestamp: new Date(), 300 | duration: 0, 301 | co2: 0, 302 | bytesSent: 0, 303 | bytesReceived: 0 304 | } 305 | 306 | const captures: CapturesStorage = capturesStorage ?? {} 307 | const cookies = new CookieJar() 308 | let previous: StepResult | undefined 309 | let testData: object = {} 310 | 311 | // Load test data 312 | if (test.testdata) { 313 | const parsedCSV = await parseCSV(test.testdata, { ...test.testdata.options, workflowPath: options?.path }) 314 | testData = parsedCSV[Math.floor(Math.random() * parsedCSV.length)] 315 | } 316 | 317 | for (let step of test.steps) { 318 | const tryStep = async () => runStep(previous, step, id, test, captures, cookies, schemaValidator, testData, options, config, env) 319 | let stepResult = await tryStep() 320 | 321 | // Retries 322 | if ((stepResult.errored || (!stepResult.passed && !stepResult.skipped)) && step.retries && step.retries.count > 0) { 323 | for (let i = 0; i < step.retries.count; i++) { 324 | await new Promise(resolve => { 325 | if (typeof step.retries?.interval === 'string') { 326 | setTimeout(resolve, parseDuration(step.retries?.interval) ?? undefined) 327 | } else { 328 | setTimeout(resolve, step.retries?.interval) 329 | } 330 | }) 331 | 332 | stepResult = await tryStep() 333 | if (stepResult.passed) break 334 | } 335 | } 336 | 337 | testResult.steps.push(stepResult) 338 | previous = stepResult 339 | options?.ee?.emit('step:result', stepResult) 340 | } 341 | 342 | testResult.duration = Date.now() - testResult.timestamp.valueOf() 343 | testResult.co2 = testResult.steps.map(step => step.co2).reduce((a, b) => a + b) 344 | testResult.bytesSent = testResult.steps.map(step => step.bytesSent).reduce((a, b) => a + b) 345 | testResult.bytesReceived = testResult.steps.map(step => step.bytesReceived).reduce((a, b) => a + b) 346 | testResult.passed = testResult.steps.every(step => step.passed) 347 | 348 | options?.ee?.emit('test:result', testResult) 349 | return testResult 350 | } 351 | 352 | async function runStep (previous: StepResult | undefined, step: Step, id: string, test: Test, captures: CapturesStorage, cookies: CookieJar, schemaValidator: Ajv, testData: object, options?: WorkflowOptions, config?: WorkflowConfig, env?: object) { 353 | let stepResult: StepResult = { 354 | id: step.id, 355 | testId: id, 356 | name: step.name, 357 | timestamp: new Date(), 358 | passed: true, 359 | errored: false, 360 | skipped: false, 361 | duration: 0, 362 | responseTime: 0, 363 | bytesSent: 0, 364 | bytesReceived: 0, 365 | co2: 0 366 | } 367 | 368 | let runResult: StepRunResult | undefined 369 | 370 | // Skip current step is the previous one failed or condition was unmet 371 | if (!config?.continueOnFail && (previous && !previous.passed)) { 372 | stepResult.passed = false 373 | stepResult.errorMessage = 'Step was skipped because previous one failed' 374 | stepResult.skipped = true 375 | } else if (step.if && !checkCondition(step.if, { captures, env: { ...env, ...test.env } })) { 376 | stepResult.skipped = true 377 | stepResult.errorMessage = 'Step was skipped because the condition was unmet' 378 | } else { 379 | try { 380 | step = renderObject(step, { 381 | captures, 382 | env: { ...env, ...test.env }, 383 | secrets: options?.secrets, 384 | testdata: testData 385 | }) 386 | 387 | if (step.http) { 388 | runResult = await runHTTPStep(step.http, captures, cookies, schemaValidator, options, config) 389 | } 390 | 391 | if (step.trpc) { 392 | runResult = await runTRPCStep(step.trpc, captures, cookies, schemaValidator, options, config) 393 | } 394 | 395 | if (step.graphql) { 396 | runResult = await runGraphQLStep(step.graphql, captures, cookies, schemaValidator, options, config) 397 | } 398 | 399 | if (step.grpc) { 400 | runResult = await runGRPCStep(step.grpc, captures, schemaValidator, options, config) 401 | } 402 | 403 | if (step.sse) { 404 | runResult = await runSSEStep(step.sse, captures, schemaValidator, options, config) 405 | } 406 | 407 | if (step.delay) { 408 | runResult = await runDelayStep(step.delay) 409 | } 410 | 411 | if (step.plugin) { 412 | runResult = await runPluginStep(step.plugin, captures, cookies, schemaValidator, options, config) 413 | } 414 | 415 | stepResult.passed = didChecksPass(runResult?.checks) 416 | } catch (error) { 417 | stepResult.passed = false 418 | stepResult.errored = true 419 | stepResult.errorMessage = (error as Error).message 420 | options?.ee?.emit('step:error', error) 421 | } 422 | } 423 | 424 | stepResult.type = runResult?.type 425 | stepResult.request = runResult?.request 426 | stepResult.response = runResult?.response 427 | stepResult.checks = runResult?.checks 428 | stepResult.responseTime = runResult?.response?.duration || 0 429 | stepResult.co2 = runResult?.response?.co2 || 0 430 | stepResult.bytesSent = runResult?.request?.size || 0 431 | stepResult.bytesReceived = runResult?.response?.size || 0 432 | stepResult.duration = Date.now() - stepResult.timestamp.valueOf() 433 | stepResult.captures = Object.keys(captures).length > 0 ? captures : undefined 434 | stepResult.cookies = Object.keys(cookies.toJSON().cookies).length > 0 ? cookies.toJSON().cookies : undefined 435 | return stepResult 436 | } 437 | -------------------------------------------------------------------------------- /src/loadtesting.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import yaml from 'js-yaml' 3 | import $RefParser from '@apidevtools/json-schema-ref-parser' 4 | import { runPhases, Phase } from 'phasic' 5 | import { quantile, mean, min, max, median } from 'simple-statistics' 6 | import { run, Workflow, WorkflowOptions, WorkflowResult } from './index' 7 | import { Matcher, CheckResult, checkResult } from './matcher' 8 | 9 | export type LoadTestResult = { 10 | workflow: Workflow, 11 | result: { 12 | stats: { 13 | tests: { 14 | failed: number 15 | passed: number 16 | total: number 17 | }, 18 | steps: { 19 | failed: number 20 | passed: number 21 | skipped: number 22 | errored: number 23 | total: number 24 | } 25 | } 26 | bytesSent: number 27 | bytesReceived: number 28 | co2: number 29 | responseTime: LoadTestMetric 30 | iterations: number 31 | rps: number 32 | duration: number 33 | passed: boolean 34 | checks?: LoadTestChecksResult 35 | } 36 | } 37 | 38 | type LoadTestMetric = { 39 | min: number, 40 | max: number, 41 | avg: number, 42 | med: number, 43 | p95: number, 44 | p99: number 45 | } 46 | 47 | export type LoadTestCheck = { 48 | min?: number | Matcher[], 49 | max?: number | Matcher[], 50 | avg?: number | Matcher[], 51 | med?: number | Matcher[], 52 | p95?: number | Matcher[], 53 | p99?: number | Matcher[], 54 | } 55 | 56 | type LoadTestChecksResult = { 57 | min?: CheckResult, 58 | max?: CheckResult, 59 | avg?: CheckResult, 60 | med?: CheckResult, 61 | p95?: CheckResult, 62 | p99?: CheckResult, 63 | } 64 | 65 | function metricsResult (numbers: number[]): LoadTestMetric { 66 | return { 67 | min: min(numbers), 68 | max: max(numbers), 69 | avg: mean(numbers), 70 | med: median(numbers), 71 | p95: quantile(numbers, 0.95), 72 | p99: quantile(numbers, 0.99), 73 | } 74 | } 75 | 76 | export async function loadTestFromFile (path: string, options?: WorkflowOptions): Promise { 77 | const testFile = await fs.promises.readFile(path) 78 | const workflow = yaml.load(testFile.toString()) 79 | const dereffed = await $RefParser.dereference(workflow as any, { 80 | dereference: { 81 | circular: 'ignore' 82 | } 83 | }) as unknown as Workflow 84 | return loadTest(dereffed, { ...options, path }) 85 | } 86 | 87 | // Load-testing functionality 88 | export async function loadTest (workflow: Workflow, options?: WorkflowOptions): Promise { 89 | if (!workflow.config?.loadTest?.phases) throw Error('No load test config detected') 90 | 91 | const start = new Date() 92 | const resultList = await runPhases(workflow.config?.loadTest?.phases as Phase[], () => run(workflow, options)) 93 | const results = resultList.map(result => (result as PromiseFulfilledResult).value.result) 94 | 95 | // Tests metrics 96 | const testsPassed = results.filter((r) => r.passed === true).length 97 | const testsFailed = results.filter((r) => r.passed === false).length 98 | 99 | // Steps metrics 100 | const steps = results.map(r => r.tests).map(test => test.map(test => test.steps)).flat(2) 101 | const stepsPassed = steps.filter(step => step.passed === true).length 102 | const stepsFailed = steps.filter(step => step.passed === false).length 103 | const stepsSkipped = steps.filter(step => step.skipped === true).length 104 | const stepsErrored = steps.filter(step => step.errored === true).length 105 | 106 | // Response metrics 107 | const responseTime = metricsResult(steps.map(step => step.responseTime)) 108 | 109 | // Size Metrics 110 | const bytesSent = results.map(result => result.bytesSent).reduce((a, b) => a + b) 111 | const bytesReceived = results.map(result => result.bytesReceived).reduce((a, b) => a + b) 112 | const co2 = results.map(result => result.co2).reduce((a, b) => a + b) 113 | 114 | // Checks 115 | let checks: LoadTestChecksResult | undefined 116 | if (workflow.config?.loadTest?.check) { 117 | checks = {} 118 | 119 | if (workflow.config?.loadTest?.check.min) { 120 | checks.min = checkResult(responseTime.min, workflow.config?.loadTest?.check.min) 121 | } 122 | 123 | if (workflow.config?.loadTest?.check.max) { 124 | checks.max = checkResult(responseTime.max, workflow.config?.loadTest?.check.max) 125 | } 126 | 127 | if (workflow.config?.loadTest?.check.avg) { 128 | checks.avg = checkResult(responseTime.avg, workflow.config?.loadTest?.check.avg) 129 | } 130 | 131 | if (workflow.config?.loadTest?.check.med) { 132 | checks.med = checkResult(responseTime.med, workflow.config?.loadTest?.check.med) 133 | } 134 | 135 | if (workflow.config?.loadTest?.check.p95) { 136 | checks.p95 = checkResult(responseTime.p95, workflow.config?.loadTest?.check.p95) 137 | } 138 | 139 | if (workflow.config?.loadTest?.check.p99) { 140 | checks.p99 = checkResult(responseTime.p99, workflow.config?.loadTest?.check.p99) 141 | } 142 | } 143 | 144 | const result: LoadTestResult = { 145 | workflow, 146 | result: { 147 | stats: { 148 | steps: { 149 | failed: stepsFailed, 150 | passed: stepsPassed, 151 | skipped: stepsSkipped, 152 | errored: stepsErrored, 153 | total: steps.length 154 | }, 155 | tests: { 156 | failed: testsFailed, 157 | passed: testsPassed, 158 | total: results.length 159 | }, 160 | }, 161 | responseTime, 162 | bytesSent, 163 | bytesReceived, 164 | co2, 165 | rps: steps.length / ((Date.now() - start.valueOf()) / 1000), 166 | iterations: results.length, 167 | duration: Date.now() - start.valueOf(), 168 | checks, 169 | passed: checks ? Object.entries(checks).map(([i, check]) => check.passed).every(passed => passed) : true 170 | } 171 | } 172 | 173 | options?.ee?.emit('loadtest:result', result) 174 | return result 175 | } 176 | -------------------------------------------------------------------------------- /src/matcher.ts: -------------------------------------------------------------------------------- 1 | import deepEqual from 'deep-equal' 2 | 3 | export type Matcher = { 4 | eq?: any 5 | ne?: any 6 | gt?: number 7 | gte?: number 8 | lt?: number 9 | lte?: number 10 | in?: object 11 | nin?: object 12 | match?: string 13 | isNumber?: boolean 14 | isString?: boolean 15 | isBoolean?: boolean 16 | isNull?: boolean 17 | isDefined?: boolean 18 | isObject?: boolean 19 | isArray?: boolean 20 | } 21 | 22 | export type CheckResult = { 23 | expected: any 24 | given: any 25 | passed: boolean 26 | } 27 | 28 | export type CheckResults = { 29 | [key: string]: CheckResult 30 | } 31 | 32 | export function checkResult (given: any, expected: Matcher[] | any) : CheckResult { 33 | return { 34 | expected, 35 | given, 36 | passed: check(given, expected) 37 | } 38 | } 39 | 40 | function check (given: any, expected: Matcher[] | any) : boolean { 41 | if (Array.isArray(expected)) { 42 | return expected.map((test: Matcher) => { 43 | if ('eq' in test) return deepEqual(given, test.eq, { strict: true }) 44 | if ('ne' in test) return given !== test.ne 45 | // @ts-ignore is possibly 'undefined' 46 | if ('gt' in test) return given > test.gt 47 | // @ts-ignore is possibly 'undefined' 48 | if ('gte' in test) return given >= test.gte 49 | // @ts-ignore is possibly 'undefined' 50 | if ('lt' in test) return given < test.lt 51 | // @ts-ignore is possibly 'undefined' 52 | if ('lte' in test) return given <= test.lte 53 | if ('in' in test) return given.includes(test.in) 54 | if ('nin' in test) return !given.includes(test.nin) 55 | // @ts-ignore is possibly 'undefined' 56 | if ('match' in test) return new RegExp(test.match).test(given) 57 | if ('isNumber' in test) return test.isNumber ? typeof given === 'number' : typeof given !== 'number' 58 | if ('isString' in test) return test.isString ? typeof given === 'string' : typeof given !== 'string' 59 | if ('isBoolean' in test) return test.isBoolean ? typeof given === 'boolean' : typeof given !== 'boolean' 60 | if ('isNull' in test) return test.isNull ? given === null : given !== null 61 | if ('isDefined' in test) return test.isDefined ? typeof given !== 'undefined' : typeof given === 'undefined' 62 | if ('isObject' in test) return test.isObject ? typeof given === 'object' : typeof given !== 'object' 63 | if ('isArray' in test) return test.isArray ? Array.isArray(given) : !Array.isArray(given) 64 | }) 65 | .every((test: boolean) => test === true) 66 | } 67 | 68 | // Check whether the expected value is regex 69 | if (/^\/.*\/$/.test(expected)) { 70 | const regex = new RegExp(expected.match(/^\/(.*?)\/$/)[1]) 71 | return regex.test(given) 72 | } 73 | 74 | return deepEqual(given, expected) 75 | } 76 | -------------------------------------------------------------------------------- /src/steps/delay.ts: -------------------------------------------------------------------------------- 1 | import parseDuration from 'parse-duration' 2 | import { StepRunResult } from '..' 3 | 4 | export default async function (params: string | number) { 5 | const stepResult: StepRunResult = { 6 | type: 'delay', 7 | } 8 | 9 | stepResult.type = 'delay' 10 | await new Promise((resolve) => 11 | setTimeout(resolve, typeof params === 'string' ? (parseDuration(params) ?? undefined) : params) 12 | ) 13 | 14 | return stepResult 15 | } 16 | -------------------------------------------------------------------------------- /src/steps/graphql.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | import { CookieJar } from 'tough-cookie' 3 | import { CapturesStorage } from '../utils/runner' 4 | import { WorkflowConfig, WorkflowOptions } from '..' 5 | import runHTTPStep, { HTTPStepBase, HTTPStepGraphQL } from './http' 6 | 7 | export type GraphQLStep = HTTPStepGraphQL & HTTPStepBase 8 | 9 | export default async function ( 10 | params: GraphQLStep, 11 | captures: CapturesStorage, 12 | cookies: CookieJar, 13 | schemaValidator: Ajv, 14 | options?: WorkflowOptions, 15 | config?: WorkflowConfig 16 | ) { 17 | return runHTTPStep( 18 | { 19 | graphql: { 20 | query: params.query, 21 | variables: params.variables, 22 | }, 23 | ...params, 24 | }, 25 | captures, 26 | cookies, 27 | schemaValidator, 28 | options, 29 | config 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/steps/grpc.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { JSONPath } from 'jsonpath-plus' 3 | import Ajv from 'ajv' 4 | import parseDuration from 'parse-duration' 5 | import { gRPCRequestMetadata, makeRequest } from 'cool-grpc' 6 | const { co2 } = require('@tgwf/co2') 7 | import { 8 | StepCheckCaptures, 9 | StepCheckJSONPath, 10 | StepCheckMatcher, 11 | StepCheckPerformance, 12 | } from '..' 13 | import { CapturesStorage } from './../utils/runner' 14 | import { TLSCertificate, getTLSCertificate } from './../utils/auth' 15 | import { Credential } from './../utils/auth' 16 | import { StepRunResult, WorkflowConfig, WorkflowOptions } from '..' 17 | import { Matcher, checkResult } from '../matcher' 18 | 19 | export type gRPCStep = { 20 | proto: string | string[] 21 | host: string 22 | service: string 23 | method: string 24 | data?: object | object[] 25 | timeout?: string | number 26 | metadata?: gRPCRequestMetadata 27 | auth?: gRPCStepAuth 28 | captures?: gRPCStepCaptures 29 | check?: gRPCStepCheck 30 | } 31 | 32 | export type gRPCStepAuth = { 33 | tls?: Credential['tls'] 34 | } 35 | 36 | export type gRPCStepCaptures = { 37 | [key: string]: gRPCStepCapture 38 | } 39 | 40 | export type gRPCStepCapture = { 41 | jsonpath?: string 42 | } 43 | 44 | export type gRPCStepCheck = { 45 | json?: object 46 | schema?: object 47 | jsonpath?: StepCheckJSONPath | StepCheckMatcher 48 | captures?: StepCheckCaptures 49 | performance?: StepCheckPerformance | StepCheckMatcher 50 | size?: number | Matcher[] 51 | co2?: number | Matcher[] 52 | } 53 | 54 | export type gRPCStepRequest = { 55 | proto?: string | string[] 56 | host: string 57 | service: string 58 | method: string 59 | metadata?: gRPCRequestMetadata 60 | data?: object | object[] 61 | tls?: Credential['tls'] 62 | size?: number 63 | } 64 | 65 | export type gRPCStepResponse = { 66 | body: object | object[] 67 | duration: number 68 | co2: number 69 | size: number 70 | status?: number 71 | statusText?: string 72 | metadata?: object 73 | } 74 | 75 | export default async function ( 76 | params: gRPCStep, 77 | captures: CapturesStorage, 78 | schemaValidator: Ajv, 79 | options?: WorkflowOptions, 80 | config?: WorkflowConfig 81 | ) { 82 | const stepResult: StepRunResult = { 83 | type: 'grpc', 84 | } 85 | 86 | const ssw = new co2() 87 | 88 | // Load TLS configuration from file or string 89 | let tlsConfig: TLSCertificate | undefined 90 | if (params.auth) { 91 | tlsConfig = await getTLSCertificate(params.auth.tls, { 92 | workflowPath: options?.path, 93 | }) 94 | } 95 | 96 | const protos: string[] = [] 97 | if (config?.grpc?.proto) { 98 | protos.push(...config.grpc.proto) 99 | } 100 | 101 | if (params.proto) { 102 | protos.push( 103 | ...(Array.isArray(params.proto) ? params.proto : [params.proto]) 104 | ) 105 | } 106 | 107 | const proto = protos.map((p) => 108 | path.join(path.dirname(options?.path || __dirname), p) 109 | ) 110 | 111 | const request: gRPCStepRequest = { 112 | proto, 113 | host: params.host, 114 | metadata: params.metadata, 115 | service: params.service, 116 | method: params.method, 117 | data: params.data, 118 | } 119 | 120 | const { metadata, statusCode, statusMessage, data, size } = await makeRequest( 121 | proto, 122 | { 123 | ...request, 124 | tls: tlsConfig, 125 | beforeRequest: (req) => { 126 | options?.ee?.emit('step:grpc_request', request) 127 | }, 128 | afterResponse: (res) => { 129 | options?.ee?.emit('step:grpc_response', res) 130 | }, 131 | options: { 132 | deadline: typeof params.timeout === 'string' ? (parseDuration(params.timeout) ?? undefined) : params.timeout 133 | } 134 | } 135 | ) 136 | 137 | stepResult.request = request 138 | stepResult.response = { 139 | body: data, 140 | co2: ssw.perByte(size), 141 | size: size, 142 | status: statusCode, 143 | statusText: statusMessage, 144 | metadata, 145 | } 146 | 147 | // Captures 148 | if (params.captures) { 149 | for (const name in params.captures) { 150 | const capture = params.captures[name] 151 | if (capture.jsonpath) { 152 | captures[name] = JSONPath({ path: capture.jsonpath, json: data })[0] 153 | } 154 | } 155 | } 156 | 157 | if (params.check) { 158 | stepResult.checks = {} 159 | 160 | // Check JSON 161 | if (params.check.json) { 162 | stepResult.checks.json = checkResult(data, params.check.json) 163 | } 164 | 165 | // Check Schema 166 | if (params.check.schema) { 167 | const validate = schemaValidator.compile(params.check.schema) 168 | stepResult.checks.schema = { 169 | expected: params.check.schema, 170 | given: data, 171 | passed: validate(data), 172 | } 173 | } 174 | 175 | // Check JSONPath 176 | if (params.check.jsonpath) { 177 | stepResult.checks.jsonpath = {} 178 | 179 | for (const path in params.check.jsonpath) { 180 | const result = JSONPath({ path, json: data }) 181 | stepResult.checks.jsonpath[path] = checkResult( 182 | result[0], 183 | params.check.jsonpath[path] 184 | ) 185 | } 186 | } 187 | 188 | // Check captures 189 | if (params.check.captures) { 190 | stepResult.checks.captures = {} 191 | 192 | for (const capture in params.check.captures) { 193 | stepResult.checks.captures[capture] = checkResult( 194 | captures[capture], 195 | params.check.captures[capture] 196 | ) 197 | } 198 | } 199 | 200 | // Check performance 201 | if (params.check.performance) { 202 | stepResult.checks.performance = {} 203 | 204 | if (params.check.performance.total) { 205 | stepResult.checks.performance.total = checkResult( 206 | stepResult.response?.duration, 207 | params.check.performance.total 208 | ) 209 | } 210 | } 211 | 212 | // Check byte size 213 | if (params.check.size) { 214 | stepResult.checks.size = checkResult(size, params.check.size) 215 | } 216 | 217 | // Check co2 emissions 218 | if (params.check.co2) { 219 | stepResult.checks.co2 = checkResult( 220 | stepResult.response?.co2, 221 | params.check.co2 222 | ) 223 | } 224 | } 225 | 226 | return stepResult 227 | } 228 | -------------------------------------------------------------------------------- /src/steps/http.ts: -------------------------------------------------------------------------------- 1 | import got, { Method, Headers, PlainResponse } from 'got' 2 | import parseDuration from 'parse-duration' 3 | import { ProxyAgent } from 'proxy-agent' 4 | import xpath from 'xpath' 5 | import * as cheerio from 'cheerio' 6 | import { DOMParser } from '@xmldom/xmldom' 7 | import { JSONPath } from 'jsonpath-plus' 8 | const { co2 } = require('@tgwf/co2') 9 | import FormData from 'form-data' 10 | import Ajv from 'ajv' 11 | import { CookieJar } from 'tough-cookie' 12 | import fs from 'fs' 13 | import { PeerCertificate, TLSSocket } from 'node:tls' 14 | import crypto from 'node:crypto' 15 | import { Agent } from 'node:https' 16 | import path from 'node:path' 17 | import { tryFile, StepFile } from './../utils/files' 18 | import { CapturesStorage, getCookie } from './../utils/runner' 19 | import { 20 | HTTPCertificate, 21 | getAuthHeader, 22 | getClientCertificate, 23 | Credential, 24 | } from './../utils/auth' 25 | import { 26 | StepCheckCaptures, 27 | StepCheckJSONPath, 28 | StepCheckMatcher, 29 | StepCheckPerformance, 30 | StepCheckValue, 31 | StepRunResult, 32 | WorkflowConfig, 33 | WorkflowOptions, 34 | } from '..' 35 | import { Matcher, checkResult } from '../matcher' 36 | 37 | export type HTTPStepBase = { 38 | url: string 39 | method: string 40 | headers?: HTTPStepHeaders 41 | params?: HTTPStepParams 42 | cookies?: HTTPStepCookies 43 | auth?: Credential 44 | captures?: HTTPStepCaptures 45 | check?: HTTPStepCheck 46 | followRedirects?: boolean 47 | timeout?: string | number 48 | retries?: number 49 | } 50 | 51 | export type HTTPStep = { 52 | body?: string | StepFile 53 | form?: HTTPStepForm 54 | formData?: HTTPStepMultiPartForm 55 | json?: object 56 | graphql?: HTTPStepGraphQL 57 | trpc?: HTTPStepTRPC 58 | } & HTTPStepBase 59 | 60 | export type HTTPStepTRPC = { 61 | query?: 62 | | { 63 | [key: string]: object 64 | } 65 | | { 66 | [key: string]: object 67 | }[] 68 | mutation?: { 69 | [key: string]: object 70 | } 71 | } 72 | 73 | export type HTTPStepHeaders = { 74 | [key: string]: string 75 | } 76 | 77 | export type HTTPStepParams = { 78 | [key: string]: string 79 | } 80 | 81 | export type HTTPStepCookies = { 82 | [key: string]: string 83 | } 84 | 85 | export type HTTPStepForm = { 86 | [key: string]: string 87 | } 88 | 89 | export type HTTPRequestPart = { 90 | type?: string 91 | value?: string 92 | json?: object 93 | } 94 | 95 | export type HTTPStepMultiPartForm = { 96 | [key: string]: string | StepFile | HTTPRequestPart 97 | } 98 | 99 | export type HTTPStepGraphQL = { 100 | query: string 101 | variables: object 102 | } 103 | 104 | export type HTTPStepCaptures = { 105 | [key: string]: HTTPStepCapture 106 | } 107 | 108 | export type HTTPStepCapture = { 109 | xpath?: string 110 | jsonpath?: string 111 | header?: string 112 | selector?: string 113 | cookie?: string 114 | regex?: string 115 | body?: boolean 116 | } 117 | 118 | export type HTTPStepCheck = { 119 | status?: string | number | Matcher[] 120 | statusText?: string | Matcher[] 121 | redirected?: boolean 122 | redirects?: string[] 123 | headers?: StepCheckValue | StepCheckMatcher 124 | body?: string | Matcher[] 125 | json?: object 126 | schema?: object 127 | jsonpath?: StepCheckJSONPath | StepCheckMatcher 128 | xpath?: StepCheckValue | StepCheckMatcher 129 | selectors?: StepCheckValue | StepCheckMatcher 130 | cookies?: StepCheckValue | StepCheckMatcher 131 | captures?: StepCheckCaptures 132 | sha256?: string 133 | md5?: string 134 | performance?: StepCheckPerformance | StepCheckMatcher 135 | ssl?: StepCheckSSL 136 | size?: number | Matcher[] 137 | requestSize?: number | Matcher[] 138 | bodySize?: number | Matcher[] 139 | co2?: number | Matcher[] 140 | } 141 | 142 | export type StepCheckSSL = { 143 | valid?: boolean 144 | signed?: boolean 145 | daysUntilExpiration?: number | Matcher[] 146 | } 147 | 148 | export type HTTPStepRequest = { 149 | protocol: string 150 | url: string 151 | method?: string 152 | headers?: HTTPStepHeaders 153 | body?: string | Buffer | FormData 154 | size?: number 155 | } 156 | 157 | export type HTTPStepResponse = { 158 | protocol: string 159 | status: number 160 | statusText?: string 161 | duration?: number 162 | contentType?: string 163 | timings: PlainResponse['timings'] 164 | headers?: Headers 165 | ssl?: StepResponseSSL 166 | body: Buffer 167 | co2: number 168 | size?: number 169 | bodySize?: number 170 | } 171 | 172 | export type StepResponseSSL = { 173 | valid: boolean 174 | signed: boolean 175 | validUntil: Date 176 | daysUntilExpiration: number 177 | } 178 | 179 | export default async function ( 180 | params: HTTPStep, 181 | captures: CapturesStorage, 182 | cookies: CookieJar, 183 | schemaValidator: Ajv, 184 | options?: WorkflowOptions, 185 | config?: WorkflowConfig 186 | ) { 187 | const stepResult: StepRunResult = { 188 | type: 'http', 189 | } 190 | 191 | const ssw = new co2() 192 | 193 | let requestBody: string | Buffer | FormData | undefined 194 | let url = params.url || '' 195 | 196 | // Prefix URL 197 | if (config?.http?.baseURL) { 198 | try { 199 | new URL(url) 200 | } catch { 201 | url = config.http.baseURL + params.url 202 | } 203 | } 204 | 205 | // Body 206 | if (params.body) { 207 | requestBody = await tryFile(params.body, { 208 | workflowPath: options?.path, 209 | }) 210 | } 211 | 212 | // JSON 213 | if (params.json) { 214 | if (!params.headers) params.headers = {} 215 | if (!params.headers['Content-Type']) { 216 | params.headers['Content-Type'] = 'application/json' 217 | } 218 | 219 | requestBody = JSON.stringify(params.json) 220 | } 221 | 222 | // GraphQL 223 | if (params.graphql) { 224 | params.method = 'POST' 225 | if (!params.headers) params.headers = {} 226 | params.headers['Content-Type'] = 'application/json' 227 | requestBody = JSON.stringify(params.graphql) 228 | } 229 | 230 | // tRPC 231 | if (params.trpc) { 232 | if (params.trpc.query) { 233 | params.method = 'GET' 234 | 235 | // tRPC Batch queries 236 | if (Array.isArray(params.trpc.query)) { 237 | const payload = params.trpc.query.map((e) => { 238 | return { 239 | op: Object.keys(e)[0], 240 | data: Object.values(e)[0], 241 | } 242 | }) 243 | 244 | const procedures = payload.map((p) => p.op).join(',') 245 | url = url + '/' + procedures.replaceAll('/', '.') 246 | params.params = { 247 | batch: '1', 248 | input: JSON.stringify( 249 | Object.assign( 250 | {}, 251 | payload.map((p) => p.data) 252 | ) 253 | ), 254 | } 255 | } else { 256 | const [procedure, data] = Object.entries(params.trpc.query)[0] 257 | url = url + '/' + procedure.replaceAll('/', '.') 258 | params.params = { 259 | input: JSON.stringify(data), 260 | } 261 | } 262 | } 263 | 264 | if (params.trpc.mutation) { 265 | const [procedure, data] = Object.entries(params.trpc.mutation)[0] 266 | params.method = 'POST' 267 | url = url + '/' + procedure 268 | requestBody = JSON.stringify(data) 269 | } 270 | } 271 | 272 | // Form Data 273 | if (params.form) { 274 | const formData = new URLSearchParams() 275 | for (const field in params.form) { 276 | formData.append(field, params.form[field]) 277 | } 278 | 279 | requestBody = formData.toString() 280 | } 281 | 282 | // Multipart Form Data 283 | if (params.formData) { 284 | const formData = new FormData() 285 | for (const field in params.formData) { 286 | const appendOptions = {} as FormData.AppendOptions 287 | if (typeof params.formData[field] != 'object') { 288 | formData.append(field, params.formData[field]) 289 | } else if (Array.isArray(params.formData[field])) { 290 | const stepFiles = params.formData[field] as StepFile[]; 291 | for (const stepFile of stepFiles) { 292 | const filepath = path.join( 293 | path.dirname(options?.path || __dirname), 294 | stepFile.file, 295 | ) 296 | appendOptions.filename = path.parse(filepath).base; 297 | formData.append( 298 | field, 299 | await fs.promises.readFile(filepath), 300 | appendOptions, 301 | ) 302 | } 303 | } else if ((params.formData[field] as StepFile).file) { 304 | const stepFile = params.formData[field] as StepFile 305 | const filepath = path.join( 306 | path.dirname(options?.path || __dirname), 307 | stepFile.file 308 | ) 309 | appendOptions.filename = path.parse(filepath).base 310 | formData.append(field, await fs.promises.readFile(filepath), appendOptions) 311 | } else { 312 | const requestPart = params.formData[field] as HTTPRequestPart 313 | if ('json' in requestPart) { 314 | appendOptions.contentType = 'application/json' 315 | formData.append(field, JSON.stringify(requestPart.json), appendOptions) 316 | } else { 317 | appendOptions.contentType = requestPart.type 318 | formData.append(field, requestPart.value, appendOptions) 319 | } 320 | } 321 | } 322 | 323 | requestBody = formData 324 | } 325 | 326 | // Auth 327 | let clientCredentials: HTTPCertificate | undefined 328 | if (params.auth) { 329 | const authHeader = await getAuthHeader(params.auth) 330 | if (authHeader) { 331 | if (!params.headers) params.headers = {} 332 | params.headers['Authorization'] = authHeader 333 | } 334 | 335 | clientCredentials = await getClientCertificate(params.auth.certificate, { 336 | workflowPath: options?.path, 337 | }) 338 | } 339 | 340 | // Set Cookies 341 | if (params.cookies) { 342 | for (const cookie in params.cookies) { 343 | await cookies.setCookie(cookie + '=' + params.cookies[cookie], url) 344 | } 345 | } 346 | 347 | let sslCertificate: PeerCertificate | undefined 348 | let requestSize: number | undefined = 0 349 | let responseSize: number | undefined = 0 350 | 351 | // Make a request 352 | const res = await got(url, { 353 | agent: { 354 | http: new ProxyAgent(), 355 | https: new ProxyAgent(new Agent({ maxCachedSessions: 0 })), 356 | }, 357 | method: params.method as Method, 358 | headers: { ...params.headers }, 359 | body: requestBody, 360 | searchParams: params.params 361 | ? new URLSearchParams(params.params) 362 | : undefined, 363 | throwHttpErrors: false, 364 | followRedirect: params.followRedirects ?? true, 365 | timeout: 366 | typeof params.timeout === 'string' 367 | ? (parseDuration(params.timeout) ?? undefined) 368 | : params.timeout, 369 | retry: params.retries ?? 0, 370 | cookieJar: cookies, 371 | http2: config?.http?.http2 ?? false, 372 | https: { 373 | ...clientCredentials, 374 | rejectUnauthorized: config?.http?.rejectUnauthorized ?? false, 375 | }, 376 | }) 377 | .on('request', (request) => options?.ee?.emit('step:http_request', request)) 378 | .on('request', (request) => { 379 | request.once('socket', (s) => { 380 | s.once('close', () => { 381 | requestSize = request.socket?.bytesWritten 382 | responseSize = request.socket?.bytesRead 383 | }) 384 | }) 385 | }) 386 | .on('response', (response) => 387 | options?.ee?.emit('step:http_response', response) 388 | ) 389 | .on('response', (response) => { 390 | if ((response.socket as TLSSocket).getPeerCertificate) { 391 | sslCertificate = (response.socket as TLSSocket).getPeerCertificate() 392 | if (Object.keys(sslCertificate).length === 0) sslCertificate = undefined 393 | } 394 | }) 395 | 396 | const responseData = res.rawBody 397 | const body = new TextDecoder().decode(responseData) 398 | 399 | stepResult.request = { 400 | protocol: 'HTTP/1.1', 401 | url: res.url, 402 | method: params.method, 403 | headers: params.headers, 404 | body: requestBody, 405 | size: requestSize, 406 | } 407 | 408 | stepResult.response = { 409 | protocol: `HTTP/${res.httpVersion}`, 410 | status: res.statusCode, 411 | statusText: res.statusMessage, 412 | duration: res.timings.phases.total, 413 | headers: res.headers, 414 | contentType: res.headers['content-type']?.split(';')[0], 415 | timings: res.timings, 416 | body: responseData, 417 | size: responseSize, 418 | bodySize: responseData.length, 419 | co2: ssw.perByte(responseData.length), 420 | } 421 | 422 | if (sslCertificate) { 423 | stepResult.response.ssl = { 424 | valid: new Date(sslCertificate.valid_to) > new Date(), 425 | signed: sslCertificate.issuer.CN !== sslCertificate.subject.CN, 426 | validUntil: new Date(sslCertificate.valid_to), 427 | daysUntilExpiration: Math.round( 428 | Math.abs( 429 | new Date().valueOf() - new Date(sslCertificate.valid_to).valueOf() 430 | ) / 431 | (24 * 60 * 60 * 1000) 432 | ), 433 | } 434 | } 435 | 436 | // Captures 437 | if (params.captures) { 438 | for (const name in params.captures) { 439 | const capture = params.captures[name] 440 | 441 | if (capture.jsonpath) { 442 | try { 443 | const json = JSON.parse(body) 444 | captures[name] = JSONPath({ path: capture.jsonpath, json, wrap: false }) 445 | } catch { 446 | captures[name] = undefined 447 | } 448 | } 449 | 450 | if (capture.xpath) { 451 | const dom = new DOMParser().parseFromString(body) 452 | const result = xpath.select(capture.xpath, dom) 453 | captures[name] = 454 | result.length > 0 ? (result[0] as any).firstChild.data : undefined 455 | } 456 | 457 | if (capture.header) { 458 | captures[name] = res.headers[capture.header] 459 | } 460 | 461 | if (capture.selector) { 462 | const dom = cheerio.load(body) 463 | captures[name] = dom(capture.selector).html() 464 | } 465 | 466 | if (capture.cookie) { 467 | captures[name] = getCookie(cookies, capture.cookie, res.url) 468 | } 469 | 470 | if (capture.regex) { 471 | captures[name] = body.match(capture.regex)?.[1] 472 | } 473 | 474 | if (capture.body) { 475 | captures[name] = body 476 | } 477 | } 478 | } 479 | 480 | if (params.check) { 481 | stepResult.checks = {} 482 | 483 | // Check headers 484 | if (params.check.headers) { 485 | stepResult.checks.headers = {} 486 | 487 | for (const header in params.check.headers) { 488 | stepResult.checks.headers[header] = checkResult( 489 | res.headers[header.toLowerCase()], 490 | params.check.headers[header] 491 | ) 492 | } 493 | } 494 | 495 | // Check body 496 | if (params.check.body) { 497 | stepResult.checks.body = checkResult(body.trim(), params.check.body) 498 | } 499 | 500 | // Check JSON 501 | if (params.check.json) { 502 | try { 503 | const json = JSON.parse(body) 504 | stepResult.checks.json = checkResult(json, params.check.json) 505 | } catch { 506 | stepResult.checks.json = { 507 | expected: params.check.json, 508 | given: body, 509 | passed: false, 510 | } 511 | } 512 | } 513 | 514 | // Check Schema 515 | if (params.check.schema) { 516 | let sample = body 517 | 518 | if (res.headers['content-type']?.includes('json')) { 519 | sample = JSON.parse(body) 520 | } 521 | 522 | const validate = schemaValidator.compile(params.check.schema) 523 | stepResult.checks.schema = { 524 | expected: params.check.schema, 525 | given: sample, 526 | passed: validate(sample), 527 | } 528 | } 529 | 530 | // Check JSONPath 531 | if (params.check.jsonpath) { 532 | stepResult.checks.jsonpath = {} 533 | try { 534 | const json = JSON.parse(body) 535 | for (const path in params.check.jsonpath) { 536 | const result = JSONPath({ path, json, wrap: false }) 537 | stepResult.checks.jsonpath[path] = checkResult( 538 | result, 539 | params.check.jsonpath[path] 540 | ) 541 | } 542 | } catch { 543 | for (const path in params.check.jsonpath) { 544 | stepResult.checks.jsonpath[path] = { 545 | expected: params.check.jsonpath[path], 546 | given: body, 547 | passed: false, 548 | } 549 | } 550 | } 551 | } 552 | 553 | // Check XPath 554 | if (params.check.xpath) { 555 | stepResult.checks.xpath = {} 556 | 557 | for (const path in params.check.xpath) { 558 | const dom = new DOMParser().parseFromString(body) 559 | const result = xpath.select(path, dom) 560 | stepResult.checks.xpath[path] = checkResult( 561 | result.length > 0 ? (result[0] as any).firstChild.data : undefined, 562 | params.check.xpath[path] 563 | ) 564 | } 565 | } 566 | 567 | // Check HTML5 Selectors 568 | if (params.check.selectors) { 569 | stepResult.checks.selectors = {} 570 | const dom = cheerio.load(body) 571 | 572 | for (const selector in params.check.selectors) { 573 | const result = dom(selector).html() 574 | stepResult.checks.selectors[selector] = checkResult( 575 | result, 576 | params.check.selectors[selector] 577 | ) 578 | } 579 | } 580 | 581 | // Check Cookies 582 | if (params.check.cookies) { 583 | stepResult.checks.cookies = {} 584 | 585 | for (const cookie in params.check.cookies) { 586 | const value = getCookie(cookies, cookie, res.url) 587 | stepResult.checks.cookies[cookie] = checkResult( 588 | value, 589 | params.check.cookies[cookie] 590 | ) 591 | } 592 | } 593 | 594 | // Check captures 595 | if (params.check.captures) { 596 | stepResult.checks.captures = {} 597 | 598 | for (const capture in params.check.captures) { 599 | stepResult.checks.captures[capture] = checkResult( 600 | captures[capture], 601 | params.check.captures[capture] 602 | ) 603 | } 604 | } 605 | 606 | // Check status 607 | if (params.check.status) { 608 | stepResult.checks.status = checkResult( 609 | res.statusCode, 610 | params.check.status 611 | ) 612 | } 613 | 614 | // Check statusText 615 | if (params.check.statusText) { 616 | stepResult.checks.statusText = checkResult( 617 | res.statusMessage, 618 | params.check.statusText 619 | ) 620 | } 621 | 622 | // Check whether request was redirected 623 | if ('redirected' in params.check) { 624 | stepResult.checks.redirected = checkResult( 625 | res.redirectUrls.length > 0, 626 | params.check.redirected 627 | ) 628 | } 629 | 630 | // Check redirects 631 | if (params.check.redirects) { 632 | stepResult.checks.redirects = checkResult( 633 | res.redirectUrls, 634 | params.check.redirects 635 | ) 636 | } 637 | 638 | // Check sha256 639 | if (params.check.sha256) { 640 | const hash = crypto 641 | .createHash('sha256') 642 | .update(Buffer.from(responseData)) 643 | .digest('hex') 644 | stepResult.checks.sha256 = checkResult(hash, params.check.sha256) 645 | } 646 | 647 | // Check md5 648 | if (params.check.md5) { 649 | const hash = crypto 650 | .createHash('md5') 651 | .update(Buffer.from(responseData)) 652 | .digest('hex') 653 | stepResult.checks.md5 = checkResult(hash, params.check.md5) 654 | } 655 | 656 | // Check Performance 657 | if (params.check.performance) { 658 | stepResult.checks.performance = {} 659 | 660 | for (const metric in params.check.performance) { 661 | stepResult.checks.performance[metric] = checkResult( 662 | (res.timings.phases as any)[metric], 663 | params.check.performance[metric] 664 | ) 665 | } 666 | } 667 | 668 | // Check SSL certs 669 | if (params.check.ssl && sslCertificate) { 670 | stepResult.checks.ssl = {} 671 | 672 | if ('valid' in params.check.ssl) { 673 | stepResult.checks.ssl.valid = checkResult( 674 | stepResult.response?.ssl.valid, 675 | params.check.ssl.valid 676 | ) 677 | } 678 | 679 | if ('signed' in params.check.ssl) { 680 | stepResult.checks.ssl.signed = checkResult( 681 | stepResult.response?.ssl.signed, 682 | params.check.ssl.signed 683 | ) 684 | } 685 | 686 | if (params.check.ssl.daysUntilExpiration) { 687 | stepResult.checks.ssl.daysUntilExpiration = checkResult( 688 | stepResult.response?.ssl.daysUntilExpiration, 689 | params.check.ssl.daysUntilExpiration 690 | ) 691 | } 692 | } 693 | 694 | // Check request/response size 695 | if (params.check.size) { 696 | stepResult.checks.size = checkResult(responseSize, params.check.size) 697 | } 698 | 699 | if (params.check.requestSize) { 700 | stepResult.checks.requestSize = checkResult( 701 | requestSize, 702 | params.check.requestSize 703 | ) 704 | } 705 | 706 | if (params.check.bodySize) { 707 | stepResult.checks.bodySize = checkResult( 708 | stepResult.response?.bodySize, 709 | params.check.bodySize 710 | ) 711 | } 712 | 713 | if (params.check.co2) { 714 | stepResult.checks.co2 = checkResult( 715 | stepResult.response.co2, 716 | params.check.co2 717 | ) 718 | } 719 | } 720 | 721 | return stepResult 722 | } 723 | -------------------------------------------------------------------------------- /src/steps/plugin.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | import { CookieJar } from 'tough-cookie' 3 | import { CapturesStorage } from '../utils/runner' 4 | import { WorkflowConfig, WorkflowOptions } from '..' 5 | 6 | export type PluginStep = { 7 | id: string 8 | params?: object 9 | check?: object 10 | } 11 | 12 | export default async function ( 13 | params: PluginStep, 14 | captures: CapturesStorage, 15 | cookies: CookieJar, 16 | schemaValidator: Ajv, 17 | options?: WorkflowOptions, 18 | config?: WorkflowConfig 19 | ) { 20 | const plugin = require(params.id) 21 | return plugin.default( 22 | params, 23 | captures, 24 | cookies, 25 | schemaValidator, 26 | options, 27 | config 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/steps/sse.ts: -------------------------------------------------------------------------------- 1 | import EventSource from 'eventsource' 2 | import { JSONPath } from 'jsonpath-plus' 3 | const { co2 } = require('@tgwf/co2') 4 | import { CapturesStorage } from './../utils/runner' 5 | import { CheckResult, CheckResults, Matcher } from '../matcher' 6 | 7 | import Ajv from 'ajv' 8 | import { 9 | StepCheckJSONPath, 10 | StepCheckMatcher, 11 | StepRunResult, 12 | WorkflowConfig, 13 | WorkflowOptions, 14 | } from '..' 15 | import { checkResult } from '../matcher' 16 | import { Credential, getAuthHeader } from './../utils/auth' 17 | import { HTTPStepHeaders, HTTPStepParams } from './http' 18 | 19 | export type SSEStep = { 20 | url: string 21 | headers?: HTTPStepHeaders 22 | params?: HTTPStepParams 23 | auth?: Credential 24 | json?: object 25 | check?: { 26 | messages?: SSEStepCheck[] 27 | } 28 | timeout?: number 29 | } 30 | 31 | export type SSEStepCheck = { 32 | id: string 33 | json?: object 34 | schema?: object 35 | jsonpath?: StepCheckJSONPath | StepCheckMatcher 36 | body?: string | Matcher[] 37 | } 38 | 39 | export type SSEStepRequest = { 40 | url?: string 41 | headers?: HTTPStepHeaders 42 | size?: number 43 | } 44 | 45 | export type SSEStepResponse = { 46 | contentType?: string 47 | duration?: number 48 | body: Buffer 49 | size?: number 50 | bodySize?: number 51 | co2: number 52 | } 53 | 54 | export default async function ( 55 | params: SSEStep, 56 | captures: CapturesStorage, 57 | schemaValidator: Ajv, 58 | options?: WorkflowOptions, 59 | config?: WorkflowConfig 60 | ) { 61 | const stepResult: StepRunResult = { 62 | type: 'sse', 63 | } 64 | 65 | const ssw = new co2() 66 | 67 | stepResult.type = 'sse' 68 | 69 | if (params.auth) { 70 | const authHeader = await getAuthHeader(params.auth) 71 | if (authHeader) { 72 | if (!params.headers) params.headers = {} 73 | params.headers['Authorization'] = authHeader 74 | } 75 | } 76 | 77 | await new Promise((resolve, reject) => { 78 | const ev = new EventSource(params.url || '', { 79 | headers: params.headers, 80 | rejectUnauthorized: config?.http?.rejectUnauthorized ?? false, 81 | }) 82 | 83 | const messages: MessageEvent[] = [] 84 | 85 | const timeout = setTimeout(() => { 86 | ev.close() 87 | 88 | const messagesBuffer = Buffer.from(messages.map((m) => m.data).join('\n')) 89 | 90 | stepResult.request = { 91 | url: params.url, 92 | headers: params.headers, 93 | size: 0, 94 | } 95 | 96 | stepResult.response = { 97 | contentType: 'text/event-stream', 98 | body: messagesBuffer, 99 | size: messagesBuffer.length, 100 | bodySize: messagesBuffer.length, 101 | co2: ssw.perByte(messagesBuffer.length), 102 | duration: params.timeout, 103 | } 104 | 105 | resolve(true) 106 | }, params.timeout || 10000) 107 | 108 | ev.onerror = (error) => { 109 | clearTimeout(timeout) 110 | ev.close() 111 | reject(error) 112 | } 113 | 114 | if (params.check) { 115 | if (!stepResult.checks) stepResult.checks = {} 116 | if (!stepResult.checks.messages) stepResult.checks.messages = {} 117 | 118 | params.check.messages?.forEach((check) => { 119 | ;(stepResult.checks?.messages as any)[check.id] = { 120 | expected: check.body || check.json || check.jsonpath || check.schema, 121 | given: undefined, 122 | passed: false, 123 | } 124 | }) 125 | } 126 | 127 | ev.onmessage = (message) => { 128 | messages.push(message) 129 | 130 | if (params.check) { 131 | params.check.messages?.forEach((check, id) => { 132 | if (check.body) { 133 | const result = checkResult(message.data, check.body) 134 | if (result.passed && stepResult.checks?.messages) 135 | (stepResult.checks.messages as CheckResults)[check.id] = result 136 | } 137 | 138 | if (check.json) { 139 | try { 140 | const result = checkResult(JSON.parse(message.data), check.json) 141 | if (result.passed && stepResult.checks?.messages) 142 | (stepResult.checks.messages as CheckResults)[check.id] = result 143 | } catch (e) { 144 | reject(e) 145 | } 146 | } 147 | 148 | if (check.schema) { 149 | try { 150 | const sample = JSON.parse(message.data) 151 | const validate = schemaValidator.compile(check.schema) 152 | const result = { 153 | expected: check.schema, 154 | given: sample, 155 | passed: validate(sample), 156 | } 157 | 158 | if (result.passed && stepResult.checks?.messages) 159 | (stepResult.checks.messages as CheckResults)[check.id] = result 160 | } catch (e) { 161 | reject(e) 162 | } 163 | } 164 | 165 | if (check.jsonpath) { 166 | try { 167 | let jsonpathResult: CheckResults = {} 168 | const json = JSON.parse(message.data) 169 | for (const path in check.jsonpath) { 170 | const result = JSONPath({ path, json }) 171 | jsonpathResult[path] = checkResult( 172 | result[0], 173 | check.jsonpath[path] 174 | ) 175 | } 176 | 177 | const passed = Object.values(jsonpathResult) 178 | .map((c: CheckResult) => c.passed) 179 | .every((passed) => passed) 180 | 181 | if (passed && stepResult.checks?.messages) 182 | (stepResult.checks.messages as CheckResults)[check.id] = { 183 | expected: check.jsonpath, 184 | given: jsonpathResult, 185 | passed, 186 | } 187 | } catch (e) { 188 | reject(e) 189 | } 190 | } 191 | }) 192 | } 193 | } 194 | }) 195 | 196 | return stepResult 197 | } 198 | -------------------------------------------------------------------------------- /src/steps/trpc.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | import { CookieJar } from 'tough-cookie' 3 | import { CapturesStorage } from '../utils/runner' 4 | import { WorkflowConfig, WorkflowOptions } from '..' 5 | import runHTTPStep, { HTTPStepBase, HTTPStepTRPC } from './http' 6 | 7 | export type tRPCStep = HTTPStepTRPC & HTTPStepBase 8 | 9 | export default async function ( 10 | params: tRPCStep, 11 | captures: CapturesStorage, 12 | cookies: CookieJar, 13 | schemaValidator: Ajv, 14 | options?: WorkflowOptions, 15 | config?: WorkflowConfig 16 | ) { 17 | return runHTTPStep( 18 | { 19 | trpc: { 20 | query: params.query, 21 | mutation: params.mutation, 22 | }, 23 | ...params, 24 | }, 25 | captures, 26 | cookies, 27 | schemaValidator, 28 | options, 29 | config 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import got from 'got' 2 | import { StepFile, TryFileOptions, tryFile } from './files' 3 | 4 | export type Credential = { 5 | basic?: { 6 | username: string 7 | password: string 8 | } 9 | bearer?: { 10 | token: string 11 | } 12 | oauth?: { 13 | endpoint: string 14 | client_id: string 15 | client_secret: string 16 | audience?: string 17 | } 18 | certificate?: { 19 | ca?: string | StepFile 20 | cert?: string | StepFile 21 | key?: string | StepFile 22 | passphrase?: string 23 | } 24 | tls?: { 25 | rootCerts?: string | StepFile 26 | privateKey?: string | StepFile 27 | certChain?: string | StepFile 28 | } 29 | } 30 | 31 | export type CredentialsStorage = { 32 | [key: string]: Credential 33 | } 34 | 35 | type OAuthClientConfig = { 36 | endpoint: string 37 | client_id: string 38 | client_secret: string 39 | audience?: string 40 | } 41 | 42 | export type OAuthResponse = { 43 | access_token: string 44 | expires_in: number 45 | token_type: string 46 | } 47 | 48 | export type HTTPCertificate = { 49 | certificate?: string | Buffer 50 | key?: string | Buffer 51 | certificateAuthority?: string | Buffer 52 | passphrase?: string 53 | } 54 | 55 | export type TLSCertificate = { 56 | rootCerts?: string | Buffer 57 | privateKey?: string | Buffer 58 | certChain?: string | Buffer 59 | } 60 | 61 | export async function getOAuthToken (clientConfig: OAuthClientConfig): Promise { 62 | return await got.post(clientConfig.endpoint, { 63 | headers: { 64 | 'Content-Type': 'application/json' 65 | }, 66 | body: JSON.stringify({ 67 | grant_type: 'client_credentials', 68 | client_id: clientConfig.client_id, 69 | client_secret: clientConfig.client_secret, 70 | audience: clientConfig.audience 71 | }) 72 | }) 73 | .json() as OAuthResponse 74 | } 75 | 76 | export async function getAuthHeader (credential: Credential): Promise { 77 | if (credential.basic) { 78 | return 'Basic ' + Buffer.from(credential.basic.username + ':' + credential.basic.password).toString('base64') 79 | } 80 | 81 | if (credential.bearer) { 82 | return 'Bearer ' + credential.bearer.token 83 | } 84 | 85 | if (credential.oauth) { 86 | const { access_token } = await getOAuthToken(credential.oauth) 87 | return 'Bearer ' + access_token 88 | } 89 | } 90 | 91 | export async function getClientCertificate (certificate: Credential['certificate'], options?: TryFileOptions): Promise { 92 | if (certificate) { 93 | const cert: HTTPCertificate = {} 94 | 95 | if (certificate.cert) { 96 | cert.certificate = await tryFile(certificate.cert, options) 97 | } 98 | 99 | if (certificate.key) { 100 | cert.key = await tryFile(certificate.key, options) 101 | } 102 | 103 | if (certificate.ca) { 104 | cert.certificateAuthority = await tryFile(certificate.ca, options) 105 | } 106 | 107 | if (certificate.passphrase) { 108 | cert.passphrase = certificate.passphrase 109 | } 110 | 111 | return cert 112 | } 113 | } 114 | 115 | export async function getTLSCertificate (certificate: Credential['tls'], options?: TryFileOptions): Promise { 116 | if (certificate) { 117 | const tlsConfig: TLSCertificate = {} 118 | 119 | if (certificate.rootCerts) { 120 | tlsConfig.rootCerts = await tryFile(certificate.rootCerts, options) 121 | } 122 | 123 | if (certificate.privateKey) { 124 | tlsConfig.privateKey = await tryFile(certificate.privateKey, options) 125 | } 126 | 127 | if (certificate.certChain) { 128 | tlsConfig.certChain = await tryFile(certificate.certChain, options) 129 | } 130 | 131 | return tlsConfig 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/utils/files.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | export type StepFile = { 5 | file: string 6 | } 7 | 8 | export type TryFileOptions = { 9 | workflowPath?: string 10 | } 11 | 12 | export async function tryFile (input: string | StepFile, options?: TryFileOptions): Promise { 13 | if ((input as StepFile).file) { 14 | return await fs.promises.readFile(path.join(path.dirname(options?.workflowPath || __dirname), (input as StepFile).file)) 15 | } else { 16 | return input as string 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/runner.ts: -------------------------------------------------------------------------------- 1 | import { StepCheckResult } from '../index' 2 | import { compileExpression } from 'filtrex' 3 | import flatten from 'flat' 4 | import { CookieJar } from 'tough-cookie' 5 | 6 | export type CapturesStorage = { 7 | [key: string]: any 8 | } 9 | 10 | export type TestConditions = { 11 | captures?: CapturesStorage 12 | env?: object 13 | } 14 | 15 | // Check if expression 16 | export function checkCondition (expression: string, data: TestConditions): boolean { 17 | const filter = compileExpression(expression) 18 | return filter(flatten(data)) 19 | } 20 | 21 | // Get cookie 22 | export function getCookie (store: CookieJar, name: string, url: string): string { 23 | return store.getCookiesSync(url).filter(cookie => cookie.key === name)[0]?.value 24 | } 25 | 26 | // Did all checks pass? 27 | export function didChecksPass (checks?: StepCheckResult) { 28 | if (!checks) return true 29 | 30 | return Object.values(checks as object).map(check => { 31 | return check['passed'] ? check.passed : Object.values(check).map((c: any) => c.passed).every(passed => passed) 32 | }) 33 | .every(passed => passed) 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/schema.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | 3 | export function addCustomSchemas (schemaValidator: Ajv, schemas: any) { 4 | for (const schema in schemas) { 5 | schemaValidator.addSchema(schemas[schema], `#/components/schemas/${schema}`) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/testdata.ts: -------------------------------------------------------------------------------- 1 | import * as csv from '@fast-csv/parse' 2 | import path from 'path' 3 | 4 | export type TestData = { 5 | content?: string 6 | file?: string 7 | options?: TestDataOptions 8 | } 9 | 10 | export type TestDataOptions = { 11 | delimiter?: string 12 | quote?: string | null 13 | escape?: string 14 | headers?: boolean | string[] 15 | workflowPath?: string 16 | } 17 | 18 | // Parse CSV 19 | export function parseCSV (testData: TestData, options?: TestDataOptions): Promise{ 20 | return new Promise((resolve, reject) => { 21 | const defaultOptions = { headers: true } 22 | 23 | let parsedData: object[] = [] 24 | if (testData.file) { 25 | csv.parseFile(path.join(path.dirname(options?.workflowPath || __dirname), testData.file), { ...defaultOptions, ...options }) 26 | .on('data', data => parsedData.push(data)) 27 | .on('end', () => resolve(parsedData)) 28 | } else { 29 | csv.parseString((testData.content as string), { ...defaultOptions, ...options }) 30 | .on('data', data => parsedData.push(data)) 31 | .on('end', () => resolve(parsedData)) 32 | } 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /tests/auth.yml: -------------------------------------------------------------------------------- 1 | tests: 2 | example: 3 | steps: 4 | - name: Basic Auth 5 | http: 6 | url: https://httpbin.org/basic-auth/hello/world 7 | method: GET 8 | auth: 9 | $ref: '#/components/credentials/example' 10 | check: 11 | status: 200 12 | -------------------------------------------------------------------------------- /tests/basic.yml: -------------------------------------------------------------------------------- 1 | version: "1.1" 2 | name: Basic Auth 3 | components: 4 | auth: 5 | d: 6 | basic: 7 | username: hello 8 | password: world 9 | tests: 10 | example: 11 | steps: 12 | - name: Basic Auth 13 | http: 14 | url: https://httpbin.org/basic-auth/hello/world 15 | method: GET 16 | auth: 17 | $ref: "#/components/auth/d" 18 | check: 19 | status: 200 20 | -------------------------------------------------------------------------------- /tests/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "tests": { 3 | "example": { 4 | "steps": [ 5 | { 6 | "name": "GET request", 7 | "http": { 8 | "url": "http://localhost:3000/api/trpc", 9 | "trpc": { 10 | "query": [ 11 | { 12 | "example.hello": { 13 | "json": { 14 | "text": "Mish" 15 | } 16 | } 17 | }, 18 | { 19 | "example.hello": { 20 | "json": { 21 | "text": "Mish" 22 | } 23 | } 24 | } 25 | ] 26 | }, 27 | "check": { 28 | "status": 200 29 | } 30 | } 31 | } 32 | ] 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/filelist.yml: -------------------------------------------------------------------------------- 1 | version: "1.1" 2 | name: FileList 3 | tests: 4 | multipart: 5 | steps: 6 | - name: FileList 7 | http: 8 | url: https://httpbin.org/post 9 | method: POST 10 | formData: 11 | files: 12 | - file: ../README.md 13 | -------------------------------------------------------------------------------- /tests/helloworld.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2015 gRPC authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | option java_multiple_files = true; 18 | option java_package = "io.grpc.examples.helloworld"; 19 | option java_outer_classname = "HelloWorldProto"; 20 | option objc_class_prefix = "HLW"; 21 | 22 | package helloworld; 23 | 24 | // The greeting service definition. 25 | service Greeter { 26 | // Sends a greeting 27 | rpc SayHello (HelloRequest) returns (HelloReply) {} 28 | } 29 | 30 | // The request message containing the user's name. 31 | message HelloRequest { 32 | string name = 1; 33 | } 34 | 35 | // The response message containing the greetings 36 | message HelloReply { 37 | string message = 1; 38 | } 39 | -------------------------------------------------------------------------------- /tests/loadtesting.ts: -------------------------------------------------------------------------------- 1 | import { loadTest } from '../src/loadtesting' 2 | 3 | // Example workflow 4 | const workflow = { 5 | version: "1.1", 6 | name: "Status Test", 7 | config: { 8 | loadTest: { 9 | phases: [{ 10 | duration: 2, 11 | arrivalRate: 1 12 | }], 13 | check: { 14 | p99: [{ 15 | lte: 500 16 | }], 17 | p95: [{ 18 | lte: 500 19 | }] 20 | } 21 | } 22 | }, 23 | tests: { 24 | example: { 25 | steps: [ 26 | { 27 | "name": "GET request", 28 | "http": { 29 | "url": "https://example.com", 30 | "method": "GET", 31 | "check": { 32 | "status": 200 33 | } 34 | } 35 | }, 36 | { 37 | "name": "GET request", 38 | "http": { 39 | "url": "https://example.com", 40 | "method": "GET", 41 | "check": { 42 | "status": 300 43 | } 44 | } 45 | }, 46 | ] 47 | }, 48 | example2: { 49 | steps: [ 50 | { 51 | "name": "GET request", 52 | "http": { 53 | "url": "https://example.com", 54 | "method": "GET", 55 | "check": { 56 | "status": 200 57 | } 58 | } 59 | } 60 | ] 61 | } 62 | } 63 | } 64 | 65 | loadTest(workflow).then(({ result }) => console.log(result)) 66 | -------------------------------------------------------------------------------- /tests/multipart.yml: -------------------------------------------------------------------------------- 1 | version: "1.1" 2 | name: Multipart 3 | tests: 4 | multipart: 5 | steps: 6 | - name: Multipart 7 | http: 8 | url: https://httpbin.org/post 9 | method: POST 10 | formData: 11 | foo: bar 12 | bar: 13 | value: jane 14 | jsonfield: 15 | json: 16 | foo: bar 17 | bar: foo 18 | jsonfieldarray: 19 | json: 20 | - foo1: bar1 21 | - foo2: bar2 22 | filefield: 23 | file: example.json 24 | type: application/json 25 | check: 26 | status: 200 27 | jsonpath: 28 | $.files.filefield: 29 | - isNull: false 30 | $.form.foo: bar 31 | $.form.bar: jane 32 | $.form.jsonfield: '{"foo":"bar","bar":"foo"}' 33 | $.form.jsonfieldarray: '[{"foo1":"bar1"},{"foo2":"bar2"}]' 34 | -------------------------------------------------------------------------------- /tests/plugin.js: -------------------------------------------------------------------------------- 1 | const { checkResult } = require('./../dist/matcher') 2 | 3 | function f ( 4 | { params, check }, 5 | captures, 6 | cookies, 7 | schemaValidator, 8 | options, 9 | config 10 | ) { 11 | const stepResult = { 12 | type: '@yourcompany/plugin', 13 | } 14 | 15 | if (check) { 16 | stepResult.checks = {} 17 | 18 | if (check.reply) { 19 | stepResult.checks['reply'] = checkResult(params.hello, check.reply) 20 | } 21 | } 22 | 23 | return stepResult 24 | } 25 | 26 | module.exports = { 27 | default: f 28 | } 29 | -------------------------------------------------------------------------------- /tests/plugin.yml: -------------------------------------------------------------------------------- 1 | version: "1.1" 2 | name: "Hello Plugins" 3 | tests: 4 | example: 5 | steps: 6 | - name: Plugin 7 | plugin: 8 | id: "../../tests/plugin.js" 9 | params: 10 | hello: world 11 | check: 12 | reply: world 13 | -------------------------------------------------------------------------------- /tests/sse.js: -------------------------------------------------------------------------------- 1 | var http = require("http"); 2 | var fs = require("fs"); 3 | 4 | /* 5 | * send interval in millis 6 | */ 7 | var sendInterval = 5000; 8 | 9 | function sendServerSendEvent(req, res) { 10 | res.writeHead(200, { 11 | "Content-Type": "text/event-stream", 12 | "Cache-Control": "no-cache", 13 | Connection: "keep-alive", 14 | }); 15 | 16 | var sseId = new Date().toLocaleTimeString(); 17 | 18 | setInterval(function () { 19 | writeServerSendEvent(res, sseId, new Date().toLocaleTimeString()); 20 | }, sendInterval); 21 | 22 | writeServerSendEvent(res, sseId, new Date().toLocaleTimeString()); 23 | } 24 | 25 | function writeServerSendEvent(res, sseId, data) { 26 | res.write("id: " + sseId + "\n"); 27 | res.write(`data: {"hello": "world"}` + "\n\n"); 28 | } 29 | 30 | http 31 | .createServer(function (req, res) { 32 | if (req.headers.accept && req.headers.accept == "text/event-stream") { 33 | sendServerSendEvent(req, res); 34 | } 35 | }) 36 | .listen(8080); 37 | -------------------------------------------------------------------------------- /tests/sse.yml: -------------------------------------------------------------------------------- 1 | tests: 2 | example: 3 | steps: 4 | - name: SSE 5 | sse: 6 | url: http://localhost:8080 7 | timeout: 10000 8 | check: 9 | - id: 'message' 10 | jsonpath: 11 | $.hello: "world" 12 | -------------------------------------------------------------------------------- /tests/test.ts: -------------------------------------------------------------------------------- 1 | import { runFromFile } from '../src/index' 2 | import { EventEmitter } from 'node:events' 3 | 4 | const ee = new EventEmitter() 5 | runFromFile('./tests/basic.yml').then(({ result }) => console.log(result.tests[0].steps)) 6 | runFromFile('./tests/filelist.yml').then(({ result }) => console.log(result.tests[0].steps)) 7 | runFromFile('./tests/multipart.yml').then(({ result }) => console.log(result.tests[0].steps)) 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node16/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": true 6 | }, 7 | "include": ["src/**/*.ts"], 8 | } 9 | --------------------------------------------------------------------------------