├── go-test-app-02 ├── go-test-app-01 ├── go.mod ├── foo.go ├── main_test.go ├── Makefile └── main.go ├── .gitignore ├── .github ├── FUNDING.yaml └── workflows │ └── ci.yaml ├── scripts ├── pull.sh ├── beautify-html.sh └── push.sh ├── src ├── check-threshold.js ├── normalize-path.js └── update-comment.js ├── Makefile ├── LICENSE.md ├── assets ├── index.html ├── index.css └── index.js ├── action.yaml └── README.md /go-test-app-02: -------------------------------------------------------------------------------- 1 | go-test-app-01 -------------------------------------------------------------------------------- /go-test-app-01/go.mod: -------------------------------------------------------------------------------- 1 | module cointoss 2 | 3 | go 1.22.2 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *.out 3 | cover-test 4 | cover.html 5 | cover.txt 6 | -------------------------------------------------------------------------------- /go-test-app-01/foo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func foo() string { 4 | return "foo" 5 | } 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yaml: -------------------------------------------------------------------------------- 1 | github: kilianc 2 | buy_me_a_coffee: kilianciuffolo 3 | custom: "https://tinyurl.com/kilian-venmo-me" 4 | -------------------------------------------------------------------------------- /go-test-app-01/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestC(t *testing.T) { 6 | main() 7 | } 8 | -------------------------------------------------------------------------------- /go-test-app-01/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | @go test -coverprofile=cover.out ./... 4 | 5 | .PHONY: clean 6 | clean: 7 | @rm -f cover.* 8 | -------------------------------------------------------------------------------- /scripts/pull.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -xeo pipefail 4 | 5 | cd go-cover 6 | git fetch origin 7 | 8 | # if branch exists, pull it, otherwise create it 9 | if git rev-parse --verify "origin/${INPUTS_BRANCH}"; then 10 | git checkout "${INPUTS_BRANCH}" 11 | git pull origin "${INPUTS_BRANCH}" 12 | else 13 | git checkout --orphan "${INPUTS_BRANCH}" 14 | rm .git/index 15 | git clean -fdx 16 | mkdir -p "./${INPUTS_PATH}/head" 17 | touch "./${INPUTS_PATH}/head/head.html" 18 | touch "./${INPUTS_PATH}/head/head.txt" 19 | echo "mode: set" > "./${INPUTS_PATH}/head/head.out" 20 | fi 21 | -------------------------------------------------------------------------------- /src/check-threshold.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const normalizePath = require('./normalize-path') 3 | 4 | const checkThreshold = module.exports = async ({ threshold, path, revision }) => { 5 | path = normalizePath(path) 6 | 7 | const coverageText = fs.readFileSync(`go-cover/${path}/revisions/${revision}.txt`, 'utf8').split('\n').slice(0, -1) 8 | const coverageTextSummary = coverageText[coverageText.length-1].split('\t').pop() 9 | 10 | const coverage = parseFloat(coverageTextSummary.replace('%', ''), 10) 11 | 12 | if (coverage < threshold) { 13 | console.log(`\x1b[91m✘ coverage ${coverage}% < ${threshold}%`) 14 | process.exit(1) 15 | } 16 | 17 | console.log(`\x1b[92m✔ coverage ${coverage}% >= ${threshold}%`) 18 | } 19 | -------------------------------------------------------------------------------- /go-test-app-01/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | ) 7 | 8 | const side = "right" 9 | 10 | func main() { 11 | if tossCoin() == "heads" { 12 | 13 | 14 | fmt.Println("Heads") } else { fmt.Println(` 15 | Tails`) } 16 | 17 | printColor("red") 18 | 19 | fmt.Println("Maybe:", maybe()) 20 | fmt.Println("Foo:", foo()) 21 | } 22 | 23 | func tossCoin() string { 24 | if rand.Intn(2) == 0 { 25 | return "heads" 26 | } else { 27 | return "tails" 28 | } 29 | } 30 | 31 | func maybe() bool { 32 | if side == "right" { 33 | return true 34 | } else { 35 | return false 36 | } 37 | } 38 | 39 | func printColor(color string) { 40 | switch color { 41 | case "red": fmt.Println("Red") 42 | case "blue": fmt.Println("Blue") 43 | case "green": fmt.Println("Green") 44 | default: fmt.Println("Unknown color") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | permissions: 14 | pull-requests: write 15 | contents: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: '1.22' 23 | 24 | - name: Generate Coverage Files 25 | run: | 26 | cd go-test-app-01 27 | make test 28 | 29 | - name: Go Beautiful HTML Coverage 30 | if: always() 31 | uses: './' 32 | with: 33 | path: go-test-app-01/ 34 | threshold: 66.7 35 | 36 | - name: Go Beautiful HTML Coverage 37 | uses: './' 38 | with: 39 | path: go-test-app-02/ 40 | -------------------------------------------------------------------------------- /scripts/beautify-html.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "${REVISION}" = "local" ]; then 4 | set -eo pipefail 5 | else 6 | set -xeo pipefail 7 | fi 8 | 9 | if [ -z "${REVISION}" ]; then 10 | echo "REVISION is not set" 11 | exit 1 12 | fi 13 | 14 | # this is useful for browser caching 15 | hash=$(cat index.css index.js | md5sum | awk '{print $1}') 16 | 17 | for file in "revisions/${REVISION}.html" "revisions/${REVISION}-inc.html"; do 18 | ex -sc '%s/\n\t\t 47 | 85 | 86 | 87 |
88 |

Waiting for GitHub Pages Deployment

89 | 90 | 91 | -------------------------------------------------------------------------------- /action.yaml: -------------------------------------------------------------------------------- 1 | name: 'Go Beautiful HTML Coverage' 2 | description: 'A GitHub Action to track code coverage in your pull requests, with a beautiful HTML preview, for free.' 3 | author: 'Kilian Ciuffolo' 4 | branding: 5 | icon: 'bar-chart' 6 | color: 'green' 7 | inputs: 8 | repository: 9 | description: 'Repository name with owner. For example, actions/checkout' 10 | default: ${{ github.repository }} 11 | branch: 12 | default: cover 13 | description: The branch to checkout or create and push coverage to. 14 | token: 15 | description: The token to use for pushing to the repository. 16 | default: ${{ github.token }} 17 | path: 18 | description: The relative path of your go project. Useful for monorepos and custom folder structures. 19 | default: './' 20 | threshold: 21 | description: The minimum % of coverage required. 22 | default: '0' 23 | runs: 24 | using: composite 25 | steps: 26 | - name: Checkout Coverage Branch 27 | uses: actions/checkout@v4 28 | with: 29 | repository: ${{ inputs.repository }} 30 | path: go-cover 31 | token: ${{ inputs.token }} 32 | 33 | - id : 'normalize-path' 34 | name: Normalize Input Path 35 | uses: actions/github-script@v7 36 | with: 37 | result-encoding: string 38 | script: | 39 | const script = require(`${process.env.GITHUB_ACTION_PATH}/src/normalize-path.js`) 40 | return script('${{ inputs.path }}') 41 | 42 | - name: Checkout Coverage Branch 43 | shell: bash 44 | run: | 45 | export INPUTS_BRANCH="${{ inputs.branch }}" 46 | export INPUTS_PATH="${{ steps.normalize-path.outputs.result }}" 47 | ${GITHUB_ACTION_PATH}/scripts/pull.sh 48 | 49 | - name: Push Coverage 50 | shell: bash 51 | run: | 52 | export REVISION="${{ github.event.pull_request.head.sha || github.sha }}" 53 | export INPUTS_PATH="${{ steps.normalize-path.outputs.result }}" 54 | export INPUTS_BRANCH="${{ inputs.branch }}" 55 | export REF_NAME="${{ github.ref_name }}" 56 | ${GITHUB_ACTION_PATH}/scripts/push.sh 57 | 58 | - name: Post Code Coverage Comment 59 | if: ${{ github.event_name == 'pull_request' }} 60 | uses: actions/github-script@v7 61 | with: 62 | github-token: ${{ inputs.token }} 63 | script: | 64 | const script = require(`${process.env.GITHUB_ACTION_PATH}/src/update-comment.js`) 65 | const revision = '${{ github.event.pull_request.head.sha || github.sha }}' 66 | const threshold = parseFloat('${{ inputs.threshold }}', 10) 67 | const path = '${{ steps.normalize-path.outputs.result }}' 68 | await script({ context, github, path, revision, threshold }) 69 | 70 | - name: Check Coverage Threshold 71 | if: ${{ github.event_name == 'pull_request' }} 72 | uses: actions/github-script@v7 73 | with: 74 | github-token: ${{ inputs.token }} 75 | script: | 76 | const script = require(`${process.env.GITHUB_ACTION_PATH}/src/check-threshold.js`) 77 | const revision = '${{ github.event.pull_request.head.sha || github.sha }}' 78 | const threshold = parseFloat('${{ inputs.threshold }}', 10) 79 | const path = '${{ steps.normalize-path.outputs.result }}' 80 | await script({ threshold, path, revision }) 81 | -------------------------------------------------------------------------------- /assets/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --nav-gap: 15px; 3 | --covered: green; 4 | --uncovered: #bf616a; 5 | --mixed: #ddaa00; 6 | --background: #ffffff; 7 | --topbar-background: #f6f8fa; 8 | --topbar-border: #d0d7de; 9 | --topbar-color: #2e3440; 10 | --topbar-hover-color: #afb8c133; 11 | --select-background: #d8dee9; 12 | --select-border: #d0d7de; 13 | --select-color: #2e3440; 14 | --gutter-color: #636c76; 15 | } 16 | 17 | :root.dark { 18 | --covered: rgb(71, 210, 71); 19 | --uncovered: #bf616a; 20 | --mixed: #ffcc00; 21 | --background: #242931; 22 | --topbar-background: #2e3440; 23 | --topbar-border: #4c566a; 24 | --topbar-color: #d8dee9; 25 | --select-background: #242931; 26 | --select-border: #4c566a; 27 | --select-color: #d8dee9; 28 | --gutter-color: #636c76; 29 | } 30 | 31 | * { 32 | font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, 33 | "Liberation Mono", monospace; 34 | font-size: 12px; 35 | margin: 0; 36 | padding: 0; 37 | box-sizing: border-box; 38 | } 39 | 40 | body { 41 | background: var(--background); 42 | transition: all 0.1s ease-in-out; 43 | min-height: 100vh; 44 | } 45 | 46 | #topbar { 47 | position: sticky; 48 | top: 0; 49 | height: 50px; 50 | display: flex; 51 | align-items: center; 52 | z-index: 1000; 53 | background: var(--topbar-background); 54 | border-color: var(--topbar-border); 55 | border-bottom: 1px solid var(--topbar-border); 56 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 57 | padding-left: var(--nav-gap); 58 | 59 | #legend { 60 | display: flex; 61 | align-items: center; 62 | margin-right: auto; 63 | 64 | span { 65 | padding: 2px 4px; 66 | margin-left: var(--nav-gap); 67 | border-radius: 2px; 68 | color: var(--topbar-color); 69 | } 70 | } 71 | 72 | #nav { 73 | position: relative; 74 | display: inline-block; 75 | 76 | &::after { 77 | content: "▼"; 78 | position: absolute; 79 | right: 1.2em; 80 | top: 50%; 81 | transform: translateY(-50%); 82 | pointer-events: none; 83 | font-size: 0.75em; 84 | color: var(--select-color); 85 | } 86 | 87 | select { 88 | display: block; 89 | -webkit-appearance: none; 90 | -moz-appearance: none; 91 | appearance: none; 92 | background-color: var(--select-background); 93 | color: var(--select-color); 94 | border: 1px solid var(--select-border); 95 | border-radius: 3px; 96 | padding: 4px 24px 4px 8px; 97 | } 98 | } 99 | 100 | a.incremental { 101 | display: flex; 102 | background: var(--topbar-hover-color); 103 | padding: 2px 4px; 104 | border-radius: 2px; 105 | box-shadow: inset 0 0 0 1px var(--topbar-hover-color); 106 | color: var(--topbar-color); 107 | text-decoration: none; 108 | align-items: center; 109 | 110 | &:hover { 111 | background: transparent; 112 | box-shadow: inset 0 0 0 1px var(--topbar-hover-color); 113 | } 114 | 115 | &:active { 116 | transform: translateY(1px); 117 | } 118 | } 119 | 120 | input[type="checkbox"] { 121 | height: 0; 122 | width: 0; 123 | visibility: hidden; 124 | margin-left: var(--nav-gap); 125 | 126 | &:checked + label { 127 | background: #00000033; 128 | } 129 | 130 | &:checked + label:after { 131 | left: 100%; 132 | transform: scale(0.9) translateX(-110%); 133 | } 134 | } 135 | 136 | label { 137 | cursor: pointer; 138 | height: 20px; 139 | width: 50px; 140 | border-radius: 15px; 141 | background: #afb8c133; 142 | display: block; 143 | position: relative; 144 | margin-right: 15px; 145 | margin-right: var(--nav-gap); 146 | 147 | &:after { 148 | content: ""; 149 | position: absolute; 150 | top: 0; 151 | left: 0; 152 | height: 100%; 153 | aspect-ratio: 1 / 1; 154 | background: #fff; 155 | border-radius: 50%; 156 | transform: scale(0.9); 157 | transition: 0.3s; 158 | } 159 | } 160 | } 161 | 162 | #content { 163 | padding: 15px 0; 164 | line-height: 1.67em; 165 | 166 | pre { 167 | position: relative; 168 | background: transparent !important; 169 | 170 | .code, 171 | .coverage { 172 | display: flex; 173 | flex-direction: row; 174 | gap: 10px; 175 | } 176 | 177 | .code { 178 | .gutter { 179 | opacity: 0; 180 | } 181 | 182 | .editor { 183 | background: transparent !important; 184 | } 185 | } 186 | 187 | .coverage { 188 | color: transparent !important; 189 | background: transparent; 190 | 191 | .editor { 192 | .cov { 193 | display: inline-block; 194 | border-width: 0 4px; 195 | border-style: solid; 196 | border-radius: 2px; 197 | border-color: transparent; 198 | color: transparent; 199 | margin-left: -8px; 200 | transform: translateX(4px); 201 | } 202 | } 203 | } 204 | 205 | .gutter { 206 | display: flex; 207 | flex-direction: column; 208 | color: var(--gutter-color); 209 | 210 | .ln { 211 | text-align: right; 212 | padding: 0 16px; 213 | } 214 | } 215 | } 216 | } 217 | 218 | .cov-covered { 219 | color: var(--covered); 220 | background-color: color-mix(in srgb, var(--covered) 10%, transparent); 221 | } 222 | 223 | .cov-uncovered { 224 | color: var(--uncovered); 225 | background-color: color-mix(in srgb, var(--uncovered) 20%, transparent); 226 | } 227 | 228 | .cov-mixed { 229 | color: var(--mixed); 230 | background-color: color-mix(in srgb, var(--mixed) 10%, transparent); 231 | } 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # `go-beautiful-html-coverage` 3 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge-flat.svg)](https://github.com/avelino/awesome-go) 4 | 5 | A GitHub Action to track code coverage in your pull requests, with [a beautiful HTML preview ↗](https://kilianc.github.io/pretender/head/head.html#file0), for free. 6 | 7 | Buy Me A Coffee 8 | 9 | ## Usage 10 | 11 | To use this action simply add it to your pre-existent ci workflow. A bare minimal example might look like this: 12 | 13 | ```yaml 14 | name: Go 15 | 16 | on: 17 | push: 18 | branches: [ "main" ] 19 | pull_request: 20 | branches: [ "main" ] 21 | 22 | jobs: 23 | test: 24 | name: Build and Test 25 | runs-on: ubuntu-latest 26 | permissions: 27 | pull-requests: write # required for posting comments 28 | contents: write # required for git push 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Set up Go 33 | uses: actions/setup-go@v5 34 | 35 | - name: Test # this should generate cover.out 36 | run: make test 37 | 38 | - name: Go Beautiful HTML Coverage 39 | uses: 'gha-common/go-beautiful-html-coverage@v1' 40 | ``` 41 | 42 | ## How it works 43 | 44 | This GHA expects `cover.out` to be present in the root of your repo at runtime. `cover.out` is usually generated by `go test` when passing the `-coverprofile=cover.out` flag: 45 | 46 | ```sh 47 | go test -coverprofile=cover.out ./... 48 | ``` 49 | 50 | For examples on how you might do that you can peak at [`go-test-app/Makefile`](./Makefile), or some of my other go projects like [`pretender`](https://github.com/kilianc/pretender/blob/main/Makefile#L44-L57) and [`base-go-cli`](https://github.com/kilianc/base-golang-cli/blob/main/Makefile#L76-L92). 51 | 52 | Once your test has ran and `cover.out` has been generated, the GHA does the following: 53 | 54 | 1. Create and push [new orphan branch](https://github.com/gha-common/go-beautiful-html-coverage/tree/cover) if one doesn't exist. 55 | 1. Generate `cover.html` and `cover.txt` from `cover.out`. 56 | 1. Customize `cover.html` and rename it `.html`. 57 | 1. `git-push` the `.html` file to the orphan branch. This will trigger a `GitHub Pages` deployment. 58 | 1. Post a comment to your PR with your code coverage summary (`cover.txt`) and a link to your `.html`. 59 | 60 | ### Screenshots 61 | 62 |
63 | PR Comment 64 | HTML Preview (Dark) 65 | HTML Preview (Light) 66 |

67 | 68 | > [!NOTE] 69 | > In order for the HTML preview links to work, configure `GitHub Pages` in your target repo *(`Settings > Pages`)* to `Deploy from a branch` and pick your target branch, which is, by default, `cover`. 70 | > 71 | > ![GitHub Pages Setup](https://github.com/gha-common/go-beautiful-html-coverage/assets/385716/a14f4df6-6263-4ae3-8685-e7901a1dbbe2) 72 | 73 | ## Reference 74 | 75 | ```yaml 76 | - name: Go Beautiful HTML Coverage 77 | uses: 'gha-common/go-beautiful-html-coverage@v1' 78 | with: 79 | # Repository name with owner. For example, actions/checkout. 80 | # Default: ${{ github.repository }} 81 | repository: '' 82 | 83 | # The branch to checkout or create and push coverage to. 84 | # Default: 'cover' 85 | branch: '' 86 | 87 | # The token to use for pushing to the repository. 88 | # Default: ${{ github.token }} 89 | token: '' 90 | 91 | # The relative path of your go project. Useful for monorepos and custom folder structures. 92 | # Default: ./ 93 | path: '' 94 | 95 | # The minimum % of coverage required. 96 | # Default: 0 97 | threshold: '' 98 | ``` 99 | 100 | ## Examples 101 | 102 | **You can customize the name of the branch that hosts the code coverage files.** 103 | 104 | ```yaml 105 | - name: Go Beautiful HTML Coverage 106 | uses: 'gha-common/go-beautiful-html-coverage@v1' 107 | with: 108 | branch: 'my-coverage' 109 | ``` 110 | 111 | Just make sure to update the `GitHub Pages` deployment settings to match. 112 | 113 | **You can customize the repository that hosts the code coverage files.** 114 | 115 | This is helpful if you don't want to clutter your project's repo, or if you want to centralize coverage reporting across multiple repos, or you can't turn on `GitHub Pages` in your project's repo. 116 | 117 | ```yaml 118 | - name: Go Beautiful HTML Coverage 119 | uses: 'gha-common/go-beautiful-html-coverage@v1' 120 | with: 121 | repository: yourname/coverage 122 | token: ${{ secrets.GHA_COVERAGE_TOKEN }} 123 | ``` 124 | 125 | Where `GHA_COVERAGE_TOKEN` is a repository secret with a personal token that has write access to `yourname/coverage`. 126 | 127 | **You can customize the path to your go project in the repo.** 128 | 129 | This is helpful if you have a monorepo with multiple apps, or simply you keep your go files in a subfolder. Just make sure to generate `cover.out` for all your apps before running this GHA. 130 | 131 | ```yaml 132 | - name: Go Beautiful HTML Coverage 133 | uses: 'gha-common/go-beautiful-html-coverage@v1' 134 | with: 135 | path: ./go-app-01 136 | 137 | - name: Go Beautiful HTML Coverage 138 | uses: 'gha-common/go-beautiful-html-coverage@v1' 139 | with: 140 | path: ./go-app-02 141 | ``` 142 | 143 | ## License 144 | 145 | MIT License, see [LICENSE](./LICENSE.md) 146 | -------------------------------------------------------------------------------- /assets/index.js: -------------------------------------------------------------------------------- 1 | // hide the page until fully setup 2 | document.documentElement.style.setProperty('opacity', '0') 3 | 4 | let loading = load([ 5 | 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css', 6 | 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css', 7 | 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 8 | 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/go.min.js', 9 | '../index.css?' + document.querySelector('script[src*="index.js"]').src.split('?').pop(), 10 | ]) 11 | 12 | // wait for the page to fully load 13 | document.addEventListener('DOMContentLoaded', main) 14 | 15 | function main() { 16 | // wait for highlight.js to load 17 | if (!window.hljs) { 18 | console.log('loading: waiting for highlight.js to load...') 19 | setTimeout(main, 100) 20 | return 21 | } 22 | 23 | // wait for all assets to load 24 | if (!loading.isDone()) { 25 | console.log('loading: waiting for assets to load...') 26 | setTimeout(main, 100) 27 | return 28 | } 29 | 30 | // setup the layout 31 | configureLegend() 32 | configureFileSelect() 33 | addIncrementalButton() 34 | addThemeButton() 35 | 36 | // setup the content 37 | configureCodeBlocks() 38 | configureSyntaxHighlight('pre .code .editor') 39 | addCoverageSpans('pre .coverage .editor') 40 | addLineNumbers() 41 | 42 | // setup complete, restore the page visibility 43 | document.documentElement.style.setProperty('opacity', '1') 44 | } 45 | 46 | function configureLegend() { 47 | let spans = document.querySelectorAll('#legend span') 48 | for (let i = 0; i < spans.length; i++) { 49 | if (spans[i].classList.length === 0) continue 50 | 51 | if (spans[i].classList[0] === 'cov0') { 52 | spans[i].classList.add('cov-uncovered') 53 | } else { 54 | spans[i].classList.add('cov-covered') 55 | } 56 | } 57 | } 58 | 59 | function addCoverageSpans(cssSelector) { 60 | let spans = Array.from(document.querySelectorAll(`${cssSelector} span`)) 61 | 62 | spans.forEach((span) => { 63 | let html = span.innerHTML 64 | let lines = html.split('\n') 65 | let covClass = span.classList[0] === 'cov0' ? 'cov-uncovered' : 'cov-covered' 66 | 67 | for (let i = 0; i < lines.length; i++) { 68 | let trimmed = lines[i].trim() 69 | let [start, end] = lines[i].split(trimmed) 70 | 71 | if (trimmed[0] === '{' || trimmed[0] === '}') { 72 | trimmed = trimmed.slice(1).trim() 73 | start = lines[i].replace(trimmed, '') 74 | } 75 | 76 | if (trimmed[trimmed.length - 1] === '{' || trimmed[trimmed.length - 1] === '}') { 77 | trimmed = trimmed.slice(0, -1).trim() 78 | end = lines[i].split(trimmed)[1] 79 | } 80 | 81 | if (trimmed === '') { 82 | lines[i] = `${start || ''}${trimmed}${end || ''}` 83 | } else { 84 | lines[i] = `${start || ''}${trimmed}${end || ''}` 85 | } 86 | } 87 | 88 | span.innerHTML = lines.join('\n') 89 | }) 90 | } 91 | 92 | function addIncrementalButton() { 93 | let url = window.location.href 94 | let isInc = url.includes('-inc.html') 95 | 96 | let link = document.createElement('a') 97 | link.classList = 'incremental' 98 | link.classList.add('hljs-selector-id') 99 | 100 | if (isInc) { 101 | link.title = 'Toggle to absolute coverage' 102 | link.href = url.replace('-inc.html', '.html') 103 | } else { 104 | link.title = 'Toggle to incremental coverage' 105 | link.href = url.replace('.html', '-inc.html') 106 | } 107 | 108 | link.innerHTML = ` 109 | 110 | 111 | ${isInc ? 'inc' : 'abs'} 112 | ` 113 | 114 | document.querySelector('#topbar').appendChild(link) 115 | } 116 | 117 | function configureFileSelect() { 118 | let selected = document.location.hash.slice(1) 119 | let files = document.getElementById('files') 120 | 121 | files.addEventListener('change', (e) => { 122 | let el = document.getElementById(e.target.value) 123 | 124 | if (!el) { 125 | files.value = 'file0' 126 | files.dispatchEvent(new Event('change')) 127 | return 128 | } 129 | 130 | document.location.hash = e.target.value 131 | 132 | document.body.scrollTop = 0; 133 | 134 | document.querySelectorAll('.file').forEach((el) => (el.style.display = 'none')) 135 | el.style.display = 'block' 136 | }) 137 | 138 | files.value = selected 139 | files.dispatchEvent(new Event('change')) 140 | } 141 | 142 | function addThemeButton() { 143 | let isDark = localStorage.getItem('dark') === 'true' 144 | 145 | let switchInput = document.createElement('input') 146 | switchInput.type = 'checkbox' 147 | switchInput.id = 'switch' 148 | switchInput.checked = isDark 149 | 150 | let switchLabel = document.createElement('label') 151 | switchLabel.htmlFor = 'switch' 152 | 153 | document.querySelector('#topbar').appendChild(switchInput) 154 | document.querySelector('#topbar').appendChild(switchLabel) 155 | 156 | if (isDark) { 157 | toggleDarkMode() 158 | } 159 | 160 | document.querySelector('#switch').addEventListener('click', toggleDarkMode) 161 | } 162 | 163 | function toggleDarkMode() { 164 | let lightStyle = document.querySelector('link[href*="github.min.css"]') 165 | let darkStyle = document.querySelector('link[href*="github-dark.min.css"]') 166 | 167 | document.documentElement.classList.toggle('dark') 168 | 169 | if (document.documentElement.classList.contains('dark')) { 170 | localStorage.setItem('dark', 'true') 171 | lightStyle.setAttribute('disabled', 'disabled') 172 | darkStyle.removeAttribute('disabled') 173 | } else { 174 | localStorage.setItem('dark', 'false') 175 | lightStyle.removeAttribute('disabled') 176 | darkStyle.setAttribute('disabled', 'disabled') 177 | } 178 | } 179 | 180 | function configureCodeBlocks() { 181 | document.querySelectorAll('#content pre').forEach((pre) => { 182 | let gutter = document.createElement('div') 183 | gutter.classList.add('gutter') 184 | 185 | let editor = document.createElement('div') 186 | editor.classList.add('editor', 'language-go') 187 | editor.innerHTML = pre.innerHTML.replaceAll(' ', ' ') 188 | 189 | let code = document.createElement('div') 190 | code.appendChild(gutter) 191 | code.appendChild(editor) 192 | 193 | let coverage = code.cloneNode(true) 194 | coverage.classList = 'coverage' 195 | 196 | editor.innerHTML = editor.textContent 197 | code.classList = 'code' 198 | code.style.setProperty('position', 'absolute') 199 | code.style.setProperty('top', '0') 200 | code.style.setProperty('left', '0') 201 | 202 | pre.innerHTML = '' 203 | pre.appendChild(coverage) 204 | pre.appendChild(code) 205 | }) 206 | } 207 | 208 | function configureSyntaxHighlight(cssSelector) { 209 | hljs.configure({ cssSelector, ignoreUnescapedHTML: true }) 210 | hljs.highlightAll() 211 | } 212 | 213 | function addLineNumbers() { 214 | let pres = Array.from(document.querySelectorAll('#content pre')) 215 | 216 | pres.forEach((pre) => { 217 | let code = pre.querySelector('.coverage') 218 | let gutter = code.querySelector('.gutter') 219 | let editor = code.querySelector('.editor') 220 | let lines = editor.innerHTML.split('\n') 221 | let gutterHtml = '' 222 | 223 | // this function has two goals: 224 | // 1. add line numbers to the gutter 225 | // 2. assign a color to the line number based on the coverage of the line 226 | // 227 | // first, we add a .line-start span to each line in the editor. 228 | // this allows us to group the spans in the editor by line. 229 | // if a line has only one span, we assign the color of the span to the line number in the gutter. 230 | // if a line has more than one span and they have different background colors, 231 | // we can assume that the line has multiple statements with multiple coverage states 232 | // and we assign a yellow-ish color to the line number in the gutter. 233 | 234 | editor.innerHTML = lines.map((line) => `${line}`).join('\n') 235 | 236 | let lineNumber = 1 237 | let spansInLine 238 | let spans = Array.from(editor.querySelectorAll('span')) 239 | 240 | for (let i = 0; i < spans.length; i++) { 241 | let currentSpan = spans[i] 242 | let nextSpan = spans[i + 1] 243 | 244 | if (currentSpan.classList.contains('line-start')) { 245 | spansInLine = [] 246 | } 247 | 248 | if (nextSpan?.classList?.contains('cov')) { 249 | spansInLine.push(nextSpan) 250 | continue 251 | } 252 | 253 | if (!nextSpan?.classList.contains('line-start')) { 254 | continue 255 | } 256 | 257 | let classes = new Set(spansInLine.map((el) => el.classList[1])) 258 | let className = classes.size > 1 ? 'cov-mixed' : classes.values().next().value || '' 259 | 260 | gutterHtml += `
${lineNumber}
` 261 | 262 | lineNumber++ 263 | } 264 | 265 | gutterHtml += `
${lineNumber}
` 266 | gutter.innerHTML = gutterHtml 267 | 268 | // add line numbers to the code gutter 269 | pre.querySelector('.code .gutter').innerHTML = gutterHtml 270 | }) 271 | } 272 | 273 | function loadScript(src, state) { 274 | let script = document.createElement('script') 275 | script.src = src 276 | script.async = false 277 | script.onload = () => { 278 | console.info(`loaded: ${src}`) 279 | state.loaded++ 280 | } 281 | document.head.appendChild(script) 282 | } 283 | 284 | function loadStyle(src, state) { 285 | let style = document.createElement('link') 286 | style.rel = 'stylesheet' 287 | style.href = src 288 | style.async = true 289 | style.onload = () => { 290 | console.info(`loaded: ${src}`) 291 | state.loaded++ 292 | } 293 | document.head.appendChild(style) 294 | } 295 | 296 | function load(urls) { 297 | let state = { 298 | loaded: 0, 299 | isDone: () => state.loaded === urls.length, 300 | } 301 | 302 | for (let url of urls) { 303 | if (url.endsWith('.js')) { 304 | loadScript(url, state) 305 | } else { 306 | loadStyle(url, state) 307 | } 308 | } 309 | 310 | return state 311 | } 312 | --------------------------------------------------------------------------------