From 274d51b6e4192d61a6989193b8c597bc148401f9 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Mon, 29 Jun 2026 19:39:14 -0700 Subject: [PATCH 01/10] Add version comparison support to `Microsoft/OSInfo` --- Cargo.lock | 7 + Cargo.toml | 2 + lib/dsc-lib-osinfo/Cargo.toml | 1 + lib/dsc-lib-osinfo/src/lib.rs | 148 +++++++++++++++++++++- resources/osinfo/osinfo.dsc.resource.json | 18 ++- resources/osinfo/src/main.rs | 33 ++++- resources/osinfo/tests/osinfo.tests.ps1 | 40 ++++++ 7 files changed, 241 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 20d1f0ca2..7e1466916 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -847,6 +847,7 @@ dependencies = [ "os_info", "serde", "serde_json", + "version-compare", ] [[package]] @@ -3754,6 +3755,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 783faad7c..8be02844d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -186,6 +186,8 @@ nix = { version = "0.31.3" } num-traits = { version = "0.2" } # dsc-lib-osinfo os_info = { version = "3.15" } +# dsc-lib-osinfo +version-compare = { version = "0.2" } # dsc, dsc-lib path-absolutize = { version = "3.1" } # dsc-bicep-ext diff --git a/lib/dsc-lib-osinfo/Cargo.toml b/lib/dsc-lib-osinfo/Cargo.toml index 8d9ce0135..ba4daa65e 100644 --- a/lib/dsc-lib-osinfo/Cargo.toml +++ b/lib/dsc-lib-osinfo/Cargo.toml @@ -10,3 +10,4 @@ doctest = false os_info = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +version-compare = { workspace = true } diff --git a/lib/dsc-lib-osinfo/src/lib.rs b/lib/dsc-lib-osinfo/src/lib.rs index c20c63f6a..94ade2337 100644 --- a/lib/dsc-lib-osinfo/src/lib.rs +++ b/lib/dsc-lib-osinfo/src/lib.rs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::fmt::Display; use std::string::ToString; +use version_compare::Cmp; /// Returns information about the operating system. #[derive(Debug, Clone, PartialEq, Serialize)] @@ -24,10 +25,27 @@ pub struct OsInfo { architecture: Option, #[serde(rename = "_name", skip_serializing_if = "Option::is_none")] name: Option, + /// Indicates whether the resource is in the desired state. Only emitted by the test operation. + #[serde(rename = "_inDesiredState", skip_serializing_if = "Option::is_none")] + in_desired_state: Option, +} + +/// Desired state for the test operation. All fields are optional. +/// The `version` field may include a comparison operator prefix: `>`, `<`, `=`, `>=`, or `<=`. +#[derive(Debug, Default, Deserialize)] +pub struct OsTestInput { + pub family: Option, + pub version: Option, + pub edition: Option, + pub codename: Option, + pub bitness: Option, + pub architecture: Option, + #[serde(rename = "_name")] + pub name: Option, } /// Defines whether the operating system is Linux, macOS, or Windows. -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Family { Linux, #[serde(rename = "macOS")] @@ -80,6 +98,132 @@ impl OsInfo { bitness: bits, architecture, name, + in_desired_state: None, + } + } +} + +/// Parse the optional comparison operator prefix from a version constraint string. +/// +/// Returns a tuple of `(operator, version_str)` where `operator` is one of +/// `">"`, `"<"`, `"="`, `">="`, `"<="`, and `version_str` is the version +/// string with whitespace trimmed. When no operator prefix is present, the +/// operator defaults to `"="` (exact match). +/// +/// An operator is only recognised when the remainder after stripping it starts +/// with an ASCII digit. Strings like `">> 1.0"` (double operator) are +/// therefore treated as literal exact-match strings rather than producing a +/// misleading parsed operator. +fn parse_version_constraint(constraint: &str) -> (&str, &str) { + let constraint = constraint.trim(); + // Check two-character operators before single-character ones. + if let Some(rest) = constraint.strip_prefix(">=") { + let rest = rest.trim(); + if rest.starts_with(|c: char| c.is_ascii_digit()) { + return (">=", rest); + } + } else if let Some(rest) = constraint.strip_prefix("<=") { + let rest = rest.trim(); + if rest.starts_with(|c: char| c.is_ascii_digit()) { + return ("<=", rest); + } + } else if let Some(rest) = constraint.strip_prefix('>') { + let rest = rest.trim(); + if rest.starts_with(|c: char| c.is_ascii_digit()) { + return (">", rest); } + } else if let Some(rest) = constraint.strip_prefix('<') { + let rest = rest.trim(); + if rest.starts_with(|c: char| c.is_ascii_digit()) { + return ("<", rest); + } + } else if let Some(rest) = constraint.strip_prefix('=') { + let rest = rest.trim(); + if rest.starts_with(|c: char| c.is_ascii_digit()) { + return ("=", rest); + } + } + // No recognised operator, or the remainder does not look like a version. + // Treat the entire string as a literal exact-match value. + ("=", constraint) +} + +/// Returns `true` when `actual` satisfies the `constraint`. +/// +/// `constraint` may be a plain version string (exact match) or a version +/// string prefixed with one of the comparison operators `>`, `<`, `=`, `>=`, +/// or `<=`. Operator and version string may be separated by optional +/// whitespace. Comparison is performed by the `version_compare` crate, which +/// handles version strings that are not strict semver (e.g. `"22.04"`, +/// `"10.15.7"`, `"11"`). Returns `false` when either version string cannot +/// be parsed. +fn version_matches(constraint: &str, actual: &str) -> bool { + let (operator, desired_ver) = parse_version_constraint(constraint); + + if operator == "=" { + return desired_ver == actual; + } + + match version_compare::compare(actual, desired_ver) { + Ok(cmp) => match operator { + ">" => cmp == Cmp::Gt, + "<" => cmp == Cmp::Lt, + ">=" => matches!(cmp, Cmp::Gt | Cmp::Eq), + "<=" => matches!(cmp, Cmp::Lt | Cmp::Eq), + _ => false, + }, + Err(()) => false, } } + +/// Perform the test operation against the current OS state. +/// +/// Parses `input_json` as the desired state (`OsTestInput`), retrieves the +/// actual OS information, and evaluates each specified field. For `version`, +/// an optional comparison operator prefix is supported (see +/// `version_matches`). Returns the actual `OsInfo` with `_inDesiredState` +/// set to indicate whether all specified fields are satisfied. +/// +/// # Errors +/// +/// Returns an error string when `input_json` cannot be parsed as valid JSON. +pub fn perform_test(input_json: &str) -> Result { + let desired: OsTestInput = serde_json::from_str(input_json) + .map_err(|e| format!("Failed to parse test input as JSON: {e}"))?; + + let actual = OsInfo::new(false); + + let mut in_desired_state = true; + + if let Some(desired_family) = desired.family + && desired_family != actual.family { + in_desired_state = false; + } + + if let Some(ref desired_version) = desired.version + && !version_matches(desired_version, &actual.version) { + in_desired_state = false; + } + + if let Some(ref desired_edition) = desired.edition + && actual.edition.as_deref() != Some(desired_edition.as_str()) { + in_desired_state = false; + } + + if let Some(ref desired_codename) = desired.codename + && actual.codename.as_deref() != Some(desired_codename.as_str()) { + in_desired_state = false; + } + + if let Some(desired_bitness) = desired.bitness + && actual.bitness != Some(desired_bitness) { + in_desired_state = false; + } + + if let Some(ref desired_architecture) = desired.architecture + && actual.architecture.as_deref() != Some(desired_architecture.as_str()) { + in_desired_state = false; + } + + Ok(OsInfo { in_desired_state: Some(in_desired_state), ..actual }) +} diff --git a/resources/osinfo/osinfo.dsc.resource.json b/resources/osinfo/osinfo.dsc.resource.json index 25cb6464d..db064363e 100644 --- a/resources/osinfo/osinfo.dsc.resource.json +++ b/resources/osinfo/osinfo.dsc.resource.json @@ -12,6 +12,14 @@ "get": { "executable": "osinfo" }, + "test": { + "executable": "osinfo", + "args": [ + "test" + ], + "input": "stdin", + "return": "state" + }, "export": { "executable": "osinfo", "args": [ @@ -43,6 +51,12 @@ "description": "Returns the name of the OSInfo instance.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#name\n", "markdownDescription": "Returns the name of the OSInfo instance.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#name\n" }, + "_inDesiredState": { + "type": "boolean", + "readOnly": true, + "title": "In desired state", + "description": "Indicates whether the resource instance is in the desired state. Only present in the output of the test operation.\n" + }, "architecture": { "type": "string", "title": "Processor architecture", @@ -81,8 +95,8 @@ "version": { "type": "string", "title": "Operating system version", - "description": "Defines the version of the operating system as a string.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#version\n", - "markdownDescription": "Defines the version of the operating system as a string.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#version\n" + "description": "Defines the version of the operating system as a string.\n\nWhen used with the test operation, the version string may include a comparison operator prefix to compare the actual version against a constraint. Supported operators are: `>`, `<`, `=`, `>=`, and `<=`. The operator and version may be separated by optional whitespace, for example `>= 10.0`. Without an operator, the comparison is an exact string match.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#version\n", + "markdownDescription": "Defines the version of the operating system as a string.\n\nWhen used with the **test** operation, the version string may include a comparison operator prefix to compare the actual version against a constraint. Supported operators are: `>`, `<`, `=`, `>=`, and `<=`. The operator and version may be separated by optional whitespace, for example `>= 10.0`. Without an operator, the comparison is an exact string match.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#version\n" } } } diff --git a/resources/osinfo/src/main.rs b/resources/osinfo/src/main.rs index b00c3b07e..40aced04e 100644 --- a/resources/osinfo/src/main.rs +++ b/resources/osinfo/src/main.rs @@ -1,11 +1,36 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use dsc_lib_osinfo::OsInfo; +use dsc_lib_osinfo::{perform_test, OsInfo}; +use std::io::Read; fn main() { let args: Vec = std::env::args().collect(); - let include_name = args.len() > 1 && args[1] == "export"; - let json = serde_json::to_string(&OsInfo::new(include_name)).unwrap(); - println!("{json}"); + match args.get(1).map(String::as_str) { + Some("export") => { + let json = serde_json::to_string(&OsInfo::new(true)).unwrap_or_default(); + println!("{json}"); + }, + Some("test") => { + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut input) { + eprintln!("Failed to read stdin: {e}"); + std::process::exit(1); + } + match perform_test(&input) { + Ok(result) => { + let json = serde_json::to_string(&result).unwrap_or_default(); + println!("{json}"); + }, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + }, + } + }, + _ => { + let json = serde_json::to_string(&OsInfo::new(false)).unwrap_or_default(); + println!("{json}"); + }, + } } diff --git a/resources/osinfo/tests/osinfo.tests.ps1 b/resources/osinfo/tests/osinfo.tests.ps1 index b07e81ced..e3db5f972 100644 --- a/resources/osinfo/tests/osinfo.tests.ps1 +++ b/resources/osinfo/tests/osinfo.tests.ps1 @@ -57,3 +57,43 @@ Describe 'osinfo resource tests' { $out.resources[0].name | Should -BeExactly "$($out.resources[0].properties.family) $($out.resources[0].properties.version) $($out.resources[0].properties.architecture)" } } + +Describe 'osinfo test subcommand version operator tests' { + BeforeDiscovery { + $osGetResult = dsc resource get -r Microsoft/OSInfo | ConvertFrom-Json + $currentVersion = $osGetResult.actualState.version + + $versionTestCases = @( + @{ constraint = $currentVersion; expectedState = $true; description = 'exact version without operator' } + @{ constraint = "= $currentVersion"; expectedState = $true; description = 'exact version with = operator' } + @{ constraint = ">= $currentVersion"; expectedState = $true; description = '>= current version' } + @{ constraint = "<= $currentVersion"; expectedState = $true; description = '<= current version' } + @{ constraint = "> $currentVersion"; expectedState = $false; description = '> current version' } + @{ constraint = "< $currentVersion"; expectedState = $false; description = '< current version' } + ) + + $invalidSyntaxCases = @( + @{ constraint = '?? 1.0'; description = 'unknown ?? operator treated as exact match' } + @{ constraint = '~= 1.0'; description = 'unsupported ~= operator treated as exact match' } + @{ constraint = '>> 1.0'; description = 'unsupported >> operator treated as exact match' } + ) + } + + Context 'valid version constraints' { + It 'version constraint "" () should report inDesiredState = ' -ForEach $versionTestCases { + $json = "{`"version`": `"$constraint`"}" + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -Be $expectedState + } + } + + Context 'unsupported version syntax' { + It 'version "" () should not be in desired state' -ForEach $invalidSyntaxCases { + $json = "{`"version`": `"$constraint`"}" + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -Be $false + } + } +} From b2d869e76aaa67f9f16c61e4e2275d2043dd68ee Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 30 Jun 2026 07:08:22 -0700 Subject: [PATCH 02/10] address copilot feedback --- lib/dsc-lib-osinfo/src/lib.rs | 2 ++ resources/osinfo/src/main.rs | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/dsc-lib-osinfo/src/lib.rs b/lib/dsc-lib-osinfo/src/lib.rs index 94ade2337..5f4e0bec1 100644 --- a/lib/dsc-lib-osinfo/src/lib.rs +++ b/lib/dsc-lib-osinfo/src/lib.rs @@ -23,6 +23,7 @@ pub struct OsInfo { /// Defines the processor architecture as reported by `uname -m` on the operating system. #[serde(skip_serializing_if = "Option::is_none")] architecture: Option, + /// Support returning generated name for the OSInfo instance for export. #[serde(rename = "_name", skip_serializing_if = "Option::is_none")] name: Option, /// Indicates whether the resource is in the desired state. Only emitted by the test operation. @@ -191,6 +192,7 @@ pub fn perform_test(input_json: &str) -> Result { let desired: OsTestInput = serde_json::from_str(input_json) .map_err(|e| format!("Failed to parse test input as JSON: {e}"))?; + // name is ignored for test since it's only generated for export and not a property of the actual OS state. let actual = OsInfo::new(false); let mut in_desired_state = true; diff --git a/resources/osinfo/src/main.rs b/resources/osinfo/src/main.rs index 40aced04e..ab4e4f464 100644 --- a/resources/osinfo/src/main.rs +++ b/resources/osinfo/src/main.rs @@ -8,7 +8,10 @@ fn main() { let args: Vec = std::env::args().collect(); match args.get(1).map(String::as_str) { Some("export") => { - let json = serde_json::to_string(&OsInfo::new(true)).unwrap_or_default(); + let json = serde_json::to_string(&OsInfo::new(true)).unwrap_or_else(|e| { + eprintln!("Failed to serialize OS info as JSON: {e}"); + std::process::exit(1); + }); println!("{json}"); }, Some("test") => { @@ -19,7 +22,10 @@ fn main() { } match perform_test(&input) { Ok(result) => { - let json = serde_json::to_string(&result).unwrap_or_default(); + let json = serde_json::to_string(&result).unwrap_or_else(|e| { + eprintln!("Failed to serialize test result as JSON: {e}"); + std::process::exit(1); + }); println!("{json}"); }, Err(e) => { @@ -29,7 +35,10 @@ fn main() { } }, _ => { - let json = serde_json::to_string(&OsInfo::new(false)).unwrap_or_default(); + let json = serde_json::to_string(&OsInfo::new(false)).unwrap_or_else(|e| { + eprintln!("Failed to serialize OS info as JSON: {e}"); + std::process::exit(1); + }); println!("{json}"); }, } From 789c5b31829802da4dcec169152a983220d520b0 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 30 Jun 2026 08:45:29 -0700 Subject: [PATCH 03/10] Use instrumented binaries for code coverage collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build-RustProject now uses `cargo llvm-cov build --no-report` when -CodeCoverage is specified, producing instrumented binaries that write profraw data when executed. Previously the -CodeCoverage parameter was accepted but unused — the function always called `cargo build`. When Pester tests run with -CodeCoverage enabled, LLVM_PROFILE_FILE is set via a new Get-LlvmProfileFilePattern helper so that externally invoked instrumented binaries write profraw data to the target directory where `cargo llvm-cov report` can discover it. The CI workflow is updated to combine the build and coverage test steps into a single `./build.ps1 -Clippy -Test -CodeCoverage` invocation, ensuring the instrumented build is used for coverage collection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust.yml | 18 +++++---------- build.ps1 | 8 +++++++ helpers.build.psm1 | 46 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c87d2bece..8994f799f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -52,12 +52,10 @@ jobs: fetch-depth: 0 - name: Install prerequisites run: ./build.ps1 -SkipBuild -Clippy -Verbose - - name: Build - run: ./build.ps1 -Clippy -Verbose - - name: Run rust tests with code coverage + - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -SkipBuild -Test -CodeCoverage -ExcludePesterTests -Verbose + run: ./build.ps1 -Clippy -Test -CodeCoverage -ExcludePesterTests -Verbose - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -110,12 +108,10 @@ jobs: fetch-depth: 0 - name: Install prerequisites run: ./build.ps1 -SkipBuild -Clippy -Verbose - - name: Build - run: ./build.ps1 -Clippy -Verbose - - name: Run rust tests with code coverage + - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -SkipBuild -Test -CodeCoverage -ExcludePesterTests -Verbose + run: ./build.ps1 -Clippy -Test -CodeCoverage -ExcludePesterTests -Verbose - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -172,12 +168,10 @@ jobs: fetch-depth: 0 - name: Install prerequisites run: ./build.ps1 -SkipBuild -Clippy -Verbose - - name: Build - run: ./build.ps1 -Clippy -Verbose - - name: Run rust tests with code coverage + - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -SkipBuild -Test -CodeCoverage -ExcludePesterTests -Verbose + run: ./build.ps1 -Clippy -Test -CodeCoverage -ExcludePesterTests -Verbose - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 diff --git a/build.ps1 b/build.ps1 index bd15dec2d..3d2c16497 100755 --- a/build.ps1 +++ b/build.ps1 @@ -337,6 +337,11 @@ process { Test-RustProject @docTestParams @VerboseParam } if (-not $ExcludePesterTests) { + if ($CodeCoverage) { + # Set LLVM_PROFILE_FILE so instrumented binaries write profraw + # data that cargo llvm-cov report can discover. + $env:LLVM_PROFILE_FILE = Get-LlvmProfileFilePattern @VerboseParam + } $installParams = @{ UsingADO = $usingADO } @@ -350,6 +355,9 @@ process { Install-PowerShellTestPrerequisite @installParams @VerboseParam Write-BuildProgress @progressParams -Status "Invoking pester" Test-ProjectWithPester @pesterParams @VerboseParam + if ($CodeCoverage) { + Remove-Item Env:\LLVM_PROFILE_FILE -ErrorAction Ignore + } } } diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 15bae8654..4dbb9dd6d 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1664,8 +1664,13 @@ function Build-RustProject { $members = Get-DefaultWorkspaceMemberGroup Write-Verbose -Verbose "Building rust projects: [$members]" - Write-Verbose "Invoking cargo:`n`tcargo build $flags" - cargo build @flags + if ($CodeCoverage) { + Write-Verbose "Invoking cargo:`n`tcargo llvm-cov build --no-report $flags" + cargo llvm-cov build --no-report @flags + } else { + Write-Verbose "Invoking cargo:`n`tcargo build $flags" + cargo build @flags + } if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { throw "Last exit code is $LASTEXITCODE, build failed for at least one project" @@ -1919,6 +1924,43 @@ function Initialize-CodeCoverage { } } +function Get-LlvmProfileFilePattern { + <# + .SYNOPSIS + Returns the LLVM_PROFILE_FILE pattern used by cargo-llvm-cov. + + .DESCRIPTION + Parses the output of `cargo llvm-cov show-env` to extract the LLVM_PROFILE_FILE + pattern. When this environment variable is set before running instrumented binaries + (such as during Pester tests), the profraw data is written to a location that + `cargo llvm-cov report` can discover, enabling code coverage collection from + integration tests that invoke compiled binaries externally. + + .OUTPUTS + System.String — The LLVM_PROFILE_FILE pattern string. + #> + [CmdletBinding()] + [OutputType([string])] + param() + + process { + $showEnvOutput = cargo llvm-cov show-env 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to retrieve cargo-llvm-cov environment: $showEnvOutput" + } + + $profileLine = $showEnvOutput | Where-Object { $_ -match '^LLVM_PROFILE_FILE=' } + if (-not $profileLine) { + throw 'Could not find LLVM_PROFILE_FILE in cargo llvm-cov show-env output' + } + + # Extract value, stripping optional surrounding quotes + $pattern = ($profileLine -replace "^LLVM_PROFILE_FILE='?", '') -replace "'?$", '' + Write-Verbose -Verbose "Using LLVM_PROFILE_FILE pattern: $pattern" + $pattern + } +} + function Export-CodeCoverageReport { <# .SYNOPSIS From 9b87eb8e0f0027a6af32ed6a4dec346ca0d6d3da Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 30 Jun 2026 08:55:47 -0700 Subject: [PATCH 04/10] Fix instrumented build to use cargo llvm-cov show-env cargo-llvm-cov does not have a `build` subcommand. Instead, use `cargo llvm-cov show-env` to retrieve the environment variables (RUSTC_WRAPPER, LLVM_PROFILE_FILE, etc.) and set them in the process. A normal `cargo build` then produces instrumented binaries transparently. Replace Get-LlvmProfileFilePattern with Set-LlvmCovEnvironment / Reset-LlvmCovEnvironment which set and restore all required env vars. The env vars are set before the build so both cargo build and Pester tests benefit from instrumentation, and restored after the LCOV report is generated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build.ps1 | 16 +++++----- helpers.build.psm1 | 73 ++++++++++++++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 34 deletions(-) diff --git a/build.ps1 b/build.ps1 index 3d2c16497..301cb3acb 100755 --- a/build.ps1 +++ b/build.ps1 @@ -265,6 +265,9 @@ process { Write-BuildProgress @progressParams -Status 'Configuring cargo-llvm-cov environment' Remove-Item $CodeCoverageOutputPath -Force -ErrorAction Ignore Initialize-CodeCoverage -UseCFS:$UseCFS @VerboseParam + + Write-BuildProgress @progressParams -Status 'Setting llvm-cov environment variables' + $priorLlvmCovEnv = Set-LlvmCovEnvironment @VerboseParam } #endregion Code coverage instrumentation @@ -297,7 +300,7 @@ process { Clean = $Clean } Write-BuildProgress @progressParams -Status 'Compiling Rust' - Build-RustProject @buildParams -Audit:$Audit -Clippy:$Clippy -CodeCoverage:$CodeCoverage @VerboseParam + Build-RustProject @buildParams -Audit:$Audit -Clippy:$Clippy @VerboseParam Write-BuildProgress @progressParams -Status "Copying build artifacts" Copy-BuildArtifact @buildParams -ExecutableFile $BuildData.PackageFiles.Executable @VerboseParam } @@ -337,11 +340,6 @@ process { Test-RustProject @docTestParams @VerboseParam } if (-not $ExcludePesterTests) { - if ($CodeCoverage) { - # Set LLVM_PROFILE_FILE so instrumented binaries write profraw - # data that cargo llvm-cov report can discover. - $env:LLVM_PROFILE_FILE = Get-LlvmProfileFilePattern @VerboseParam - } $installParams = @{ UsingADO = $usingADO } @@ -355,9 +353,6 @@ process { Install-PowerShellTestPrerequisite @installParams @VerboseParam Write-BuildProgress @progressParams -Status "Invoking pester" Test-ProjectWithPester @pesterParams @VerboseParam - if ($CodeCoverage) { - Remove-Item Env:\LLVM_PROFILE_FILE -ErrorAction Ignore - } } } @@ -368,6 +363,9 @@ process { Write-BuildProgress @progressParams -Status "Writing LCOV report to $CodeCoverageOutputPath" Export-CodeCoverageReport -OutputPath $CodeCoverageOutputPath @VerboseParam + Write-BuildProgress @progressParams -Status 'Restoring environment variables' + Reset-LlvmCovEnvironment -PriorValues $priorLlvmCovEnv @VerboseParam + # Determine base and head SHAs for analysis $baseSha = $CodeCoverageBaseSha $headSha = $CodeCoverageHeadSha diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 4dbb9dd6d..7c5ad5e98 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1602,8 +1602,7 @@ function Build-RustProject { [switch]$Clean, [switch]$UpdateLockFile, [switch]$Audit, - [switch]$Clippy, - [switch]$CodeCoverage + [switch]$Clippy ) begin { @@ -1664,13 +1663,8 @@ function Build-RustProject { $members = Get-DefaultWorkspaceMemberGroup Write-Verbose -Verbose "Building rust projects: [$members]" - if ($CodeCoverage) { - Write-Verbose "Invoking cargo:`n`tcargo llvm-cov build --no-report $flags" - cargo llvm-cov build --no-report @flags - } else { - Write-Verbose "Invoking cargo:`n`tcargo build $flags" - cargo build @flags - } + Write-Verbose "Invoking cargo:`n`tcargo build $flags" + cargo build @flags if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { throw "Last exit code is $LASTEXITCODE, build failed for at least one project" @@ -1924,23 +1918,25 @@ function Initialize-CodeCoverage { } } -function Get-LlvmProfileFilePattern { +function Set-LlvmCovEnvironment { <# .SYNOPSIS - Returns the LLVM_PROFILE_FILE pattern used by cargo-llvm-cov. + Sets the environment variables required by cargo-llvm-cov for instrumented builds. .DESCRIPTION - Parses the output of `cargo llvm-cov show-env` to extract the LLVM_PROFILE_FILE - pattern. When this environment variable is set before running instrumented binaries - (such as during Pester tests), the profraw data is written to a location that - `cargo llvm-cov report` can discover, enabling code coverage collection from - integration tests that invoke compiled binaries externally. + Parses the output of `cargo llvm-cov show-env` and sets the corresponding + environment variables (LLVM_PROFILE_FILE, RUSTC_WRAPPER, CARGO_LLVM_COV, etc.) + in the current process. This enables a normal `cargo build` to produce + instrumented binaries, and allows externally invoked instrumented binaries + (such as during Pester tests) to write profraw data to a location that + `cargo llvm-cov report` can discover. .OUTPUTS - System.String — The LLVM_PROFILE_FILE pattern string. + System.Collections.Hashtable — Prior values of the modified environment variables + so they can be restored with Reset-LlvmCovEnvironment. #> [CmdletBinding()] - [OutputType([string])] + [OutputType([hashtable])] param() process { @@ -1949,15 +1945,42 @@ function Get-LlvmProfileFilePattern { throw "Failed to retrieve cargo-llvm-cov environment: $showEnvOutput" } - $profileLine = $showEnvOutput | Where-Object { $_ -match '^LLVM_PROFILE_FILE=' } - if (-not $profileLine) { - throw 'Could not find LLVM_PROFILE_FILE in cargo llvm-cov show-env output' + $priorValues = @{} + foreach ($line in $showEnvOutput) { + if ($line -match '^([A-Z_][A-Z0-9_]+)=(.*)$') { + $name = $Matches[1] + # Strip optional surrounding single quotes from the value + $value = ($Matches[2] -replace "^'", '') -replace "'$", '' + $priorValues[$name] = [System.Environment]::GetEnvironmentVariable($name) + [System.Environment]::SetEnvironmentVariable($name, $value) + Write-Verbose "Set $name=$value" + } } - # Extract value, stripping optional surrounding quotes - $pattern = ($profileLine -replace "^LLVM_PROFILE_FILE='?", '') -replace "'?$", '' - Write-Verbose -Verbose "Using LLVM_PROFILE_FILE pattern: $pattern" - $pattern + Write-Verbose -Verbose "Set $($priorValues.Count) cargo-llvm-cov environment variables" + $priorValues + } +} + +function Reset-LlvmCovEnvironment { + <# + .SYNOPSIS + Restores environment variables modified by Set-LlvmCovEnvironment. + + .PARAMETER PriorValues + The hashtable returned by Set-LlvmCovEnvironment containing original values. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$PriorValues + ) + + process { + foreach ($entry in $PriorValues.GetEnumerator()) { + [System.Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) + } + Write-Verbose -Verbose "Restored $($PriorValues.Count) environment variables" } } From 0106fabe30d7abff092a9ef5c3d4e736e0adc9c5 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 30 Jun 2026 09:05:16 -0700 Subject: [PATCH 05/10] Remove cargo llvm-cov test/build from Build/Test-RustProject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since Set-LlvmCovEnvironment sets RUSTC_WRAPPER and LLVM_PROFILE_FILE, normal `cargo build` and `cargo test` already produce instrumented binaries and write profraw data. Calling `cargo llvm-cov test` on top of the pre-set env vars caused conflicts and errors. Remove the -CodeCoverage parameter from both Build-RustProject and Test-RustProject — the env vars make instrumentation transparent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build.ps1 | 2 +- helpers.build.psm1 | 23 +++++++---------------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/build.ps1 b/build.ps1 index 301cb3acb..84cf5323e 100755 --- a/build.ps1 +++ b/build.ps1 @@ -327,7 +327,7 @@ process { $rustTestParams.TestFilter = $RustTestFilter } Write-BuildProgress @progressParams -Status "Testing Rust projects" - Test-RustProject @rustTestParams -CodeCoverage:$CodeCoverage @VerboseParam + Test-RustProject @rustTestParams @VerboseParam } if ($RustDocs) { $docTestParams = @{ diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 7c5ad5e98..7a1d5cae7 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1893,9 +1893,9 @@ function Initialize-CodeCoverage { .DESCRIPTION Installs cargo-llvm-cov if needed and cleans any prior coverage artifacts from the - workspace. When coverage is enabled, Build-RustProject and Test-RustProject use - `cargo llvm-cov build` and `cargo llvm-cov test --no-report` respectively, which - handle all instrumentation and profraw management internally. + workspace. After initialization, call Set-LlvmCovEnvironment to set the environment + variables that make normal `cargo build` and `cargo test` invocations produce + instrumented binaries and write profraw data. #> [CmdletBinding()] param( @@ -2242,8 +2242,7 @@ function Test-RustProject { $Architecture = 'current', [switch]$Release, [switch]$Docs, - [string]$TestFilter, - [switch]$CodeCoverage + [string]$TestFilter ) begin { @@ -2273,18 +2272,10 @@ function Test-RustProject { } else { Write-Verbose -Verbose "Testing rust projects: [$members]" } - if ($CodeCoverage) { - if (-not [string]::IsNullOrEmpty($TestFilter)) { - cargo llvm-cov test --no-report @flags -- $TestFilter - } else { - cargo llvm-cov test --no-report @flags - } + if (-not [string]::IsNullOrEmpty($TestFilter)) { + cargo test @flags -- $TestFilter } else { - if (-not [string]::IsNullOrEmpty($TestFilter)) { - cargo test @flags -- $TestFilter - } else { - cargo test @flags - } + cargo test @flags } if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { From 872ca44ca147cba11af9557aa0367b3c0e5f831a Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 30 Jun 2026 09:31:47 -0700 Subject: [PATCH 06/10] Fail CI if code coverage is below 70% Add a step to the coverage-report job that exits with an error when the coverage percentage on changed Rust code is less than 70%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8994f799f..f91d1a0b0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -316,3 +316,12 @@ jobs: ## Code Coverage Report No Rust files were changed in this PR. Coverage analysis skipped. + + - name: Fail if coverage is below 70% + if: >- + steps.coverage.outputs.has_rust_changes == 'true' + && steps.coverage.outputs.coverage_failed != 'true' + && steps.coverage.outputs.percentage < 70 + run: | + Write-Error "Code coverage is ${{ steps.coverage.outputs.percentage }}%, which is below the 70% minimum threshold." + exit 1 From 1dd75ce8d091809dab2d9e48914eced2f051eab5 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 30 Jun 2026 09:35:49 -0700 Subject: [PATCH 07/10] Add Pester tests for test operation properties Add tests covering edition (Windows-only), codename (Linux-only), bitness, and architecture properties in the OSInfo test operation. Also tests combining multiple properties to verify partial mismatch detection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- resources/osinfo/tests/osinfo.tests.ps1 | 89 +++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/resources/osinfo/tests/osinfo.tests.ps1 b/resources/osinfo/tests/osinfo.tests.ps1 index e3db5f972..f7b5ed262 100644 --- a/resources/osinfo/tests/osinfo.tests.ps1 +++ b/resources/osinfo/tests/osinfo.tests.ps1 @@ -97,3 +97,92 @@ Describe 'osinfo test subcommand version operator tests' { } } } + +Describe 'osinfo test subcommand property tests' { + BeforeAll { + $actual = (dsc resource get -r Microsoft/OSInfo | ConvertFrom-Json).actualState + } + + Context 'edition property' -Skip:(-not $IsWindows) { + It 'should be in desired state when edition matches actual' { + $json = @{ edition = $actual.edition } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeTrue + } + + It 'should not be in desired state when edition does not match' { + $json = @{ edition = 'NonExistentEdition' } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeFalse + } + } + + Context 'codename property' -Skip:(-not $IsLinux) { + It 'should be in desired state when codename matches actual' { + $json = @{ codename = $actual.codename } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeTrue + } + + It 'should not be in desired state when codename does not match' { + $json = @{ codename = 'nonexistentcodename' } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeFalse + } + } + + Context 'bitness property' { + It 'should be in desired state when bitness matches actual' { + $json = @{ bitness = [int]$actual.bitness } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeTrue + } + + It 'should not be in desired state when bitness does not match' { + $wrongBitness = if ([int]$actual.bitness -eq 64) { 32 } else { 64 } + $json = @{ bitness = $wrongBitness } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeFalse + } + } + + Context 'architecture property' { + It 'should be in desired state when architecture matches actual' { + $json = @{ architecture = $actual.architecture } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeTrue + } + + It 'should not be in desired state when architecture does not match' { + $json = @{ architecture = 'mips' } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeFalse + } + } + + Context 'multiple properties combined' { + It 'should be in desired state when all specified properties match' { + $desiredState = @{ family = $actual.family; bitness = [int]$actual.bitness; architecture = $actual.architecture } + $json = $desiredState | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeTrue + } + + It 'should not be in desired state when one of multiple properties does not match' { + $desiredState = @{ family = $actual.family; bitness = [int]$actual.bitness; architecture = 'mips' } + $json = $desiredState | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeFalse + } + } +} From af4ad34afca59e470c5224befcfb44f8f0e18dff Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 30 Jun 2026 23:35:37 -0700 Subject: [PATCH 08/10] Run Pester resource tests during coverage build step The coverage report showed 0% because the build step excluded Pester tests (-ExcludePesterTests). Since the osinfo test operation code paths are only exercised by Pester tests (not Rust unit tests), the instrumented binaries never recorded any hits for those functions. Change the build step to run the 'resources' Pester test group alongside Rust tests so the instrumented osinfo binary is exercised and profraw data is recorded. Also filter out CARGO_LLVM_COV_SHOW_ENV from Set-LlvmCovEnvironment since propagating that flag causes cargo-llvm-cov to print env vars and exit rather than performing compilation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust.yml | 6 +++--- helpers.build.psm1 | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index f91d1a0b0..d98fcaa2b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -55,7 +55,7 @@ jobs: - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -Clippy -Test -CodeCoverage -ExcludePesterTests -Verbose + run: ./build.ps1 -Clippy -Test -CodeCoverage -Verbose -PesterTestGroup resources - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -111,7 +111,7 @@ jobs: - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -Clippy -Test -CodeCoverage -ExcludePesterTests -Verbose + run: ./build.ps1 -Clippy -Test -CodeCoverage -Verbose -PesterTestGroup resources - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -171,7 +171,7 @@ jobs: - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -Clippy -Test -CodeCoverage -ExcludePesterTests -Verbose + run: ./build.ps1 -Clippy -Test -CodeCoverage -Verbose -PesterTestGroup resources - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 7a1d5cae7..591ca7cdc 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1949,6 +1949,11 @@ function Set-LlvmCovEnvironment { foreach ($line in $showEnvOutput) { if ($line -match '^([A-Z_][A-Z0-9_]+)=(.*)$') { $name = $Matches[1] + # CARGO_LLVM_COV_SHOW_ENV is an output-only flag that tells + # cargo-llvm-cov to print env and exit; do not propagate it. + if ($name -eq 'CARGO_LLVM_COV_SHOW_ENV') { + continue + } # Strip optional surrounding single quotes from the value $value = ($Matches[2] -replace "^'", '') -replace "'$", '' $priorValues[$name] = [System.Environment]::GetEnvironmentVariable($name) From 79ccb180efbcdf2334c8c120b5baadd7fc42aa2f Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Wed, 1 Jul 2026 15:19:32 -0700 Subject: [PATCH 09/10] Use merge-base for accurate PR diff in coverage report The coverage report used github.event.pull_request.base.sha directly with three-dot git diff syntax. After a rebase or when the base branch advances, base.sha points to the current tip of the base branch rather than the common ancestor, causing the diff to include unrelated changes or miss actual PR changes. Fix by computing git merge-base between base and head SHAs in the coverage-report job, then using two-dot diff syntax throughout. This ensures only the PR's own changes are analyzed regardless of rebases or upstream merges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust.yml | 12 +++++++++++- helpers.build.psm1 | 6 +++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d98fcaa2b..8337230f3 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -245,8 +245,18 @@ jobs: $baseSha = '${{ github.event.pull_request.base.sha }}' $headSha = '${{ github.event.pull_request.head.sha }}' + # Compute the merge-base to get an accurate diff of only PR changes. + # Using base.sha directly is unreliable after rebases or when the base + # branch has advanced, since it points to the tip of the base branch at + # event time rather than the common ancestor. + $mergeBase = git merge-base $baseSha $headSha 2>$null + if ($LASTEXITCODE -eq 0 -and $mergeBase) { + Write-Verbose -Verbose "Using merge-base $mergeBase (base=$baseSha, head=$headSha)" + $baseSha = $mergeBase + } + # Determine if any Rust files changed from git diff - $changedFiles = git diff --name-only --diff-filter=ACMR "$baseSha...$headSha" -- '*.rs' | Where-Object { $_ } + $changedFiles = git diff --name-only --diff-filter=ACMR "$baseSha..$headSha" -- '*.rs' | Where-Object { $_ } if (-not $changedFiles) { "has_rust_changes=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT return diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 591ca7cdc..9db0f0769 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1874,14 +1874,14 @@ function Get-ChangedRustFile { ) process { - $changedFiles = git diff --name-only --diff-filter=ACMR "$BaseSha...$HeadSha" -- '*.rs' + $changedFiles = git diff --name-only --diff-filter=ACMR "$BaseSha..$HeadSha" -- '*.rs' if ($LASTEXITCODE -ne 0) { Write-Warning "Failed to detect changed files between $BaseSha and $HeadSha" return @() } $result = @($changedFiles | Where-Object { $_ }) - Write-Verbose "Found $($result.Count) changed Rust file(s)" + Write-Verbose -Verbose "Found $($result.Count) changed Rust file(s)" return $result } } @@ -2147,7 +2147,7 @@ function Get-CodeCoverageReport { } # Parse diff to get added line numbers in the new file - $diffOutput = git diff "$BaseSha...$HeadSha" -- $file + $diffOutput = git diff "$BaseSha..$HeadSha" -- $file $addedLineNumbers = @() $currentLineNum = 0 From 5da1477cde8443ee7478a8fd51cdf05afcff51e8 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Wed, 1 Jul 2026 19:38:14 -0700 Subject: [PATCH 10/10] Fix LCOV path matching for cross-job coverage report The coverage-report job failed to match LCOV source file paths because the matching logic did not handle relative paths from cargo-llvm-cov. Added direct comparison with relative file path and normalized slash handling. Also added verbose diagnostic output when no match is found to aid future debugging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- helpers.build.psm1 | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 9db0f0769..1a62b8c51 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -2166,14 +2166,23 @@ function Get-CodeCoverageReport { # Find matching LCOV entry for this file $absPath = (Resolve-Path $file).Path + $normalizedFile = $file.Replace('\', '/') $fileCoverage = $null foreach ($key in $lcovData.Keys) { - if ($key -eq $absPath -or $key.EndsWith("/$file") -or $key.EndsWith("\$file")) { + $normalizedKey = $key.Replace('\', '/') + if ($normalizedKey -eq $absPath -or + $normalizedKey -eq $normalizedFile -or + $normalizedKey.EndsWith("/$normalizedFile") -or + $normalizedKey.EndsWith("\$normalizedFile")) { $fileCoverage = $lcovData[$key] break } } + if (-not $fileCoverage) { + Write-Verbose -Verbose "No LCOV match for '$file' (absPath='$absPath'). LCOV keys: $($lcovData.Keys -join ', ')" + } + # Build per-line coverage map for this file (only added executable lines) $lineCoverageMap = @{} if ($fileCoverage) {