plugins/coverage/Jax.Plugin.Coverage.psm1
|
function Invoke-JaxCoverageMaintenanceScript { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string] $Path, [Parameter(Mandatory = $true)] [string] $Label, [hashtable] $ScriptArguments = @{} ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } $previousLastExitCode = $global:LASTEXITCODE try { $global:LASTEXITCODE = 0 & $Path @ScriptArguments if ($LASTEXITCODE -ne 0) { Write-Warning "[coverage-plugin] $Label exited with code $LASTEXITCODE" } } catch { Write-Warning "[coverage-plugin] $Label failed: $_" } finally { $global:LASTEXITCODE = $previousLastExitCode } } function Register-JaxCoveragePlugin { [CmdletBinding()] param() $commonParams = Get-JaxCommonParameters -BoundParameters $PSBoundParameters if ($null -eq $commonParams) { $commonParams = @{} } $hooks = @{ # Resolve -coverage / -nocoverage against persisted preference and stash the # effective state on the run context. Mirrors the vault plugin's pattern. 'BeforeRunHeader' = { param($hook) $context = $hook.Context $pluginConfig = $hook.PluginConfig $resolved = $null if ($hook.Data -and $hook.Data.ContainsKey('Resolved')) { $resolved = $hook.Data['Resolved'] } $coverageEnabled = $false if ($pluginConfig -and $pluginConfig -is [System.Collections.IDictionary]) { if ($pluginConfig.Contains('enabled') -and $pluginConfig['enabled'] -is [bool]) { $coverageEnabled = [bool]$pluginConfig['enabled'] } } $statePref = $null if ($context.ContainsKey('State') -and $context['State'] -is [System.Collections.IDictionary]) { $st = $context['State'] if ($st.Contains('plugins') -and $st['plugins'] -is [System.Collections.IDictionary]) { $plugins = $st['plugins'] if ($plugins.Contains('coverage') -and $plugins['coverage'] -is [System.Collections.IDictionary]) { $prefs = $plugins['coverage'] if ($prefs.Contains('enabled') -and $prefs['enabled'] -is [bool]) { $statePref = [bool]$prefs['enabled'] } } } } if ($statePref -is [bool]) { $coverageEnabled = $statePref } $cliOverride = $null if ($resolved -and $resolved -is [System.Collections.IDictionary]) { if ($resolved.Contains('nocoverage') -and [bool]$resolved['nocoverage']) { $cliOverride = $false } elseif ($resolved.Contains('coverage') -and [bool]$resolved['coverage']) { $cliOverride = $true } } # Pre-set *_COVERAGE = true (e.g. from a CI step environment) signals # opt-in equivalent to passing -coverage on the CLI. Without this the # plugin would strip the env vars below and tests would silently lose # their --coverage flags. if ($null -eq $cliOverride -and ( $env:VITEST_COVERAGE -eq 'true' -or $env:FLUTTER_COVERAGE -eq 'true' -or $env:SWIFT_COVERAGE -eq 'true' -or $env:PYTHON_COVERAGE -eq 'true' -or $env:PESTER_COVERAGE -eq 'true' )) { $cliOverride = $true } if ($cliOverride -is [bool]) { $coverageEnabled = $cliOverride $noSave = $false if ($context.ContainsKey('NoSavedSettings') -and [bool]$context['NoSavedSettings']) { $noSave = $true } if ($resolved -and $resolved.Contains('noSavedSettings') -and [bool]$resolved['noSavedSettings']) { $noSave = $true } if (-not $noSave) { Update-JaxState -RepoRoot $context['RepoRoot'] -Updates @{ plugins = @{ coverage = @{ enabled = $coverageEnabled } } } | Out-Null } } if ($resolved -and $resolved -is [System.Collections.IDictionary]) { $resolved['coverageEnabledOverride'] = $coverageEnabled } $context['CoverageEnabledOverride'] = $coverageEnabled # Wipe coverage/ at run start so stale coverage-final.json from a # prior run doesn't get picked up by the merge step. if ($coverageEnabled) { $repoRoot = $context['RepoRoot'] if (-not [string]::IsNullOrWhiteSpace($repoRoot)) { $coverageRoot = Join-Path $repoRoot 'coverage' if (Test-Path -LiteralPath $coverageRoot) { try { Remove-Item -LiteralPath $coverageRoot -Recurse -Force -ErrorAction Stop } catch { Write-Warning "[coverage-plugin] Could not clear $coverageRoot before run: $_" } } } } } # Set or clear the per-language coverage env vars + $env:COVERAGE_DIR before # each entity. COVERAGE_DIR is per-entity so multi-task runs don't overwrite # each other; it's built from <SourcePath relative to repo root>--<entity name>, # sanitized for filesystem use. Originals are restored in AfterRunEntities. # # Each language helper reads its own env var (VITEST_COVERAGE → Invoke-VitestProject, # FLUTTER_COVERAGE → Invoke-FlutterTestProject, SWIFT_COVERAGE → Invoke-SwiftTestProject) # so the single `-coverage` jax flag transparently covers TS, Dart/Flutter, and Swift # tasks without each helper needing to know about the others. 'BeforeRunEntity' = { param($hook) $context = $hook.Context $data = $hook.Data $coverageEnvNames = @('VITEST_COVERAGE', 'FLUTTER_COVERAGE', 'SWIFT_COVERAGE', 'PYTHON_COVERAGE', 'PESTER_COVERAGE', 'COVERAGE_DIR') if (-not $context.ContainsKey('CoverageEnvSnapshot')) { $snapshot = @{} foreach ($name in $coverageEnvNames) { $snapshot[$name] = @{ WasSet = (Test-Path "Env:$name") Value = (Get-Item "Env:$name" -ErrorAction SilentlyContinue).Value } } $context['CoverageEnvSnapshot'] = $snapshot } $coverageEnabled = $false if ($context.ContainsKey('CoverageEnabledOverride') -and $context['CoverageEnabledOverride'] -is [bool]) { $coverageEnabled = [bool]$context['CoverageEnabledOverride'] } $context['CoverageEnabled'] = $coverageEnabled if (-not $coverageEnabled) { foreach ($name in $coverageEnvNames) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue } return } $env:VITEST_COVERAGE = 'true' $env:FLUTTER_COVERAGE = 'true' $env:SWIFT_COVERAGE = 'true' $env:PYTHON_COVERAGE = 'true' $env:PESTER_COVERAGE = 'true' # Build a stable, unique-per-entity coverage subdir. $entity = $null if ($null -ne $data -and $data.Contains('Entity')) { $entity = $data['Entity'] } $entityName = $null $sourcePath = $null $entityTasks = $null $entityScript = $null if ($entity -is [System.Collections.IDictionary]) { if ($entity.Contains('Name')) { $entityName = [string]$entity['Name'] } if ($entity.Contains('SourcePath')) { $sourcePath = [string]$entity['SourcePath'] } if ($entity.Contains('Tasks')) { $entityTasks = $entity['Tasks'] } if ($entity.Contains('Script')) { $entityScript = [string]$entity['Script'] } if ([string]::IsNullOrWhiteSpace($entityName) -and $entity.Contains('Key')) { $entityName = [string]$entity['Key'] } } elseif ($null -ne $entity) { $props = $entity.PSObject.Properties if ($props.Match('Name').Count -gt 0) { $entityName = [string]$entity.Name } if ($props.Match('SourcePath').Count -gt 0) { $sourcePath = [string]$entity.SourcePath } if ($props.Match('Tasks').Count -gt 0) { $entityTasks = $entity.Tasks } if ($props.Match('Script').Count -gt 0) { $entityScript = [string]$entity.Script } if ([string]::IsNullOrWhiteSpace($entityName) -and $props.Match('Key').Count -gt 0) { $entityName = [string]$entity.Key } } if ([string]::IsNullOrWhiteSpace($entityName)) { if ($null -ne $entityTasks) { if ($entityTasks -is [string]) { $entityName = $entityTasks } else { $entityName = (@($entityTasks) -join '+') } } } if ([string]::IsNullOrWhiteSpace($entityName) -and -not [string]::IsNullOrWhiteSpace($entityScript)) { $entityName = [System.IO.Path]::GetFileNameWithoutExtension($entityScript) } $repoRoot = $context['RepoRoot'] $relSource = $null if (-not [string]::IsNullOrWhiteSpace($sourcePath) -and -not [string]::IsNullOrWhiteSpace($repoRoot)) { try { $relSource = [System.IO.Path]::GetRelativePath($repoRoot, $sourcePath) } catch { $relSource = $sourcePath } } $segments = @() if (-not [string]::IsNullOrWhiteSpace($relSource)) { $segments += $relSource } if (-not [string]::IsNullOrWhiteSpace($entityName)) { $segments += $entityName } if ($segments.Count -eq 0) { $segments = @('unknown') } $raw = $segments -join '--' $sanitized = ($raw -replace '[\\/:\s.]+', '-' -replace '[^A-Za-z0-9_-]', '_').Trim('-', '_') if ([string]::IsNullOrWhiteSpace($sanitized)) { $sanitized = 'entity' } $env:COVERAGE_DIR = "coverage/$sanitized" } # Restore env vars and merge per-entity coverage into a single repo-level report. 'AfterRunEntities' = { param($hook) $context = $hook.Context if (-not $context.ContainsKey('CoverageEnvSnapshot')) { return } $coverageWasEnabled = $context.ContainsKey('CoverageEnabledOverride') -and [bool]$context['CoverageEnabledOverride'] $snapshot = $context['CoverageEnvSnapshot'] foreach ($name in @('VITEST_COVERAGE', 'FLUTTER_COVERAGE', 'SWIFT_COVERAGE', 'PYTHON_COVERAGE', 'PESTER_COVERAGE', 'COVERAGE_DIR')) { $snap = $snapshot[$name] if ($null -eq $snap) { continue } $wasSet = $false if ($snap.ContainsKey('WasSet')) { $wasSet = [bool]$snap['WasSet'] } if ($wasSet) { $value = $null if ($snap.ContainsKey('Value')) { $value = $snap['Value'] } if ($null -ne $value) { Set-Item -Path "Env:$name" -Value $value -ErrorAction SilentlyContinue | Out-Null } else { Remove-Item "Env:$name" -ErrorAction SilentlyContinue } } else { Remove-Item "Env:$name" -ErrorAction SilentlyContinue } } if (-not $coverageWasEnabled) { return } $repoRoot = $context['RepoRoot'] if ([string]::IsNullOrWhiteSpace($repoRoot)) { return } $coverageRoot = Join-Path $repoRoot 'coverage' if (-not (Test-Path -LiteralPath $coverageRoot)) { Write-Host "[coverage-plugin] No coverage outputs were produced; skipping coverage merge." -ForegroundColor DarkGray return } # Step 1 — Vitest per-project coverage-final.json → repo-root # JSON+lcov via nyc merge. Only runs when there's something to # merge (the script no-ops on missing inputs). $mergeScript = Join-Path $repoRoot 'devops/ci/merge-coverage.ps1' if (Test-Path -LiteralPath $mergeScript) { Write-Host "[coverage-plugin] Merging per-entity Vitest coverage into $coverageRoot ..." -ForegroundColor Cyan Invoke-JaxCoverageMaintenanceScript -Path $mergeScript -Label 'merge-coverage' -ScriptArguments @{ CoverageRoot = $coverageRoot } } # Step 2 — concatenate every per-package <pkg>/coverage/lcov.info # (Flutter, Swift, pytest, and Vitest's merged lcov from step 1) # into one repo-root coverage/lcov.info with absolute paths. # Coverage Gutters reads ONE file rather than scanning all # subdirs — visibly faster for monorepos with many packages. $mergeLcovScript = Join-Path $repoRoot 'devops/ci/merge-lcov.ps1' if (Test-Path -LiteralPath $mergeLcovScript) { Write-Host "[coverage-plugin] Concatenating per-package lcov files into $coverageRoot/lcov.info ..." -ForegroundColor Cyan Invoke-JaxCoverageMaintenanceScript -Path $mergeLcovScript -Label 'merge-lcov' -ScriptArguments @{ RepoRoot = $repoRoot } } } } Register-JaxPlugin -Name 'coverage' -Hooks $hooks -SourcePath $MyInvocation.MyCommand.Path @commonParams } Register-JaxCoveragePlugin |