From 8c143e985a195c40763135a0ccdbb99e24c41169 Mon Sep 17 00:00:00 2001 From: Lenin Mehedy Date: Tue, 7 Nov 2023 09:31:56 +1100 Subject: [PATCH] feat(cli): implement package downloader and custom errors modules (#490) Signed-off-by: Lenin Mehedy --- .github/workflows/zxc-compile-code.yaml | 6 +- fullstack-network-manager/README.md | 23 +- fullstack-network-manager/package-lock.json | 258 ++++++++++++++++-- fullstack-network-manager/package.json | 4 +- .../src/commands/base.mjs | 9 +- .../src/commands/chart.mjs | 13 +- .../src/commands/cluster.mjs | 28 +- .../src/commands/init.mjs | 4 +- fullstack-network-manager/src/core/errors.mjs | 68 +++++ fullstack-network-manager/src/core/index.mjs | 3 +- .../src/core/logging.mjs | 2 +- .../src/core/package_downloader.mjs | 163 +++++++++++ .../src/core/shell_runner.mjs | 12 +- .../e2e/core/package_downloader_e2e.test.js | 26 ++ .../test/{ => unit}/commands/base.test.js | 8 +- .../test/{ => unit}/commands/init.test.js | 8 +- .../test/unit/core/package_downloader.test.js | 125 +++++++++ 17 files changed, 686 insertions(+), 74 deletions(-) create mode 100644 fullstack-network-manager/src/core/errors.mjs create mode 100644 fullstack-network-manager/src/core/package_downloader.mjs create mode 100644 fullstack-network-manager/test/e2e/core/package_downloader_e2e.test.js rename fullstack-network-manager/test/{ => unit}/commands/base.test.js (87%) rename fullstack-network-manager/test/{ => unit}/commands/init.test.js (79%) create mode 100644 fullstack-network-manager/test/unit/core/package_downloader.test.js diff --git a/.github/workflows/zxc-compile-code.yaml b/.github/workflows/zxc-compile-code.yaml index 4381e552c..3cb442aca 100644 --- a/.github/workflows/zxc-compile-code.yaml +++ b/.github/workflows/zxc-compile-code.yaml @@ -147,8 +147,10 @@ jobs: id: nodejs-test working-directory: fullstack-network-manager if: ${{ inputs.enable-nodejs-tests && !cancelled() && !failure() }} - run: - npm i && NODE_OPTIONS=--experimental-vm-modules npm test --coverage + run: | + npm i + npm test + npm run test-e2e # This step tests the Helm chart direct mode of operation which uses the ubi8-init-java17 image. - name: Helm Chart Test (Direct Install) diff --git a/fullstack-network-manager/README.md b/fullstack-network-manager/README.md index 52dcfa6b5..60b7d07c0 100644 --- a/fullstack-network-manager/README.md +++ b/fullstack-network-manager/README.md @@ -29,25 +29,26 @@ Select a command - In order to support ES6 modules with `jest`, set an env variable `export NODE_OPTIONS=--experimental-vm-modules >> ~/.zshrc` - If you are using Intellij and would like to use debugger tools, you will need to enable `--experimental-vm-modules` for `Jest`. - `Run->Edit Configurations->Edit Configuration Templates->Jest` and then set `--experimental-vm-modules` in `Node Options`. -- Run `npm test` to run the tests +- Run `npm i` to install the required packages +- Run `npm link` to install `fsnetman` as the CLI (you need to do it once) +- Run `npm test` or `npm run test` to run the unit tests +- Run `npm run test-e2e` to run the long-running integration tests - Run `npm run fsnetman` to access the CLI as shown below: ``` ❯ npm run fsnetman - -> @hashgraph/fullstack-network-manager@0.1.0 fsnetman -> NODE_OPTIONS=--experimental-vm-modules node fsnetman.mjs - -Usage: fsnetman.mjs [options] +Usage: + fsnetman [options] Commands: - fsnetman.mjs init Perform dependency checks and initialize local environme - nt - fsnetman.mjs cluster Manager FST cluster + fsnetman init Perform dependency checks and initialize local environment + fsnetman cluster Manage FST cluster + fsnetman chart Manage FST chart deployment Options: - -h, --help Show help [boolean] - -v, --version Show version number [boolean] + -h, --help Show help [boolean] + -v, --version Show version number [boolean] Select a command + ``` diff --git a/fullstack-network-manager/package-lock.json b/fullstack-network-manager/package-lock.json index f63a3d17f..dd4c37210 100644 --- a/fullstack-network-manager/package-lock.json +++ b/fullstack-network-manager/package-lock.json @@ -1,17 +1,22 @@ { - "name": "@hedera/fullstack-network-manager", - "version": "1.0.0", + "name": "@hashgraph/fullstack-network-manager", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@hedera/fullstack-network-manager", - "version": "1.0.0", + "name": "@hashgraph/fullstack-network-manager", + "version": "0.1.0", "license": "Apache2.0", + "os": [ + "darwin", + "linux" + ], "dependencies": { "chalk": "^5.3.0", "esm": "^3.2.25", "figlet": "^1.6.0", + "got": "^13.0.0", "inquirer": "^9.2.11", "uuid": "^9.0.1", "winston": "^3.11.0", @@ -23,6 +28,10 @@ "devDependencies": { "@jest/globals": "^29.7.0", "jest": "^29.7.0" + }, + "engines": { + "node": ">=18.18.2", + "npm": ">=9.8.1" } }, "node_modules/@ampproject/remapping": { @@ -1126,6 +1135,17 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", @@ -1144,6 +1164,17 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, "node_modules/@types/babel__core": { "version": "7.20.3", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.3.tgz", @@ -1194,6 +1225,11 @@ "@types/node": "*" } }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.3.tgz", + "integrity": "sha512-V46MYLFp08Wf2mmaBhvgjStM3tPa+2GAdy/iqoX+noX1//zje2x4XmrIU0cAwyClATsTmahbtoQ2EwP7I5WSiA==" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", @@ -1566,6 +1602,31 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", + "dependencies": { + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -1887,6 +1948,31 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", @@ -1921,6 +2007,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "engines": { + "node": ">=10" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2083,6 +2177,17 @@ "node": ">=4" } }, + "node_modules/external-editor/node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2159,6 +2264,14 @@ "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, + "node_modules/form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "engines": { + "node": ">= 14.17" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2231,7 +2344,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, "engines": { "node": ">=10" }, @@ -2268,6 +2380,30 @@ "node": ">=4" } }, + "node_modules/got": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", + "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2318,6 +2454,23 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, + "node_modules/http2-wrapper": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.0.tgz", + "integrity": "sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -3446,6 +3599,11 @@ "node": ">=4" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -3464,6 +3622,14 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3567,6 +3733,17 @@ "node": ">= 12.0.0" } }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3660,6 +3837,17 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3712,6 +3900,17 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.0.tgz", + "integrity": "sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -3811,6 +4010,14 @@ "node": ">=0.10.0" } }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "engines": { + "node": ">=12.20" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4007,6 +4214,17 @@ } ] }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -4051,6 +4269,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -4081,6 +4304,20 @@ "node": ">=10" } }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -4370,17 +4607,6 @@ "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/fullstack-network-manager/package.json b/fullstack-network-manager/package.json index 641f4765f..c85a4f26f 100644 --- a/fullstack-network-manager/package.json +++ b/fullstack-network-manager/package.json @@ -11,7 +11,8 @@ "fsnetman": "fsnetman.mjs" }, "scripts": { - "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --detectOpenHandles --testPathIgnorePatterns=\".*/e2e/.*\"", + "test-e2e": "NODE_OPTIONS=--experimental-vm-modules jest --detectOpenHandles --testPathIgnorePatterns=\\\".*/unit/.*\\\"", "fsnetman": "NODE_OPTIONS=--experimental-vm-modules node fsnetman.mjs" }, "keywords": [ @@ -24,6 +25,7 @@ "chalk": "^5.3.0", "esm": "^3.2.25", "figlet": "^1.6.0", + "got": "^13.0.0", "inquirer": "^9.2.11", "uuid": "^9.0.1", "winston": "^3.11.0", diff --git a/fullstack-network-manager/src/commands/base.mjs b/fullstack-network-manager/src/commands/base.mjs index ebd010685..41a20a6a1 100644 --- a/fullstack-network-manager/src/commands/base.mjs +++ b/fullstack-network-manager/src/commands/base.mjs @@ -1,5 +1,4 @@ "use strict" -import {exec} from "child_process"; import * as core from "../core/index.mjs" import chalk from "chalk"; import {ShellRunner} from "../core/shell_runner.mjs"; @@ -50,11 +49,11 @@ export class BaseCommand extends ShellRunner { this.logger.debug("Checking for required dependencies: %s", deps) for (let i = 0; i < deps.length; i++) { - let dep = deps[i] + const dep = deps[i] this.logger.debug("Checking for dependency '%s'", dep) let status = false - let check = this.checks.get(dep) + const check = this.checks.get(dep) if (check) { status = await check() } @@ -88,7 +87,7 @@ export class BaseCommand extends ShellRunner { async chartInstall(namespaceName, chartName, chartPath, valuesArg = '') { try { - let charts = await this.getInstalledCharts(namespaceName) + const charts = await this.getInstalledCharts(namespaceName) if (!charts.includes(chartName)) { this.logger.showUser(chalk.cyan('> running helm dependency update for chart:'), chalk.yellow(`${chartName} ...`)) await this.helm.dependency('update', chartPath) @@ -111,7 +110,7 @@ export class BaseCommand extends ShellRunner { async chartUninstall(namespaceName, chartName) { try { this.logger.showUser(chalk.cyan('> checking chart:'), chalk.yellow(`${chartName}`)) - let charts = await this.getInstalledCharts(namespaceName) + const charts = await this.getInstalledCharts(namespaceName) if (charts.includes(chartName)) { this.logger.showUser(chalk.cyan('> uninstalling chart:'), chalk.yellow(`${chartName}`)) await this.helm.uninstall(`-n ${namespaceName} ${chartName}`) diff --git a/fullstack-network-manager/src/commands/chart.mjs b/fullstack-network-manager/src/commands/chart.mjs index 5662b0d6b..535319ca5 100644 --- a/fullstack-network-manager/src/commands/chart.mjs +++ b/fullstack-network-manager/src/commands/chart.mjs @@ -1,5 +1,4 @@ import {BaseCommand} from "./base.mjs"; -import chalk from "chalk"; import * as core from "../core/index.mjs"; import * as flags from "./flags.mjs"; @@ -9,7 +8,7 @@ export class ChartCommand extends BaseCommand { chartName = "fullstack-deployment" prepareValuesArg(argv) { - let {valuesFile, mirrorNode, hederaExplorer} = argv + const {valuesFile, mirrorNode, hederaExplorer} = argv let valuesArg = `--values ${this.chartPath}/values.yaml` if (valuesFile) { @@ -22,21 +21,21 @@ export class ChartCommand extends BaseCommand { } async install(argv) { - let namespace = argv.namespace - let valuesArg = this.prepareValuesArg(argv) + const namespace = argv.namespace + const valuesArg = this.prepareValuesArg(argv) return await this.chartInstall(namespace, this.chartName, this.chartPath, valuesArg) } async uninstall(argv) { - let namespace = argv.namespace + const namespace = argv.namespace return await this.chartUninstall(namespace, this.chartName) } async upgrade(argv) { - let namespace = argv.namespace - let valuesArg = this.prepareValuesArg(argv) + const namespace = argv.namespace + const valuesArg = this.prepareValuesArg(argv) return await this.chartUpgrade(namespace, this.chartName, this.chartPath, valuesArg) } diff --git a/fullstack-network-manager/src/commands/cluster.mjs b/fullstack-network-manager/src/commands/cluster.mjs index 05e80ac8a..dd3742c52 100644 --- a/fullstack-network-manager/src/commands/cluster.mjs +++ b/fullstack-network-manager/src/commands/cluster.mjs @@ -62,9 +62,9 @@ export class ClusterCommand extends BaseCommand { */ async getClusterInfo(argv) { try { - let clusterName = argv.clusterName - let cmd = `kubectl cluster-info --context kind-${clusterName}` - let output = await this.run(cmd) + const clusterName = argv.clusterName + const cmd = `kubectl cluster-info --context kind-${clusterName}` + const output = await this.run(cmd) this.logger.showUser(`Cluster information (${clusterName})\n---------------------------------------`) output.forEach(line => this.logger.showUser(line)) @@ -79,8 +79,8 @@ export class ClusterCommand extends BaseCommand { async createNamespace(argv) { try { - let namespace = argv.namespace - let namespaces = await this.getNameSpaces() + const namespace = argv.namespace + const namespaces = await this.getNameSpaces() this.logger.showUser(chalk.cyan('> checking namespace:'), chalk.yellow(`${namespace}`)) if (!namespaces.includes(`namespace/${namespace}`)) { this.logger.showUser(chalk.cyan('> creating namespace:'), chalk.yellow(`${namespace} ...`)) @@ -107,8 +107,8 @@ export class ClusterCommand extends BaseCommand { */ async create(argv) { try { - let clusterName = argv.clusterName - let clusters = await this.getClusters() + const clusterName = argv.clusterName + const clusters = await this.getClusters() this.logger.showUser(chalk.cyan('> checking cluster:'), chalk.yellow(`${clusterName}`)) if (!clusters.includes(clusterName)) { @@ -144,8 +144,8 @@ export class ClusterCommand extends BaseCommand { */ async delete(argv) { try { - let clusterName = argv.clusterName - let clusters = await this.getClusters() + const clusterName = argv.clusterName + const clusters = await this.getClusters() this.logger.showUser(chalk.cyan('> checking cluster:'), chalk.yellow(`${clusterName}`)) if (clusters.includes(clusterName)) { this.logger.showUser(chalk.cyan('> deleting cluster:'), chalk.yellow(`${clusterName} ...`)) @@ -180,11 +180,11 @@ export class ClusterCommand extends BaseCommand { // create cluster await this.create(argv) - let clusterName = argv.clusterName - let chartName = "fullstack-cluster-setup" - let namespace = argv.namespace - let chartPath = `${core.constants.FST_HOME_DIR}/full-stack-testing/charts/${chartName}` - let valuesArg = this.prepareValuesArg(argv.prometheusStack, argv.minio, argv.envoyGateway) + const clusterName = argv.clusterName + const chartName = "fullstack-cluster-setup" + const namespace = argv.namespace + const chartPath = `${core.constants.FST_HOME_DIR}/full-stack-testing/charts/${chartName}` + const valuesArg = this.prepareValuesArg(argv.prometheusStack, argv.minio, argv.envoyGateway) this.logger.showUser(chalk.cyan('> setting up cluster:'), chalk.yellow(`${clusterName}`)) await this.chartInstall(namespace, chartName, chartPath, valuesArg) diff --git a/fullstack-network-manager/src/commands/init.mjs b/fullstack-network-manager/src/commands/init.mjs index 40203ac8a..36dfac593 100644 --- a/fullstack-network-manager/src/commands/init.mjs +++ b/fullstack-network-manager/src/commands/init.mjs @@ -11,13 +11,13 @@ export class InitCommand extends BaseCommand { * @returns {Promise} */ async init() { - let deps = [ + const deps = [ core.constants.HELM, core.constants.KIND, core.constants.KUBECTL, ] - let status = await this.checkDependencies(deps) + const status = await this.checkDependencies(deps) if (!status) { return false } diff --git a/fullstack-network-manager/src/core/errors.mjs b/fullstack-network-manager/src/core/errors.mjs new file mode 100644 index 000000000..be876db04 --- /dev/null +++ b/fullstack-network-manager/src/core/errors.mjs @@ -0,0 +1,68 @@ +export class FullstackTestingError extends Error { + /** + * Create a custom error object + * + * error metadata will include the `cause` + * + * @param message error message + * @param cause source error (if any) + * @param meta additional metadata (if any) + */ + constructor(message, cause = {}, meta = {}) { + super(message); + this.name = this.constructor.name + + this.meta = meta + if (cause) { + this.cause = cause + } + + Error.captureStackTrace(this, this.constructor) + } +} + +export class ResourceNotFoundError extends FullstackTestingError { + /** + * Create a custom error for resource not found scenario + * + * error metadata will include `resource` + * + * @param message error message + * @param resource name of the resource + * @param cause source error (if any) + */ + constructor(message, resource, cause = {}) { + super(message, cause, {resource: resource}); + } +} + +export class IllegalArgumentError extends FullstackTestingError { + /** + * Create a custom error for illegal argument scenario + * + * error metadata will include `value` + * + * @param message error message + * @param value value of the invalid argument + * @param cause source error (if any) + */ + constructor(message, value = '', cause = {}) { + super(message, cause, {value: value}); + } +} + +export class DataValidationError extends FullstackTestingError { + /** + * Create a custom error for data validation error scenario + * + * error metadata will include `expected` and `found` values. + * + * @param message error message + * @param expected expected value + * @param found value found + * @param cause source error (if any) + */ + constructor(message, expected, found, cause = {}) { + super(message, cause, {expected: expected, found: found}); + } +} diff --git a/fullstack-network-manager/src/core/index.mjs b/fullstack-network-manager/src/core/index.mjs index f75019e41..f1c97b607 100644 --- a/fullstack-network-manager/src/core/index.mjs +++ b/fullstack-network-manager/src/core/index.mjs @@ -3,6 +3,7 @@ import {constants} from './constants.mjs' import {Kind} from './kind.mjs' import {Helm} from './helm.mjs' import {Kubectl} from "./kubectl.mjs"; +import {PackageDownloader} from "./package_downloader.mjs"; // Expose components from the core module -export {logging, constants, Kind, Helm, Kubectl} +export {logging, constants, Kind, Helm, Kubectl, PackageDownloader} diff --git a/fullstack-network-manager/src/core/logging.mjs b/fullstack-network-manager/src/core/logging.mjs index e2093c467..a4270f699 100644 --- a/fullstack-network-manager/src/core/logging.mjs +++ b/fullstack-network-manager/src/core/logging.mjs @@ -54,7 +54,7 @@ const Logger = class { * @constructor */ constructor(level) { - let self = this + const self = this this.nextTraceId() this.winsonLogger = winston.createLogger({ diff --git a/fullstack-network-manager/src/core/package_downloader.mjs b/fullstack-network-manager/src/core/package_downloader.mjs new file mode 100644 index 000000000..51c93cccc --- /dev/null +++ b/fullstack-network-manager/src/core/package_downloader.mjs @@ -0,0 +1,163 @@ +import * as crypto from 'crypto' +import * as fs from "fs"; +import {pipeline as streamPipeline} from 'node:stream/promises'; +import got from 'got'; +import {DataValidationError, FullstackTestingError, IllegalArgumentError, ResourceNotFoundError} from "./errors.mjs"; +import * as https from "https"; + +export class PackageDownloader { + /** + * Create an instance of Downloader + * @param logger an instance of core/Logger + */ + constructor(logger) { + if (!logger) throw new IllegalArgumentError("an instance of core/Logger is required", logger) + this.logger = logger + } + + isValidURL(url) { + try { + // attempt to parse to check URL format + new URL(url); + return true + } catch (e) { + } + + return false + } + + async urlExists(url) { + const self = this + + return new Promise((resolve, reject) => { + try { + self.logger.debug(`Checking URL: ${url}`) + // attempt to send a HEAD request to check URL exists + const req = https.request(url, {method: 'HEAD', timeout: 100, headers: {"Connection": 'close'}}) + + req.on('response', r => { + self.logger.debug(r.headers) + if (r.statusCode === 200) { + return resolve(true) + } + + resolve(false) + }) + + req.on('error', err => { + self.logger.error(err) + resolve(false) + }) + + req.end() // make the request + } catch (e) { + self.logger.error(e) + resolve(false) + } + }) + } + + /** + * Fetch data from a URL and save the output to a file + * + * @param url source file URL + * @param destPath destination path for the downloaded file + */ + async fetchFile(url, destPath) { + if (!url) throw new IllegalArgumentError('source file URL is required', url) + if (!destPath) throw new IllegalArgumentError('destination path is required', destPath) + if (!this.isValidURL(url)) { + throw new IllegalArgumentError(`source URL is invalid`, url) + } + + return new Promise(async (resolve, reject) => { + if (!await this.urlExists(url)) { + reject(new ResourceNotFoundError(`source URL does not exist`, url)) + } + + try { + await streamPipeline( + got.stream(url), + fs.createWriteStream(destPath) + ) + resolve(destPath) + } catch (e) { + reject(new ResourceNotFoundError(`failed to download file: ${url}`, url, {url, destPath})) + } + }) + } + + /** + * Compute hash of the file contents + * @param filePath path of the file + * @param algo hash algorithm + * @returns {Promise} returns hex digest of the computed hash + * @throws Error if the file cannot be read + */ + async computeFileHash(filePath, algo = 'sha384') { + return new Promise((resolve, reject) => { + try { + const checksum = crypto.createHash(algo); + const s = fs.createReadStream(filePath) + s.on('data', function (d) { + checksum.update(d); + }); + s.on('end', function () { + const d = checksum.digest('hex'); + resolve(d) + }) + } catch (e) { + reject(new FullstackTestingError('failed to compute file hash', e, {filePath, algo})) + } + }) + } + + /** + * Verifies that the checksum of the sourceFile matches with the contents of the checksumFile + * + * It throws error if the checksum doesn't match. + * + * @param sourceFile path to the file for which checksum to be computed + * @param checksum expected checksum + * @param algo hash algorithm to be used to compute checksum + * @throws DataValidationError if the checksum doesn't match + */ + async verifyChecksum(sourceFile, checksum, algo = 'sha384') { + const computed = await this.computeFileHash(sourceFile, algo) + if (checksum !== computed) throw new DataValidationError('checksum', checksum, computed) + } + + /** + * Fetch platform release artifact + * + * It fetches the build.zip file containing the release from a URL like: https://builds.hedera.com/node/software/v0.40/build-v0.40.4.zip + * + * @param tag full semantic version e.g. v0.40.4 + * @param destDir directory where the artifact needs to be saved + * @returns {Promise} full path to the downloaded file + */ + async fetchPlatform(tag, destDir) { + const parsed = tag.split('.') + if (parsed.length < 3) throw new Error(`tag (${tag}) must include major, minor and patch fields (e.g. v0.40.4)`) + if (!destDir) throw new Error('destination directory path is required') + + return new Promise(async (resolve, reject) => { + try { + const releaseDir = `${parsed[0]}.${parsed[1]}` + const packageURL = `https://builds.hedera.com/node/software/${releaseDir}/build-${tag}.zip` + const packagePath = `${destDir}/build-${tag}.zip` + const checksumURL = `https://builds.hedera.com/node/software/${releaseDir}/build-${tag}.sha384` + const checksumPath = `${destDir}/build-${tag}.sha384` + + await this.fetchFile(packageURL, packagePath) + await this.fetchFile(checksumURL, checksumPath) + + const checksum = fs.readFileSync(checksumPath).toString().split(" ")[0] + await this.verifyChecksum(packagePath, checksum) + resolve(packagePath) + } catch (e) { + reject(new FullstackTestingError(`failed to fetch platform artifacts: ${e.message}`, e, {tag, destDir})) + } + }) + } +} diff --git a/fullstack-network-manager/src/core/shell_runner.mjs b/fullstack-network-manager/src/core/shell_runner.mjs index 1705e9312..9e09e8e8e 100644 --- a/fullstack-network-manager/src/core/shell_runner.mjs +++ b/fullstack-network-manager/src/core/shell_runner.mjs @@ -14,16 +14,16 @@ export class ShellRunner { */ async run(cmd) { const self = this - let callStack= new Error().stack // capture the callstack to be included in error + const callStack= new Error().stack // capture the callstack to be included in error return new Promise((resolve, reject) => { const child = spawn(cmd, { shell: true, }) - let output = [] + const output = [] child.stdout.on('data', d => { - let items = d.toString().split(/\r?\n/) + const items = d.toString().split(/\r?\n/) items.forEach(item => { if (item) { output.push(item) @@ -31,9 +31,9 @@ export class ShellRunner { }) }) - let errOutput= [] + const errOutput= [] child.stderr.on('data', d => { - let items = d.toString().split(/\r?\n/) + const items = d.toString().split(/\r?\n/) items.forEach(item => { if (item) { errOutput.push(item) @@ -44,7 +44,7 @@ export class ShellRunner { child.on('exit', (code, signal) => { if (code) { - let err = new Error(`Command exit with error code: ${code}`) + const err = new Error(`Command exit with error code: ${code}`) // include the callStack to the parent run() instead of from inside this handler. // this is needed to ensure we capture the proper callstack for easier debugging. diff --git a/fullstack-network-manager/test/e2e/core/package_downloader_e2e.test.js b/fullstack-network-manager/test/e2e/core/package_downloader_e2e.test.js new file mode 100644 index 000000000..cb41fa661 --- /dev/null +++ b/fullstack-network-manager/test/e2e/core/package_downloader_e2e.test.js @@ -0,0 +1,26 @@ +import {describe, expect, it} from "@jest/globals"; +import * as core from "../../../src/core/index.mjs"; +import {PackageDownloader} from "../../../src/core/package_downloader.mjs"; +import * as fs from 'fs' +import * as path from "path"; +import * as os from "os"; + +describe('PackageDownloaderE2E', () => { + const testLogger = core.logging.NewLogger('debug') + const downloader = new PackageDownloader(testLogger) + + it('should succeed with a valid Hedera release tag', async () => { + let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downloader-')); + + let tag = 'v0.42.5' + let destPath = `${tmpDir}/build-${tag}.zip` + await expect(downloader.fetchPlatform(tag, tmpDir)).resolves.toBe(destPath) + expect(fs.existsSync(destPath)).toBeTruthy() + testLogger.showUser(destPath) + + // remove the downloaded files to reduce disk usage + fs.rmSync(`${tmpDir}/build-${tag}.zip`) + fs.rmSync(`${tmpDir}/build-${tag}.sha384`) + fs.rmdirSync(tmpDir) + }, 100000) +}) diff --git a/fullstack-network-manager/test/commands/base.test.js b/fullstack-network-manager/test/unit/commands/base.test.js similarity index 87% rename from fullstack-network-manager/test/commands/base.test.js rename to fullstack-network-manager/test/unit/commands/base.test.js index 559f01617..1ecb110f8 100644 --- a/fullstack-network-manager/test/commands/base.test.js +++ b/fullstack-network-manager/test/unit/commands/base.test.js @@ -1,8 +1,8 @@ import {test, expect, it, describe} from "@jest/globals"; -import {Helm, Kubectl, logging} from "../../src/core/index.mjs"; -import {BaseCommand} from "../../src/commands/base.mjs"; -import * as core from "../../src/core/index.mjs" -import {Kind} from "../../src/core/kind.mjs"; +import {Helm, Kubectl, logging} from "../../../src/core/index.mjs"; +import {BaseCommand} from "../../../src/commands/base.mjs"; +import * as core from "../../../src/core/index.mjs" +import {Kind} from "../../../src/core/kind.mjs"; const testLogger = logging.NewLogger("debug") diff --git a/fullstack-network-manager/test/commands/init.test.js b/fullstack-network-manager/test/unit/commands/init.test.js similarity index 79% rename from fullstack-network-manager/test/commands/init.test.js rename to fullstack-network-manager/test/unit/commands/init.test.js index 62acbfc20..eceadbecd 100644 --- a/fullstack-network-manager/test/commands/init.test.js +++ b/fullstack-network-manager/test/unit/commands/init.test.js @@ -1,8 +1,8 @@ -import {InitCommand} from "../../src/commands/init.mjs"; +import {InitCommand} from "../../../src/commands/init.mjs"; import {expect, describe, it} from "@jest/globals"; -import * as core from "../../src/core/index.mjs"; -import {Helm, Kind, Kubectl} from "../../src/core/index.mjs"; -import {BaseCommand} from "../../src/commands/base.mjs"; +import * as core from "../../../src/core/index.mjs"; +import {Helm, Kind, Kubectl} from "../../../src/core/index.mjs"; +import {BaseCommand} from "../../../src/commands/base.mjs"; const testLogger = core.logging.NewLogger('debug') describe('InitCommand', () => { diff --git a/fullstack-network-manager/test/unit/core/package_downloader.test.js b/fullstack-network-manager/test/unit/core/package_downloader.test.js new file mode 100644 index 000000000..7e529badf --- /dev/null +++ b/fullstack-network-manager/test/unit/core/package_downloader.test.js @@ -0,0 +1,125 @@ +import {describe, expect, it} from "@jest/globals"; +import * as core from "../../../src/core/index.mjs"; +import {PackageDownloader} from "../../../src/core/package_downloader.mjs"; +import * as fs from 'fs' +import * as path from "path"; +import * as os from "os"; +import {IllegalArgumentError, ResourceNotFoundError} from "../../../src/core/errors.mjs"; + +describe('PackageDownloader', () => { + const testLogger = core.logging.NewLogger('debug') + const downloader = new PackageDownloader(testLogger) + + describe('urlExists', () => { + it('should return true if source URL is valid', async () => { + expect.assertions(1) + let url = `https://builds.hedera.com/node/software/v0.42/build-v0.42.5.sha384` + await expect(downloader.urlExists(url)).resolves.toBe(true) + }) + it('should return false if source URL is valid', async () => { + expect.assertions(1) + let url = `https://builds.hedera.com/node/software/v0.42/build-v0.42.5.INVALID` + await expect(downloader.urlExists(url)).resolves.toBe(false) + }) + + }) + + describe('fetchFile', () => { + it('should fail if source URL is missing', async () => { + expect.assertions(1) + + try { + await downloader.fetchFile('', os.tmpdir()) + } catch (e) { + expect(e.message).toBe('source file URL is required') + } + }) + + it('should fail if destination path is missing', async () => { + expect.assertions(1) + + try { + await downloader.fetchFile('https://localhost', '') + } catch (e) { + expect(e.message).toBe('destination path is required') + } + }) + + it('should fail with a malformed URL', async () => { + expect.assertions(2) + + try { + await downloader.fetchFile('INVALID_URL', os.tmpdir()) + } catch (e) { + expect(e).toBeInstanceOf(IllegalArgumentError) + expect(e.message).toBe('source URL is invalid') + } + }) + + it('should fail with an invalid URL', async () => { + expect.assertions(2) + + try { + await downloader.fetchFile('https://localhost/INVALID_FILE', os.tmpdir()) + } catch (e) { + expect(e).toBeInstanceOf(ResourceNotFoundError) + expect(e.message).toBe('source URL does not exist') + } + }) + + it('should succeed with a valid release artifact URL', async () => { + try { + let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downloader-')); + + let tag = 'v0.42.5' + let destPath = `${tmpDir}/build-${tag}.sha384` + + // we use the build-.sha384 file URL to test downloading a small file + let url = `https://builds.hedera.com/node/software/v0.42/build-${tag}.sha384` + await expect(downloader.fetchFile(url, destPath)).resolves.toBe(destPath) + expect(fs.existsSync(destPath)).toBeTruthy() + + // remove the file to reduce disk usage + fs.rmSync(destPath) + fs.rmdirSync(tmpDir) + } catch (e) { + expect(e).toBeNull() + } + }) + + }) + + describe('fetchPlatform', () => { + it('should fail if platform release tag is missing', async () => { + expect.assertions(2) + + let tag = 'v0.40.0-INVALID' + + try { + await downloader.fetchPlatform(tag, os.tmpdir()) + } catch (e) { + expect(e.cause).not.toBeNull() + expect(e.cause).toBeInstanceOf(ResourceNotFoundError) + } + }) + + it('should fail if platform release tag is invalid', async () => { + expect.assertions(1) + + try { + await downloader.fetchPlatform('INVALID', os.tmpdir()) + } catch (e) { + expect(e.message).toContain('must include major, minor and patch fields') + } + }) + + it('should fail if destination directory is null', async () => { + expect.assertions(1) + try { + await downloader.fetchPlatform('v0.40.0', '') + } catch (e) { + expect(e.message).toContain('destination directory path is required') + } + }) + }) +})