├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── codeql.yml │ ├── scorecards.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── __tests__ ├── DiffChecker.test.ts └── main.test.ts ├── action.yml ├── dist └── index.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── DiffChecker.ts ├── Model │ ├── CoverageData.ts │ ├── CoverageReport.ts │ ├── DiffCoverageData.ts │ ├── DiffCoverageReport.ts │ ├── DiffFileCoverageData.ts │ └── FileCoverageData.ts └── main.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": ["plugin:github/es6"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | "eslint-comments/no-use": "off", 12 | "import/no-namespace": "off", 13 | "no-unused-vars": "off", 14 | "@typescript-eslint/no-unused-vars": "error", 15 | "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}], 16 | "@typescript-eslint/no-require-imports": "error", 17 | "@typescript-eslint/array-type": "error", 18 | "@typescript-eslint/await-thenable": "error", 19 | "@typescript-eslint/ban-ts-ignore": "error", 20 | "camelcase": "off", 21 | "@typescript-eslint/camelcase": "error", 22 | "@typescript-eslint/class-name-casing": "error", 23 | "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}], 24 | "@typescript-eslint/func-call-spacing": ["error", "never"], 25 | "@typescript-eslint/generic-type-naming": ["error", "^[A-Z][A-Za-z]*$"], 26 | "@typescript-eslint/no-array-constructor": "error", 27 | "@typescript-eslint/no-empty-interface": "error", 28 | "@typescript-eslint/no-explicit-any": "error", 29 | "@typescript-eslint/no-extraneous-class": "error", 30 | "@typescript-eslint/no-for-in-array": "error", 31 | "@typescript-eslint/no-inferrable-types": "error", 32 | "@typescript-eslint/no-misused-new": "error", 33 | "@typescript-eslint/no-namespace": "error", 34 | "@typescript-eslint/no-non-null-assertion": "warn", 35 | "@typescript-eslint/no-object-literal-type-assertion": "error", 36 | "@typescript-eslint/no-unnecessary-qualifier": "error", 37 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 38 | "@typescript-eslint/no-useless-constructor": "error", 39 | "@typescript-eslint/no-var-requires": "error", 40 | "@typescript-eslint/prefer-for-of": "warn", 41 | "@typescript-eslint/prefer-function-type": "warn", 42 | "@typescript-eslint/prefer-includes": "error", 43 | "@typescript-eslint/prefer-interface": "error", 44 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 45 | "@typescript-eslint/promise-function-async": "error", 46 | "@typescript-eslint/require-array-sort-compare": "error", 47 | "@typescript-eslint/restrict-plus-operands": "error", 48 | "semi": "off", 49 | "@typescript-eslint/semi": ["error", "never"], 50 | "@typescript-eslint/type-annotation-spacing": "error", 51 | "@typescript-eslint/unbound-method": "error" 52 | }, 53 | "env": { 54 | "node": true, 55 | "es6": true, 56 | "jest/globals": true 57 | } 58 | } -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '19 20 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v3 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v2 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v2 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v2 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /.github/workflows/scorecards.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecards supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: '30 4 * * *' 14 | push: 15 | branches: [ "master" ] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecards analysis 23 | runs-on: ubuntu-latest 24 | permissions: 25 | # Needed to upload the results to code-scanning dashboard. 26 | security-events: write 27 | # Needed to publish results and get a badge (see publish_results below). 28 | id-token: write 29 | # Uncomment the permissions below if installing in a private repository. 30 | # contents: read 31 | # actions: read 32 | 33 | steps: 34 | - name: "Checkout code" 35 | uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0 36 | with: 37 | persist-credentials: false 38 | 39 | - name: "Run analysis" 40 | uses: ossf/scorecard-action@99c53751e09b9529366343771cc321ec74e9bd3d # v2.0.6 41 | with: 42 | results_file: results.sarif 43 | results_format: sarif 44 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 45 | # - you want to enable the Branch-Protection check on a *public* repository, or 46 | # - you are installing Scorecards on a *private* repository 47 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 48 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 49 | 50 | # Public repositories: 51 | # - Publish results to OpenSSF REST API for easy access by consumers 52 | # - Allows the repository to include the Scorecard badge. 53 | # - See https://github.com/ossf/scorecard-action#publishing-results. 54 | # For private repositories: 55 | # - `publish_results` will always be set to `false`, regardless 56 | # of the value entered here. 57 | publish_results: true 58 | 59 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 60 | # format to the repository Actions tab. 61 | - name: "Upload artifact" 62 | uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # v3.1.0 63 | with: 64 | name: SARIF file 65 | path: results.sarif 66 | retention-days: 5 67 | 68 | # Upload the results to GitHub's code scanning dashboard. 69 | - name: "Upload to code-scanning" 70 | id: csupload 71 | uses: github/codeql-action/upload-sarif@807578363a7869ca324a79039e6db9c843e0e100 # v2.1.27 72 | with: 73 | sarif_file: results.sarif 74 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "build-test" 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | - 'releases/*' 8 | 9 | jobs: 10 | build: # make sure build/ci work properly 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v1 14 | - run: | 15 | npm install 16 | npm run all -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | lib/**/* 100 | 101 | # Ignore editor config 102 | /.idea -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid", 10 | "parser": "typescript" 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 GitHub, Inc. and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jest coverage diff 2 | 3 | Use the action to get jest coverage diff for pull requests as a comment on the pull request 4 | Helps the code reviewer to get the high level view of code coverage changes without leaving the pull request window 5 | 6 | example: 7 | 8 | Code coverage comparison master vs testBranch: 9 | 10 | File | % Stmts | % Branch | % Funcs | % Lines 11 | -----|---------|----------|---------|------ 12 | total | ~~99.55~~ **97.73** | ~~100~~ **96.97** | ~~97.96~~ **95.92** | ~~99.54~~ **97.7** 13 | src/Error/TestError.ts | ~~100~~ **77.78** | ~~100~~ **100** | ~~100~~ **66.67** | ~~100~~ **77.78** 14 | src/Utility/Utility.ts | ~~96.67~~ **90** | ~~100~~ **75** | ~~88.89~~ **88.89** | ~~96.67~~ **90** 15 | 16 | # How It Works 17 | 18 | uses the following jest command to get code coverage summary as json for the pull request. 19 | ```bash 20 | npx jest --coverage --coverageReporters="json-summary" 21 | ``` 22 | 23 | Then switches branch to the base branch on which the pull request has been raised and runs the same command again. 24 | Calculates the diff between the two reports to figure out additions, removals, increase or decrease in code coverage. 25 | And then posts that diff as a comment on the PR 26 | 27 | NOTE : The action will work perfectly only for pull requests. Have not been tested with other events or on schedule workflows 28 | 29 | # Configuration 30 | 31 | The action assumes jest configuration and jest module already present in the workflow and uses the installed module and the already present config to run the tests. 32 | 33 | **NEW:** 34 | 35 | - The action now supports runnning a command just after switching to base branch, this is extremely helpful in cases where there might be some packages removed in the pull request raised, in such cases there now is a possibility to re run the npm ci/npm install commands before running the jset coverage on base branch. Use `afterSwitchCommand` variable to pass a custom command to be run after switching to base branch. 36 | - The action now supports custom run command, for custom use cases, using the variable runCommand, you can now pass your own command to run. Following is an example where we want to collect coverage from only few files out of all the code and want to use custom options such as `forceExit` & `detectOpenHandles`. 37 | ```bash 38 | runCommand: "npx jest --collectCoverageFrom='[\"src/**/*.{js,jsx,ts,tsx}\"]' --coverage --collectCoverage=true --coverageDirectory='./' --coverageReporters='json-summary' --forceExit --detectOpenHandles test/.*test.*" 39 | ``` 40 | **NOTE:** If using custom command, `--coverage --collectCoverage=true --coverageDirectory='./' --coverageReporters='json-summary'`, these options are necessary for the action to work properly. These options tells jest to collect the coverage in json summary format and put the final output in the root folder. Since these are necessary, will make the action add them automatically in the next version. 41 | 42 | - Do you want to fail the workflow if the commited code decreases the percentage below a tolerable level? Do you to start a healthy culture of writing test cases? 43 | The action now also supports failing the run if percentage diff is more than a specified delta value for any file, you can specify the delta value using the variable delta 44 | ```bash 45 | delta: 1 // the action will fail if any of the percentage dip is more than 1% for any changed file 46 | ``` 47 | 48 | Sample workflow for running this action 49 | 50 | ``` 51 | name: Node.js CI 52 | 53 | on: pull_request 54 | 55 | jobs: 56 | build: 57 | strategy: 58 | matrix: 59 | node-version: [14.x] 60 | platform: [ubuntu-latest] 61 | runs-on: ${{ matrix.platform }} 62 | steps: 63 | - uses: actions/checkout@v2 64 | - name: Use Node.js ${{ matrix.node-version }} 65 | uses: actions/setup-node@v1 66 | with: 67 | node-version: ${{ matrix.node-version }} 68 | - run: npm ci 69 | - name: TestCoverage 70 | id: testCoverage 71 | uses: anuraag016/Jest-Coverage-Diff@master 72 | with: 73 | fullCoverageDiff: false // defaults to false, if made true whole coverage report is commented with the diff 74 | runCommand: "npx jest --collectCoverageFrom='[\"src/**/*.{js,jsx,ts,tsx}\"]' --coverage --collectCoverage=true --coverageDirectory='./' --coverageReporters='json-summary' --forceExit --detectOpenHandles test/.*test.*" 75 | delta: 0.5 76 | ``` 77 | -------------------------------------------------------------------------------- /__tests__/DiffChecker.test.ts: -------------------------------------------------------------------------------- 1 | import { DiffChecker } from '../src/DiffChecker' 2 | 3 | describe('DiffChecker', () => { 4 | const mock100Coverage = { 5 | total: 100, 6 | covered: 100, 7 | skipped: 0, 8 | pct: 100 9 | } 10 | const mock99Coverage = { 11 | total: 100, 12 | covered: 99, 13 | skipped: 1, 14 | pct: 99 15 | } 16 | const mock98Coverage = { 17 | total: 100, 18 | covered: 98, 19 | skipped: 1, 20 | pct: 98 21 | } 22 | const mockEmptyCoverage = { 23 | total: 100, 24 | covered: 0, 25 | skipped: 100, 26 | pct: 0 27 | } 28 | const mock99CoverageFile = { 29 | statements: mock99Coverage, 30 | branches: mock99Coverage, 31 | functions: mock99Coverage, 32 | lines: mock99Coverage 33 | } 34 | const mock100CoverageFile = { 35 | statements: mock100Coverage, 36 | branches: mock100Coverage, 37 | functions: mock100Coverage, 38 | lines: mock100Coverage 39 | } 40 | const mock98CoverageFile = { 41 | statements: mock98Coverage, 42 | branches: mock98Coverage, 43 | functions: mock98Coverage, 44 | lines: mock98Coverage 45 | } 46 | const mockEmptyCoverageFile = { 47 | statements: mockEmptyCoverage, 48 | branches: mockEmptyCoverage, 49 | functions: mockEmptyCoverage, 50 | lines: mockEmptyCoverage 51 | } 52 | it('generates the correct diff', () => { 53 | const codeCoverageOld = { 54 | file1: mock99CoverageFile, 55 | file2: mock100CoverageFile, 56 | file4: mock100CoverageFile, 57 | file5: mock99CoverageFile 58 | } 59 | const codeCoverageNew = { 60 | file1: mock100CoverageFile, 61 | file2: mock99CoverageFile, 62 | file3: mock100CoverageFile, 63 | file5: { 64 | statements: mock99Coverage, 65 | branches: mockEmptyCoverage, 66 | functions: mock99Coverage, 67 | lines: mock99Coverage 68 | } 69 | } 70 | const diffChecker = new DiffChecker(codeCoverageNew, codeCoverageOld) 71 | const details = diffChecker.getCoverageDetails(false, '') 72 | expect(details).toStrictEqual([ 73 | ' :green_circle: | file1 | 100 **(1)** | 100 **(1)** | 100 **(1)** | 100 **(1)**', 74 | ' :red_circle: | file2 | 99 **(-1)** | 99 **(-1)** | 99 **(-1)** | 99 **(-1)**', 75 | ' :sparkles: :new: | **file3** | **100** | **100** | **100** | **100**', 76 | ' :red_circle: | file5 | 99 **(0)** | 0 **(-99)** | 99 **(0)** | 99 **(0)**', 77 | ' :x: | ~~file4~~ | ~~100~~ | ~~100~~ | ~~100~~ | ~~100~~' 78 | ]) 79 | }) 80 | describe("testing checkIfTestCoverageFallsBelowDelta", () => { 81 | describe("respects total_delta for total and delta for other files", () => { 82 | it("returns true because delta diff is too high, even if total_delta is okay", () => { 83 | const codeCoverageOld = { 84 | total: mock100CoverageFile, 85 | file1: mock100CoverageFile 86 | } 87 | const codeCoverageNew = { 88 | total: mock98CoverageFile, 89 | file1: mock98CoverageFile 90 | } 91 | const diffChecker = new DiffChecker(codeCoverageNew, codeCoverageOld) 92 | const isTestCoverageFallsBelowDelta = diffChecker.checkIfTestCoverageFallsBelowDelta(1, 50) 93 | expect(isTestCoverageFallsBelowDelta).toBeTruthy(); 94 | }) 95 | it("returns true because total_delta diff is too high, even if delta is okay", () => { 96 | const codeCoverageOld = { 97 | total: mock100CoverageFile, 98 | file1: mock100CoverageFile 99 | } 100 | const codeCoverageNew = { 101 | total: mock98CoverageFile, 102 | file1: mock98CoverageFile 103 | } 104 | const diffChecker = new DiffChecker(codeCoverageNew, codeCoverageOld) 105 | const isTestCoverageFallsBelowDelta = diffChecker.checkIfTestCoverageFallsBelowDelta(50, 1) 106 | expect(isTestCoverageFallsBelowDelta).toBeTruthy(); 107 | }) 108 | it("returns true if delta diff is too high - total_delta is not defined", () => { 109 | const codeCoverageOld = { 110 | total: mock100CoverageFile, 111 | file1: mock100CoverageFile 112 | } 113 | const codeCoverageNew = { 114 | total: mock98CoverageFile, 115 | file1: mock98CoverageFile 116 | } 117 | const diffChecker = new DiffChecker(codeCoverageNew, codeCoverageOld) 118 | const isTestCoverageFallsBelowDelta = diffChecker.checkIfTestCoverageFallsBelowDelta(1, null) 119 | expect(isTestCoverageFallsBelowDelta).toBeTruthy(); 120 | }) 121 | it("returns false if total_delta and delta are okay", () => { 122 | const codeCoverageOld = { 123 | total: mock100CoverageFile, 124 | file1: mock100CoverageFile 125 | } 126 | const codeCoverageNew = { 127 | total: mock98CoverageFile, 128 | file1: mock98CoverageFile 129 | } 130 | const diffChecker = new DiffChecker(codeCoverageNew, codeCoverageOld) 131 | const isTestCoverageFallsBelowDelta = diffChecker.checkIfTestCoverageFallsBelowDelta(50, 50) 132 | expect(isTestCoverageFallsBelowDelta).toBeFalsy(); 133 | }) 134 | it("returns false if delta is okay - total_delta is not defined", () => { 135 | const codeCoverageOld = { 136 | total: mock100CoverageFile, 137 | file1: mock100CoverageFile 138 | } 139 | const codeCoverageNew = { 140 | total: mock98CoverageFile, 141 | file1: mock98CoverageFile 142 | } 143 | const diffChecker = new DiffChecker(codeCoverageNew, codeCoverageOld) 144 | const isTestCoverageFallsBelowDelta = diffChecker.checkIfTestCoverageFallsBelowDelta(50, null) 145 | expect(isTestCoverageFallsBelowDelta).toBeFalsy(); 146 | }) 147 | }) 148 | it("detects that total coverage dropped below total_delta", () => { 149 | const codeCoverageOld = { 150 | total: mock100CoverageFile, 151 | } 152 | const codeCoverageNew = { 153 | total: mock98CoverageFile, 154 | } 155 | const diffChecker = new DiffChecker(codeCoverageNew, codeCoverageOld) 156 | const isTestCoverageFallsBelowDelta = diffChecker.checkIfTestCoverageFallsBelowDelta(2, 1) 157 | expect(isTestCoverageFallsBelowDelta).toBeTruthy(); 158 | }) 159 | it("detects that total coverage did not drop below total_delta", () => { 160 | const codeCoverageOld = { 161 | total: mock100CoverageFile, 162 | } 163 | const codeCoverageNew = { 164 | total: mock98CoverageFile, 165 | } 166 | const diffChecker = new DiffChecker(codeCoverageNew, codeCoverageOld) 167 | const isTestCoverageFallsBelowDelta = diffChecker.checkIfTestCoverageFallsBelowDelta(1, 5) 168 | expect(isTestCoverageFallsBelowDelta).toBeFalsy(); 169 | }) 170 | it("detects that total coverage dropped below delta", () => { 171 | const codeCoverageOld = { 172 | total: mock100CoverageFile, 173 | } 174 | const codeCoverageNew = { 175 | total: mock98CoverageFile, 176 | } 177 | const diffChecker = new DiffChecker(codeCoverageNew, codeCoverageOld) 178 | const isTestCoverageFallsBelowDelta = diffChecker.checkIfTestCoverageFallsBelowDelta(1, null) 179 | expect(isTestCoverageFallsBelowDelta).toBeTruthy(); 180 | }) 181 | it("detects that total coverage did not drop below delta", () => { 182 | const codeCoverageOld = { 183 | total: mock100CoverageFile, 184 | } 185 | const codeCoverageNew = { 186 | total: mock98CoverageFile, 187 | } 188 | const diffChecker = new DiffChecker(codeCoverageNew, codeCoverageOld) 189 | const isTestCoverageFallsBelowDelta = diffChecker.checkIfTestCoverageFallsBelowDelta(2, null) 190 | expect(isTestCoverageFallsBelowDelta).toBeFalsy(); 191 | }) 192 | 193 | }) 194 | }) 195 | -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | describe('Basic Test Suite', () => { 2 | it('', () => {}) 3 | }) 4 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Jest Coverage Diff' 2 | description: 'A github action to comment jest coverage diff on a PR' 3 | author: 'Anuraag Puri' 4 | inputs: 5 | accessToken: 6 | description: 'access token required to comment on a pr' 7 | default: ${{ github.token }} 8 | fullCoverageDiff: 9 | description: 'get the full coverage with diff or only the diff' 10 | default: false 11 | runCommand: 12 | description: 'custom command to get json-summary' 13 | default: 'npx jest --coverage --coverageReporters="json-summary" --coverageDirectory="./"' 14 | afterSwitchCommand: 15 | description: 'command to run after switching to default branch' 16 | default: null 17 | delta: 18 | description: 'Difference between the old and final test coverage' 19 | default: 100 20 | total_delta: 21 | description: 'Difference between the old and final test coverage at the total level' 22 | default: null 23 | useSameComment: 24 | description: 'While commenting on the PR update the exisiting comment' 25 | default: false 26 | branding: 27 | color: red 28 | icon: git-pull-request 29 | runs: 30 | using: 'node12' 31 | main: 'dist/index.js' 32 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest' 9 | }, 10 | verbose: true 11 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jest-coverage-diff", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "A github action to comment jest coverage diff on a PR", 6 | "main": "src/main.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "format": "prettier --write **/*.ts", 10 | "format-check": "prettier --check **/*.ts", 11 | "lint": "eslint src/**/*.ts", 12 | "pack": "ncc build", 13 | "test": "jest", 14 | "all": "npm run build && npm run format && npm run lint && npm run pack && npm test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/anuraag016/Jest-Coverage-Diff" 19 | }, 20 | "keywords": [ 21 | "actions", 22 | "node", 23 | "jest", 24 | "coverage" 25 | ], 26 | "author": "Anuraag Puri", 27 | "license": "MIT", 28 | "dependencies": { 29 | "@actions/core": "^1.2.4", 30 | "@actions/github": "^4.0.0" 31 | }, 32 | "devDependencies": { 33 | "@types/jest": "^24.9.1", 34 | "@types/node": "^12.12.50", 35 | "@typescript-eslint/parser": "^2.34.0", 36 | "@zeit/ncc": "^0.20.5", 37 | "eslint": "^5.16.0", 38 | "eslint-plugin-github": "^2.0.0", 39 | "eslint-plugin-jest": "^22.21.0", 40 | "jest": "^24.9.0", 41 | "jest-circus": "^24.9.0", 42 | "js-yaml": "^3.14.0", 43 | "prettier": "^1.19.1", 44 | "ts-jest": "^24.3.0", 45 | "typescript": "^3.9.6" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/DiffChecker.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import {CoverageReport} from './Model/CoverageReport' 3 | import {DiffCoverageReport} from './Model/DiffCoverageReport' 4 | import {CoverageData} from './Model/CoverageData' 5 | import {DiffFileCoverageData} from './Model/DiffFileCoverageData' 6 | import {DiffCoverageData} from './Model/DiffCoverageData' 7 | 8 | const increasedCoverageIcon = ':green_circle:' 9 | const decreasedCoverageIcon = ':red_circle:' 10 | const newCoverageIcon = ':sparkles: :new:' 11 | const removedCoverageIcon = ':x:' 12 | 13 | export class DiffChecker { 14 | private diffCoverageReport: DiffCoverageReport = {} 15 | constructor( 16 | coverageReportNew: CoverageReport, 17 | coverageReportOld: CoverageReport 18 | ) { 19 | const reportNewKeys = Object.keys(coverageReportNew) 20 | const reportOldKeys = Object.keys(coverageReportOld) 21 | const reportKeys = new Set([...reportNewKeys, ...reportOldKeys]) 22 | 23 | for (const filePath of reportKeys) { 24 | this.diffCoverageReport[filePath] = { 25 | branches: { 26 | newPct: this.getPercentage(coverageReportNew[filePath]?.branches), 27 | oldPct: this.getPercentage(coverageReportOld[filePath]?.branches) 28 | }, 29 | statements: { 30 | newPct: this.getPercentage(coverageReportNew[filePath]?.statements), 31 | oldPct: this.getPercentage(coverageReportOld[filePath]?.statements) 32 | }, 33 | lines: { 34 | newPct: this.getPercentage(coverageReportNew[filePath]?.lines), 35 | oldPct: this.getPercentage(coverageReportOld[filePath]?.lines) 36 | }, 37 | functions: { 38 | newPct: this.getPercentage(coverageReportNew[filePath]?.functions), 39 | oldPct: this.getPercentage(coverageReportOld[filePath]?.functions) 40 | } 41 | } 42 | } 43 | } 44 | 45 | getCoverageDetails(diffOnly: boolean, currentDirectory: string): string[] { 46 | const keys = Object.keys(this.diffCoverageReport) 47 | const returnStrings: string[] = [] 48 | for (const key of keys) { 49 | if (this.compareCoverageValues(this.diffCoverageReport[key]) !== 0) { 50 | returnStrings.push( 51 | this.createDiffLine( 52 | key.replace(currentDirectory, ''), 53 | this.diffCoverageReport[key] 54 | ) 55 | ) 56 | } else { 57 | if (!diffOnly) { 58 | returnStrings.push( 59 | `${key.replace(currentDirectory, '')} | ${ 60 | this.diffCoverageReport[key].statements.newPct 61 | } | ${this.diffCoverageReport[key].branches.newPct} | ${ 62 | this.diffCoverageReport[key].functions.newPct 63 | } | ${this.diffCoverageReport[key].lines.newPct}` 64 | ) 65 | } 66 | } 67 | } 68 | return returnStrings 69 | } 70 | 71 | checkIfTestCoverageFallsBelowDelta( 72 | delta: number, 73 | totalDelta: number | null 74 | ): boolean { 75 | const files = Object.keys(this.diffCoverageReport) 76 | for (const file of files) { 77 | const diffCoverageData = this.diffCoverageReport[file] 78 | const keys: ('lines' | 'statements' | 'branches' | 'functions')[] = < 79 | ('lines' | 'statements' | 'branches' | 'functions')[] 80 | >Object.keys(diffCoverageData) 81 | // No new coverage found so that means we deleted a file coverage 82 | const fileRemovedCoverage = Object.values(diffCoverageData).every( 83 | coverageData => coverageData.newPct === 0 84 | ) 85 | if (fileRemovedCoverage) { 86 | core.info( 87 | `${file} : deleted or renamed and is not considered for coverage diff.` 88 | ) 89 | // since the file is deleted don't include in delta calculation 90 | continue 91 | } 92 | for (const key of keys) { 93 | if (diffCoverageData[key].oldPct !== diffCoverageData[key].newPct) { 94 | const deltaToCompareWith = 95 | file === 'total' && totalDelta !== null ? totalDelta : delta 96 | if ( 97 | -this.getPercentageDiff(diffCoverageData[key]) > deltaToCompareWith 98 | ) { 99 | const percentageDiff = this.getPercentageDiff(diffCoverageData[key]) 100 | core.info( 101 | `percentage Diff: ${percentageDiff} is greater than delta for ${file}` 102 | ) 103 | return true 104 | } 105 | } 106 | } 107 | } 108 | 109 | return false 110 | } 111 | 112 | private createDiffLine( 113 | name: string, 114 | diffFileCoverageData: DiffFileCoverageData 115 | ): string { 116 | // No old coverage found so that means we added a new file coverage 117 | const fileNewCoverage = Object.values(diffFileCoverageData).every( 118 | coverageData => coverageData.oldPct === 0 119 | ) 120 | // No new coverage found so that means we deleted a file coverage 121 | const fileRemovedCoverage = Object.values(diffFileCoverageData).every( 122 | coverageData => coverageData.newPct === 0 123 | ) 124 | if (fileNewCoverage) { 125 | return ` ${newCoverageIcon} | **${name}** | **${diffFileCoverageData.statements.newPct}** | **${diffFileCoverageData.branches.newPct}** | **${diffFileCoverageData.functions.newPct}** | **${diffFileCoverageData.lines.newPct}**` 126 | } else if (fileRemovedCoverage) { 127 | return ` ${removedCoverageIcon} | ~~${name}~~ | ~~${diffFileCoverageData.statements.oldPct}~~ | ~~${diffFileCoverageData.branches.oldPct}~~ | ~~${diffFileCoverageData.functions.oldPct}~~ | ~~${diffFileCoverageData.lines.oldPct}~~` 128 | } 129 | // Coverage existed before so calculate the diff status 130 | const statusIcon = this.getStatusIcon(diffFileCoverageData) 131 | return ` ${statusIcon} | ${name} | ${ 132 | diffFileCoverageData.statements.newPct 133 | } **(${this.getPercentageDiff(diffFileCoverageData.statements)})** | ${ 134 | diffFileCoverageData.branches.newPct 135 | } **(${this.getPercentageDiff(diffFileCoverageData.branches)})** | ${ 136 | diffFileCoverageData.functions.newPct 137 | } **(${this.getPercentageDiff(diffFileCoverageData.functions)})** | ${ 138 | diffFileCoverageData.lines.newPct 139 | } **(${this.getPercentageDiff(diffFileCoverageData.lines)})**` 140 | } 141 | 142 | private compareCoverageValues( 143 | diffCoverageData: DiffFileCoverageData 144 | ): number { 145 | const keys: ('lines' | 'statements' | 'branches' | 'functions')[] = < 146 | ('lines' | 'statements' | 'branches' | 'functions')[] 147 | >Object.keys(diffCoverageData) 148 | for (const key of keys) { 149 | if (diffCoverageData[key].oldPct !== diffCoverageData[key].newPct) { 150 | return 1 151 | } 152 | } 153 | return 0 154 | } 155 | 156 | private getPercentage(coverageData: CoverageData): number { 157 | return coverageData?.pct || 0 158 | } 159 | 160 | private getStatusIcon( 161 | diffFileCoverageData: DiffFileCoverageData 162 | ): ':green_circle:' | ':red_circle:' { 163 | let overallDiff = 0 164 | Object.values(diffFileCoverageData).forEach(coverageData => { 165 | overallDiff = overallDiff + this.getPercentageDiff(coverageData) 166 | }) 167 | if (overallDiff < 0) { 168 | return decreasedCoverageIcon 169 | } 170 | return increasedCoverageIcon 171 | } 172 | 173 | private getPercentageDiff(diffData: DiffCoverageData): number { 174 | // get diff 175 | const diff = Number(diffData.newPct) - Number(diffData.oldPct) 176 | // round off the diff to 2 decimal places 177 | return Math.round((diff + Number.EPSILON) * 100) / 100 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/Model/CoverageData.ts: -------------------------------------------------------------------------------- 1 | export interface CoverageData { 2 | total: number 3 | covered: number 4 | skipped: number 5 | pct: number 6 | } 7 | -------------------------------------------------------------------------------- /src/Model/CoverageReport.ts: -------------------------------------------------------------------------------- 1 | import {FileCoverageData} from './FileCoverageData' 2 | 3 | export interface CoverageReport { 4 | [filePath: string]: FileCoverageData 5 | } 6 | -------------------------------------------------------------------------------- /src/Model/DiffCoverageData.ts: -------------------------------------------------------------------------------- 1 | export interface DiffCoverageData { 2 | oldPct?: number 3 | newPct?: number 4 | } 5 | -------------------------------------------------------------------------------- /src/Model/DiffCoverageReport.ts: -------------------------------------------------------------------------------- 1 | import {DiffFileCoverageData} from './DiffFileCoverageData' 2 | 3 | export interface DiffCoverageReport { 4 | [filePath: string]: DiffFileCoverageData 5 | } 6 | -------------------------------------------------------------------------------- /src/Model/DiffFileCoverageData.ts: -------------------------------------------------------------------------------- 1 | import {DiffCoverageData} from './DiffCoverageData' 2 | 3 | export interface DiffFileCoverageData { 4 | lines: DiffCoverageData 5 | statements: DiffCoverageData 6 | branches: DiffCoverageData 7 | functions: DiffCoverageData 8 | } 9 | -------------------------------------------------------------------------------- /src/Model/FileCoverageData.ts: -------------------------------------------------------------------------------- 1 | import {CoverageData} from './CoverageData' 2 | 3 | export interface FileCoverageData { 4 | statements: CoverageData 5 | branches: CoverageData 6 | functions: CoverageData 7 | lines: CoverageData 8 | } 9 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import {execSync} from 'child_process' 4 | import fs from 'fs' 5 | import {CoverageReport} from './Model/CoverageReport' 6 | import {DiffChecker} from './DiffChecker' 7 | import {Octokit} from '@octokit/core' 8 | import {PaginateInterface} from '@octokit/plugin-paginate-rest' 9 | import {RestEndpointMethods} from '@octokit/plugin-rest-endpoint-methods/dist-types/generated/method-types' 10 | 11 | async function run(): Promise { 12 | try { 13 | const repoName = github.context.repo.repo 14 | const repoOwner = github.context.repo.owner 15 | const commitSha = github.context.sha 16 | const githubToken = core.getInput('accessToken') 17 | const fullCoverage = JSON.parse(core.getInput('fullCoverageDiff')) 18 | const commandToRun = core.getInput('runCommand') 19 | const commandAfterSwitch = core.getInput('afterSwitchCommand') 20 | const delta = Number(core.getInput('delta')) 21 | const rawTotalDelta = core.getInput('total_delta') 22 | const githubClient = github.getOctokit(githubToken) 23 | const prNumber = github.context.issue.number 24 | const branchNameBase = github.context.payload.pull_request?.base.ref 25 | const branchNameHead = github.context.payload.pull_request?.head.ref 26 | const useSameComment = JSON.parse(core.getInput('useSameComment')) 27 | const commentIdentifier = `` 28 | const deltaCommentIdentifier = `` 29 | let totalDelta = null 30 | if (rawTotalDelta !== null) { 31 | totalDelta = Number(rawTotalDelta) 32 | } 33 | let commentId = null 34 | execSync(commandToRun) 35 | const codeCoverageNew = ( 36 | JSON.parse(fs.readFileSync('coverage-summary.json').toString()) 37 | ) 38 | execSync('/usr/bin/git fetch') 39 | execSync('/usr/bin/git stash') 40 | execSync(`/usr/bin/git checkout --progress --force ${branchNameBase}`) 41 | if (commandAfterSwitch) { 42 | execSync(commandAfterSwitch) 43 | } 44 | execSync(commandToRun) 45 | const codeCoverageOld = ( 46 | JSON.parse(fs.readFileSync('coverage-summary.json').toString()) 47 | ) 48 | const currentDirectory = execSync('pwd') 49 | .toString() 50 | .trim() 51 | const diffChecker: DiffChecker = new DiffChecker( 52 | codeCoverageNew, 53 | codeCoverageOld 54 | ) 55 | let messageToPost = `## Test coverage results :test_tube: \n 56 | Code coverage diff between base branch:${branchNameBase} and head branch: ${branchNameHead} \n\n` 57 | const coverageDetails = diffChecker.getCoverageDetails( 58 | !fullCoverage, 59 | `${currentDirectory}/` 60 | ) 61 | if (coverageDetails.length === 0) { 62 | messageToPost = 63 | 'No changes to code coverage between the base branch and the head branch' 64 | } else { 65 | messageToPost += 66 | 'Status | File | % Stmts | % Branch | % Funcs | % Lines \n -----|-----|---------|----------|---------|------ \n' 67 | messageToPost += coverageDetails.join('\n') 68 | } 69 | messageToPost = `${commentIdentifier}\nCommit SHA:${commitSha}\n${messageToPost}` 70 | if (useSameComment) { 71 | commentId = await findComment( 72 | githubClient, 73 | repoName, 74 | repoOwner, 75 | prNumber, 76 | commentIdentifier 77 | ) 78 | } 79 | await createOrUpdateComment( 80 | commentId, 81 | githubClient, 82 | repoOwner, 83 | repoName, 84 | messageToPost, 85 | prNumber 86 | ) 87 | 88 | // check if the test coverage is falling below delta/tolerance. 89 | if (diffChecker.checkIfTestCoverageFallsBelowDelta(delta, totalDelta)) { 90 | if (useSameComment) { 91 | commentId = await findComment( 92 | githubClient, 93 | repoName, 94 | repoOwner, 95 | prNumber, 96 | deltaCommentIdentifier 97 | ) 98 | } 99 | messageToPost = `Current PR reduces the test coverage percentage by ${delta} for some tests` 100 | messageToPost = `${deltaCommentIdentifier}\nCommit SHA:${commitSha}\n${messageToPost}` 101 | await createOrUpdateComment( 102 | commentId, 103 | githubClient, 104 | repoOwner, 105 | repoName, 106 | messageToPost, 107 | prNumber 108 | ) 109 | throw Error(messageToPost) 110 | } 111 | } catch (error) { 112 | core.setFailed(error) 113 | } 114 | } 115 | 116 | async function createOrUpdateComment( 117 | commentId: number | null, 118 | githubClient: {[x: string]: any} & {[x: string]: any} & Octokit & 119 | RestEndpointMethods & {paginate: PaginateInterface}, 120 | repoOwner: string, 121 | repoName: string, 122 | messageToPost: string, 123 | prNumber: number 124 | ) { 125 | if (commentId) { 126 | await githubClient.issues.updateComment({ 127 | owner: repoOwner, 128 | repo: repoName, 129 | comment_id: commentId, 130 | body: messageToPost 131 | }) 132 | } else { 133 | await githubClient.issues.createComment({ 134 | repo: repoName, 135 | owner: repoOwner, 136 | body: messageToPost, 137 | issue_number: prNumber 138 | }) 139 | } 140 | } 141 | 142 | async function findComment( 143 | githubClient: {[x: string]: any} & {[x: string]: any} & Octokit & 144 | RestEndpointMethods & {paginate: PaginateInterface}, 145 | repoName: string, 146 | repoOwner: string, 147 | prNumber: number, 148 | identifier: string 149 | ): Promise { 150 | const comments = await githubClient.issues.listComments({ 151 | owner: repoOwner, 152 | repo: repoName, 153 | issue_number: prNumber 154 | }) 155 | 156 | for (const comment of comments.data) { 157 | if (comment.body.startsWith(identifier)) { 158 | return comment.id 159 | } 160 | } 161 | return 0 162 | } 163 | 164 | run() 165 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "strict": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | }, 11 | "exclude": ["node_modules", "**/*.test.ts"] 12 | } 13 | --------------------------------------------------------------------------------