├── .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("")}${name}>`
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("")
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 | [](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 | "",
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 | "",
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 | "",
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 | "",
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 |
--------------------------------------------------------------------------------