diff --git a/README.md b/README.md index 7cd2d3b..d11ac45 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,11 @@ This Action defines the following formal inputs. | **`gist_badge_label`** | false | If specified, the Test Report Gist will also include an adjacent badge rendered with the status of the associated Test Report and and label content of this input. In addition to any static text you can provide _escape tokens_ of the form `%name%` where name can be the name of any field returned from a Pester Result, such as `ExecutedAt` or `Result`. If you want a literal percent, just specify an empty name as in `%%`. | **`gist_badge_message`** | false | If Gist badge generation is enabled by providing a value for the `gist_badge_label` input, this input allows you to override the default message on the badge, which is equivalent to the the Pester Result `Status` such as `Failed` or `Passed`. As with the label input, you can specify escape tokens in addition to literal text. See the label input description for more details. | **`gist_token`** | false | GitHub OAuth/PAT token to be used for accessing Gist to store test results report. The integrated GITHUB_TOKEN that is normally accessible during a Workflow does not include read/write permissions to associated Gists, therefore a separate token is needed. You can control which account is used to actually store the state by generating a token associated with the target account. +| **`coverage_paths`** | false | Comma-separated list of one or more directories to scan for code coverage, relative to the root of the project. Will include all .ps1 and .psm1 files under these directories recursively. +| **`coverage_report_name`** | false | The name of the code coverage report object that will be attached to the Workflow Run. Defaults to the name `COVERAGE_RESULTS_` where `` is in the form `yyyyMMdd_hhmmss`. +| **`coverage_report_title`** | false | The title of the code coverage report that will be embedded in the report itself, which defaults to the same as the `code_coverage_report_name` input. +| **`coverage_gist`** | false | If true, will attach the coverage results to the gist specified in `gist_name`. +| **`coverage_gist_badge_label`** | false | If specified, the Test Report Gist will also include an adjacent badge rendered with the percentage of the associated Coverage Report and label content of this input. | **`tests_fail_step`** | false | If true, will cause the step to fail if one or more tests fails. @@ -116,6 +121,7 @@ This Action defines the following formal outputs. | **`total_count`** | Total number of tests discovered. | **`passed_count`** | Total number of tests passed. | **`failed_count`** | Total number of tests failed. +| **`coverage_results_path`** | Path to the code coverage results file in JaCoCo XML format. ### PowerShell GitHub Action diff --git a/action.ps1 b/action.ps1 index 2a44e08..6b4ed5f 100644 --- a/action.ps1 +++ b/action.ps1 @@ -40,6 +40,11 @@ $inputs = @{ gist_token = Get-ActionInput gist_token gist_badge_label = Get-ActionInput gist_badge_label gist_badge_message = Get-ActionInput gist_badge_message + coverage_paths = Get-ActionInput coverage_paths + coverage_report_name = Get-ActionInput coverage_report_name + coverage_report_title = Get-ActionInput coverage_report_title + coverage_gist = Get-ActionInput coverage_gist + coverage_gist_badge_label = Get-ActionInput coverage_gist_badge_label tests_fail_step = Get-ActionInput tests_fail_step } @@ -65,6 +70,7 @@ else { $exclude_paths = splitListInput $inputs.exclude_paths $include_tags = splitListInput $inputs.include_tags $exclude_tags = splitListInput $inputs.exclude_tags + $coverage_paths = splitListInput $inputs.coverage_paths $output_level = splitListInput $inputs.output_level Write-ActionInfo "Running Pester tests with following:" @@ -111,6 +117,19 @@ else { $pesterConfig.Output.Verbosity = $output_level } + if ($coverage_paths) { + Write-ActionInfo " * coverage_paths:" + writeListInput $coverage_paths + $coverageFiles = @() + foreach ($path in $coverage_paths) { + $coverageFiles += Get-ChildItem $Path -Recurse -Include @("*.ps1","*.psm1") -Exclude "*.Tests.ps1" + } + $pesterConfig.CodeCoverage.Enabled = $true + $pesterConfig.CodeCoverage.Path = $coverageFiles + $coverage_results_path = Join-Path $test_results_dir coverage.xml + $pesterConfig.CodeCoverage.OutputPath = $coverage_results_path + } + if ($inputs.tests_fail_step) { Write-ActionInfo " * tests_fail_step: true" } @@ -211,9 +230,33 @@ function Build-MarkdownReport { } } +function Build-CoverageReport { + Write-ActionInfo "Building human-readable code-coverage report" + $script:coverage_report_name = $inputs.coverage_report_name + $script:coverage_report_title = $inputs.coverage_report_title + + if (-not $script:coverage_report_name) { + $script:coverage_report_name = "COVERAGE_RESULTS_$([datetime]::Now.ToString('yyyyMMdd_hhmmss'))" + } + if (-not $coverage_report_title) { + $script:coverage_report_title = $report_name + } + + $script:coverage_report_path = Join-Path $test_results_dir coverage-results.md + & "$PSScriptRoot/jacoco-report/jacocoxml2md.ps1" -Verbose ` + -xmlFile $script:coverage_results_path ` + -mdFile $script:coverage_report_path -xslParams @{ + reportTitle = $script:coverage_report_title + } + + & "$PSScriptRoot/jacoco-report/embedmissedlines.ps1" -mdFile $script:coverage_report_path +} + function Publish-ToCheckRun { param( - [string]$reportData + [string]$reportData, + [string]$reportName, + [string]$reportTitle ) Write-ActionInfo "Publishing Report to GH Workflow" @@ -247,12 +290,12 @@ function Publish-ToCheckRun { Authorization = "token $ghToken" } $bdy = @{ - name = $report_name + name = $reportName head_sha = $ref status = 'completed' conclusion = 'neutral' output = @{ - title = $report_title + title = $reportTitle summary = "This run completed at ``$([datetime]::Now)``" text = $reportData } @@ -262,7 +305,8 @@ function Publish-ToCheckRun { function Publish-ToGist { param( - [string]$reportData + [string]$reportData, + [string]$coverageData ) Write-ActionInfo "Publishing Report to GH Workflow" @@ -273,7 +317,7 @@ function Publish-ToGist { $gistsApiUrl = "https://api.github.com/gists" $apiHeaders = @{ - Accept = "application/vnd.github.v2+json" + Accept = "application/vnd.github.v3+json" Authorization = "token $gist_token" } @@ -334,6 +378,49 @@ function Publish-ToGist { $gistFiles."$($reportGistName)_badge.svg" = @{ content = $gistBadgeResult.Content } } } + if ($coverageData) { + $gistFiles."$([io.path]::GetFileNameWithoutExtension($reportGistName))_Coverage.md" = @{ content = $coverageData } + } + if ($inputs.coverage_gist_badge_label) { + $coverage_gist_badge_label = $inputs.coverage_gist_badge_label + $coverage_gist_badge_label = Resolve-EscapeTokens $coverage_gist_badge_label $pesterResult -UrlEncode + + $coverageXmlData = Select-Xml -Path $coverage_results_path -XPath "/report/counter[@type='LINE']" + $coveredLines = $coverageXmlData.Node.covered + Write-Host "Covered Lines: $coveredLines" + $missedLines = $coverageXmlData.Node.missed + Write-Host "Missed Lines: $missedLines" + if ($missedLines -eq 0) { + $coveragePercentage = 100 + } else { + $coveragePercentage = [math]::Round(100 - (($missedLines / $coveredLines) * 100)) + } + $coveragePercentageString = "$coveragePercentage%" + + if ($coveragePercentage -eq 100) { + $coverage_gist_badge_color = 'brightgreen' + } elseif ($coveragePercentage -ge 80) { + $coverage_gist_badge_color = 'green' + } elseif ($coveragePercentage -ge 60) { + $coverage_gist_badge_color = 'yellowgreen' + } elseif ($coveragePercentage -ge 40) { + $coverage_gist_badge_color = 'yellow' + } elseif ($coveragePercentage -ge 20) { + $coverage_gist_badge_color = 'orange' + } else { + $coverage_gist_badge_color = 'red' + } + + $coverage_gist_badge_url = "https://img.shields.io/badge/$coverage_gist_badge_label-$coveragePercentageString-$coverage_gist_badge_color" + Write-ActionInfo "Computed Coverage Badge URL: $coverage_gist_badge_url" + $coverageGistBadgeResult = Invoke-WebRequest $coverage_gist_badge_url -ErrorVariable $coverageGistBadgeError + if ($coverageGistBadgeError) { + $gistFiles."$($reportGistName)_coverage_badge.txt" = @{ content = $coverageGistBadgeError.Message } + } + else { + $gistFiles."$($reportGistName)_coverage_badge.svg" = @{ content = $coverageGistBadgeResult.Content } + } + } if (-not $reportGist) { Write-ActionInfo "Creating initial Tests Report Gist" @@ -363,11 +450,26 @@ if ($test_results_path) { $reportData = [System.IO.File]::ReadAllText($test_report_path) + if ($coverage_results_path) { + Set-ActionOutput -Name coverage_results_path -Value $coverage_results_path + + Build-CoverageReport + + $coverageSummaryData = [System.IO.File]::ReadAllText($coverage_report_path) + } + if ($inputs.skip_check_run -ne $true) { - Publish-ToCheckRun -ReportData $reportData + Publish-ToCheckRun -ReportData $reportData -ReportName $report_name -ReportTitle $report_title + if ($coverage_results_path) { + Publish-ToCheckRun -ReportData $coverageSummaryData -ReportName $coverage_report_name -ReportTitle $coverage_report_title + } } if ($inputs.gist_name -and $inputs.gist_token) { - Publish-ToGist -ReportData $reportData + if ($inputs.coverage_gist) { + Publish-ToGist -ReportData $reportData -CoverageData $coverageSummaryData + } else { + Publish-ToGist -ReportData $reportData + } } } diff --git a/action.yml b/action.yml index 7a5c639..f7c5428 100644 --- a/action.yml +++ b/action.yml @@ -132,6 +132,41 @@ inputs: You can control which account is used to actually store the state by generating a token associated with the target account. + coverage_paths: + description: | + Comma-separated list of one or more directories to scan for code + coverage, relative to the root of the project. Will include all .ps1 + and .psm1 files under these directories recursively. + required: false + + coverage_report_name: + description: | + The name of the code coverage report object that will be attached + to the Workflow Run. Defaults to the name + `COVERAGE_RESULTS_` where `` is in the form + `yyyyMMdd_hhmmss`. + required: false + + coverage_report_title: + description: | + The title of the code coverage report that will be embedded in the + report itself, which defaults to the same as the + `code_coverage_report_name` input. + required: false + + coverage_gist: + description: | + If true, will attach the coverage results to the gist specified in + `gist_name`. + required: false + + coverage_gist_badge_label: + description: | + If specified, the Test Report Gist will also include an adjacent + badge rendered with the percentage of the associated Coverage Report + and label content of this input. + required: false + tests_fail_step: description: | If true, will cause the step to fail if one or more tests fails. @@ -181,6 +216,10 @@ outputs: failed_count: description: Total number of tests failed. + coverage_results_path: + description: | + Path to the code coverage results file in JaCoCo XML format. + branding: color: purple diff --git a/jacoco-report/embedmissedlines.ps1 b/jacoco-report/embedmissedlines.ps1 new file mode 100644 index 0000000..a1acc5a --- /dev/null +++ b/jacoco-report/embedmissedlines.ps1 @@ -0,0 +1,37 @@ + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$mdFile +) + +$mdData = Get-Content -Path $mdFile + +$outputData = @() +foreach ($line in $mdData) { + if ($line -like "- Line #*") { + $linePrefix = $line.Split("|")[0] + $lineNumber = $linePrefix.Split("#")[1] + $arrayLineNumber = $lineNumber - 1 + + $filePath = $line.Split("|")[1] + if ($IsWindows -or $PSVersionTable.PSVersion.Major -le 5) { + $filePath = $filePath.Replace("/","\") + } + $workspaceFiles = Get-ChildItem -Path "$env:GITHUB_WORKSPACE" -Recurse -File + $resolvedFilePath = $workspaceFiles | Where-Object {$_.FullName -like "*$filePath"} + $fileContents = Get-Content -Path $resolvedFilePath + $missedLine = $fileContents[$arrayLineNumber] + + $outputData += $linePrefix + $outputData += "``````" + $outputData += $missedLine + $outputData += "``````" + + } + else { + $outputData += $line + } +} + +Set-Content -Value $outputData -Path $mdFile \ No newline at end of file diff --git a/jacoco-report/example.jacoco.md b/jacoco-report/example.jacoco.md new file mode 100644 index 0000000..4b22538 --- /dev/null +++ b/jacoco-report/example.jacoco.md @@ -0,0 +1,98 @@ + +# Coverage Report: Pester (04/16/2021 11:51:47) + +* Pester (04/16/2021 11:51:47) + +Outcome: 98.43% Coverage + | Lines Covered: 191 + | Lines Missed: 3 + +## Details: + + +### src + +
+ +:x: ChangelogManagement.psm1 + + + +#### Lines Missed: + +- Line #4 +``` + . $PrivateFile.FullName +``` +
+ + +### src/public + +
+ +:heavy_check_mark: Add-ChangelogData.ps1 + + + +#### All Lines Covered! + +
+ + + +
+ +:heavy_check_mark: ConvertFrom-Changelog.ps1 + + + +#### All Lines Covered! + +
+ + + +
+ +:heavy_check_mark: Get-ChangelogData.ps1 + + + +#### All Lines Covered! + +
+ + + +
+ +:heavy_check_mark: New-Changelog.ps1 + + + +#### All Lines Covered! + +
+ + + +
+ +:x: Update-Changelog.ps1 + + + +#### Lines Missed: + +- Line #79 +``` + throw "You must be running in GitHub Actions to use GitHub LinkMode" +``` +- Line #89 +``` + throw "You must be running in Azure Pipelines to use AzureDevOps LinkMode" +``` +
+ + diff --git a/jacoco-report/example.jacoco.xml b/jacoco-report/example.jacoco.xml new file mode 100644 index 0000000..ae83a29 --- /dev/null +++ b/jacoco-report/example.jacoco.xml @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jacoco-report/jacocoxml2md.ps1 b/jacoco-report/jacocoxml2md.ps1 new file mode 100644 index 0000000..1ce12ac --- /dev/null +++ b/jacoco-report/jacocoxml2md.ps1 @@ -0,0 +1,83 @@ + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$xmlFile, + [string]$mdFile=$null, + [string]$xslFile=$null, + [hashtable]$xslParams=$null +) + +if ($xmlFile -notmatch '^[/\\]') { + $xmlFile = [System.IO.Path]::Combine($PWD, $xmlFile) + Write-Verbose "Resolving XML file relative to current directory: $xmlFile" +} + +if (-not $mdFile) { + $mdFile = $xmlFile + if ([System.IO.Path]::GetExtension($xmlFile) -ieq '.xml') { + $mdFile = $xmlFile -ireplace '.xml$','' + } + $mdFile += '.md' + Write-Verbose "Resolving default MD file: $mdFile" +} +elseif ($mdFile -notmatch '^[/\\]') { + $mdFile = [System.IO.Path]::Combine($PWD, $mdFile) + Write-Verbose "Resolving MD file relative to current directory: $mdFile" +} + +if (-not $xslFile) { + $xslFile = "$PSScriptRoot/jacocoxml2md.xsl" + Write-Verbose "Resolving default XSL file: $xslFile" +} +elseif ($xslFile -notmatch '^[/\\]') { + $xslFile = [System.IO.Path]::Combine($PWD, $xslFile) + Write-Verbose "Resolving XSL file relative to current directory: $xslFile" + +} + +class NUnitXML { + [double]DiffSeconds([datetime]$from, [datetime]$till) { + return ($till - $from).TotalSeconds + } +} + + +if (-not $script:xslt) { + $script:urlr = [System.Xml.XmlUrlResolver]::new() + $script:opts = [System.Xml.Xsl.XsltSettings]::new() + #$script:opts.EnableScript = $true + $script:xslt = [System.Xml.Xsl.XslCompiledTransform]::new() + try { + $script:xslt.Load($xslFile, $script:opts, $script:urlr) + } + catch { + Write-Error $Error[0] + return + } + Write-Verbose "Loaded XSL transformer" +} + +$script:list = [System.Xml.Xsl.XsltArgumentList]::new() +$script:list.AddExtensionObject("urn:nuxml", [NUnitXML]::new()) +if ($xslParams) { + foreach ($xp in $xslParams.GetEnumerator()) { + $script:list.AddParam($xp.Key, [string]::Empty, $xp.Value) + } +} + +$script:wrtr = [System.IO.StreamWriter]::new($mdFile) +try { + Write-Verbose "Transforming XML to MD" + $script:readerSettings = [System.Xml.XmlReaderSettings]::new() + $script:readerSettings.DtdProcessing = "Parse" + $script:reader = [System.Xml.XmlReader]::Create($xmlFile,$script:readerSettings) + + $script:xslt.Transform( + [System.Xml.XmlReader]$script:reader, + [System.Xml.Xsl.XsltArgumentList]$script:list, + [System.IO.TextWriter]$script:wrtr) +} +finally { + $script:wrtr.Dispose() +} diff --git a/jacoco-report/jacocoxml2md.xsl b/jacoco-report/jacocoxml2md.xsl new file mode 100644 index 0000000..0e916b9 --- /dev/null +++ b/jacoco-report/jacocoxml2md.xsl @@ -0,0 +1,87 @@ + + + + + + + + + + + + + +# Coverage Report: + +* + + + + 100 + + + + +Outcome: % Coverage + | Lines Covered: + | Lines Missed: + +## Details: + + + + + +### + + + + + + + + + + :heavy_check_mark: + :x: + + + +<details> + <summary> + + + + </summary> + + +#### Lines Missed: + + + +#### All Lines Covered! + + + +</details> + + + + + +- Line #|/ + + + \ No newline at end of file