├── .gitignore ├── .vscode └── settings.json ├── .editorconfig ├── codecov.yml ├── rollup.config.js ├── src ├── lcov.js ├── html.js ├── cli.js ├── __tests__ │ ├── html_test.js │ ├── lcov_test.js │ ├── comment_test.js │ ├── tabulate_test.js │ └── index_test.js ├── comment.js ├── tabulate.js └── index.js ├── .github └── workflows │ ├── release.yml │ └── code_coverage.yml ├── action.yml ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "CODECOV" 4 | ] 5 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig, see http://EditorConfig.org for a tutorial on how to 2 | # configure your editor. 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = tab 11 | indent_size = 2 12 | trim_trailing_whitespace = true 13 | 14 | [*.yml] 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # validate this file: 2 | # curl --data-binary @.codecov.yml https://codecov.io/validate 3 | 4 | comment: true 5 | 6 | # partial line support 7 | parsers: 8 | javascript: 9 | enable_partials: yes 10 | 11 | # docs: 12 | # https://codecov.readme.io/v4.3.6/docs/commit-status 13 | # https://gist.github.com/stevepeak/53bee7b2c326b24a9b4a 14 | coverage: 15 | status: 16 | project: 17 | default: 18 | threshold: 0% 19 | if_not_found: success # no commit found? still set a success 20 | patch: 21 | default: 22 | if_not_found: success -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from "@rollup/plugin-commonjs" 2 | import resolve from "@rollup/plugin-node-resolve" 3 | import json from "@rollup/plugin-json" 4 | import externals from "rollup-plugin-node-externals" 5 | 6 | export default { 7 | input: "src/index.js", 8 | output: { 9 | file: "dist/main.js", 10 | format: "cjs", 11 | }, 12 | treeshake: true, 13 | plugins: [ 14 | externals({ 15 | builtin: true, 16 | deps: false, 17 | }), 18 | resolve({ 19 | preferBuiltins: true, 20 | mainFields: [ "main" ], 21 | }), 22 | commonjs(), 23 | json(), 24 | ], 25 | } 26 | -------------------------------------------------------------------------------- /src/lcov.js: -------------------------------------------------------------------------------- 1 | import lcov from "lcov-parse" 2 | 3 | // Parse lcov string into lcov data 4 | export function parse(data) { 5 | return new Promise(function(resolve, reject) { 6 | lcov(data, function(err, res) { 7 | if (err) { 8 | reject(err) 9 | return 10 | } 11 | resolve(res) 12 | }) 13 | }) 14 | } 15 | 16 | // Get the total coverage percentage from the lcov data. 17 | export function percentage(lcov) { 18 | let hit = 0 19 | let found = 0 20 | for (const entry of lcov) { 21 | hit += entry.lines.hit 22 | found += entry.lines.found 23 | } 24 | 25 | return (hit / found) * 100 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | on: 3 | push: 4 | tags: 5 | # Push events to matching v*, i.e. v1.0, v20.15.10 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | name: Create Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@master 15 | - name: Create Release 16 | id: create_release 17 | uses: actions/create-release@latest 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | tag_name: ${{ github.ref }} 22 | release_name: Release ${{ github.ref }} 23 | draft: false 24 | prerelease: false 25 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Jest Lcov Reporter 2 | description: Comments a pull request with the code coverage 3 | author: Veddha Edsa 4 | branding: 5 | icon: check-square 6 | color: green 7 | inputs: 8 | github-token: 9 | description: Github token 10 | required: true 11 | name: 12 | description: Name of report 13 | required: false 14 | default: "" 15 | lcov-file: 16 | description: The location of the lcov.info file 17 | required: false 18 | lcov-base: 19 | description: The location of the lcov file for the base branch 20 | required: false 21 | update-comment: 22 | description: Update the existing comment if it exists instead of creating a new one 23 | required: false 24 | default: false 25 | runs: 26 | using: node12 27 | main: dist/main.js 28 | -------------------------------------------------------------------------------- /.github/workflows/code_coverage.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: 4 | - master 5 | push: 6 | branches: 7 | - master 8 | 9 | name: Code Coverage 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | 17 | - uses: actions/setup-node@master 18 | with: 19 | node-version: 12 20 | 21 | - name: Unit test 22 | run: | 23 | npm install -g yarn 24 | yarn install 25 | yarn test 26 | 27 | - name : Scan Code 28 | uses: codecov/codecov-action@v1 29 | with: 30 | token: ${{ secrets.CODECOV_TOKEN }} 31 | file: ./coverage/clover.xml # optional 32 | flags: unit_tests # optional 33 | -------------------------------------------------------------------------------- /src/html.js: -------------------------------------------------------------------------------- 1 | function tag(name) { 2 | return function(...children) { 3 | const props = 4 | typeof children[0] === "object" 5 | ? Object.keys(children[0]) 6 | .map(key => ` ${key}='${children[0][key]}'`) 7 | .join("") 8 | : "" 9 | 10 | const c = typeof children[0] === "string" ? children : children.slice(1) 11 | 12 | return `<${name}${props}>${c.join("")}` 13 | } 14 | } 15 | 16 | export const h2 = tag("h2") 17 | export const details = tag("details") 18 | export const summary = tag("summary") 19 | export const tr = tag("tr") 20 | export const td = tag("td") 21 | export const th = tag("th") 22 | export const b = tag("b") 23 | export const table = tag("table") 24 | export const tbody = tag("tbody") 25 | export const a = tag("a") 26 | export const span = tag("span") 27 | export const p = tag("p") 28 | 29 | export const fragment = function(...children) { 30 | return children.join("") 31 | } 32 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | import process from "process" 2 | import { promises as fs } from "fs" 3 | import path from "path" 4 | 5 | import { parse } from "./lcov" 6 | import { diff } from "./comment" 7 | 8 | async function main() { 9 | const file = process.argv[2] 10 | const beforeFile = process.argv[3] 11 | console.log(file) 12 | const prefix = path.dirname(path.dirname(path.resolve(file))) + "/" 13 | 14 | const content = await fs.readFile(file, "utf-8") 15 | const lcov = await parse(content) 16 | 17 | let before 18 | if (beforeFile) { 19 | const content = await fs.readFile(beforeFile, "utf-8") 20 | before = await parse(content) 21 | } 22 | 23 | const options = { 24 | repository: "example/foo", 25 | commit: "f9d42291812ed03bb197e48050ac38ac6befe4e5", 26 | prefix, 27 | head: "feat/test", 28 | base: "master", 29 | } 30 | 31 | console.log(diff(lcov, before, options)) 32 | 33 | // fs.writeFile("./tmp/test.html", diff(lcov, before, options), function(err) { 34 | // if(err) { 35 | // return console.log(err); 36 | // } 37 | // console.log("The file was saved!"); 38 | // }); 39 | } 40 | 41 | main().catch(function(err) { 42 | console.log(err) 43 | process.exit(1) 44 | }) 45 | -------------------------------------------------------------------------------- /src/__tests__/html_test.js: -------------------------------------------------------------------------------- 1 | import { 2 | details, 3 | summary, 4 | tr, 5 | td, 6 | th, 7 | b, 8 | table, 9 | tbody, 10 | a, 11 | span, 12 | fragment, 13 | h2, 14 | p, 15 | } from "../html" 16 | 17 | test("html tags should return the correct html", function() { 18 | expect(details("foo", "bar")).toBe("
foobar
") 19 | expect(summary("foo", "bar")).toBe("foobar") 20 | expect(tr("foo", "bar")).toBe("foobar") 21 | expect(td("foo", "bar")).toBe("foobar") 22 | expect(th("foo", "bar")).toBe("foobar") 23 | expect(b("foo", "bar")).toBe("foobar") 24 | expect(table("foo", "bar")).toBe("foobar
") 25 | expect(tbody("foo", "bar")).toBe("foobar") 26 | expect(a("foo", "bar")).toBe("foobar") 27 | expect(span("foo", "bar")).toBe("foobar") 28 | expect(h2("foo", "bar")).toBe("

foobar

") 29 | expect(p("foo", "bar")).toBe("

foobar

") 30 | }) 31 | 32 | test("html fragment should return the children", function() { 33 | expect(fragment()).toBe("") 34 | expect(fragment("foo")).toBe("foo") 35 | expect(fragment("foo", "bar")).toBe("foobar") 36 | }) 37 | 38 | test("html tags should accept props", function() { 39 | expect(a({ href: "http://www.example.com" }, "example")).toBe( 40 | "example", 41 | ) 42 | expect( 43 | a({ href: "http://www.example.com", target: "_blank" }, "example"), 44 | ).toBe("example") 45 | }) 46 | -------------------------------------------------------------------------------- /src/comment.js: -------------------------------------------------------------------------------- 1 | import { 2 | details, 3 | summary, 4 | b, 5 | fragment, 6 | table, 7 | tbody, 8 | tr, 9 | th, 10 | h2, 11 | p, 12 | } from "./html" 13 | 14 | import { percentage } from "./lcov" 15 | import { tabulate } from "./tabulate" 16 | 17 | function heading(name) { 18 | if (name) { 19 | return h2(`Code Coverage Report: ${name}`) 20 | } else { 21 | return h2(`Code Coverage Report`) 22 | } 23 | } 24 | 25 | function comment(lcov, table, options) { 26 | return fragment( 27 | heading(options.name), 28 | p(`Coverage after merging ${b(options.head)} into ${b(options.base)}`), 29 | table, 30 | "\n\n", 31 | details(summary("Coverage Report"), tabulate(lcov, options)), 32 | commentIdentifier(options.workflowName), 33 | ) 34 | } 35 | 36 | export function commentIdentifier(workflowName) { 37 | return `` 38 | } 39 | 40 | export function diff(lcov, before, options) { 41 | if (!before) { 42 | return comment( 43 | lcov, 44 | table(tbody(tr(th(percentage(lcov).toFixed(2), "%")))), 45 | options, 46 | ) 47 | } 48 | 49 | const pbefore = percentage(before) 50 | const pafter = percentage(lcov) 51 | const pdiff = pafter - pbefore 52 | const plus = pdiff > 0 ? "+" : "" 53 | const arrow = pdiff === 0 ? "" : pdiff < 0 ? "▾" : "▴" 54 | 55 | return comment( 56 | lcov, 57 | table( 58 | tbody( 59 | tr( 60 | th(pafter.toFixed(2), "%"), 61 | th(arrow, " ", plus, pdiff.toFixed(2), "%"), 62 | ), 63 | ), 64 | ), 65 | options, 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jest-lcov-reporter", 3 | "version": "0.2.0", 4 | "description": "Comments a pull request with the lcov code coverage", 5 | "license": "MIT", 6 | "author": "Veddha Edsa <@vebr>", 7 | "repository": "https://github.com/vebr/jest-lcov-reporter", 8 | "keywords": [ 9 | "actions", 10 | "pull-request", 11 | "comment", 12 | "message" 13 | ], 14 | "main": "index.js", 15 | "scripts": { 16 | "build": "rollup -c", 17 | "test": "jest --passWithNoTests --coverage", 18 | "local": "babel-node src/cli", 19 | "format": "prettier --write src/*.js src/**/*.js" 20 | }, 21 | "dependencies": { 22 | "@actions/core": "^1.2.0", 23 | "@actions/github": "^1.1.0", 24 | "lcov-parse": "^1.0.0" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.8.6", 28 | "@babel/node": "^7.8.4", 29 | "@babel/preset-env": "^7.8.6", 30 | "@rollup/plugin-commonjs": "^11.0.2", 31 | "@rollup/plugin-json": "^4.0.2", 32 | "@rollup/plugin-node-resolve": "^7.1.1", 33 | "babel-jest": "^25.1.0", 34 | "core-js": "3", 35 | "jest": "^25.1.0", 36 | "prettier": "^1.19.1", 37 | "regenerator-runtime": "^0.13.3", 38 | "rollup": "^1.32.0", 39 | "rollup-plugin-node-externals": "^2.1.3" 40 | }, 41 | "babel": { 42 | "presets": [ 43 | [ 44 | "@babel/preset-env", 45 | { 46 | "useBuiltIns": "usage", 47 | "corejs": 3 48 | } 49 | ] 50 | ] 51 | }, 52 | "jest": { 53 | "testMatch": [ 54 | "/src/*_test.js", 55 | "/src/**/*_test.js" 56 | ] 57 | }, 58 | "prettier": { 59 | "semi": false, 60 | "useTabs": true, 61 | "trailingComma": "all" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jest reporter action 2 | 3 | [![codecov](https://codecov.io/gh/vebr/jest-lcov-reporter/branch/master/graph/badge.svg)](https://codecov.io/gh/vebr/jest-lcov-reporter) 4 | 5 | This action comments a pull request with a HTML test coverage report. 6 | 7 | The report is based on the lcov coverage report generated by your test runner. 8 | 9 | Note that this action does not run any tests, but instead expects the tests to have been run 10 | by another action already. 11 | 12 | ## Inputs 13 | 14 | ##### `github-token` (**Required**) 15 | Github token used for posting the comment. To use the key provided by the GitHub 16 | action runner, use `${{ secrets.GITHUB_TOKEN }}`. 17 | 18 | ##### `name` (**Optional**) 19 | A name that will be included in the title of the comment. Good if you have multiple 20 | reports being run and want to differentiate them. 21 | 22 | ##### `lcov-file` (**Optional**) 23 | The location of the lcov file to read the coverage report from. Defaults to 24 | `./coverage/lcov.info`. 25 | 26 | ##### `lcov-base` (**Optional**) 27 | The location of the lcov file resulting from running the tests in the base 28 | branch. When this is set a diff of the coverage percentages is shown. 29 | 30 | ##### `update-comment` (**Optional**) 31 | If `true` the comment left on the PR will be updated (if one exists) with the new report data 32 | instead of a new comment being created. If `false` every run will create a new comment on the PR 33 | with the new data and keep the previous comments. Defaults to `false`. 34 | 35 | ## Example usage 36 | 37 | ```yml 38 | uses: vebr/jest-lcov-reporter@v0.2.0 39 | with: 40 | github-token: ${{ secrets.GITHUB_TOKEN }} 41 | lcov-file: ./coverage/lcov.info 42 | ``` 43 | 44 | ## Acknowledgements 45 | 46 | The initial code is based on [ziishaned/jest-reporter-action](https://github.com/ziishaned/jest-reporter-action). 47 | -------------------------------------------------------------------------------- /src/__tests__/lcov_test.js: -------------------------------------------------------------------------------- 1 | import { parse, percentage } from "../lcov" 2 | 3 | test("parse should parse lcov strings correctly", async function() { 4 | const data = ` 5 | TN: 6 | SF:/files/project/foo.js 7 | FN:19,foo 8 | FN:33,bar 9 | FN:54,baz 10 | FNF:3 11 | FNH:2 12 | DA:20,3 13 | DA:21,3 14 | DA:22,3 15 | LF:23 16 | LH:21 17 | BRDA:21,0,0,1 18 | BRDA:21,0,1,2 19 | BRDA:22,1,0,1 20 | BRDA:22,1,1,2 21 | BRDA:37,2,0,0 22 | BRF:5 23 | BRH:4 24 | end_of_record 25 | ` 26 | 27 | const lcov = await parse(data) 28 | expect(lcov).toEqual([ 29 | { 30 | title: "", 31 | file: "/files/project/foo.js", 32 | lines: { 33 | found: 23, 34 | hit: 21, 35 | details: [ 36 | { 37 | line: 20, 38 | hit: 3, 39 | }, 40 | { 41 | line: 21, 42 | hit: 3, 43 | }, 44 | { 45 | line: 22, 46 | hit: 3, 47 | }, 48 | ], 49 | }, 50 | functions: { 51 | hit: 2, 52 | found: 3, 53 | details: [ 54 | { 55 | name: "foo", 56 | line: 19, 57 | }, 58 | { 59 | name: "bar", 60 | line: 33, 61 | }, 62 | { 63 | name: "baz", 64 | line: 54, 65 | }, 66 | ], 67 | }, 68 | branches: { 69 | hit: 4, 70 | found: 5, 71 | details: [ 72 | { 73 | line: 21, 74 | block: 0, 75 | branch: 0, 76 | taken: 1, 77 | }, 78 | { 79 | line: 21, 80 | block: 0, 81 | branch: 1, 82 | taken: 2, 83 | }, 84 | { 85 | line: 22, 86 | block: 1, 87 | branch: 0, 88 | taken: 1, 89 | }, 90 | { 91 | line: 22, 92 | block: 1, 93 | branch: 1, 94 | taken: 2, 95 | }, 96 | { 97 | line: 37, 98 | block: 2, 99 | branch: 0, 100 | taken: 0, 101 | }, 102 | ], 103 | }, 104 | }, 105 | ]) 106 | }) 107 | 108 | test("parse should fail on invalid lcov", async function() { 109 | await expect(parse("invalid")).rejects.toBe("Failed to parse string") 110 | }) 111 | 112 | test("percentage should calculate the correct percentage", function() { 113 | expect( 114 | percentage([ 115 | { lines: { hit: 20, found: 25 } }, 116 | { lines: { hit: 10, found: 15 } }, 117 | ]), 118 | ).toBe(75) 119 | }) 120 | -------------------------------------------------------------------------------- /src/tabulate.js: -------------------------------------------------------------------------------- 1 | import { th, tr, td, table, tbody, a, b, span, fragment } from "./html" 2 | 3 | // Tabulate the lcov data in a HTML table. 4 | export function tabulate(lcov, options) { 5 | const head = tr( 6 | th("File"), 7 | th("Branches"), 8 | th("Funcs"), 9 | th("Lines"), 10 | th("Uncovered Lines"), 11 | ) 12 | 13 | const folders = {} 14 | for (const file of lcov) { 15 | const parts = file.file.replace(options.prefix, "").split("/") 16 | const folder = parts.slice(0, -1).join("/") 17 | folders[folder] = folders[folder] || [] 18 | folders[folder].push(file) 19 | } 20 | 21 | const rows = Object.keys(folders) 22 | .sort() 23 | .reduce( 24 | (acc, key) => [ 25 | ...acc, 26 | toFolder(key, options), 27 | ...folders[key].map(file => toRow(file, key !== "", options)), 28 | ], 29 | [], 30 | ) 31 | 32 | return table(tbody(head, ...rows)) 33 | } 34 | 35 | function toFolder(path) { 36 | if (path === "") { 37 | return "" 38 | } 39 | 40 | return tr(td({ colspan: 5 }, b(path))) 41 | } 42 | 43 | function toRow(file, indent, options) { 44 | return tr( 45 | td(filename(file, indent, options)), 46 | td(percentage(file.branches, options)), 47 | td(percentage(file.functions, options)), 48 | td(percentage(file.lines, options)), 49 | td(uncovered(file, options)), 50 | ) 51 | } 52 | 53 | function filename(file, indent, options) { 54 | const relative = file.file.replace(options.prefix, "") 55 | const parts = relative.split("/") 56 | const last = parts[parts.length - 1] 57 | const space = indent ? "   " : "" 58 | return fragment(space, span(last)) 59 | } 60 | 61 | function percentage(item) { 62 | if (!item) { 63 | return "N/A" 64 | } 65 | 66 | const value = item.found === 0 ? 100 : (item.hit / item.found) * 100 67 | const rounded = value.toFixed(2).replace(/\.0*$/, "") 68 | 69 | const tag = value === 100 ? fragment : b 70 | 71 | return tag(`${rounded}%`) 72 | } 73 | 74 | function uncovered(file, options) { 75 | const branches = (file.branches ? file.branches.details : []) 76 | .filter(branch => branch.taken === 0) 77 | .map(branch => branch.line) 78 | 79 | const lines = (file.lines ? file.lines.details : []) 80 | .filter(line => line.hit === 0) 81 | .map(line => line.line) 82 | const allLines = [...branches, ...lines] 83 | let tempAll = ["..."] 84 | let all = [...branches, ...lines].sort() 85 | if (all.length > 4) { 86 | const lastFour = all.slice(Math.max(all.length - 4, 0)) 87 | 88 | all = tempAll.concat(lastFour) 89 | } 90 | return all 91 | .map(function(line) { 92 | const relative = file.file.replace(options.prefix, "") 93 | const path = `${line}` 94 | return span(path) 95 | }) 96 | .join(", ") 97 | } 98 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs" 2 | import core from "@actions/core" 3 | import { GitHub, context } from "@actions/github" 4 | 5 | import { parse } from "./lcov" 6 | import { commentIdentifier, diff } from "./comment" 7 | 8 | async function main() { 9 | const token = core.getInput("github-token") 10 | const name = core.getInput("name") 11 | const lcovFile = core.getInput("lcov-file") || "./coverage/lcov.info" 12 | const baseFile = core.getInput("lcov-base") 13 | const updateComment = core.getInput("update-comment") 14 | 15 | const raw = await fs.readFile(lcovFile, "utf-8").catch(err => null) 16 | if (!raw) { 17 | console.log(`No coverage report found at '${lcovFile}', exiting...`) 18 | return 19 | } 20 | 21 | const baseRaw = 22 | baseFile && (await fs.readFile(baseFile, "utf-8").catch(err => null)) 23 | if (baseFile && !baseRaw) { 24 | console.log(`No coverage report found at '${baseFile}', ignoring...`) 25 | } 26 | 27 | const isPullRequest = Boolean(context.payload.pull_request) 28 | if (!isPullRequest) { 29 | console.log("Not a pull request, skipping...") 30 | return 31 | } 32 | 33 | const options = { 34 | name, 35 | repository: context.payload.repository.full_name, 36 | commit: context.payload.pull_request.head.sha, 37 | prefix: `${process.env.GITHUB_WORKSPACE}/`, 38 | head: context.payload.pull_request.head.ref, 39 | base: context.payload.pull_request.base.ref, 40 | workflowName: process.env.GITHUB_WORKFLOW, 41 | } 42 | 43 | const lcov = await parse(raw) 44 | const baselcov = baseRaw && (await parse(baseRaw)) 45 | const body = await diff(lcov, baselcov, options) 46 | const githubClient = new GitHub(token) 47 | 48 | const createGitHubComment = () => 49 | githubClient.issues.createComment({ 50 | repo: context.repo.repo, 51 | owner: context.repo.owner, 52 | issue_number: context.payload.pull_request.number, 53 | body, 54 | }) 55 | 56 | const updateGitHubComment = commentId => 57 | githubClient.issues.updateComment({ 58 | repo: context.repo.repo, 59 | owner: context.repo.owner, 60 | comment_id: commentId, 61 | body, 62 | }) 63 | 64 | if (updateComment) { 65 | const issueComments = await githubClient.issues.listComments({ 66 | repo: context.repo.repo, 67 | owner: context.repo.owner, 68 | issue_number: context.payload.pull_request.number, 69 | }) 70 | 71 | const existingComment = issueComments.data.find(comment => 72 | comment.body.includes(commentIdentifier(options.workflowName)), 73 | ) 74 | 75 | if (existingComment) { 76 | await updateGitHubComment(existingComment.id) 77 | return 78 | } 79 | } 80 | 81 | await createGitHubComment() 82 | } 83 | 84 | export default main().catch(function(err) { 85 | console.log(err) 86 | core.setFailed(err.message) 87 | }) 88 | -------------------------------------------------------------------------------- /src/__tests__/comment_test.js: -------------------------------------------------------------------------------- 1 | import { commentIdentifier, diff } from "../comment" 2 | import { percentage } from "../lcov" 3 | import { tabulate } from "../tabulate" 4 | 5 | jest.mock("../lcov") 6 | jest.mock("../tabulate") 7 | 8 | describe("commentIdentifier", () => { 9 | test("a comment identifier is returned", function() { 10 | const result = commentIdentifier("workflow name") 11 | expect(result).toEqual("") 12 | }) 13 | }) 14 | 15 | describe("diff", () => { 16 | const options = { 17 | repository: "repo", 18 | commit: "commit-sha", 19 | prefix: "prefix", 20 | head: "head-ref", 21 | base: "base-ref", 22 | workflowName: "workflow name", 23 | } 24 | 25 | test("a comment is returned with the code coverage details", () => { 26 | percentage.mockReturnValueOnce(50) 27 | tabulate.mockReturnValueOnce("") 28 | 29 | const lcovData = "LCOV_DATA" 30 | const result = diff(lcovData, null, options) 31 | 32 | expect(percentage).toHaveBeenCalledWith(lcovData) 33 | expect(tabulate).toHaveBeenCalledWith(lcovData, options) 34 | 35 | expect(result).toContain("

Code Coverage Report

") 36 | expect(result).toContain( 37 | "

Coverage after merging head-ref into base-ref

", 38 | ) 39 | expect(result).toContain( 40 | "
50.00%
", 41 | ) 42 | expect(result).toContain( 43 | "
Coverage Report
", 44 | ) 45 | expect(result).toContain("") 46 | }) 47 | 48 | test("a comment is returned with provided name", () => { 49 | percentage.mockReturnValueOnce(50) 50 | tabulate.mockReturnValueOnce("") 51 | 52 | const lcovData = "LCOV_DATA" 53 | const result = diff(lcovData, null, { name: "Project Name", ...options }) 54 | 55 | expect(result).toContain("

Code Coverage Report: Project Name

") 56 | }) 57 | 58 | test("a comment is returned with code coverage details with the base line comparison (increase)", () => { 59 | percentage.mockReturnValueOnce(50).mockReturnValueOnce(70) 60 | tabulate.mockReturnValueOnce("") 61 | 62 | const lcovData = "LCOV_DATA" 63 | const lcovBeforeData = "LCOV_BEFORE_DATA" 64 | const result = diff(lcovData, lcovBeforeData, options) 65 | 66 | expect(result).toContain( 67 | "
70.00%▴ +20.00%
", 68 | ) 69 | }) 70 | 71 | test("a comment is returned with code coverage details with the base line comparison (decrease)", () => { 72 | percentage.mockReturnValueOnce(50).mockReturnValueOnce(30) 73 | tabulate.mockReturnValueOnce("") 74 | 75 | const lcovData = "LCOV_DATA" 76 | const lcovBeforeData = "LCOV_BEFORE_DATA" 77 | const result = diff(lcovData, lcovBeforeData, options) 78 | 79 | expect(result).toContain( 80 | "
30.00%▾ -20.00%
", 81 | ) 82 | }) 83 | 84 | test("a comment is returned with code coverage details with the base line comparison (no change)", () => { 85 | percentage.mockReturnValueOnce(50).mockReturnValueOnce(50) 86 | tabulate.mockReturnValueOnce("") 87 | 88 | const lcovData = "LCOV_DATA" 89 | const lcovBeforeData = "LCOV_BEFORE_DATA" 90 | const result = diff(lcovData, lcovBeforeData, options) 91 | 92 | expect(result).toContain( 93 | "
50.00% 0.00%
", 94 | ) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /src/__tests__/tabulate_test.js: -------------------------------------------------------------------------------- 1 | import { tabulate } from "../tabulate" 2 | import { th, tr, td, table, tbody, b, span, fragment } from "../html" 3 | 4 | test("tabulate should generate a correct table", function() { 5 | const data = [ 6 | { 7 | file: "/files/project/index.js", 8 | functions: { 9 | found: 0, 10 | hit: 0, 11 | details: [], 12 | }, 13 | }, 14 | { 15 | file: "/files/project/src/foo.js", 16 | lines: { 17 | found: 23, 18 | hit: 21, 19 | details: [ 20 | { 21 | line: 20, 22 | hit: 3, 23 | }, 24 | { 25 | line: 21, 26 | hit: 3, 27 | }, 28 | { 29 | line: 22, 30 | hit: 3, 31 | }, 32 | ], 33 | }, 34 | functions: { 35 | hit: 2, 36 | found: 3, 37 | details: [ 38 | { 39 | name: "foo", 40 | line: 19, 41 | }, 42 | { 43 | name: "bar", 44 | line: 33, 45 | }, 46 | { 47 | name: "baz", 48 | line: 54, 49 | }, 50 | ], 51 | }, 52 | branches: { 53 | hit: 3, 54 | found: 3, 55 | details: [ 56 | { 57 | line: 21, 58 | block: 0, 59 | branch: 0, 60 | taken: 1, 61 | }, 62 | { 63 | line: 21, 64 | block: 0, 65 | branch: 1, 66 | taken: 2, 67 | }, 68 | { 69 | line: 37, 70 | block: 1, 71 | branch: 0, 72 | taken: 0, 73 | }, 74 | ], 75 | }, 76 | }, 77 | { 78 | file: "/files/project/src/bar/baz.js", 79 | lines: { 80 | found: 10, 81 | hit: 5, 82 | details: [ 83 | { 84 | line: 22, 85 | hit: 0, 86 | }, 87 | { 88 | line: 31, 89 | hit: 0, 90 | }, 91 | { 92 | line: 32, 93 | hit: 0, 94 | }, 95 | { 96 | line: 33, 97 | hit: 0, 98 | }, 99 | { 100 | line: 34, 101 | hit: 0, 102 | }, 103 | ], 104 | }, 105 | functions: { 106 | hit: 4, 107 | found: 6, 108 | details: [ 109 | { 110 | name: "foo", 111 | line: 19, 112 | }, 113 | { 114 | name: "bar", 115 | line: 33, 116 | }, 117 | { 118 | name: "baz", 119 | line: 54, 120 | }, 121 | { 122 | name: "foobar", 123 | line: 62, 124 | }, 125 | ], 126 | }, 127 | }, 128 | ] 129 | 130 | const options = { 131 | repository: "example/foo", 132 | commit: "2e15bee6fe0df5003389aa5ec894ec0fea2d874a", 133 | prefix: "/files/project/", 134 | } 135 | 136 | const html = table( 137 | tbody( 138 | tr( 139 | th("File"), 140 | th("Branches"), 141 | th("Funcs"), 142 | th("Lines"), 143 | th("Uncovered Lines"), 144 | ), 145 | tr( 146 | td( 147 | span( 148 | "index.js", 149 | ), 150 | ), 151 | td("N/A"), 152 | td("100%"), 153 | td("N/A"), 154 | td(), 155 | ), 156 | tr(td({ colspan: 5 }, b("src"))), 157 | tr( 158 | td( 159 | "   ", 160 | span( 161 | "foo.js", 162 | ), 163 | ), 164 | td("100%"), 165 | td(b("66.67%")), 166 | td(b("91.30%")), 167 | td( 168 | span("37"), 169 | ), 170 | ), 171 | tr(td({ colspan: 5 }, b("src/bar"))), 172 | tr( 173 | td( 174 | "   ", 175 | span( 176 | "baz.js", 177 | ), 178 | ), 179 | td("N/A"), 180 | td(b("66.67%")), 181 | td(b("50%")), 182 | td( 183 | span( 184 | "..." 185 | ), 186 | ", ", 187 | span( 188 | "31", 189 | ), 190 | ", ", 191 | span( 192 | "32", 193 | ), 194 | ", ", 195 | span( 196 | "33", 197 | ), 198 | ", ", 199 | span( 200 | "34", 201 | ), 202 | ), 203 | ), 204 | ), 205 | ) 206 | expect(tabulate(data, options)).toBe(html) 207 | }) 208 | -------------------------------------------------------------------------------- /src/__tests__/index_test.js: -------------------------------------------------------------------------------- 1 | import core from "@actions/core" 2 | import { promises as fs } from "fs" 3 | import { context, GitHub } from "@actions/github" 4 | import { parse } from "../lcov" 5 | import { commentIdentifier, diff } from "../comment" 6 | 7 | jest.mock("@actions/core", () => ({ 8 | getInput: jest.fn(), 9 | setFailed: jest.fn(), 10 | })) 11 | 12 | jest.mock("@actions/github", () => ({ 13 | GitHub: jest.fn(), 14 | context: { 15 | payload: { 16 | repository: { full_name: "name" }, 17 | pull_request: {}, 18 | }, 19 | repo: { 20 | repo: "repo", 21 | owner: "owner", 22 | }, 23 | }, 24 | })) 25 | 26 | jest.mock("fs", () => ({ 27 | promises: { 28 | readFile: jest.fn(), 29 | }, 30 | })) 31 | 32 | jest.mock("../lcov") 33 | jest.mock("../comment") 34 | 35 | let updateComment = false 36 | beforeEach(() => { 37 | core.getInput.mockImplementation(arg => { 38 | if (arg === "github-token") return "GITHUB_TOKEN" 39 | if (arg === "name") return "NAME" 40 | if (arg === "lcov-file") return "LCOV_FILE" 41 | if (arg === "lcov-base") return "LCOV_BASE" 42 | if (arg === "update-comment") return updateComment 43 | return "" 44 | }) 45 | 46 | context.payload.pull_request = { 47 | base: { ref: "base-ref" }, 48 | head: { ref: "head-ref" }, 49 | number: 1, 50 | } 51 | 52 | parse.mockReturnValueOnce(Promise.resolve("report")) 53 | diff.mockReturnValueOnce(Promise.resolve("diff")) 54 | }) 55 | 56 | afterEach(() => { 57 | jest.clearAllMocks() 58 | }) 59 | 60 | test("it logs a message when lcov file does not exist", async () => { 61 | jest.spyOn(console, "log") 62 | fs.readFile.mockReturnValue(Promise.resolve(null)) 63 | 64 | let module 65 | jest.isolateModules(() => { 66 | module = require("../index").default 67 | }) 68 | 69 | await module 70 | 71 | expect(console.log).toHaveBeenCalledWith( 72 | "No coverage report found at 'LCOV_FILE', exiting...", 73 | ) 74 | }) 75 | 76 | test("it catches and logs if an error occurs", async () => { 77 | jest.spyOn(console, "log") 78 | 79 | fs.readFile 80 | .mockReturnValueOnce(Promise.resolve("file")) 81 | .mockReturnValueOnce(Promise.resolve(null)) 82 | 83 | const error = new Error("Something went wrong...") 84 | const createCommentMock = jest.fn().mockReturnValue(Promise.reject(error)) 85 | GitHub.mockReturnValue({ 86 | issues: { 87 | createComment: createCommentMock, 88 | }, 89 | }) 90 | 91 | let module 92 | jest.isolateModules(() => { 93 | module = require("../index").default 94 | }) 95 | 96 | await module 97 | 98 | expect(console.log).toHaveBeenCalledWith(error) 99 | expect(core.setFailed).toHaveBeenCalledWith(error.message) 100 | }) 101 | 102 | test("when a non pull_request event is passed it logs a message", async () => { 103 | jest.spyOn(console, "log") 104 | 105 | context.payload.pull_request = null 106 | 107 | fs.readFile 108 | .mockReturnValueOnce(Promise.resolve("file")) 109 | .mockReturnValueOnce(Promise.resolve(null)) 110 | 111 | const createCommentMock = jest.fn().mockReturnValue(Promise.resolve()) 112 | const updateCommentMock = jest.fn().mockReturnValue(Promise.resolve()) 113 | GitHub.mockReturnValue({ 114 | issues: { 115 | createComment: createCommentMock, 116 | updateComment: updateCommentMock, 117 | }, 118 | }) 119 | 120 | let module 121 | jest.isolateModules(() => { 122 | module = require("../index").default 123 | }) 124 | 125 | await module 126 | 127 | expect(console.log).toHaveBeenCalledWith("Not a pull request, skipping...") 128 | }) 129 | 130 | test("a comment is created on the pull request with the coverage details", async () => { 131 | updateComment = false 132 | 133 | fs.readFile 134 | .mockReturnValueOnce(Promise.resolve("file")) 135 | .mockReturnValueOnce(Promise.resolve(null)) 136 | 137 | const createCommentMock = jest.fn().mockReturnValue(Promise.resolve()) 138 | GitHub.mockReturnValue({ 139 | issues: { 140 | createComment: createCommentMock, 141 | }, 142 | }) 143 | 144 | let module 145 | jest.isolateModules(() => { 146 | module = require("../index").default 147 | }) 148 | 149 | await module 150 | 151 | expect(createCommentMock).toHaveBeenCalledWith({ 152 | repo: "repo", 153 | owner: "owner", 154 | issue_number: 1, 155 | body: "diff", 156 | }) 157 | }) 158 | 159 | describe("when update-comment is enabled", () => { 160 | test("a comment is created on the pull request when there is no comment to update", async () => { 161 | updateComment = true 162 | 163 | fs.readFile 164 | .mockReturnValueOnce(Promise.resolve("file")) 165 | .mockReturnValueOnce(Promise.resolve(null)) 166 | 167 | const createCommentMock = jest.fn().mockReturnValue(Promise.resolve()) 168 | GitHub.mockReturnValue({ 169 | issues: { 170 | createComment: createCommentMock, 171 | listComments: jest.fn().mockReturnValue(Promise.resolve({ data: [] })), 172 | }, 173 | }) 174 | 175 | let module 176 | jest.isolateModules(() => { 177 | module = require("../index").default 178 | }) 179 | 180 | await module 181 | 182 | expect(createCommentMock).toHaveBeenCalledWith({ 183 | repo: "repo", 184 | owner: "owner", 185 | issue_number: 1, 186 | body: "diff", 187 | }) 188 | }) 189 | 190 | test("a comment is updated on the pull request when there is already a comment posted", async () => { 191 | updateComment = true 192 | 193 | fs.readFile 194 | .mockReturnValueOnce(Promise.resolve("file")) 195 | .mockReturnValueOnce(Promise.resolve(null)) 196 | 197 | commentIdentifier.mockReturnValueOnce("COMMENT ID") 198 | 199 | const updateCommentMock = jest.fn().mockReturnValue(Promise.resolve()) 200 | GitHub.mockReturnValue({ 201 | issues: { 202 | updateComment: updateCommentMock, 203 | listComments: jest.fn().mockReturnValue( 204 | Promise.resolve({ 205 | data: [{ id: 1, body: "Some content " }], 206 | }), 207 | ), 208 | }, 209 | }) 210 | 211 | let module 212 | jest.isolateModules(() => { 213 | module = require("../index").default 214 | }) 215 | 216 | await module 217 | 218 | expect(updateCommentMock).toHaveBeenCalledWith({ 219 | repo: "repo", 220 | owner: "owner", 221 | comment_id: 1, 222 | body: "diff", 223 | }) 224 | }) 225 | }) 226 | --------------------------------------------------------------------------------