diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b55162..0f5a176 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,23 @@ jobs: - run: npm run build - run: npm test + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx vitest run --coverage + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/coverage-final.json + fail_ci_if_error: true + docker: runs-on: ubuntu-latest steps: diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..7449b97 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,25 @@ +codecov: + require_ci_to_pass: true +coverage: + precision: 2 + round: down + range: "70...100" + status: + project: + default: + target: 90% + threshold: 2% + patch: + default: + target: 90% + threshold: 5% +comment: + layout: "reach,diff,flags,files" + behavior: default + require_changes: true + require_base: false + require_head: true +ignore: + - "**/test/**" + - "**/tests/**" + - "**/*.test.ts" diff --git a/package-lock.json b/package-lock.json index c872b70..5ab3858 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@types/node": "^22.15.3", + "@vitest/coverage-v8": "^3.2.4", "tsx": "^4.19.4", "typescript": "^5.8.3", "vitest": "^3.1.3" @@ -25,6 +26,80 @@ "node": ">=18.0.0" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -467,6 +542,55 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -474,6 +598,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -898,6 +1044,40 @@ "undici-types": "~6.21.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -1013,6 +1193,32 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1023,6 +1229,48 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1060,6 +1308,26 @@ "node": ">= 16" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -1069,6 +1337,21 @@ "node": ">=18" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1097,6 +1380,20 @@ "node": ">=6" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1184,6 +1481,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1212,6 +1526,165 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1226,6 +1699,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1236,6 +1716,60 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1262,6 +1796,40 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1383,6 +1951,42 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1390,6 +1994,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1414,6 +2031,110 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -1427,6 +2148,34 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1700,6 +2449,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1717,6 +2482,104 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", diff --git a/package.json b/package.json index c699fd1..531f682 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ }, "devDependencies": { "@types/node": "^22.15.3", + "@vitest/coverage-v8": "^3.2.4", "tsx": "^4.19.4", "typescript": "^5.8.3", "vitest": "^3.1.3" diff --git a/src/generator.test.ts b/src/generator.test.ts index 7d41c1c..f9fca71 100644 --- a/src/generator.test.ts +++ b/src/generator.test.ts @@ -144,6 +144,243 @@ describe("generateCard", () => { expect(html).toContain("&"); expect(html).toContain(""quotes""); }); + + it("renders personalNote from config override", () => { + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + repos: { "test-repo": { personalNote: "My favorite project" } }, + }, + }; + const html = generateCard(mockRepo, null, config); + + expect(html).toContain("My favorite project"); + }); + + it("renders keyFeatures list from config override", () => { + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + repos: { + "test-repo": { + keyFeatures: ["Feature A", "Feature B"], + }, + }, + }, + }; + const html = generateCard(mockRepo, null, config); + + expect(html).toContain("Key Features:"); + expect(html).toContain("
  • Feature A
  • "); + expect(html).toContain("
  • Feature B
  • "); + }); + + it("uses techStack override from config", () => { + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + repos: { "test-repo": { techStack: "Rust, WASM" } }, + }, + }; + const html = generateCard(mockRepo, null, config); + + expect(html).toContain("Rust, WASM"); + expect(html).not.toContain("TypeScript"); + }); + + it("renders custom badge type 'docker'", () => { + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + repos: { + "test-repo": { + badges: [{ type: "docker" as const, label: "myuser/myimage" }], + }, + }, + }, + }; + const html = generateCard(mockRepo, null, config); + + expect(html).toContain("hub.docker.com/r/myuser/myimage"); + expect(html).toContain("docker/pulls/myuser/myimage"); + }); + + it("renders custom badge type 'platform'", () => { + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + repos: { + "test-repo": { + badges: [ + { type: "platform" as const, label: "macOS", color: "000", logo: "apple" }, + ], + }, + }, + }, + }; + const html = generateCard(mockRepo, null, config); + + expect(html).toContain("platform"); + expect(html).toContain("macOS"); + expect(html).toContain("apple"); + }); + + it("renders custom badge type 'docs'", () => { + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + repos: { + "test-repo": { + badges: [{ type: "docs" as const, url: "https://docs.example.com" }], + }, + }, + }, + }; + const html = generateCard(mockRepo, null, config); + + expect(html).toContain("https://docs.example.com"); + expect(html).toContain("docs"); + }); + + it("renders custom badge type 'custom'", () => { + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + repos: { + "test-repo": { + badges: [ + { type: "custom" as const, label: "MCP", value: "Official", color: "E6522C" }, + ], + }, + }, + }, + }; + const html = generateCard(mockRepo, null, config); + + expect(html).toContain("MCP"); + expect(html).toContain("Official"); + expect(html).toContain("E6522C"); + }); + + it("renders custom badge type 'website' with explicit url and label", () => { + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + repos: { + "test-repo": { + badges: [ + { type: "website" as const, url: "https://my-site.com", label: "MySite" }, + ], + }, + }, + }, + }; + const html = generateCard(mockRepo, null, config); + + expect(html).toContain("https://my-site.com"); + expect(html).toContain("MySite"); + }); + + it("renders custom badge type 'awesome-list'", () => { + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + repos: { + "test-repo": { + badges: [{ type: "awesome-list" as const, color: "ff0000" }], + }, + }, + }, + }; + const html = generateCard(mockRepo, null, config); + + expect(html).toContain("awesome"); + expect(html).toContain("ff0000"); + }); + + it("skips auto awesome-list badge when already in custom badges", () => { + const repo = { ...mockRepo, topics: ["awesome-list"] }; + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + repos: { + "test-repo": { + badges: [{ type: "awesome-list" as const }], + }, + }, + }, + }; + const html = generateCard(repo, null, config); + + // Should have exactly one awesome badge (from custom), not two + const matches = html.match(/awesomelists/g); + expect(matches).toHaveLength(1); + }); + + it("adds docs badge for github.io homepage", () => { + const repo = { ...mockRepo, homepage: "https://testuser.github.io/test-repo" }; + const html = generateCard(repo, null, mockConfig); + + expect(html).toContain("docs"); + expect(html).toContain("testuser.github.io"); + }); + + it("skips docs badge when already in custom badges", () => { + const repo = { ...mockRepo, homepage: "https://testuser.github.io/test-repo" }; + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + repos: { + "test-repo": { + badges: [{ type: "docs" as const, url: "https://docs.example.com" }], + }, + }, + }, + }; + const html = generateCard(repo, null, config); + + // Custom docs badge URL should appear, not the auto-detected one + expect(html).toContain("https://docs.example.com"); + }); + + it("skips website badge for docker hub homepage", () => { + const repo = { ...mockRepo, homepage: "https://hub.docker.com/r/myuser/myimage" }; + const html = generateCard(repo, null, mockConfig); + + expect(html).not.toContain("website"); + }); + + it("skips license badge when no license", () => { + const repo = { ...mockRepo, license: null }; + const html = generateCard(repo, null, mockConfig); + + expect(html).not.toContain("github/license"); + }); + + it("returns null tech stack when no language or relevant topics", () => { + const repo = { ...mockRepo, language: null, topics: [] }; + const html = generateCard(repo, null, mockConfig); + + expect(html).not.toContain("Tech Stack:"); + }); + + it("shows no description when repo has none and no override", () => { + const repo = { ...mockRepo, description: null }; + const html = generateCard(repo, null, mockConfig); + + expect(html).not.toContain(""); + }); }); describe("generateFooter", () => { @@ -167,6 +404,64 @@ describe("generateFooter", () => { expect(footer).toBeNull(); }); + + it("shows only viewAll link when showStats is false", () => { + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + footer: { showStats: false, showViewAll: true }, + }, + }; + const footer = generateFooter([mockRepo], config); + + expect(footer).toContain("View All Repositories"); + expect(footer).not.toContain("Total Stars"); + }); + + it("shows stats without viewAll when showViewAll is false", () => { + const config = { + ...mockConfig, + portfolio: { + ...mockConfig.portfolio, + footer: { showStats: true, showViewAll: false }, + }, + }; + const footer = generateFooter([mockRepo], config); + + expect(footer).toContain("42+"); + expect(footer).not.toContain("View All Repositories"); + }); + + it("aggregates stats across multiple repos", () => { + const repos = [ + mockRepo, + { ...mockRepo, name: "repo2", stargazers_count: 10, language: "Python", topics: ["flask"] }, + ]; + const footer = generateFooter(repos, mockConfig); + + expect(footer).toContain("52+"); // 42 + 10 + expect(footer).toContain("2"); // 2 repos + expect(footer).toContain("TypeScript"); + expect(footer).toContain("Python"); + }); + + it("shows focus areas from topic counts", () => { + const repos = [ + { ...mockRepo, topics: ["docker", "kubernetes"] }, + { ...mockRepo, name: "r2", topics: ["docker", "terraform"] }, + ]; + const footer = generateFooter(repos, mockConfig); + + expect(footer).toContain("Docker"); + }); + + it("shows no languages line when none present", () => { + const repos = [{ ...mockRepo, language: null, topics: [] }]; + const footer = generateFooter(repos, mockConfig); + + expect(footer).not.toContain("Primary Languages"); + }); }); describe("buildLexical", () => { diff --git a/src/ghost.test.ts b/src/ghost.test.ts index 70349bf..7bceb43 100644 --- a/src/ghost.test.ts +++ b/src/ghost.test.ts @@ -1,6 +1,40 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { createHmac } from "node:crypto"; -import { generateJwt } from "./ghost.js"; +import { generateJwt, fetchPage, updatePage } from "./ghost.js"; +import type { Config } from "./types.js"; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.restoreAllMocks(); + global.fetch = mockFetch; +}); + +function makeConfig(overrides: Partial = {}): Config { + return { + github: { username: "testuser" }, + ghost: { + url: "https://ghost.example.com", + adminApiKey: "keyid:aabbccdd", + pageSlug: "portfolio", + ...overrides, + }, + portfolio: { + minStars: 2, + maxRepos: 50, + excludeRepos: [], + includeForked: false, + excludeAwesomeLists: false, + badgeStyle: "for-the-badge", + showBanner: true, + centerContent: true, + defaultBannerPath: "docs/images/banner.svg", + bannerPaths: {}, + repos: {}, + footer: { showStats: true, showViewAll: true }, + }, + }; +} describe("Ghost JWT generation", () => { it("generates a valid 3-part JWT", () => { @@ -50,3 +84,88 @@ describe("Ghost JWT generation", () => { ); }); }); + +describe("fetchPage", () => { + it("fetches page by pageId", async () => { + const page = { id: "abc123", updated_at: "2024-01-01", title: "Portfolio", lexical: "{}" }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ pages: [page] }), + }); + + const config = makeConfig({ pageId: "abc123", pageSlug: undefined }); + const result = await fetchPage(config); + + expect(result).toEqual(page); + expect(mockFetch).toHaveBeenCalledTimes(1); + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain("/pages/abc123/"); + }); + + it("fetches page by pageSlug when no pageId", async () => { + const page = { id: "xyz789", updated_at: "2024-01-01", title: "Portfolio", lexical: "{}" }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ pages: [page] }), + }); + + const config = makeConfig({ pageId: undefined, pageSlug: "portfolio" }); + const result = await fetchPage(config); + + expect(result).toEqual(page); + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain("/pages/slug/portfolio/"); + }); + + it("throws on Ghost API error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => "Not found", + }); + + const config = makeConfig(); + await expect(fetchPage(config)).rejects.toThrow("Ghost API error 404: Not found"); + }); +}); + +describe("updatePage", () => { + it("sends PUT with lexical body and returns updated page", async () => { + const updatedPage = { id: "abc123", updated_at: "2024-01-02", title: "Portfolio", lexical: "{}" }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ pages: [updatedPage] }), + }); + + const config = makeConfig(); + const lexical = { + root: { children: [], direction: "ltr", format: "", indent: 0, type: "root", version: 1 }, + }; + const result = await updatePage(config, "abc123", "2024-01-01", lexical); + + expect(result).toEqual(updatedPage); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [calledUrl, calledInit] = mockFetch.mock.calls[0]; + expect(calledUrl).toContain("/pages/abc123/"); + expect(calledInit.method).toBe("PUT"); + const body = JSON.parse(calledInit.body); + expect(body.pages[0].updated_at).toBe("2024-01-01"); + expect(body.pages[0].lexical).toBe(JSON.stringify(lexical)); + }); + + it("throws on Ghost update error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + text: async () => "Validation error", + }); + + const config = makeConfig(); + const lexical = { + root: { children: [], direction: "ltr", format: "", indent: 0, type: "root", version: 1 }, + }; + await expect(updatePage(config, "abc123", "2024-01-01", lexical)).rejects.toThrow( + "Ghost update error 422: Validation error", + ); + }); +}); diff --git a/src/github.test.ts b/src/github.test.ts index 77b91cd..e8f9f59 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -287,4 +287,207 @@ describe("detectBanner", () => { expect(result).toBeNull(); }); + + it("tries candidate paths when override and default fail", async () => { + // Override path fails, default fails, then a candidate succeeds + mockFetch + .mockResolvedValueOnce({ ok: false, status: 404, headers: new Headers() }) // override + .mockResolvedValueOnce({ ok: false, status: 404, headers: new Headers() }) // default + .mockResolvedValueOnce({ ok: false, status: 404, headers: new Headers() }) // skip default in candidates + .mockResolvedValueOnce({ ok: true, headers: new Headers() }); // docs/images/banner.png + + const { detectBanner } = await importModule(); + const config = makeConfig({ + portfolio: { + ...makeConfig().portfolio, + repos: { "test-repo": { bannerPath: "custom/missing.svg" } }, + }, + }); + const result = await detectBanner(makeRepo(), config); + + expect(result).not.toBeNull(); + }); + + it("returns null on persistent error (catch block)", async () => { + // Return a response where accessing .ok throws, triggering the catch in detectBanner + mockFetch.mockResolvedValue({ + get ok() { throw new Error("Unexpected network failure"); }, + status: 0, + headers: new Headers(), + }); + + const { detectBanner } = await importModule(); + const result = await detectBanner(makeRepo(), makeConfig()); + + expect(result).toBeNull(); + }); + + it("returns override URL when override path exists but bannerPaths does not", async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 404, headers: new Headers() }); // override fails + mockFetch.mockResolvedValueOnce({ ok: true, headers: new Headers() }); // default succeeds + + const { detectBanner } = await importModule(); + const config = makeConfig({ + portfolio: { + ...makeConfig().portfolio, + repos: { "test-repo": { bannerPath: "nonexistent.png" } }, + }, + }); + const result = await detectBanner(makeRepo(), config); + + expect(result).toContain("docs/images/banner.svg"); // falls back to default + }); +}); + +describe("fetchPortfolioConfig", () => { + async function importModule() { + const mod = await import("./github.js"); + return mod; + } + + it("returns parsed config from .ghost-portfolio.yml", async () => { + const yamlContent = ` +description: "Custom description" +personalNote: "A note" +dockerImage: "user/image" +keyFeatures: + - Feature 1 + - Feature 2 +techStack: "Python, Docker" +bannerPath: "assets/banner.svg" +badges: + - type: custom + label: MCP +`; + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => yamlContent, + }); + + const { fetchPortfolioConfig } = await importModule(); + const result = await fetchPortfolioConfig(makeRepo(), makeConfig()); + + expect(result).not.toBeNull(); + expect(result!.description).toBe("Custom description"); + expect(result!.personalNote).toBe("A note"); + expect(result!.dockerImage).toBe("user/image"); + expect(result!.keyFeatures).toEqual(["Feature 1", "Feature 2"]); + expect(result!.techStack).toBe("Python, Docker"); + expect(result!.bannerPath).toBe("assets/banner.svg"); + expect(result!.badges).toHaveLength(1); + }); + + it("returns null when file does not exist (404)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const { fetchPortfolioConfig } = await importModule(); + const result = await fetchPortfolioConfig(makeRepo(), makeConfig()); + + expect(result).toBeNull(); + }); + + it("returns null on fetch error", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const { fetchPortfolioConfig } = await importModule(); + const result = await fetchPortfolioConfig(makeRepo(), makeConfig()); + + expect(result).toBeNull(); + }); + + it("returns null when YAML parses to non-object", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => "just a string", + }); + + const { fetchPortfolioConfig } = await importModule(); + const result = await fetchPortfolioConfig(makeRepo(), makeConfig()); + + expect(result).toBeNull(); + }); + + it("returns null when YAML parses to null", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => "", + }); + + const { fetchPortfolioConfig } = await importModule(); + const result = await fetchPortfolioConfig(makeRepo(), makeConfig()); + + expect(result).toBeNull(); + }); + + it("handles partial config with missing fields", async () => { + const yamlContent = ` +description: "Only description" +`; + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => yamlContent, + }); + + const { fetchPortfolioConfig } = await importModule(); + const result = await fetchPortfolioConfig(makeRepo(), makeConfig()); + + expect(result).not.toBeNull(); + expect(result!.description).toBe("Only description"); + expect(result!.personalNote).toBeUndefined(); + expect(result!.dockerImage).toBeUndefined(); + expect(result!.keyFeatures).toBeUndefined(); + }); +}); + +describe("fetchRepos verbose mode", () => { + async function importModule() { + const mod = await import("./github.js"); + return mod; + } + + it("logs rate limit info when verbose", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const resetTime = Math.floor(Date.now() / 1000) + 3600; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [makeRepo({ stargazers_count: 10 })], + headers: new Headers({ + "x-ratelimit-limit": "60", + "x-ratelimit-remaining": "59", + "x-ratelimit-reset": String(resetTime), + }), + }); + + const { fetchRepos } = await importModule(); + await fetchRepos(makeConfig(), true); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("[rate-limit]"), + ); + consoleSpy.mockRestore(); + }); + + it("includes forked repos when includeForked is true", async () => { + const repos = [ + makeRepo({ name: "original", fork: false, stargazers_count: 10 }), + makeRepo({ name: "forked", fork: true, stargazers_count: 10 }), + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => repos, + headers: new Headers(), + }); + + const { fetchRepos } = await importModule(); + const config = makeConfig(); + config.portfolio.includeForked = true; + const result = await fetchRepos(config); + + expect(result).toHaveLength(2); + }); }); diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..03bc9b5 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { writeFileSync, unlinkSync, existsSync } from "node:fs"; + +// Mock all external dependencies before importing +vi.mock("./config.js", () => ({ + loadConfig: vi.fn(), + generateExampleConfig: vi.fn(), +})); + +vi.mock("./github.js", () => ({ + fetchRepos: vi.fn(), + detectBanner: vi.fn(), + fetchPortfolioConfig: vi.fn(), +})); + +vi.mock("./ghost.js", () => ({ + fetchPage: vi.fn(), + updatePage: vi.fn(), +})); + +vi.mock("./generator.js", () => ({ + generateCard: vi.fn(), + generateFooter: vi.fn(), + buildLexical: vi.fn(), +})); + +import type { Config, GitHubRepo } from "./types.js"; + +function makeConfig(): Config { + return { + github: { username: "testuser" }, + ghost: { + url: "https://ghost.example.com", + adminApiKey: "key:secret", + pageSlug: "portfolio", + }, + portfolio: { + minStars: 2, + maxRepos: 50, + excludeRepos: [], + includeForked: false, + excludeAwesomeLists: false, + badgeStyle: "for-the-badge", + showBanner: true, + centerContent: true, + defaultBannerPath: "docs/images/banner.svg", + bannerPaths: {}, + repos: {}, + footer: { showStats: true, showViewAll: true }, + }, + }; +} + +function makeRepo(overrides: Partial = {}): GitHubRepo { + return { + name: "test-repo", + full_name: "testuser/test-repo", + html_url: "https://github.com/testuser/test-repo", + description: "A test repo", + stargazers_count: 10, + forks_count: 2, + license: { spdx_id: "GPL-3.0" }, + fork: false, + homepage: null, + topics: [], + language: "TypeScript", + default_branch: "main", + ...overrides, + }; +} + +describe("CLI sync command", () => { + let loadConfig: ReturnType; + let fetchRepos: ReturnType; + let detectBanner: ReturnType; + let fetchPortfolioConfig: ReturnType; + let fetchPage: ReturnType; + let updatePage: ReturnType; + let generateCard: ReturnType; + let generateFooter: ReturnType; + let buildLexical: ReturnType; + + const tmpConfig = "/tmp/test-cli-config.yml"; + let consoleSpy: ReturnType; + let errorSpy: ReturnType; + let exitSpy: ReturnType; + let originalArgv: string[]; + + beforeEach(async () => { + vi.resetModules(); + + const configMod = await import("./config.js"); + const githubMod = await import("./github.js"); + const ghostMod = await import("./ghost.js"); + const generatorMod = await import("./generator.js"); + + loadConfig = configMod.loadConfig as ReturnType; + fetchRepos = githubMod.fetchRepos as ReturnType; + detectBanner = githubMod.detectBanner as ReturnType; + fetchPortfolioConfig = githubMod.fetchPortfolioConfig as ReturnType; + fetchPage = ghostMod.fetchPage as ReturnType; + updatePage = ghostMod.updatePage as ReturnType; + generateCard = generatorMod.generateCard as ReturnType; + generateFooter = generatorMod.generateFooter as ReturnType; + buildLexical = generatorMod.buildLexical as ReturnType; + + writeFileSync(tmpConfig, "dummy: true"); + consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as never); + originalArgv = process.argv; + }); + + afterEach(() => { + process.argv = originalArgv; + consoleSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + if (existsSync(tmpConfig)) unlinkSync(tmpConfig); + vi.restoreAllMocks(); + }); + + async function runCLI(args: string[]) { + process.argv = ["node", "index.js", ...args]; + vi.resetModules(); + // Re-import to trigger commander parse + await import("./index.js"); + // Allow any pending async work to complete + await new Promise((r) => setTimeout(r, 50)); + } + + it("runs sync with --dry-run and outputs preview", async () => { + const config = makeConfig(); + const repos = [makeRepo()]; + + loadConfig.mockReturnValue(config); + fetchRepos.mockResolvedValue(repos); + detectBanner.mockResolvedValue(null); + fetchPortfolioConfig.mockResolvedValue(null); + generateCard.mockReturnValue("

    test-repo

    "); + generateFooter.mockReturnValue("

    Footer

    "); + buildLexical.mockReturnValue({ root: { children: [] } }); + + await runCLI(["sync", "-c", tmpConfig, "--dry-run"]); + + expect(loadConfig).toHaveBeenCalledWith(tmpConfig); + expect(fetchRepos).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Found 1 repos")); + }); + + it("runs sync with --json and outputs JSON", async () => { + const config = makeConfig(); + const repos = [makeRepo()]; + const lexical = { root: { children: [], direction: "ltr", format: "", indent: 0, type: "root", version: 1 } }; + + loadConfig.mockReturnValue(config); + fetchRepos.mockResolvedValue(repos); + detectBanner.mockResolvedValue(null); + fetchPortfolioConfig.mockResolvedValue(null); + generateCard.mockReturnValue("

    test-repo

    "); + generateFooter.mockReturnValue(null); + buildLexical.mockReturnValue(lexical); + + await runCLI(["sync", "-c", tmpConfig, "--json"]); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(lexical, null, 2)); + }); + + it("runs sync and updates Ghost page", async () => { + const config = makeConfig(); + const repos = [makeRepo()]; + const page = { id: "page1", updated_at: "2024-01-01", title: "Portfolio" }; + const updatedPage = { ...page, updated_at: "2024-01-02" }; + const lexical = { root: { children: [] } }; + + loadConfig.mockReturnValue(config); + fetchRepos.mockResolvedValue(repos); + detectBanner.mockResolvedValue("https://example.com/banner.svg"); + fetchPortfolioConfig.mockResolvedValue(null); + generateCard.mockReturnValue("

    test-repo

    "); + generateFooter.mockReturnValue("

    Footer

    "); + buildLexical.mockReturnValue(lexical); + fetchPage.mockResolvedValue(page); + updatePage.mockResolvedValue(updatedPage); + + await runCLI(["sync", "-c", tmpConfig]); + + expect(fetchPage).toHaveBeenCalled(); + expect(updatePage).toHaveBeenCalledWith(config, "page1", "2024-01-01", lexical); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Portfolio updated")); + }); + + it("handles sync with zero repos found", async () => { + const config = makeConfig(); + + loadConfig.mockReturnValue(config); + fetchRepos.mockResolvedValue([]); + + await runCLI(["sync", "-c", tmpConfig]); + + expect(consoleSpy).toHaveBeenCalledWith("No repos found. Check your config."); + }); + + it("handles sync errors gracefully", async () => { + loadConfig.mockImplementation(() => { + throw new Error("Config file not found"); + }); + + await runCLI(["sync", "-c", "/nonexistent/path.yml"]); + + expect(errorSpy).toHaveBeenCalledWith("Error: Config file not found"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("runs sync with --verbose flag", async () => { + const config = makeConfig(); + const repos = [makeRepo()]; + + loadConfig.mockReturnValue(config); + fetchRepos.mockResolvedValue(repos); + detectBanner.mockResolvedValue(null); + fetchPortfolioConfig.mockResolvedValue(null); + generateCard.mockReturnValue("

    test-repo

    "); + generateFooter.mockReturnValue(null); + buildLexical.mockReturnValue({ root: { children: [] } }); + + await runCLI(["sync", "-c", tmpConfig, "--dry-run", "-v"]); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Fetching repos")); + }); + + it("merges per-repo portfolio config from .ghost-portfolio.yml", async () => { + const config = makeConfig(); + const repos = [makeRepo()]; + const portfolioOverride = { description: "From repo file" }; + + loadConfig.mockReturnValue(config); + fetchRepos.mockResolvedValue(repos); + detectBanner.mockResolvedValue(null); + fetchPortfolioConfig.mockResolvedValue(portfolioOverride); + generateCard.mockReturnValue("

    test-repo

    "); + generateFooter.mockReturnValue(null); + buildLexical.mockReturnValue({ root: { children: [] } }); + + await runCLI(["sync", "-c", tmpConfig, "--dry-run"]); + + // The config should have been mutated with the portfolio override + expect(config.portfolio.repos["test-repo"]).toBeDefined(); + expect(config.portfolio.repos["test-repo"].description).toBe("From repo file"); + }); + + it("filters awesome lists when excludeAwesomeLists is true", async () => { + const config = makeConfig(); + config.portfolio.excludeAwesomeLists = true; + const repos = [ + makeRepo({ name: "awesome-spain", stargazers_count: 10, topics: ["awesome-list"] }), + makeRepo({ name: "normal-repo", stargazers_count: 10 }), + ]; + + loadConfig.mockReturnValue(config); + fetchRepos.mockResolvedValue(repos); + detectBanner.mockResolvedValue(null); + fetchPortfolioConfig.mockResolvedValue(null); + generateCard.mockReturnValue("

    card

    "); + generateFooter.mockReturnValue(null); + buildLexical.mockReturnValue({ root: { children: [] } }); + + await runCLI(["sync", "-c", tmpConfig, "--dry-run"]); + + // generateCard should only be called for non-awesome repos + expect(generateCard).toHaveBeenCalledTimes(1); + }); +}); + +describe("CLI init command", () => { + let generateExampleConfig: ReturnType; + let consoleSpy: ReturnType; + let originalArgv: string[]; + const outputPath = "/tmp/test-init-config.yml"; + + beforeEach(async () => { + vi.resetModules(); + const configMod = await import("./config.js"); + generateExampleConfig = configMod.generateExampleConfig as ReturnType; + consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + originalArgv = process.argv; + }); + + afterEach(() => { + process.argv = originalArgv; + consoleSpy.mockRestore(); + if (existsSync(outputPath)) unlinkSync(outputPath); + vi.restoreAllMocks(); + }); + + it("generates example config to specified output", async () => { + generateExampleConfig.mockReturnValue("github:\n username: test\n"); + + process.argv = ["node", "index.js", "init", "-o", outputPath]; + vi.resetModules(); + await import("./index.js"); + await new Promise((r) => setTimeout(r, 50)); + + expect(existsSync(outputPath)).toBe(true); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Example config written")); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b10e399 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/types.ts"], + reporter: ["text", "json"], + }, + }, +});