Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,9 @@ jobs:
if: always()
run: |-
Import-Module ./helpers.build.psm1 -Force
$binPath = Get-ArtifactDirectoryPath | Select-Object -ExpandProperty Bin
$exportParams = @{
BinDirectory = Join-Path $PWD 'bin'
BinDirectory = $binPath
ProfileDirectory = '${{ steps.coverage-env.outputs.prof_dir }}'
OutputPath = 'pester-lcov.info'
Verbose = $true
Expand Down Expand Up @@ -203,8 +204,9 @@ jobs:
if: always()
run: |-
Import-Module ./helpers.build.psm1 -Force
$binPath = Get-ArtifactDirectoryPath | Select-Object -ExpandProperty Bin
$exportParams = @{
BinDirectory = Join-Path $PWD 'bin'
BinDirectory = $binPath
ProfileDirectory = '${{ steps.coverage-env.outputs.prof_dir }}'
OutputPath = 'pester-lcov.info'
Verbose = $true
Expand Down Expand Up @@ -301,8 +303,9 @@ jobs:
if: always()
run: |-
Import-Module ./helpers.build.psm1 -Force
$binPath = Get-ArtifactDirectoryPath | Select-Object -ExpandProperty Bin
$exportParams = @{
BinDirectory = Join-Path $PWD 'bin'
BinDirectory = $binPath
ProfileDirectory = '${{ steps.coverage-env.outputs.prof_dir }}'
OutputPath = 'pester-lcov.info'
Verbose = $true
Expand All @@ -326,7 +329,11 @@ jobs:

coverage-report:
if: github.event_name == 'pull_request'
needs: [linux-build, macos-build, windows-build, linux-pester, macos-pester, windows-pester]
# Use Linux coverage only: merging all platforms inflates total line count
# because each platform has platform-specific source files (Windows adds ~4500
# lines from registry/service/DISM resources). Single-platform coverage matches
# local `build.ps1 -codecoverage` results and avoids misleadingly low percentages.
needs: [linux-build, linux-pester]
runs-on: ubuntu-latest
Comment thread
SteveL-MSFT marked this conversation as resolved.
permissions:
pull-requests: write
Expand All @@ -339,7 +346,7 @@ jobs:
- name: Download coverage artifacts
uses: actions/download-artifact@v4
with:
pattern: '*-coverage'
pattern: 'linux*coverage'
path: coverage-data
Comment thread
SteveL-MSFT marked this conversation as resolved.

- name: Consolidate coverage data
Expand Down
90 changes: 84 additions & 6 deletions helpers.build.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -2099,15 +2099,19 @@ function Export-PesterCodeCoverageReport {
throw "llvm-profdata merge failed with exit code $LASTEXITCODE"
}

# Find all executable binaries in the bin directory
$nonBinaryExtensions = @('.pdb', '.d', '.ps1', '.psm1', '.psd1', '.json', '.yaml', '.yml', '.txt', '.md')
# Find all executable binaries in the bin directory.
# On Unix, Rust produces binaries with no file extension. We identify them by
# excluding known non-binary extensions. We cannot rely solely on the execute
# permission bit because artifact upload/download may not preserve it.
$nonBinaryExtensions = @('.pdb', '.d', '.ps1', '.psm1', '.psd1', '.json', '.yaml', '.yml', '.txt', '.md', '.sh')
$binaries = @(
if ($IsWindows) {
Get-ChildItem -Path $BinDirectory -Filter '*.exe' -File
} else {
Get-ChildItem -Path $BinDirectory -File | Where-Object {
$_.Extension -notin $nonBinaryExtensions -and
($_.Mode -match 'x')
-not $_.Extension -or
($_.Extension -notin $nonBinaryExtensions -and
$_.UnixMode -and $_.UnixMode -match 'x')
}
}
)
Expand Down Expand Up @@ -2147,6 +2151,76 @@ function Export-PesterCodeCoverageReport {
}
}

function ConvertTo-NormalizedLcovSourcePath {
<#
.SYNOPSIS
Normalizes an LCOV SF: path to a relative, forward-slash path for consistent merging.

.DESCRIPTION
LCOV files generated on different platforms contain platform-specific absolute paths
(e.g., /home/runner/work/DSC/DSC/src/foo.rs on Linux, D:\a\DSC\DSC\src\foo.rs on
Windows). This function strips common CI workspace prefixes and normalizes path
separators so that the same source file is recognized across platforms when merging
coverage data.

.PARAMETER RawPath
The raw SF: path value from an LCOV file.

.OUTPUTS
A normalized relative path using forward slashes.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
[string]$RawPath
)

process {
$normalized = $RawPath

# Normalize backslashes to forward slashes
$normalized = $normalized.Replace('\', '/')

# Strip common CI workspace prefixes:
# GitHub Actions Linux/macOS: /home/runner/work/<repo>/<repo>/ or /Users/runner/work/<repo>/<repo>/
# GitHub Actions Windows: D:/a/<repo>/<repo>/ (after backslash normalization)
$patterns = @(
'^/home/[^/]+/work/[^/]+/[^/]+/' # Linux: /home/runner/work/DSC/DSC/
'^/Users/[^/]+/work/[^/]+/[^/]+/' # macOS: /Users/runner/work/DSC/DSC/
'^[A-Za-z]:/a/[^/]+/[^/]+/' # Windows: D:/a/DSC/DSC/
)

foreach ($pattern in $patterns) {
if ($normalized -match $pattern) {
$normalized = $normalized -replace $pattern, ''
break
}
}

# If still absolute (starts with / or a drive letter), try to find a known source
# directory marker and make relative from there (e.g., from the crate root)
if ($normalized.StartsWith('/') -or $normalized -match '^[A-Za-z]:/') {
# Look for common Rust project markers in the path
$markers = @('/src/', '/tests/', '/benches/', '/examples/')
foreach ($marker in $markers) {
$markerIdx = $normalized.IndexOf($marker)
if ($markerIdx -gt 0) {
# Find the crate directory (one level up from src/tests/etc.)
$prefix = $normalized.Substring(0, $markerIdx)
$lastSlash = $prefix.LastIndexOf('/')
if ($lastSlash -ge 0) {
$normalized = $normalized.Substring($lastSlash + 1)
break
}
}
}
}

return $normalized
}
}

function Merge-LcovFile {
<#
.SYNOPSIS
Expand All @@ -2157,6 +2231,10 @@ function Merge-LcovFile {
counts for matching source files. When the same line appears in multiple reports,
the hit counts are summed.

Source file paths are normalized to relative paths before merging so that LCOV
files generated on different platforms (with different absolute workspace paths
and path separators) are correctly recognized as covering the same source files.

.PARAMETER Path
Array of paths to LCOV files to merge.

Expand All @@ -2173,7 +2251,7 @@ function Merge-LcovFile {
)

process {
# Structure: $coverage[sourceFile][lineNumber] = hitCount
# Structure: $coverage[normalizedSourceFile][lineNumber] = hitCount
$coverage = @{}

foreach ($lcovPath in $Path) {
Expand All @@ -2185,7 +2263,7 @@ function Merge-LcovFile {
$currentFile = $null
foreach ($line in Get-Content -Path $lcovPath) {
if ($line -match '^SF:(.+)$') {
$currentFile = $Matches[1]
$currentFile = ConvertTo-NormalizedLcovSourcePath -RawPath $Matches[1]
if (-not $coverage.ContainsKey($currentFile)) {
$coverage[$currentFile] = @{}
}
Expand Down
170 changes: 170 additions & 0 deletions tests/helpers.build.coverage.tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

Describe 'ConvertTo-NormalizedLcovSourcePath' {
BeforeAll {
Import-Module (Join-Path $PSScriptRoot '..' 'helpers.build.psm1') -Force
}

Context 'Linux GitHub Actions paths' {
It 'Strips /home/runner/work/<repo>/<repo>/ prefix' {
$result = ConvertTo-NormalizedLcovSourcePath -RawPath '/home/runner/work/DSC/DSC/dsc_lib/src/dscresources/command_resource.rs'
$result | Should -Be 'dsc_lib/src/dscresources/command_resource.rs'
}

It 'Handles varying usernames' {
$result = ConvertTo-NormalizedLcovSourcePath -RawPath '/home/vsts/work/MyProject/MyProject/src/main.rs'
$result | Should -Be 'src/main.rs'
}
}

Context 'macOS GitHub Actions paths' {
It 'Strips /Users/runner/work/<repo>/<repo>/ prefix' {
$result = ConvertTo-NormalizedLcovSourcePath -RawPath '/Users/runner/work/DSC/DSC/dsc_lib/src/dscresources/command_resource.rs'
$result | Should -Be 'dsc_lib/src/dscresources/command_resource.rs'
}
}

Context 'Windows GitHub Actions paths' {
It 'Strips D:/a/<repo>/<repo>/ prefix after backslash normalization' {
$result = ConvertTo-NormalizedLcovSourcePath -RawPath 'D:\a\DSC\DSC\dsc_lib\src\dscresources\command_resource.rs'
$result | Should -Be 'dsc_lib/src/dscresources/command_resource.rs'
}

It 'Handles C: drive' {
$result = ConvertTo-NormalizedLcovSourcePath -RawPath 'C:\a\DSC\DSC\dsc_lib\src\main.rs'
$result | Should -Be 'dsc_lib/src/main.rs'
}
}

Context 'Already relative paths' {
It 'Returns relative paths unchanged (with normalized separators)' {
$result = ConvertTo-NormalizedLcovSourcePath -RawPath 'dsc_lib/src/main.rs'
$result | Should -Be 'dsc_lib/src/main.rs'
}

It 'Normalizes backslashes in relative paths' {
$result = ConvertTo-NormalizedLcovSourcePath -RawPath 'dsc_lib\src\main.rs'
$result | Should -Be 'dsc_lib/src/main.rs'
}
}

Context 'Cross-platform consistency' {
It 'Produces the same output for the same file on all platforms' {
$linuxPath = '/home/runner/work/DSC/DSC/dsc_lib/src/configure.rs'
$macosPath = '/Users/runner/work/DSC/DSC/dsc_lib/src/configure.rs'
$windowsPath = 'D:\a\DSC\DSC\dsc_lib\src\configure.rs'

$linuxResult = ConvertTo-NormalizedLcovSourcePath -RawPath $linuxPath
$macosResult = ConvertTo-NormalizedLcovSourcePath -RawPath $macosPath
$windowsResult = ConvertTo-NormalizedLcovSourcePath -RawPath $windowsPath

$linuxResult | Should -Be $macosResult
$macosResult | Should -Be $windowsResult
$linuxResult | Should -Be 'dsc_lib/src/configure.rs'
}
}
}

Describe 'Merge-LcovFile' {
BeforeAll {
Import-Module (Join-Path $PSScriptRoot '..' 'helpers.build.psm1') -Force
}

Context 'Merging files with different platform paths for the same source' {
BeforeAll {
$linuxLcov = Join-Path $TestDrive 'linux-lcov.info'
$macosLcov = Join-Path $TestDrive 'macos-lcov.info'
$windowsLcov = Join-Path $TestDrive 'windows-lcov.info'
$outputPath = Join-Path $TestDrive 'merged.info'

# Same file, same line coverage, different absolute paths
@'
SF:/home/runner/work/DSC/DSC/dsc_lib/src/main.rs
DA:1,5
DA:2,3
DA:3,0
LF:3
LH:2
end_of_record
'@ | Set-Content -Path $linuxLcov -NoNewline

@'
SF:/Users/runner/work/DSC/DSC/dsc_lib/src/main.rs
DA:1,2
DA:2,0
DA:3,4
LF:3
LH:2
end_of_record
'@ | Set-Content -Path $macosLcov -NoNewline

@'
SF:D:\a\DSC\DSC\dsc_lib\src\main.rs
DA:1,1
DA:2,0
DA:3,0
LF:3
LH:1
end_of_record
'@ | Set-Content -Path $windowsLcov -NoNewline

Merge-LcovFile -Path @($linuxLcov, $macosLcov, $windowsLcov) -OutputPath $outputPath
$script:mergedContent = Get-Content -Path $outputPath -Raw
}

It 'Produces a single source file entry (not three duplicates)' {
$sfCount = ([regex]::Matches($script:mergedContent, '^SF:', [System.Text.RegularExpressions.RegexOptions]::Multiline)).Count
$sfCount | Should -Be 1
}

It 'Sums hit counts for matching lines' {
# Line 1: 5 + 2 + 1 = 8
$script:mergedContent | Should -Match '(?m)^DA:1,8\r?$'
# Line 2: 3 + 0 + 0 = 3
$script:mergedContent | Should -Match '(?m)^DA:2,3\r?$'
# Line 3: 0 + 4 + 0 = 4
$script:mergedContent | Should -Match '(?m)^DA:3,4\r?$'
}

It 'Reports all 3 lines covered (all have non-zero sum)' {
$script:mergedContent | Should -Match '(?m)^LH:3\r?$'
}
Comment thread
Copilot marked this conversation as resolved.

It 'Reports 3 total lines' {
$script:mergedContent | Should -Match '(?m)^LF:3\r?$'
}
Comment thread
Copilot marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.
}

Context 'Merging files with unique sources that do not overlap' {
BeforeAll {
$lcov1 = Join-Path $TestDrive 'lcov1.info'
$lcov2 = Join-Path $TestDrive 'lcov2.info'
$outputPath = Join-Path $TestDrive 'merged-unique.info'

@'
SF:/home/runner/work/DSC/DSC/dsc_lib/src/foo.rs
DA:1,5
LF:1
LH:1
end_of_record
'@ | Set-Content -Path $lcov1 -NoNewline

@'
SF:/home/runner/work/DSC/DSC/dsc_lib/src/bar.rs
DA:1,3
LF:1
LH:1
end_of_record
'@ | Set-Content -Path $lcov2 -NoNewline

Merge-LcovFile -Path @($lcov1, $lcov2) -OutputPath $outputPath
$script:mergedUniqueContent = Get-Content -Path $outputPath -Raw
}

It 'Preserves both source file entries' {
$sfCount = ([regex]::Matches($script:mergedUniqueContent, '^SF:', [System.Text.RegularExpressions.RegexOptions]::Multiline)).Count
$sfCount | Should -Be 2
}
}
}
Loading