maksit-certs-ui/utils/TestRunner.psm1

287 lines
9.2 KiB
PowerShell

#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
PowerShell module for running tests with code coverage.
.DESCRIPTION
Provides the Invoke-TestsWithCoverage function for running .NET tests
with Coverlet code coverage collection and parsing results.
.NOTES
Author: MaksIT
Usage: pwsh -Command "Import-Module .\TestRunner.psm1"
#>
function Import-LoggingModuleInternal {
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
return
}
$modulePath = Join-Path $PSScriptRoot "Logging.psm1"
if (Test-Path $modulePath) {
Import-Module $modulePath -Force
}
}
function Write-TestRunnerLogInternal {
param(
[Parameter(Mandatory = $true)]
[string]$Message,
[Parameter(Mandatory = $false)]
[ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")]
[string]$Level = "INFO"
)
Import-LoggingModuleInternal
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
Write-Log -Level $Level -Message $Message
return
}
Write-Host $Message -ForegroundColor Gray
}
function Invoke-TestsWithCoverage {
<#
.SYNOPSIS
Runs unit tests with code coverage and returns coverage metrics.
.PARAMETER TestProjectPath
One or more paths to test project directories (or .csproj files). Each project
is tested in order; coverage metrics are aggregated across all Cobertura outputs.
.PARAMETER Silent
Suppress console output (for JSON consumption).
.PARAMETER ResultsDirectory
Optional fixed directory where test result files are written.
.PARAMETER KeepResults
Keep the TestResults folder after execution.
.OUTPUTS
PSCustomObject with properties:
- Success: bool
- Error: string (if failed)
- LineRate: double
- BranchRate: double
- MethodRate: double
- TotalMethods: int
- CoveredMethods: int
- CoverageFile: string (ReportGenerator reports arg: one file, or semicolon-separated)
- CoverageFiles: string[] (individual Cobertura paths)
- ResultsDirectory: string (absolute folder used for dotnet test output; may be removed after run unless -KeepResults)
.EXAMPLE
$result = Invoke-TestsWithCoverage -TestProjectPath ".\Tests"
if ($result.Success) { Write-TestRunnerLogInternal -Level "INFO" -Message "Line coverage: $($result.LineRate)%" }
.EXAMPLE
$result = Invoke-TestsWithCoverage -TestProjectPath @(".\ProjA.Tests", ".\ProjB.Tests")
#>
param(
[Parameter(Mandatory = $true)]
[string[]]$TestProjectPath,
[switch]$Silent,
[string]$ResultsDirectory,
[switch]$KeepResults
)
$ErrorActionPreference = "Stop"
# Normalize to a non-empty list of absolute working directories (folder containing the test project).
$resolvedProjectDirs = [System.Collections.Generic.List[string]]::new()
foreach ($raw in $TestProjectPath) {
if ([string]::IsNullOrWhiteSpace($raw)) { continue }
$full = [System.IO.Path]::GetFullPath($raw.Trim())
if (-not (Test-Path $full)) {
return [PSCustomObject]@{
Success = $false
Error = "Test project not found at: $raw"
}
}
$item = Get-Item -LiteralPath $full
$dir = if ($item.PSIsContainer) { $item.FullName } else { $item.Directory.FullName }
if ($resolvedProjectDirs -notcontains $dir) {
[void]$resolvedProjectDirs.Add($dir)
}
}
if ($resolvedProjectDirs.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
Error = "No valid test project paths were provided."
}
}
if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) {
$ResultsDir = Join-Path $resolvedProjectDirs[0] "TestResults"
}
else {
$ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory)
}
# Clean previous results once (shared output for all test runs).
if (Test-Path $ResultsDir) {
Remove-Item -Recurse -Force $ResultsDir
}
New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null
if (-not $Silent) {
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..."
foreach ($d in $resolvedProjectDirs) {
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
}
}
foreach ($TestProjectDir in $resolvedProjectDirs) {
Push-Location $TestProjectDir
try {
$dotnetArgs = @(
"test"
"--collect:XPlat Code Coverage"
"--results-directory", $ResultsDir
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
)
if ($Silent) {
$null = & dotnet @dotnetArgs 2>&1
}
else {
& dotnet @dotnetArgs
}
$testExitCode = $LASTEXITCODE
if ($testExitCode -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "Tests failed in '$TestProjectDir' with exit code $testExitCode"
}
}
}
finally {
Pop-Location
}
}
$coverageFiles = @(Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Sort-Object FullName)
if ($coverageFiles.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
Error = "Coverage file not found under: $ResultsDir"
}
}
if (-not $Silent) {
foreach ($cf in $coverageFiles) {
Write-TestRunnerLogInternal -Level "OK" -Message "Coverage file found: $($cf.FullName)"
}
Write-TestRunnerLogInternal -Level "STEP" -Message "Parsing coverage data..."
}
# Aggregate line/branch from Cobertura counters; methods by walking all files.
$linesCoveredTotal = 0L
$linesValidTotal = 0L
$branchesCoveredTotal = 0L
$branchesValidTotal = 0L
$totalMethods = 0
$coveredMethods = 0
foreach ($cf in $coverageFiles) {
[xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw
$root = $coverageXml.coverage
$lcAttr = $root.'lines-covered'
$lvAttr = $root.'lines-valid'
if ($null -ne $lcAttr -and $null -ne $lvAttr -and [long]$lvAttr -gt 0) {
$linesCoveredTotal += [long]$lcAttr
$linesValidTotal += [long]$lvAttr
}
$bcAttr = $root.'branches-covered'
$bvAttr = $root.'branches-valid'
if ($null -ne $bcAttr -and $null -ne $bvAttr -and [long]$bvAttr -gt 0) {
$branchesCoveredTotal += [long]$bcAttr
$branchesValidTotal += [long]$bvAttr
}
foreach ($package in @($root.packages.package)) {
foreach ($class in @($package.classes.class)) {
$methodNodes = $class.methods
if ($null -eq $methodNodes) { continue }
foreach ($method in @($methodNodes.method)) {
if ($null -eq $method) { continue }
$totalMethods++
if ([double]$method.'line-rate' -gt 0) {
$coveredMethods++
}
}
}
}
}
if ($linesValidTotal -gt 0) {
$lineRate = [math]::Round(($linesCoveredTotal / $linesValidTotal) * 100, 1)
}
else {
# Fallback: average of per-file line-rate when counters are missing (older Cobertura).
$acc = 0.0
$n = 0
foreach ($cf in $coverageFiles) {
[xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw
$acc += [double]$coverageXml.coverage.'line-rate'
$n++
}
$lineRate = [math]::Round(($acc / [math]::Max($n, 1)) * 100, 1)
}
if ($branchesValidTotal -gt 0) {
$branchRate = [math]::Round(($branchesCoveredTotal / $branchesValidTotal) * 100, 1)
}
else {
$acc = 0.0
$n = 0
foreach ($cf in $coverageFiles) {
[xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw
$acc += [double]$coverageXml.coverage.'branch-rate'
$n++
}
$branchRate = [math]::Round(($acc / [math]::Max($n, 1)) * 100, 1)
}
$methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 }
$coveragePaths = @($coverageFiles | ForEach-Object { $_.FullName })
$coverageFileReportArg = $coveragePaths -join ";"
$resultsDirectoryFull = [System.IO.Path]::GetFullPath($ResultsDir)
# Cleanup unless KeepResults is specified
if (-not $KeepResults) {
if (Test-Path $ResultsDir) {
Remove-Item -Recurse -Force $ResultsDir
}
}
# Return results
return [PSCustomObject]@{
Success = $true
LineRate = $lineRate
BranchRate = $branchRate
MethodRate = $methodRate
TotalMethods = $totalMethods
CoveredMethods = $coveredMethods
CoverageFile = $coverageFileReportArg
CoverageFiles = $coveragePaths
ResultsDirectory = $resultsDirectoryFull
}
}
Export-ModuleMember -Function Invoke-TestsWithCoverage