diff --git a/.github/actions/configure-nodejs/action.yml b/.github/actions/configure-nodejs/action.yml new file mode 100644 index 0000000..cec04d0 --- /dev/null +++ b/.github/actions/configure-nodejs/action.yml @@ -0,0 +1,33 @@ +name: 'Configure Node.js' +description: 'Install Node.js and install Node.js modules or restore cache' + +inputs: + node-version: + description: 'NodeJS Version' + default: '18' + lookup-only: + description: 'If true, only checks if cache entry exists and skips download. Does not change save cache behavior' + default: 'false' + +runs: + using: 'composite' + steps: + - uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + + - name: Restore Node Modules from Cache + id: cache-node-modules + uses: actions/cache@v4 + with: + path: | + node_modules + packages/**/node_modules + !node_modules/.cache + key: node-modules-${{ inputs.node-version }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package.json', 'package-lock.json', '**/package-lock.json') }} + lookup-only: ${{ inputs.lookup-only }} + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + shell: bash + run: npm ci diff --git a/.github/actions/coverage-report/action.yml b/.github/actions/coverage-report/action.yml new file mode 100644 index 0000000..a438986 --- /dev/null +++ b/.github/actions/coverage-report/action.yml @@ -0,0 +1,41 @@ +name: 'Parse Coverage and Post Comment' +description: 'Parses a coverage report and posts a comment on a PR' +inputs: + lcov-file: + description: 'Path to the lcov.info file' + required: true + title: + description: 'Title of the comment' + default: 'Code Coverage Report' + +runs: + using: 'composite' + steps: + - name: Parse Coverage + shell: bash + if: github.event_name == 'pull_request' + id: parse + run: | + ./scripts/parse-coverage.js ${{ inputs.lcov-file }} > coverage-summary.txt + echo "coverage-summary<> $GITHUB_OUTPUT + cat coverage-summary.txt >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Find Coverage Comment + if: github.event_name == 'pull_request' + uses: peter-evans/find-comment@v3 + id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: '### 📊 ${{ inputs.title }}' + + - name: Post Coverage Comment + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + edit-mode: replace + issue-number: ${{ github.event.pull_request.number }} + body: | + ### 📊 ${{ inputs.title }} + ${{ steps.parse.outputs.coverage-summary }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de69d38..172280f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,19 +9,33 @@ on: - main jobs: - build: + check-access: runs-on: ubuntu-latest + outputs: + has-token-access: ${{ steps.check.outputs.has-token-access }} steps: - - name: Checkout code - uses: actions/checkout@v3 + - id: check + run: | + echo "has-token-access=$(if [[ '${{ github.event.pull_request.head.repo.fork }}' != 'true' && '${{ github.actor }}' != 'dependabot[bot]' ]]; then echo 'true'; else echo 'false'; fi)" >> $GITHUB_OUTPUT - - name: Use Node.js 16 - uses: actions/setup-node@v3 + install-deps: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/configure-nodejs with: - node-version: 16 + lookup-only: 'true' # We only want to lookup from the cache - if a hit, this job does nothing - - name: Install Modules - run: npm ci + build: + needs: + - install-deps + - check-access + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - uses: ./.github/actions/configure-nodejs - name: Build run: npm run build @@ -31,3 +45,10 @@ jobs: - name: Test run: npm run test + + - name: Upload code coverage + if: github.event_name == 'pull_request' && needs.check-access.outputs.has-token-access == 'true' + uses: ./.github/actions/coverage-report + with: + lcov-file: coverage/lcov.info + title: Node.js Code Coverage Report diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 17158d6..758766c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,19 +7,23 @@ on: workflow_dispatch: jobs: + install-deps: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/configure-nodejs + with: + lookup-only: 'true' # We only want to lookup from the cache - if a hit, this job does nothing + build: + needs: + - install-deps runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 - - - name: Use Node.js 16 - uses: actions/setup-node@v3 - with: - node-version: 16 + uses: actions/checkout@v4 - - name: Install Modules - run: npm ci + - uses: ./.github/actions/configure-nodejs - name: Build Docs run: npm run build:docs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 69afd0c..051e5b4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,27 +5,31 @@ on: types: [published] jobs: + install-deps: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/configure-nodejs + with: + lookup-only: 'true' # We only want to lookup from the cache - if a hit, this job does nothing + build: + needs: + - install-deps runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Use Node.js 16 - uses: actions/setup-node@v3 - with: - node-version: 16 + - uses: ./.github/actions/configure-nodejs - name: Use the Release Tag Version run: | npm version from-git --allow-same-version --no-git-tag-version - - name: Install Modules - run: npm ci - - name: Build run: npm run build diff --git a/jest.config.js b/jest.config.js index a18a13c..706d849 100644 --- a/jest.config.js +++ b/jest.config.js @@ -42,7 +42,7 @@ module.exports = { // "lcov", // "clover" // ], - coverageReporters: ['html', 'text'], + coverageReporters: ['lcov', 'html', 'text'], // An object that configures minimum threshold enforcement for coverage results // coverageThreshold: undefined, @@ -130,9 +130,7 @@ module.exports = { // roots: [ // "" // ], - roots: [ - 'src/', - ], + roots: ['src/'], // Allows you to use a custom runner instead of Jest's default test runner // runner: "jest-runner", diff --git a/package-lock.json b/package-lock.json index 45015cb..7d6f019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "eslint-config-prettier": "8.8.0", "eslint-plugin-prettier": "4.2.1", "jest": "29.5.0", + "lcov-parse": "1.0.0", "prettier": "2.8.8", "ts-jest": "29.1.0", "ts-node": "10.9.1", @@ -4887,6 +4888,15 @@ "node": ">=6" } }, + "node_modules/lcov-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz", + "integrity": "sha512-aprLII/vPzuQvYZnDRU78Fns9I2Ag3gi4Ipga/hxnVMCZC8DnR2nI7XBqrPoywGfxqIx/DgarGvDJZAD3YBTgQ==", + "dev": true, + "bin": { + "lcov-parse": "bin/cli.js" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -10163,6 +10173,12 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, + "lcov-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz", + "integrity": "sha512-aprLII/vPzuQvYZnDRU78Fns9I2Ag3gi4Ipga/hxnVMCZC8DnR2nI7XBqrPoywGfxqIx/DgarGvDJZAD3YBTgQ==", + "dev": true + }, "leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/package.json b/package.json index 5a873f7..87b2a6c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "eslint-config-prettier": "8.8.0", "eslint-plugin-prettier": "4.2.1", "jest": "29.5.0", + "lcov-parse": "1.0.0", "prettier": "2.8.8", "ts-jest": "29.1.0", "ts-node": "10.9.1", diff --git a/scripts/parse-coverage.js b/scripts/parse-coverage.js new file mode 100755 index 0000000..3a47423 --- /dev/null +++ b/scripts/parse-coverage.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node +const lcovParse = require("lcov-parse"); +const fs = require("fs"); + +const lcovPath = process.argv[2]; +const needsImprovementBelow = parseFloat( + process.argv.length >= 4 ? process.argv[3] : "90" +); +const poorBelow = parseFloat(process.argv[4] >= 5 ? process.argv[4] : "50"); + +if (!lcovPath || isNaN(needsImprovementBelow) || isNaN(poorBelow)) { + console.error( + "Please provide the path to the lcov.info file and the 'needs-improvement-below' and 'poor-below' percentages as command-line arguments." + ); + process.exit(1); +} + +if (!fs.existsSync(lcovPath)) { + console.error( + `The file ${lcovPath} does not exist. Please provide the path to the lcov.info file.` + ); + process.exit(1); +} + +const outputFormat = "markdown"; + +if (outputFormat === "markdown") { + console.log( + "| File | Lines | Lines Hit / Found | Uncovered Lines | Branches |" + ); + console.log("| --- | --- | --- | --- | --- |"); +} + +function shortenPath(path, maxLength) { + if (path.length <= maxLength) { + return path; + } + + const start = path.substring(0, maxLength / 2 - 2); // -2 for the '..' in the middle + const end = path.substring(path.length - maxLength / 2, path.length); + + return `${start}..${end}`; +} + +function getEmoji(lineCoverage, needsImprovementBelow, poorBelow) { + if (lineCoverage >= needsImprovementBelow) { + return "✅"; // white-check emoji + } else if ( + lineCoverage < needsImprovementBelow && + lineCoverage >= poorBelow + ) { + return "🟡"; // yellow-ball emoji + } else { + return "❌"; // red-x emoji + } +} + +lcovParse(lcovPath, function (err, data) { + if (err) { + console.error(err); + } else { + let totalLinesHit = 0; + let totalLinesFound = 0; + let totalBranchesHit = 0; + let totalBranchesFound = 0; + + data.forEach((file) => { + totalLinesHit += file.lines.hit; + totalLinesFound += file.lines.found; + totalBranchesHit += file.branches.hit; + totalBranchesFound += file.branches.found; + const relativePath = shortenPath( + file.file.replace(process.cwd(), ""), + 50 + ); + const lineCoverage = ((file.lines.hit / file.lines.found) * 100).toFixed( + 1 + ); + const branchCoverage = ( + (file.branches.hit / file.branches.found) * + 100 + ).toFixed(1); + let emoji = getEmoji(lineCoverage, needsImprovementBelow, poorBelow); + if (outputFormat === "markdown") { + console.log( + `| ${relativePath} | ${emoji} ${lineCoverage}% | ${ + file.lines.hit + } / ${file.lines.found} | ${ + file.lines.found - file.lines.hit + } | ${file.branches.found === 0 ? "-" : `${branchCoverage}%`} |` + ); + } else { + console.log( + `${emoji} File: ${relativePath}, Line Coverage: ${lineCoverage}%, Branch Coverage: ${branchCoverage}%` + ); + } + }); + + const overallLineCoverage = ( + (totalLinesHit / totalLinesFound) * + 100 + ).toFixed(1); + const overallBranchCoverage = ( + (totalBranchesHit / totalBranchesFound) * + 100 + ).toFixed(1); + const totalUncoveredLines = totalLinesFound - totalLinesHit; + const overallEmoji = getEmoji( + overallLineCoverage, + needsImprovementBelow, + poorBelow + ); + + if (outputFormat === "markdown") { + console.log( + `| Overall | ${overallEmoji} ${overallLineCoverage}% | ${totalLinesHit} / ${totalLinesFound} | ${totalUncoveredLines} | ${ + totalBranchesFound === 0 ? "-" : `${overallBranchCoverage}%` + } |` + ); + } else { + console.log( + `Overall Line Coverage: ${overallLineCoverage}%, Overall Branch Coverage: ${overallBranchCoverage}%` + ); + } + } +});