From 8e3057a961b50e1ebe2768e9df3a7156acc96bc2 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:11:58 -0700 Subject: [PATCH 01/30] Add PR code coverage workflow for Rust changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a GitHub Actions workflow that runs on PRs to analyze code coverage of changed Rust files using cargo-llvm-cov. Reports coverage with emoji: - 😲 100% coverage - 😁 90%+ coverage - 😊 80%+ coverage - 😐 70%+ coverage - 😢 less than 70% coverage Posts a sticky comment on the PR with coverage details. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 149 ++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 .github/workflows/pr-coverage.yml diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml new file mode 100644 index 000000000..04e9dc9d9 --- /dev/null +++ b/.github/workflows/pr-coverage.yml @@ -0,0 +1,149 @@ +name: PR Code Coverage + +on: + pull_request: + branches: [ "main", "release/*" ] + paths-ignore: + - "docs/**" + - "*.md" + - ".vscode/*.json" + - ".github/ISSUE_TEMPLATE/**" + +env: + CARGO_TERM_COLOR: always + +jobs: + coverage: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Get changed Rust files + id: changed + run: | + BASE_SHA=${{ github.event.pull_request.base.sha }} + HEAD_SHA=${{ github.event.pull_request.head.sha }} + CHANGED_FILES=$(git diff --name-only --diff-filter=ACMR "$BASE_SHA"..."$HEAD_SHA" -- '*.rs' | tr '\n' ' ') + echo "files=$CHANGED_FILES" >> "$GITHUB_OUTPUT" + if [ -z "$CHANGED_FILES" ]; then + echo "has_rust_changes=false" >> "$GITHUB_OUTPUT" + else + echo "has_rust_changes=true" >> "$GITHUB_OUTPUT" + fi + + - name: Generate coverage report + if: steps.changed.outputs.has_rust_changes == 'true' + run: | + cargo llvm-cov --lcov --output-path lcov.info + + - name: Analyze coverage on changed files + if: steps.changed.outputs.has_rust_changes == 'true' + id: coverage + run: | + BASE_SHA=${{ github.event.pull_request.base.sha }} + HEAD_SHA=${{ github.event.pull_request.head.sha }} + + # Get changed lines per file + TOTAL_CHANGED_LINES=0 + COVERED_LINES=0 + + for file in ${{ steps.changed.outputs.files }}; do + if [ ! -f "$file" ]; then + continue + fi + + # Get the added line numbers from the diff + ADDED_LINES=$(git diff "$BASE_SHA"..."$HEAD_SHA" -- "$file" | \ + grep -E '^\+' | grep -v '^\+\+\+' | wc -l | tr -d ' ') + + # Get covered lines from lcov for this file + ABS_PATH=$(realpath "$file") + FILE_SECTION=$(sed -n "/^SF:.*$(basename "$file")$/,/^end_of_record$/p" lcov.info | \ + grep "^SF:.*${file}" -A 99999 | sed '/^end_of_record$/q') + + if [ -n "$FILE_SECTION" ]; then + # Extract line coverage data (DA:line_number,execution_count) + # Get changed line numbers from diff + CHANGED_LINE_NUMS=$(git diff "$BASE_SHA"..."$HEAD_SHA" -- "$file" | \ + awk '/^@@/{split($3,a,","); line=substr(a[1],2)} /^\+[^+]/{print line; line++} /^[^+-]/{line++}') + + for line_num in $CHANGED_LINE_NUMS; do + TOTAL_CHANGED_LINES=$((TOTAL_CHANGED_LINES + 1)) + # Check if this line is covered (execution_count > 0) + HIT=$(echo "$FILE_SECTION" | grep "^DA:${line_num}," | cut -d',' -f2) + if [ -n "$HIT" ] && [ "$HIT" -gt 0 ] 2>/dev/null; then + COVERED_LINES=$((COVERED_LINES + 1)) + fi + done + else + # File not in coverage report - count all changed lines as uncovered + TOTAL_CHANGED_LINES=$((TOTAL_CHANGED_LINES + ADDED_LINES)) + fi + done + + if [ "$TOTAL_CHANGED_LINES" -eq 0 ]; then + PERCENTAGE=100 + else + PERCENTAGE=$((COVERED_LINES * 100 / TOTAL_CHANGED_LINES)) + fi + + echo "percentage=$PERCENTAGE" >> "$GITHUB_OUTPUT" + echo "covered=$COVERED_LINES" >> "$GITHUB_OUTPUT" + echo "total=$TOTAL_CHANGED_LINES" >> "$GITHUB_OUTPUT" + + # Determine emoji + if [ "$PERCENTAGE" -eq 100 ]; then + echo "emoji=😲" >> "$GITHUB_OUTPUT" + echo "label=100% coverage" >> "$GITHUB_OUTPUT" + elif [ "$PERCENTAGE" -ge 90 ]; then + echo "emoji=😁" >> "$GITHUB_OUTPUT" + echo "label=90%+ coverage" >> "$GITHUB_OUTPUT" + elif [ "$PERCENTAGE" -ge 80 ]; then + echo "emoji=😊" >> "$GITHUB_OUTPUT" + echo "label=80%+ coverage" >> "$GITHUB_OUTPUT" + elif [ "$PERCENTAGE" -ge 70 ]; then + echo "emoji=😐" >> "$GITHUB_OUTPUT" + echo "label=70%+ coverage" >> "$GITHUB_OUTPUT" + else + echo "emoji=😢" >> "$GITHUB_OUTPUT" + echo "label=less than 70% coverage" >> "$GITHUB_OUTPUT" + fi + + - name: Post coverage comment + if: steps.changed.outputs.has_rust_changes == 'true' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: coverage-report + message: | + ## ${{ steps.coverage.outputs.emoji }} Code Coverage Report + + **Changed code coverage: ${{ steps.coverage.outputs.percentage }}%** (${{ steps.coverage.outputs.label }}) + + | Metric | Value | + |--------|-------| + | Changed lines analyzed | ${{ steps.coverage.outputs.total }} | + | Lines covered by tests | ${{ steps.coverage.outputs.covered }} | + | Coverage percentage | ${{ steps.coverage.outputs.percentage }}% | + + > Coverage is measured only on changed Rust code in this PR. + + - name: Post no-changes comment + if: steps.changed.outputs.has_rust_changes == 'false' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: coverage-report + message: | + ## Code Coverage Report + + No Rust files were changed in this PR. Coverage analysis skipped. From cce23dcf5ccde9410898a6ff8f363c565904b8f8 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:14:19 -0700 Subject: [PATCH 02/30] Convert PR coverage workflow scripts from bash to PowerShell Replaces all bash shell scripts with pwsh to match the repo convention. Uses proper PowerShell idioms for diff parsing, LCOV analysis, and GitHub Actions output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 194 ++++++++++++++++++------------ 1 file changed, 114 insertions(+), 80 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 04e9dc9d9..913832e6c 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -12,6 +12,10 @@ on: env: CARGO_TERM_COLOR: always +defaults: + run: + shell: pwsh + jobs: coverage: runs-on: ubuntu-latest @@ -31,94 +35,124 @@ jobs: - name: Get changed Rust files id: changed - run: | - BASE_SHA=${{ github.event.pull_request.base.sha }} - HEAD_SHA=${{ github.event.pull_request.head.sha }} - CHANGED_FILES=$(git diff --name-only --diff-filter=ACMR "$BASE_SHA"..."$HEAD_SHA" -- '*.rs' | tr '\n' ' ') - echo "files=$CHANGED_FILES" >> "$GITHUB_OUTPUT" - if [ -z "$CHANGED_FILES" ]; then - echo "has_rust_changes=false" >> "$GITHUB_OUTPUT" - else - echo "has_rust_changes=true" >> "$GITHUB_OUTPUT" - fi + run: |- + $baseSha = '${{ github.event.pull_request.base.sha }}' + $headSha = '${{ github.event.pull_request.head.sha }}' + $changedFiles = git diff --name-only --diff-filter=ACMR "$baseSha...$headSha" -- '*.rs' + if ($changedFiles) { + $fileList = ($changedFiles | Where-Object { $_ }) -join ' ' + "files=$fileList" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "has_rust_changes=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + } else { + "files=" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "has_rust_changes=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + } - name: Generate coverage report if: steps.changed.outputs.has_rust_changes == 'true' - run: | + run: |- cargo llvm-cov --lcov --output-path lcov.info - name: Analyze coverage on changed files if: steps.changed.outputs.has_rust_changes == 'true' id: coverage - run: | - BASE_SHA=${{ github.event.pull_request.base.sha }} - HEAD_SHA=${{ github.event.pull_request.head.sha }} - - # Get changed lines per file - TOTAL_CHANGED_LINES=0 - COVERED_LINES=0 - - for file in ${{ steps.changed.outputs.files }}; do - if [ ! -f "$file" ]; then - continue - fi - - # Get the added line numbers from the diff - ADDED_LINES=$(git diff "$BASE_SHA"..."$HEAD_SHA" -- "$file" | \ - grep -E '^\+' | grep -v '^\+\+\+' | wc -l | tr -d ' ') - - # Get covered lines from lcov for this file - ABS_PATH=$(realpath "$file") - FILE_SECTION=$(sed -n "/^SF:.*$(basename "$file")$/,/^end_of_record$/p" lcov.info | \ - grep "^SF:.*${file}" -A 99999 | sed '/^end_of_record$/q') - - if [ -n "$FILE_SECTION" ]; then - # Extract line coverage data (DA:line_number,execution_count) - # Get changed line numbers from diff - CHANGED_LINE_NUMS=$(git diff "$BASE_SHA"..."$HEAD_SHA" -- "$file" | \ - awk '/^@@/{split($3,a,","); line=substr(a[1],2)} /^\+[^+]/{print line; line++} /^[^+-]/{line++}') - - for line_num in $CHANGED_LINE_NUMS; do - TOTAL_CHANGED_LINES=$((TOTAL_CHANGED_LINES + 1)) - # Check if this line is covered (execution_count > 0) - HIT=$(echo "$FILE_SECTION" | grep "^DA:${line_num}," | cut -d',' -f2) - if [ -n "$HIT" ] && [ "$HIT" -gt 0 ] 2>/dev/null; then - COVERED_LINES=$((COVERED_LINES + 1)) - fi - done - else - # File not in coverage report - count all changed lines as uncovered - TOTAL_CHANGED_LINES=$((TOTAL_CHANGED_LINES + ADDED_LINES)) - fi - done - - if [ "$TOTAL_CHANGED_LINES" -eq 0 ]; then - PERCENTAGE=100 - else - PERCENTAGE=$((COVERED_LINES * 100 / TOTAL_CHANGED_LINES)) - fi - - echo "percentage=$PERCENTAGE" >> "$GITHUB_OUTPUT" - echo "covered=$COVERED_LINES" >> "$GITHUB_OUTPUT" - echo "total=$TOTAL_CHANGED_LINES" >> "$GITHUB_OUTPUT" - - # Determine emoji - if [ "$PERCENTAGE" -eq 100 ]; then - echo "emoji=😲" >> "$GITHUB_OUTPUT" - echo "label=100% coverage" >> "$GITHUB_OUTPUT" - elif [ "$PERCENTAGE" -ge 90 ]; then - echo "emoji=😁" >> "$GITHUB_OUTPUT" - echo "label=90%+ coverage" >> "$GITHUB_OUTPUT" - elif [ "$PERCENTAGE" -ge 80 ]; then - echo "emoji=😊" >> "$GITHUB_OUTPUT" - echo "label=80%+ coverage" >> "$GITHUB_OUTPUT" - elif [ "$PERCENTAGE" -ge 70 ]; then - echo "emoji=😐" >> "$GITHUB_OUTPUT" - echo "label=70%+ coverage" >> "$GITHUB_OUTPUT" - else - echo "emoji=😢" >> "$GITHUB_OUTPUT" - echo "label=less than 70% coverage" >> "$GITHUB_OUTPUT" - fi + run: |- + $baseSha = '${{ github.event.pull_request.base.sha }}' + $headSha = '${{ github.event.pull_request.head.sha }}' + + $totalChangedLines = 0 + $coveredLines = 0 + + # Parse the LCOV file into a hashtable keyed by source file path + $lcovData = @{} + $currentFile = $null + foreach ($line in Get-Content -Path 'lcov.info') { + if ($line -match '^SF:(.+)$') { + $currentFile = $Matches[1] + $lcovData[$currentFile] = @{} + } elseif ($line -match '^DA:(\d+),(\d+)' -and $currentFile) { + $lineNum = [int]$Matches[1] + $hitCount = [int]$Matches[2] + $lcovData[$currentFile][$lineNum] = $hitCount + } elseif ($line -eq 'end_of_record') { + $currentFile = $null + } + } + + $changedFiles = '${{ steps.changed.outputs.files }}' -split '\s+' + foreach ($file in $changedFiles) { + if (-not $file -or -not (Test-Path $file)) { + continue + } + + # Parse diff to get added line numbers in the new file + $diffOutput = git diff "$baseSha...$headSha" -- $file + $addedLineNumbers = @() + $currentLineNum = 0 + + foreach ($diffLine in $diffOutput) { + if ($diffLine -match '^@@\s+\-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@') { + $currentLineNum = [int]$Matches[1] + } elseif ($diffLine.StartsWith('+') -and -not $diffLine.StartsWith('+++')) { + $addedLineNumbers += $currentLineNum + $currentLineNum++ + } elseif ($diffLine.StartsWith('-') -and -not $diffLine.StartsWith('---')) { + # Deleted lines don't advance the new file line counter + } else { + $currentLineNum++ + } + } + + # Find matching LCOV entry for this file + $absPath = (Resolve-Path $file).Path + $fileCoverage = $null + foreach ($key in $lcovData.Keys) { + if ($key -eq $absPath -or $key.EndsWith("/$file") -or $key.EndsWith("\$file")) { + $fileCoverage = $lcovData[$key] + break + } + } + + if ($fileCoverage) { + foreach ($lineNum in $addedLineNumbers) { + $totalChangedLines++ + if ($fileCoverage.ContainsKey($lineNum) -and $fileCoverage[$lineNum] -gt 0) { + $coveredLines++ + } + } + } else { + # File not in coverage report - count added lines as uncovered + $totalChangedLines += $addedLineNumbers.Count + } + } + + if ($totalChangedLines -eq 0) { + $percentage = 100 + } else { + $percentage = [math]::Floor($coveredLines * 100 / $totalChangedLines) + } + + "percentage=$percentage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "covered=$coveredLines" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "total=$totalChangedLines" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + + # Determine emoji and label + if ($percentage -eq 100) { + "emoji=😲" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "label=100% coverage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + } elseif ($percentage -ge 90) { + "emoji=😁" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "label=90%+ coverage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + } elseif ($percentage -ge 80) { + "emoji=😊" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "label=80%+ coverage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + } elseif ($percentage -ge 70) { + "emoji=😐" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "label=70%+ coverage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + } else { + "emoji=😢" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "label=less than 70% coverage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + } - name: Post coverage comment if: steps.changed.outputs.has_rust_changes == 'true' From 33e0d8d103ab92aa1508947c3025756e5d2bed2d Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:19:06 -0700 Subject: [PATCH 03/30] Use ./build.ps1 -Test for coverage instrumentation Uses cargo-llvm-cov show-env to set up instrumentation environment variables, then runs ./build.ps1 -Test to exercise both Rust and Pester tests under coverage, and finally generates the LCOV report. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 913832e6c..e635d80bd 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -48,10 +48,26 @@ jobs: "has_rust_changes=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT } - - name: Generate coverage report + - name: Install prerequisites if: steps.changed.outputs.has_rust_changes == 'true' run: |- - cargo llvm-cov --lcov --output-path lcov.info + ./build.ps1 -SkipBuild -Verbose + + - name: Build and test with coverage + if: steps.changed.outputs.has_rust_changes == 'true' + run: |- + # Set up coverage instrumentation environment + cargo llvm-cov show-env --export-prefix | ForEach-Object { + if ($_ -match '^export\s+(\w+)=(.*)$') { + [System.Environment]::SetEnvironmentVariable($Matches[1], $Matches[2].Trim('"')) + } + } + # Clean previous coverage artifacts + cargo llvm-cov clean --workspace + # Build and run tests using the project's build script + ./build.ps1 -Test -Verbose + # Generate LCOV report from collected profile data + cargo llvm-cov report --lcov --output-path lcov.info - name: Analyze coverage on changed files if: steps.changed.outputs.has_rust_changes == 'true' From 0b17b1eecca8b565c085ab69b80d5d0c8277d1bc Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:28:34 -0700 Subject: [PATCH 04/30] Handle test failures gracefully in coverage workflow Catches build.ps1 -Test failures and emits a warning, but still generates the coverage report. The PR comment includes a warning banner when tests failed indicating data may be incomplete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 39 +++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index e635d80bd..4290f5444 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -55,6 +55,7 @@ jobs: - name: Build and test with coverage if: steps.changed.outputs.has_rust_changes == 'true' + id: build-test run: |- # Set up coverage instrumentation environment cargo llvm-cov show-env --export-prefix | ForEach-Object { @@ -65,7 +66,21 @@ jobs: # Clean previous coverage artifacts cargo llvm-cov clean --workspace # Build and run tests using the project's build script - ./build.ps1 -Test -Verbose + $testsFailed = $false + try { + ./build.ps1 -Test -Verbose + if ($LASTEXITCODE -ne 0) { + $testsFailed = $true + } + } catch { + $testsFailed = $true + } + if ($testsFailed) { + Write-Warning 'One or more tests failed. Producing coverage report from partial results.' + "tests_failed=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + } else { + "tests_failed=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + } # Generate LCOV report from collected profile data cargo llvm-cov report --lcov --output-path lcov.info @@ -171,13 +186,33 @@ jobs: } - name: Post coverage comment - if: steps.changed.outputs.has_rust_changes == 'true' + if: steps.changed.outputs.has_rust_changes == 'true' && steps.build-test.outputs.tests_failed == 'false' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: coverage-report + message: | + ## ${{ steps.coverage.outputs.emoji }} Code Coverage Report + + **Changed code coverage: ${{ steps.coverage.outputs.percentage }}%** (${{ steps.coverage.outputs.label }}) + + | Metric | Value | + |--------|-------| + | Changed lines analyzed | ${{ steps.coverage.outputs.total }} | + | Lines covered by tests | ${{ steps.coverage.outputs.covered }} | + | Coverage percentage | ${{ steps.coverage.outputs.percentage }}% | + + > Coverage is measured only on changed Rust code in this PR. + + - name: Post coverage comment (tests failed) + if: steps.changed.outputs.has_rust_changes == 'true' && steps.build-test.outputs.tests_failed == 'true' uses: marocchino/sticky-pull-request-comment@v2 with: header: coverage-report message: | ## ${{ steps.coverage.outputs.emoji }} Code Coverage Report + > :warning: **One or more tests failed.** Coverage data below may be incomplete. + **Changed code coverage: ${{ steps.coverage.outputs.percentage }}%** (${{ steps.coverage.outputs.label }}) | Metric | Value | From 0e2b25eef3720bb0071460397fda0e83a73b5406 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:29:37 -0700 Subject: [PATCH 05/30] Skip Rust toolchain install when no Rust files changed Moves the changed-files check before toolchain and cargo-llvm-cov installation steps and gates them on has_rust_changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 4290f5444..467f12c62 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -27,12 +27,6 @@ jobs: with: fetch-depth: 0 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - name: Get changed Rust files id: changed run: |- @@ -48,6 +42,14 @@ jobs: "has_rust_changes=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT } + - name: Install Rust toolchain + if: steps.changed.outputs.has_rust_changes == 'true' + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-llvm-cov + if: steps.changed.outputs.has_rust_changes == 'true' + uses: taiki-e/install-action@cargo-llvm-cov + - name: Install prerequisites if: steps.changed.outputs.has_rust_changes == 'true' run: |- From 2af9a0b2fddf0a9a46498c0c68f029b178c35fc2 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:32:32 -0700 Subject: [PATCH 06/30] Add -CodeCoverage switch to build.ps1 Adds a -CodeCoverage switch and -CodeCoverageOutputPath parameter to build.ps1. When enabled, the build script sets up cargo-llvm-cov instrumentation before building/testing and generates an LCOV report afterward. Adds Initialize-CodeCoverage and Export-CodeCoverageReport helper functions to helpers.build.psm1. Updates the PR coverage workflow to use ./build.ps1 -Test -CodeCoverage instead of calling cargo-llvm-cov directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 18 +-------- build.ps1 | 30 ++++++++++++++ helpers.build.psm1 | 65 +++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 17 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 467f12c62..4bf847ec8 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -50,27 +50,13 @@ jobs: if: steps.changed.outputs.has_rust_changes == 'true' uses: taiki-e/install-action@cargo-llvm-cov - - name: Install prerequisites - if: steps.changed.outputs.has_rust_changes == 'true' - run: |- - ./build.ps1 -SkipBuild -Verbose - - name: Build and test with coverage if: steps.changed.outputs.has_rust_changes == 'true' id: build-test run: |- - # Set up coverage instrumentation environment - cargo llvm-cov show-env --export-prefix | ForEach-Object { - if ($_ -match '^export\s+(\w+)=(.*)$') { - [System.Environment]::SetEnvironmentVariable($Matches[1], $Matches[2].Trim('"')) - } - } - # Clean previous coverage artifacts - cargo llvm-cov clean --workspace - # Build and run tests using the project's build script $testsFailed = $false try { - ./build.ps1 -Test -Verbose + ./build.ps1 -Test -CodeCoverage -Verbose if ($LASTEXITCODE -ne 0) { $testsFailed = $true } @@ -83,8 +69,6 @@ jobs: } else { "tests_failed=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT } - # Generate LCOV report from collected profile data - cargo llvm-cov report --lcov --output-path lcov.info - name: Analyze coverage on changed files if: steps.changed.outputs.has_rust_changes == 'true' diff --git a/build.ps1 b/build.ps1 index 63288b54f..93edf191e 100755 --- a/build.ps1 +++ b/build.ps1 @@ -44,6 +44,16 @@ using module ./helpers.build.psm1 .PARAMETER Test Determines whether to run Rust and Pester tests for the project. + .PARAMETER CodeCoverage + Enables code coverage instrumentation using cargo-llvm-cov. Requires the `cargo-llvm-cov` + component to be installed. When specified, the build and tests run with coverage + instrumentation enabled and an LCOV report is generated at the path specified by + `-CodeCoverageOutputPath` (defaults to `lcov.info` in the repository root). + + .PARAMETER CodeCoverageOutputPath + Specifies the output path for the LCOV code coverage report. Only used when `-CodeCoverage` + is specified. Defaults to `lcov.info` in the repository root. + .PARAMETER GetPackageVersion Short circuits the build to return the current version of the DSC CLI crate. @@ -87,6 +97,8 @@ param( )] $PackageType, [switch]$Test, + [switch]$CodeCoverage, + [string]$CodeCoverageOutputPath = (Join-Path $PSScriptRoot 'lcov.info'), [string[]]$Project, [switch]$ExcludeRustTests, [string]$RustTestFilter, @@ -220,6 +232,15 @@ process { #endregion Setup + #region Code coverage instrumentation + if ($CodeCoverage) { + $progressParams.Activity = 'Setting up code coverage' + Write-BuildProgress @progressParams + Write-BuildProgress @progressParams -Status 'Configuring cargo-llvm-cov environment' + Initialize-CodeCoverage @VerboseParam + } + #endregion Code coverage instrumentation + if (!$SkipBuild) { if ($UpdateLockFile) { $lockFile = Join-Path $PSScriptRoot "Cargo.lock" @@ -305,6 +326,15 @@ process { } } + #region Code coverage report + if ($CodeCoverage) { + $progressParams.Activity = 'Generating code coverage report' + Write-BuildProgress @progressParams + Write-BuildProgress @progressParams -Status "Writing LCOV report to $CodeCoverageOutputPath" + Export-CodeCoverageReport -OutputPath $CodeCoverageOutputPath @VerboseParam + } + #endregion Code coverage report + if (-not [string]::IsNullOrEmpty($PackageType)) { $progressParams.Activity = "Packaging" $packageParams = @{ diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 32d007a27..52c2b978c 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1790,6 +1790,71 @@ function Export-RustDocs { } #endregion Documenting project functions +#region Code coverage functions +function Initialize-CodeCoverage { + <# + .SYNOPSIS + Configures the environment for code coverage instrumentation using cargo-llvm-cov. + + .DESCRIPTION + Runs `cargo llvm-cov show-env` to retrieve the required environment variables for + coverage instrumentation and sets them in the current process. Also cleans any prior + coverage artifacts from the workspace. + #> + [CmdletBinding()] + param() + + process { + $showEnvOutput = cargo llvm-cov show-env --export-prefix + if ($LASTEXITCODE -ne 0) { + throw 'Failed to retrieve cargo-llvm-cov environment. Ensure cargo-llvm-cov is installed.' + } + + foreach ($line in $showEnvOutput) { + if ($line -match '^export\s+(\w+)=(.*)$') { + $name = $Matches[1] + $value = $Matches[2].Trim('"').Trim("'") + [System.Environment]::SetEnvironmentVariable($name, $value) + Write-Verbose "Set coverage environment variable: $name" + } + } + + Write-Verbose 'Cleaning previous coverage artifacts' + cargo llvm-cov clean --workspace + if ($LASTEXITCODE -ne 0) { + Write-Warning 'Failed to clean previous coverage artifacts, continuing anyway' + } + } +} + +function Export-CodeCoverageReport { + <# + .SYNOPSIS + Generates an LCOV code coverage report from collected profile data. + + .DESCRIPTION + Runs `cargo llvm-cov report` to produce an LCOV-formatted coverage report from the + profile data collected during an instrumented build and test run. + + .PARAMETER OutputPath + The file path where the LCOV report will be written. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$OutputPath + ) + + process { + cargo llvm-cov report --lcov --output-path $OutputPath + if ($LASTEXITCODE -ne 0) { + throw "Failed to generate code coverage report at '$OutputPath'" + } + Write-Verbose "Code coverage report written to: $OutputPath" + } +} +#endregion Code coverage functions + #region Test project functions function Test-RustProject { [CmdletBinding()] From 86005dbae100bcf6f2bb199598b170f3cb164970 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:34:08 -0700 Subject: [PATCH 07/30] Install cargo-llvm-cov and llvm-tools in build script Adds Install-CargoLlvmCov helper that installs cargo-llvm-cov and the llvm-tools rustup component if not already present. Called automatically by Initialize-CodeCoverage so -CodeCoverage works locally without any prior setup beyond having Rust installed. Removes the separate taiki-e/install-action step from the workflow since build.ps1 now handles the installation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 4 ---- helpers.build.psm1 | 34 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 4bf847ec8..97c29b77e 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -46,10 +46,6 @@ jobs: if: steps.changed.outputs.has_rust_changes == 'true' uses: dtolnay/rust-toolchain@stable - - name: Install cargo-llvm-cov - if: steps.changed.outputs.has_rust_changes == 'true' - uses: taiki-e/install-action@cargo-llvm-cov - - name: Build and test with coverage if: steps.changed.outputs.has_rust_changes == 'true' id: build-test diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 52c2b978c..04352ab1d 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -483,6 +483,38 @@ function Install-CargoAudit { } } +function Install-CargoLlvmCov { + <# + .SYNOPSIS + Installs `cargo-llvm-cov` if not already available. + + .DESCRIPTION + Checks whether `cargo-llvm-cov` is installed and installs it via `cargo install` if not + found. Also ensures the `llvm-tools` rustup component is installed, which is required by + cargo-llvm-cov for coverage instrumentation. + #> + [CmdletBinding()] + param() + + process { + if (Test-CommandAvailable -Name 'cargo-llvm-cov') { + Write-Verbose 'cargo-llvm-cov already installed.' + } else { + Write-Verbose 'Installing cargo-llvm-cov...' + cargo install cargo-llvm-cov + if ($LASTEXITCODE -ne 0) { + throw 'Failed to install cargo-llvm-cov' + } + } + + Write-Verbose 'Ensuring llvm-tools rustup component is installed' + rustup component add llvm-tools + if ($LASTEXITCODE -ne 0) { + throw 'Failed to install llvm-tools rustup component' + } + } +} + function Install-TreeSitter { <# .SYNOPSIS @@ -1805,6 +1837,8 @@ function Initialize-CodeCoverage { param() process { + Install-CargoLlvmCov @VerboseParam + $showEnvOutput = cargo llvm-cov show-env --export-prefix if ($LASTEXITCODE -ne 0) { throw 'Failed to retrieve cargo-llvm-cov environment. Ensure cargo-llvm-cov is installed.' From ded37dc779d8e89b5bfdafb4ad75fe3c87b7ec4c Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:37:51 -0700 Subject: [PATCH 08/30] Move Rust toolchain and changed-file detection into build.ps1 Removes the 'Install Rust toolchain' and 'Get changed Rust files' GitHub Actions steps. The build script now handles everything: - Install-CargoLlvmCov installs cargo-llvm-cov and llvm-tools if needed - Get-ChangedRustFile detects changed .rs files between two commits - New -CodeCoverageBaseSha/-CodeCoverageHeadSha parameters skip coverage when no Rust files changed, making it usable locally without GitHub The workflow now has a single build step calling: ./build.ps1 -Test -CodeCoverage -CodeCoverageBaseSha ... -CodeCoverageHeadSha ... Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 46 ++++++++++++++----------------- build.ps1 | 32 ++++++++++++++++++--- helpers.build.psm1 | 41 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 30 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 97c29b77e..7606cf620 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -27,38 +27,28 @@ jobs: with: fetch-depth: 0 - - name: Get changed Rust files - id: changed - run: |- - $baseSha = '${{ github.event.pull_request.base.sha }}' - $headSha = '${{ github.event.pull_request.head.sha }}' - $changedFiles = git diff --name-only --diff-filter=ACMR "$baseSha...$headSha" -- '*.rs' - if ($changedFiles) { - $fileList = ($changedFiles | Where-Object { $_ }) -join ' ' - "files=$fileList" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "has_rust_changes=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - } else { - "files=" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "has_rust_changes=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - } - - - name: Install Rust toolchain - if: steps.changed.outputs.has_rust_changes == 'true' - uses: dtolnay/rust-toolchain@stable - - name: Build and test with coverage - if: steps.changed.outputs.has_rust_changes == 'true' id: build-test run: |- + $baseSha = '${{ github.event.pull_request.base.sha }}' + $headSha = '${{ github.event.pull_request.head.sha }}' $testsFailed = $false try { - ./build.ps1 -Test -CodeCoverage -Verbose + ./build.ps1 -Test -CodeCoverage -CodeCoverageBaseSha $baseSha -CodeCoverageHeadSha $headSha -Verbose if ($LASTEXITCODE -ne 0) { $testsFailed = $true } } catch { $testsFailed = $true } + + # Check if coverage was skipped (no lcov.info produced) + if (-not (Test-Path 'lcov.info')) { + "has_rust_changes=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + return + } + "has_rust_changes=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + if ($testsFailed) { Write-Warning 'One or more tests failed. Producing coverage report from partial results.' "tests_failed=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT @@ -66,8 +56,12 @@ jobs: "tests_failed=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT } + # Pass changed files list for coverage analysis + $changedFiles = (git diff --name-only --diff-filter=ACMR "$baseSha...$headSha" -- '*.rs' | Where-Object { $_ }) -join ' ' + "files=$changedFiles" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + - name: Analyze coverage on changed files - if: steps.changed.outputs.has_rust_changes == 'true' + if: steps.build-test.outputs.has_rust_changes == 'true' id: coverage run: |- $baseSha = '${{ github.event.pull_request.base.sha }}' @@ -92,7 +86,7 @@ jobs: } } - $changedFiles = '${{ steps.changed.outputs.files }}' -split '\s+' + $changedFiles = '${{ steps.build-test.outputs.files }}' -split '\s+' foreach ($file in $changedFiles) { if (-not $file -or -not (Test-Path $file)) { continue @@ -168,7 +162,7 @@ jobs: } - name: Post coverage comment - if: steps.changed.outputs.has_rust_changes == 'true' && steps.build-test.outputs.tests_failed == 'false' + if: steps.build-test.outputs.has_rust_changes == 'true' && steps.build-test.outputs.tests_failed == 'false' uses: marocchino/sticky-pull-request-comment@v2 with: header: coverage-report @@ -186,7 +180,7 @@ jobs: > Coverage is measured only on changed Rust code in this PR. - name: Post coverage comment (tests failed) - if: steps.changed.outputs.has_rust_changes == 'true' && steps.build-test.outputs.tests_failed == 'true' + if: steps.build-test.outputs.has_rust_changes == 'true' && steps.build-test.outputs.tests_failed == 'true' uses: marocchino/sticky-pull-request-comment@v2 with: header: coverage-report @@ -206,7 +200,7 @@ jobs: > Coverage is measured only on changed Rust code in this PR. - name: Post no-changes comment - if: steps.changed.outputs.has_rust_changes == 'false' + if: steps.build-test.outputs.has_rust_changes == 'false' uses: marocchino/sticky-pull-request-comment@v2 with: header: coverage-report diff --git a/build.ps1 b/build.ps1 index 93edf191e..a2b486119 100755 --- a/build.ps1 +++ b/build.ps1 @@ -45,15 +45,27 @@ using module ./helpers.build.psm1 Determines whether to run Rust and Pester tests for the project. .PARAMETER CodeCoverage - Enables code coverage instrumentation using cargo-llvm-cov. Requires the `cargo-llvm-cov` - component to be installed. When specified, the build and tests run with coverage - instrumentation enabled and an LCOV report is generated at the path specified by - `-CodeCoverageOutputPath` (defaults to `lcov.info` in the repository root). + Enables code coverage instrumentation using cargo-llvm-cov. When specified, the build and + tests run with coverage instrumentation enabled and an LCOV report is generated at the path + specified by `-CodeCoverageOutputPath` (defaults to `lcov.info` in the repository root). + Installs cargo-llvm-cov and the llvm-tools rustup component automatically if not present. + + When `-CodeCoverageBaseSha` and `-CodeCoverageHeadSha` are provided, the script first checks + whether any `.rs` files were changed between those commits. If no Rust files were changed, + coverage is skipped entirely and the script exits early. .PARAMETER CodeCoverageOutputPath Specifies the output path for the LCOV code coverage report. Only used when `-CodeCoverage` is specified. Defaults to `lcov.info` in the repository root. + .PARAMETER CodeCoverageBaseSha + The base commit SHA to compare against when detecting changed Rust files. When specified + along with `-CodeCoverageHeadSha`, coverage is skipped if no `.rs` files were modified. + + .PARAMETER CodeCoverageHeadSha + The head commit SHA to compare when detecting changed Rust files. When specified along with + `-CodeCoverageBaseSha`, coverage is skipped if no `.rs` files were modified. + .PARAMETER GetPackageVersion Short circuits the build to return the current version of the DSC CLI crate. @@ -99,6 +111,8 @@ param( [switch]$Test, [switch]$CodeCoverage, [string]$CodeCoverageOutputPath = (Join-Path $PSScriptRoot 'lcov.info'), + [string]$CodeCoverageBaseSha, + [string]$CodeCoverageHeadSha, [string[]]$Project, [switch]$ExcludeRustTests, [string]$RustTestFilter, @@ -236,6 +250,16 @@ process { if ($CodeCoverage) { $progressParams.Activity = 'Setting up code coverage' Write-BuildProgress @progressParams + + if ($CodeCoverageBaseSha -and $CodeCoverageHeadSha) { + Write-BuildProgress @progressParams -Status 'Checking for changed Rust files' + $changedRustFiles = Get-ChangedRustFile -BaseSha $CodeCoverageBaseSha -HeadSha $CodeCoverageHeadSha @VerboseParam + if ($changedRustFiles.Count -eq 0) { + Write-Information 'No Rust files changed between the specified commits. Skipping code coverage.' + return + } + } + Write-BuildProgress @progressParams -Status 'Configuring cargo-llvm-cov environment' Initialize-CodeCoverage @VerboseParam } diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 04352ab1d..16826b625 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1823,6 +1823,47 @@ function Export-RustDocs { #endregion Documenting project functions #region Code coverage functions +function Get-ChangedRustFile { + <# + .SYNOPSIS + Returns the list of Rust files changed between two commits. + + .DESCRIPTION + Uses `git diff` to identify `.rs` files that were added, copied, modified, or renamed + between the specified base and head commits. + + .PARAMETER BaseSha + The base commit SHA to compare from. + + .PARAMETER HeadSha + The head commit SHA to compare to. + + .OUTPUTS + System.String[] + An array of changed `.rs` file paths, or an empty array if none were changed. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$BaseSha, + + [Parameter(Mandatory)] + [string]$HeadSha + ) + + process { + $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)" + return $result + } +} + function Initialize-CodeCoverage { <# .SYNOPSIS From 790912234c5b2a6d3c443876d61738c00a1055eb Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:39:57 -0700 Subject: [PATCH 09/30] Add code coverage artifacts to .gitignore Ignores lcov.info, .profraw, and .profdata files generated by cargo-llvm-cov during coverage runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 038246f41..5223f38cc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,8 @@ grammars/**/src/ grammars/**/parser.* tree-sitter-ssh-server-config/ tree-sitter-dscexpression/ + +# Code coverage artifacts +lcov.info +*.profraw +*.profdata From 553f4c3999636c55fb9e70bc748f467fb8334dd8 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:41:21 -0700 Subject: [PATCH 10/30] Skip PR comment steps for fork PRs Gate all comment steps on same-repo PRs to avoid 403 errors from read-only GITHUB_TOKEN on fork PRs. Coverage still runs and reports status; only the PR comment is skipped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 7606cf620..91f2ad27d 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -162,7 +162,10 @@ jobs: } - name: Post coverage comment - if: steps.build-test.outputs.has_rust_changes == 'true' && steps.build-test.outputs.tests_failed == 'false' + if: >- + steps.build-test.outputs.has_rust_changes == 'true' + && steps.build-test.outputs.tests_failed == 'false' + && github.event.pull_request.head.repo.full_name == github.repository uses: marocchino/sticky-pull-request-comment@v2 with: header: coverage-report @@ -180,7 +183,10 @@ jobs: > Coverage is measured only on changed Rust code in this PR. - name: Post coverage comment (tests failed) - if: steps.build-test.outputs.has_rust_changes == 'true' && steps.build-test.outputs.tests_failed == 'true' + if: >- + steps.build-test.outputs.has_rust_changes == 'true' + && steps.build-test.outputs.tests_failed == 'true' + && github.event.pull_request.head.repo.full_name == github.repository uses: marocchino/sticky-pull-request-comment@v2 with: header: coverage-report @@ -200,7 +206,9 @@ jobs: > Coverage is measured only on changed Rust code in this PR. - name: Post no-changes comment - if: steps.build-test.outputs.has_rust_changes == 'false' + if: >- + steps.build-test.outputs.has_rust_changes == 'false' + && github.event.pull_request.head.repo.full_name == github.repository uses: marocchino/sticky-pull-request-comment@v2 with: header: coverage-report From 6a0a751d7d7511ed5182b425bfdd33bc7559ef63 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:50:23 -0700 Subject: [PATCH 11/30] Fix rustup component name to llvm-tools-preview The 'llvm-tools' component does not exist on standard toolchains. The correct component name is 'llvm-tools-preview'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- helpers.build.psm1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 16826b625..d7e721287 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -507,10 +507,10 @@ function Install-CargoLlvmCov { } } - Write-Verbose 'Ensuring llvm-tools rustup component is installed' - rustup component add llvm-tools + Write-Verbose 'Ensuring llvm-tools-preview rustup component is installed' + rustup component add llvm-tools-preview if ($LASTEXITCODE -ne 0) { - throw 'Failed to install llvm-tools rustup component' + throw 'Failed to install llvm-tools-preview rustup component' } } } From a81806958f4d229580d4a662bc26b000e609c0e2 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:51:34 -0700 Subject: [PATCH 12/30] Pass -UseCFS through coverage setup for ADO compatibility Adds -UseCFS parameter to Initialize-CodeCoverage and forwards it to Install-CargoLlvmCov. build.ps1 now passes its existing -UseCFS flag into the coverage setup path so ADO environments use the correct cargo config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build.ps1 | 2 +- helpers.build.psm1 | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/build.ps1 b/build.ps1 index a2b486119..9964b7507 100755 --- a/build.ps1 +++ b/build.ps1 @@ -261,7 +261,7 @@ process { } Write-BuildProgress @progressParams -Status 'Configuring cargo-llvm-cov environment' - Initialize-CodeCoverage @VerboseParam + Initialize-CodeCoverage -UseCFS:$UseCFS @VerboseParam } #endregion Code coverage instrumentation diff --git a/helpers.build.psm1 b/helpers.build.psm1 index d7e721287..634acce14 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -494,14 +494,20 @@ function Install-CargoLlvmCov { cargo-llvm-cov for coverage instrumentation. #> [CmdletBinding()] - param() + param( + [switch]$UseCFS + ) process { if (Test-CommandAvailable -Name 'cargo-llvm-cov') { Write-Verbose 'cargo-llvm-cov already installed.' } else { Write-Verbose 'Installing cargo-llvm-cov...' - cargo install cargo-llvm-cov + if ($UseCFS) { + cargo install cargo-llvm-cov --config .cargo/config.toml + } else { + cargo install cargo-llvm-cov + } if ($LASTEXITCODE -ne 0) { throw 'Failed to install cargo-llvm-cov' } @@ -1875,10 +1881,12 @@ function Initialize-CodeCoverage { coverage artifacts from the workspace. #> [CmdletBinding()] - param() + param( + [switch]$UseCFS + ) process { - Install-CargoLlvmCov @VerboseParam + Install-CargoLlvmCov -UseCFS:$UseCFS @VerboseParam $showEnvOutput = cargo llvm-cov show-env --export-prefix if ($LASTEXITCODE -ne 0) { From 8a269b4c6c334e4ff498d32730d5e969bfafdbf9 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:52:06 -0700 Subject: [PATCH 13/30] Only count executable lines in coverage analysis Changed lines that lack a DA: entry in the LCOV report (comments, blank lines, braces) are now excluded from the coverage calculation. Only lines LCOV identifies as executable are counted, preventing artificial deflation of the coverage percentage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 91f2ad27d..b7e512342 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -122,9 +122,12 @@ jobs: if ($fileCoverage) { foreach ($lineNum in $addedLineNumbers) { - $totalChangedLines++ - if ($fileCoverage.ContainsKey($lineNum) -and $fileCoverage[$lineNum] -gt 0) { - $coveredLines++ + # Only count lines that LCOV recognizes as executable (have a DA entry) + if ($fileCoverage.ContainsKey($lineNum)) { + $totalChangedLines++ + if ($fileCoverage[$lineNum] -gt 0) { + $coveredLines++ + } } } } else { From f75832fe94ee500723a805d761cebf30a93109f1 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:53:05 -0700 Subject: [PATCH 14/30] Determine has_rust_changes from git diff, not lcov.info presence Moves the Rust file change detection before the build step using git diff directly. A missing lcov.info when Rust changes exist is now treated as a failure with a dedicated error comment posted to the PR, rather than silently skipping analysis. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 45 +++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index b7e512342..7d6915c2b 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -32,6 +32,16 @@ jobs: run: |- $baseSha = '${{ github.event.pull_request.base.sha }}' $headSha = '${{ github.event.pull_request.head.sha }}' + + # Determine if any Rust files changed from git diff + $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 + } + "has_rust_changes=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "files=$($changedFiles -join ' ')" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + $testsFailed = $false try { ./build.ps1 -Test -CodeCoverage -CodeCoverageBaseSha $baseSha -CodeCoverageHeadSha $headSha -Verbose @@ -42,13 +52,6 @@ jobs: $testsFailed = $true } - # Check if coverage was skipped (no lcov.info produced) - if (-not (Test-Path 'lcov.info')) { - "has_rust_changes=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - return - } - "has_rust_changes=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - if ($testsFailed) { Write-Warning 'One or more tests failed. Producing coverage report from partial results.' "tests_failed=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT @@ -56,12 +59,16 @@ jobs: "tests_failed=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT } - # Pass changed files list for coverage analysis - $changedFiles = (git diff --name-only --diff-filter=ACMR "$baseSha...$headSha" -- '*.rs' | Where-Object { $_ }) -join ' ' - "files=$changedFiles" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + # Treat missing lcov.info as a failure when Rust changes exist + if (-not (Test-Path 'lcov.info')) { + Write-Error 'Coverage report (lcov.info) was not generated despite Rust file changes.' + "coverage_failed=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + } else { + "coverage_failed=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + } - name: Analyze coverage on changed files - if: steps.build-test.outputs.has_rust_changes == 'true' + if: steps.build-test.outputs.has_rust_changes == 'true' && steps.build-test.outputs.coverage_failed != 'true' id: coverage run: |- $baseSha = '${{ github.event.pull_request.base.sha }}' @@ -167,6 +174,7 @@ jobs: - name: Post coverage comment if: >- steps.build-test.outputs.has_rust_changes == 'true' + && steps.build-test.outputs.coverage_failed != 'true' && steps.build-test.outputs.tests_failed == 'false' && github.event.pull_request.head.repo.full_name == github.repository uses: marocchino/sticky-pull-request-comment@v2 @@ -188,6 +196,7 @@ jobs: - name: Post coverage comment (tests failed) if: >- steps.build-test.outputs.has_rust_changes == 'true' + && steps.build-test.outputs.coverage_failed != 'true' && steps.build-test.outputs.tests_failed == 'true' && github.event.pull_request.head.repo.full_name == github.repository uses: marocchino/sticky-pull-request-comment@v2 @@ -208,6 +217,20 @@ jobs: > Coverage is measured only on changed Rust code in this PR. + - name: Post coverage comment (report failed) + if: >- + steps.build-test.outputs.has_rust_changes == 'true' + && steps.build-test.outputs.coverage_failed == 'true' + && github.event.pull_request.head.repo.full_name == github.repository + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: coverage-report + message: | + ## :x: Code Coverage Report + + **Coverage report could not be generated.** The build or test run failed before + producing coverage data. Check the workflow logs for details. + - name: Post no-changes comment if: >- steps.build-test.outputs.has_rust_changes == 'false' From 5f6c328d35e9b8a6b0ff25f1a87733dbe4ecb34d Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 11:54:32 -0700 Subject: [PATCH 15/30] Change Test-RustProject to throw on test failure Replaces non-terminating Write-Error with throw so callers can reliably catch test failures via try/catch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- helpers.build.psm1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 634acce14..cfdafe86f 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1984,7 +1984,7 @@ function Test-RustProject { } if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { - Write-Error "Last exit code is $LASTEXITCODE, rust tests failed" + throw "Last exit code is $LASTEXITCODE, rust tests failed" } } From 2324ff2e418c9b978881d2c0ceeffe3e364ab4ca Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 14:28:56 -0700 Subject: [PATCH 16/30] Fix Install-CargoLlvmCov help to say llvm-tools-preview Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- helpers.build.psm1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helpers.build.psm1 b/helpers.build.psm1 index cfdafe86f..81831b3dd 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -490,8 +490,8 @@ function Install-CargoLlvmCov { .DESCRIPTION Checks whether `cargo-llvm-cov` is installed and installs it via `cargo install` if not - found. Also ensures the `llvm-tools` rustup component is installed, which is required by - cargo-llvm-cov for coverage instrumentation. + found. Also ensures the `llvm-tools-preview` rustup component is installed, which is + required by cargo-llvm-cov for coverage instrumentation. #> [CmdletBinding()] param( From d850b1e771347c0ad04807ed3b3a6620d69b2564 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 14:29:37 -0700 Subject: [PATCH 17/30] Fix build.ps1 help to say llvm-tools-preview Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.ps1 b/build.ps1 index 9964b7507..eb670a61c 100755 --- a/build.ps1 +++ b/build.ps1 @@ -48,7 +48,7 @@ using module ./helpers.build.psm1 Enables code coverage instrumentation using cargo-llvm-cov. When specified, the build and tests run with coverage instrumentation enabled and an LCOV report is generated at the path specified by `-CodeCoverageOutputPath` (defaults to `lcov.info` in the repository root). - Installs cargo-llvm-cov and the llvm-tools rustup component automatically if not present. + Installs cargo-llvm-cov and the llvm-tools-preview rustup component automatically if not present. When `-CodeCoverageBaseSha` and `-CodeCoverageHeadSha` are provided, the script first checks whether any `.rs` files were changed between those commits. If no Rust files were changed, From 428f2efe5786e71a17ea892f2e7cf0528d186fd5 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 14:32:44 -0700 Subject: [PATCH 18/30] Add temporary base64 function for coverage validation Adds a base64_encode function (no external crates) to dsc main.rs with unit tests. Called once from dsc_main() to exercise coverage instrumentation. This will be removed after validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dsc/src/main.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/dsc/src/main.rs b/dsc/src/main.rs index 928556e4d..96e689ebc 100644 --- a/dsc/src/main.rs +++ b/dsc/src/main.rs @@ -55,6 +55,9 @@ fn main() { } fn dsc_main() { + // Temporary: validate code coverage instrumentation + let _encoded = base64_encode("coverage-test"); + #[cfg(debug_assertions)] check_debug(); @@ -221,3 +224,63 @@ fn check_store() { exit(util::EXIT_INVALID_ARGS); } } + +/// Temporary function for validating code coverage instrumentation. +/// Encodes a string as base64 without external crates. +fn base64_encode(input: &str) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + let bytes = input.as_bytes(); + let mut output = String::with_capacity((bytes.len() + 2) / 3 * 4); + + for chunk in bytes.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; + + let triple = (b0 << 16) | (b1 << 8) | b2; + + output.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char); + output.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char); + + if chunk.len() > 1 { + output.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char); + } else { + output.push('='); + } + + if chunk.len() > 2 { + output.push(ALPHABET[(triple & 0x3F) as usize] as char); + } else { + output.push('='); + } + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_base64_encode_empty() { + assert_eq!(base64_encode(""), ""); + } + + #[test] + fn test_base64_encode_basic() { + assert_eq!(base64_encode("Hello"), "SGVsbG8="); + } + + #[test] + fn test_base64_encode_padding() { + assert_eq!(base64_encode("Hi"), "SGk="); + assert_eq!(base64_encode("Hey"), "SGV5"); + } + + #[test] + fn test_base64_encode_coverage_string() { + assert_eq!(base64_encode("coverage-test"), "Y292ZXJhZ2UtdGVzdA=="); + } +} From cce46ec1d1a7c552e4bc10bef2fe5c91b8269ced Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 15:09:07 -0700 Subject: [PATCH 19/30] Fix coverage setup: verbose output, binstall, scope bug Three issues caused the CI failure: 1. Initialize-CodeCoverage referenced @VerboseParam which doesn't exist in module function scope (it's defined in build.ps1). This meant Install-CargoLlvmCov ran without -Verbose, making failures invisible. Fixed by building a local verbose flag hashtable. 2. Write-Verbose calls in module functions need the -Verbose flag (like other functions in the module use) to ensure output is visible regardless of preference inheritance across module boundaries. 3. cargo install cargo-llvm-cov compiles from source which is slow and fragile in CI. Now tries cargo-binstall first (downloads pre-built binary) and falls back to cargo install. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- helpers.build.psm1 | 55 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 81831b3dd..f9e6b5bea 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -489,9 +489,10 @@ function Install-CargoLlvmCov { Installs `cargo-llvm-cov` if not already available. .DESCRIPTION - Checks whether `cargo-llvm-cov` is installed and installs it via `cargo install` if not - found. Also ensures the `llvm-tools-preview` rustup component is installed, which is - required by cargo-llvm-cov for coverage instrumentation. + Checks whether `cargo-llvm-cov` is installed and installs it if not found. Tries + `cargo binstall` first (downloads pre-built binary) for speed, then falls back to + `cargo install` (compiles from source). Also ensures the `llvm-tools-preview` rustup + component is installed, which is required by cargo-llvm-cov for coverage instrumentation. #> [CmdletBinding()] param( @@ -500,20 +501,35 @@ function Install-CargoLlvmCov { process { if (Test-CommandAvailable -Name 'cargo-llvm-cov') { - Write-Verbose 'cargo-llvm-cov already installed.' + Write-Verbose -Verbose 'cargo-llvm-cov already installed.' } else { - Write-Verbose 'Installing cargo-llvm-cov...' - if ($UseCFS) { - cargo install cargo-llvm-cov --config .cargo/config.toml - } else { - cargo install cargo-llvm-cov + $installed = $false + + # Try cargo-binstall first (downloads pre-built binary, much faster) + if (Test-CommandAvailable -Name 'cargo-binstall') { + Write-Verbose -Verbose 'Installing cargo-llvm-cov via cargo-binstall...' + cargo binstall --no-confirm cargo-llvm-cov + if ($LASTEXITCODE -eq 0) { + $installed = $true + } else { + Write-Verbose -Verbose 'cargo-binstall failed, falling back to cargo install' + } } - if ($LASTEXITCODE -ne 0) { - throw 'Failed to install cargo-llvm-cov' + + if (-not $installed) { + Write-Verbose -Verbose 'Installing cargo-llvm-cov via cargo install (compiling from source)...' + if ($UseCFS) { + cargo install cargo-llvm-cov --config .cargo/config.toml + } else { + cargo install cargo-llvm-cov + } + if ($LASTEXITCODE -ne 0) { + throw 'Failed to install cargo-llvm-cov' + } } } - Write-Verbose 'Ensuring llvm-tools-preview rustup component is installed' + Write-Verbose -Verbose 'Ensuring llvm-tools-preview rustup component is installed' rustup component add llvm-tools-preview if ($LASTEXITCODE -ne 0) { throw 'Failed to install llvm-tools-preview rustup component' @@ -1886,8 +1902,14 @@ function Initialize-CodeCoverage { ) process { - Install-CargoLlvmCov -UseCFS:$UseCFS @VerboseParam + $verboseFlag = @{} + if ($VerbosePreference -eq 'Continue') { + $verboseFlag.Verbose = $true + } + + Install-CargoLlvmCov -UseCFS:$UseCFS @verboseFlag + Write-Verbose -Verbose 'Retrieving cargo-llvm-cov environment variables' $showEnvOutput = cargo llvm-cov show-env --export-prefix if ($LASTEXITCODE -ne 0) { throw 'Failed to retrieve cargo-llvm-cov environment. Ensure cargo-llvm-cov is installed.' @@ -1898,11 +1920,11 @@ function Initialize-CodeCoverage { $name = $Matches[1] $value = $Matches[2].Trim('"').Trim("'") [System.Environment]::SetEnvironmentVariable($name, $value) - Write-Verbose "Set coverage environment variable: $name" + Write-Verbose -Verbose "Set coverage environment variable: $name=$value" } } - Write-Verbose 'Cleaning previous coverage artifacts' + Write-Verbose -Verbose 'Cleaning previous coverage artifacts' cargo llvm-cov clean --workspace if ($LASTEXITCODE -ne 0) { Write-Warning 'Failed to clean previous coverage artifacts, continuing anyway' @@ -1929,11 +1951,12 @@ function Export-CodeCoverageReport { ) process { + Write-Verbose -Verbose "Generating LCOV report at: $OutputPath" cargo llvm-cov report --lcov --output-path $OutputPath if ($LASTEXITCODE -ne 0) { throw "Failed to generate code coverage report at '$OutputPath'" } - Write-Verbose "Code coverage report written to: $OutputPath" + Write-Verbose -Verbose "Code coverage report written to: $OutputPath" } } #endregion Code coverage functions From cb5c4a13c0938189d77af9731e884850e46f4683 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 15:13:40 -0700 Subject: [PATCH 20/30] Fix clippy lint: use div_ceil instead of manual reimplementation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dsc/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dsc/src/main.rs b/dsc/src/main.rs index 96e689ebc..6e5c8c0fd 100644 --- a/dsc/src/main.rs +++ b/dsc/src/main.rs @@ -231,7 +231,7 @@ fn base64_encode(input: &str) -> String { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let bytes = input.as_bytes(); - let mut output = String::with_capacity((bytes.len() + 2) / 3 * 4); + let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4); for chunk in bytes.chunks(3) { let b0 = chunk[0] as u32; From 7075a35c1c3cfee4fb3a08f8da696248875ae56a Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 17:59:30 -0700 Subject: [PATCH 21/30] Move coverage analysis into Get-CodeCoverageReport function Fixes int overflow error by using [long] for LCOV hit counts (values can exceed Int32 max). Extracts the analysis logic into a reusable Get-CodeCoverageReport function in helpers.build.psm1 that can be run locally: Import-Module ./helpers.build.psm1 Get-CodeCoverageReport -LcovPath lcov.info -BaseSha -HeadSha The workflow now calls this function instead of inline script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-coverage.yml | 103 ++-------------------- helpers.build.psm1 | 140 ++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 96 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 7d6915c2b..cb02d9b23 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -40,7 +40,6 @@ jobs: return } "has_rust_changes=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "files=$($changedFiles -join ' ')" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT $testsFailed = $false try { @@ -71,105 +70,17 @@ jobs: if: steps.build-test.outputs.has_rust_changes == 'true' && steps.build-test.outputs.coverage_failed != 'true' id: coverage run: |- + Import-Module ./helpers.build.psm1 -Force $baseSha = '${{ github.event.pull_request.base.sha }}' $headSha = '${{ github.event.pull_request.head.sha }}' - $totalChangedLines = 0 - $coveredLines = 0 - - # Parse the LCOV file into a hashtable keyed by source file path - $lcovData = @{} - $currentFile = $null - foreach ($line in Get-Content -Path 'lcov.info') { - if ($line -match '^SF:(.+)$') { - $currentFile = $Matches[1] - $lcovData[$currentFile] = @{} - } elseif ($line -match '^DA:(\d+),(\d+)' -and $currentFile) { - $lineNum = [int]$Matches[1] - $hitCount = [int]$Matches[2] - $lcovData[$currentFile][$lineNum] = $hitCount - } elseif ($line -eq 'end_of_record') { - $currentFile = $null - } - } - - $changedFiles = '${{ steps.build-test.outputs.files }}' -split '\s+' - foreach ($file in $changedFiles) { - if (-not $file -or -not (Test-Path $file)) { - continue - } - - # Parse diff to get added line numbers in the new file - $diffOutput = git diff "$baseSha...$headSha" -- $file - $addedLineNumbers = @() - $currentLineNum = 0 - - foreach ($diffLine in $diffOutput) { - if ($diffLine -match '^@@\s+\-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@') { - $currentLineNum = [int]$Matches[1] - } elseif ($diffLine.StartsWith('+') -and -not $diffLine.StartsWith('+++')) { - $addedLineNumbers += $currentLineNum - $currentLineNum++ - } elseif ($diffLine.StartsWith('-') -and -not $diffLine.StartsWith('---')) { - # Deleted lines don't advance the new file line counter - } else { - $currentLineNum++ - } - } - - # Find matching LCOV entry for this file - $absPath = (Resolve-Path $file).Path - $fileCoverage = $null - foreach ($key in $lcovData.Keys) { - if ($key -eq $absPath -or $key.EndsWith("/$file") -or $key.EndsWith("\$file")) { - $fileCoverage = $lcovData[$key] - break - } - } + $report = Get-CodeCoverageReport -LcovPath 'lcov.info' -BaseSha $baseSha -HeadSha $headSha -Verbose - if ($fileCoverage) { - foreach ($lineNum in $addedLineNumbers) { - # Only count lines that LCOV recognizes as executable (have a DA entry) - if ($fileCoverage.ContainsKey($lineNum)) { - $totalChangedLines++ - if ($fileCoverage[$lineNum] -gt 0) { - $coveredLines++ - } - } - } - } else { - # File not in coverage report - count added lines as uncovered - $totalChangedLines += $addedLineNumbers.Count - } - } - - if ($totalChangedLines -eq 0) { - $percentage = 100 - } else { - $percentage = [math]::Floor($coveredLines * 100 / $totalChangedLines) - } - - "percentage=$percentage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "covered=$coveredLines" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "total=$totalChangedLines" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - - # Determine emoji and label - if ($percentage -eq 100) { - "emoji=😲" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "label=100% coverage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - } elseif ($percentage -ge 90) { - "emoji=😁" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "label=90%+ coverage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - } elseif ($percentage -ge 80) { - "emoji=😊" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "label=80%+ coverage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - } elseif ($percentage -ge 70) { - "emoji=😐" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "label=70%+ coverage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - } else { - "emoji=😢" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "label=less than 70% coverage" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - } + "percentage=$($report.Percentage)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "covered=$($report.CoveredLines)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "total=$($report.TotalLines)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "emoji=$($report.Emoji)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "label=$($report.Label)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - name: Post coverage comment if: >- diff --git a/helpers.build.psm1 b/helpers.build.psm1 index f9e6b5bea..483173e21 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1959,6 +1959,146 @@ function Export-CodeCoverageReport { Write-Verbose -Verbose "Code coverage report written to: $OutputPath" } } + +function Get-CodeCoverageReport { + <# + .SYNOPSIS + Analyzes code coverage on changed files and returns a coverage report object. + + .DESCRIPTION + Parses an LCOV file and cross-references it with git diff output to determine what + percentage of changed executable lines are covered by tests. + + .PARAMETER LcovPath + Path to the LCOV coverage report file. + + .PARAMETER BaseSha + The base commit SHA to compare from. + + .PARAMETER HeadSha + The head commit SHA to compare to. + + .OUTPUTS + PSCustomObject with properties: Percentage, CoveredLines, TotalLines, Emoji, Label + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$LcovPath, + + [Parameter(Mandatory)] + [string]$BaseSha, + + [Parameter(Mandatory)] + [string]$HeadSha + ) + + process { + if (-not (Test-Path $LcovPath)) { + throw "LCOV file not found at '$LcovPath'" + } + + # Parse the LCOV file into a hashtable keyed by source file path + $lcovData = @{} + $currentFile = $null + foreach ($line in Get-Content -Path $LcovPath) { + if ($line -match '^SF:(.+)$') { + $currentFile = $Matches[1] + $lcovData[$currentFile] = @{} + } elseif ($line -match '^DA:(\d+),(\d+)' -and $currentFile) { + $lineNum = [int]$Matches[1] + $hitCount = [long]$Matches[2] + $lcovData[$currentFile][$lineNum] = $hitCount + } elseif ($line -eq 'end_of_record') { + $currentFile = $null + } + } + + # Get changed Rust files + $changedFiles = Get-ChangedRustFile -BaseSha $BaseSha -HeadSha $HeadSha + + $totalChangedLines = 0 + $coveredLines = 0 + + foreach ($file in $changedFiles) { + if (-not $file -or -not (Test-Path $file)) { + continue + } + + # Parse diff to get added line numbers in the new file + $diffOutput = git diff "$BaseSha...$HeadSha" -- $file + $addedLineNumbers = @() + $currentLineNum = 0 + + foreach ($diffLine in $diffOutput) { + if ($diffLine -match '^@@\s+\-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@') { + $currentLineNum = [int]$Matches[1] + } elseif ($diffLine.StartsWith('+') -and -not $diffLine.StartsWith('+++')) { + $addedLineNumbers += $currentLineNum + $currentLineNum++ + } elseif ($diffLine.StartsWith('-') -and -not $diffLine.StartsWith('---')) { + # Deleted lines don't advance the new file line counter + } else { + $currentLineNum++ + } + } + + # Find matching LCOV entry for this file + $absPath = (Resolve-Path $file).Path + $fileCoverage = $null + foreach ($key in $lcovData.Keys) { + if ($key -eq $absPath -or $key.EndsWith("/$file") -or $key.EndsWith("\$file")) { + $fileCoverage = $lcovData[$key] + break + } + } + + if ($fileCoverage) { + foreach ($lineNum in $addedLineNumbers) { + # Only count lines that LCOV recognizes as executable (have a DA entry) + if ($fileCoverage.ContainsKey($lineNum)) { + $totalChangedLines++ + if ($fileCoverage[$lineNum] -gt 0) { + $coveredLines++ + } + } + } + } else { + # File not in coverage report - count added lines as uncovered + $totalChangedLines += $addedLineNumbers.Count + } + } + + if ($totalChangedLines -eq 0) { + $percentage = 100 + } else { + $percentage = [int][math]::Floor($coveredLines * 100 / $totalChangedLines) + } + + # Determine emoji and label + $emoji, $label = if ($percentage -eq 100) { + '😲', '100% coverage' + } elseif ($percentage -ge 90) { + '😁', '90%+ coverage' + } elseif ($percentage -ge 80) { + '😊', '80%+ coverage' + } elseif ($percentage -ge 70) { + '😐', '70%+ coverage' + } else { + '😢', 'less than 70% coverage' + } + + Write-Verbose -Verbose "Coverage: $percentage% ($coveredLines/$totalChangedLines executable lines covered)" + + [PSCustomObject]@{ + Percentage = $percentage + CoveredLines = $coveredLines + TotalLines = $totalChangedLines + Emoji = $emoji + Label = $label + } + } +} #endregion Code coverage functions #region Test project functions From 7d20d8819627e243a3bfe4fb2183791275860bef Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 18:06:56 -0700 Subject: [PATCH 22/30] Auto-run coverage analysis when SHAs are available or discoverable After generating the LCOV report, build.ps1 -CodeCoverage now automatically calls Get-CodeCoverageReport if base/head SHAs are provided or can be discovered from the current branch vs its upstream default (via git merge-base). Prints the emoji summary and coverage stats to the information stream. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build.ps1 | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/build.ps1 b/build.ps1 index eb670a61c..3cdf1eac4 100755 --- a/build.ps1 +++ b/build.ps1 @@ -356,6 +356,35 @@ process { Write-BuildProgress @progressParams Write-BuildProgress @progressParams -Status "Writing LCOV report to $CodeCoverageOutputPath" Export-CodeCoverageReport -OutputPath $CodeCoverageOutputPath @VerboseParam + + # Determine base and head SHAs for analysis + $baseSha = $CodeCoverageBaseSha + $headSha = $CodeCoverageHeadSha + + if (-not $baseSha -or -not $headSha) { + # Try to discover from current branch vs its upstream/default + $currentBranch = git rev-parse --abbrev-ref HEAD 2>$null + $defaultBranch = git rev-parse --abbrev-ref origin/HEAD 2>$null + if (-not $defaultBranch) { + $defaultBranch = 'origin/main' + } + $discoveredBase = git merge-base $defaultBranch $currentBranch 2>$null + $discoveredHead = git rev-parse HEAD 2>$null + + if ($discoveredBase -and $discoveredHead -and $discoveredBase -ne $discoveredHead) { + $baseSha = $discoveredBase + $headSha = $discoveredHead + Write-Verbose -Verbose "Discovered coverage comparison: $baseSha...$headSha" + } + } + + if ($baseSha -and $headSha) { + $progressParams.Activity = 'Analyzing code coverage' + Write-BuildProgress @progressParams + $report = Get-CodeCoverageReport -LcovPath $CodeCoverageOutputPath -BaseSha $baseSha -HeadSha $headSha @VerboseParam + Write-Information "$($report.Emoji) Changed code coverage: $($report.Percentage)% ($($report.Label))" + Write-Information " Lines analyzed: $($report.TotalLines) | Lines covered: $($report.CoveredLines)" + } } #endregion Code coverage report From d67a9aa6f435f53148973150d05036e87ae1c49a Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 18:15:41 -0700 Subject: [PATCH 23/30] Use Write-Host for coverage report output Write-Information is suppressed by default InformationPreference. Write-Host always displays to the console. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.ps1 b/build.ps1 index 3cdf1eac4..2ac86ebaf 100755 --- a/build.ps1 +++ b/build.ps1 @@ -382,8 +382,8 @@ process { $progressParams.Activity = 'Analyzing code coverage' Write-BuildProgress @progressParams $report = Get-CodeCoverageReport -LcovPath $CodeCoverageOutputPath -BaseSha $baseSha -HeadSha $headSha @VerboseParam - Write-Information "$($report.Emoji) Changed code coverage: $($report.Percentage)% ($($report.Label))" - Write-Information " Lines analyzed: $($report.TotalLines) | Lines covered: $($report.CoveredLines)" + Write-Host "$($report.Emoji) Changed code coverage: $($report.Percentage)% ($($report.Label))" + Write-Host " Lines analyzed: $($report.TotalLines) | Lines covered: $($report.CoveredLines)" } } #endregion Code coverage report From 8d8e0e73415b3cf01e5dcb111baf47a34dcf7267 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 18:19:50 -0700 Subject: [PATCH 24/30] Fix coverage env vars not visible to child processes [System.Environment]::SetEnvironmentVariable() doesn't reliably sync to PowerShell's env: provider on all platforms, so cargo never sees RUSTFLAGS=-C instrument-coverage. Use Set-Item on the env: drive instead, which ensures child processes inherit the variables. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- helpers.build.psm1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 483173e21..39d6d2441 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1919,7 +1919,7 @@ function Initialize-CodeCoverage { if ($line -match '^export\s+(\w+)=(.*)$') { $name = $Matches[1] $value = $Matches[2].Trim('"').Trim("'") - [System.Environment]::SetEnvironmentVariable($name, $value) + Set-Item -Path "env:$name" -Value $value Write-Verbose -Verbose "Set coverage environment variable: $name=$value" } } From 90fd9ce018c1e5b59923b6565b7b5a931bed324d Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 20:42:53 -0700 Subject: [PATCH 25/30] Fix 0% coverage: use cargo llvm-cov build/test instead of show-env Replace the 'cargo llvm-cov show-env' approach (which sets env vars for plain cargo commands) with direct 'cargo llvm-cov build --no-report' and 'cargo llvm-cov test --no-report' subcommands. The show-env approach failed because cargo-llvm-cov's report command expects binaries in its own managed target directory, not the standard cargo target directory. Changes: - Add -CodeCoverage switch to Build-RustProject and Test-RustProject - Simplify Initialize-CodeCoverage to just install + clean (no env vars) - Thread -CodeCoverage from build.ps1 through to both functions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build.ps1 | 4 ++-- helpers.build.psm1 | 53 +++++++++++++++++++++++----------------------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/build.ps1 b/build.ps1 index 2ac86ebaf..c00837112 100755 --- a/build.ps1 +++ b/build.ps1 @@ -294,7 +294,7 @@ process { Clean = $Clean } Write-BuildProgress @progressParams -Status 'Compiling Rust' - Build-RustProject @buildParams -Audit:$Audit -Clippy:$Clippy @VerboseParam + Build-RustProject @buildParams -Audit:$Audit -Clippy:$Clippy -CodeCoverage:$CodeCoverage @VerboseParam Write-BuildProgress @progressParams -Status "Copying build artifacts" Copy-BuildArtifact @buildParams -ExecutableFile $BuildData.PackageFiles.Executable @VerboseParam } @@ -321,7 +321,7 @@ process { $rustTestParams.TestFilter = $RustTestFilter } Write-BuildProgress @progressParams -Status "Testing Rust projects" - Test-RustProject @rustTestParams @VerboseParam + Test-RustProject @rustTestParams -CodeCoverage:$CodeCoverage @VerboseParam } if ($RustDocs) { $docTestParams = @{ diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 39d6d2441..f302e6656 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1602,7 +1602,8 @@ function Build-RustProject { [switch]$Clean, [switch]$UpdateLockFile, [switch]$Audit, - [switch]$Clippy + [switch]$Clippy, + [switch]$CodeCoverage ) begin { @@ -1663,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" @@ -1889,12 +1895,13 @@ function Get-ChangedRustFile { function Initialize-CodeCoverage { <# .SYNOPSIS - Configures the environment for code coverage instrumentation using cargo-llvm-cov. + Prepares the workspace for code coverage instrumentation using cargo-llvm-cov. .DESCRIPTION - Runs `cargo llvm-cov show-env` to retrieve the required environment variables for - coverage instrumentation and sets them in the current process. Also cleans any prior - coverage artifacts from the workspace. + 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. #> [CmdletBinding()] param( @@ -1909,21 +1916,6 @@ function Initialize-CodeCoverage { Install-CargoLlvmCov -UseCFS:$UseCFS @verboseFlag - Write-Verbose -Verbose 'Retrieving cargo-llvm-cov environment variables' - $showEnvOutput = cargo llvm-cov show-env --export-prefix - if ($LASTEXITCODE -ne 0) { - throw 'Failed to retrieve cargo-llvm-cov environment. Ensure cargo-llvm-cov is installed.' - } - - foreach ($line in $showEnvOutput) { - if ($line -match '^export\s+(\w+)=(.*)$') { - $name = $Matches[1] - $value = $Matches[2].Trim('"').Trim("'") - Set-Item -Path "env:$name" -Value $value - Write-Verbose -Verbose "Set coverage environment variable: $name=$value" - } - } - Write-Verbose -Verbose 'Cleaning previous coverage artifacts' cargo llvm-cov clean --workspace if ($LASTEXITCODE -ne 0) { @@ -2110,7 +2102,8 @@ function Test-RustProject { $Architecture = 'current', [switch]$Release, [switch]$Docs, - [string]$TestFilter + [string]$TestFilter, + [switch]$CodeCoverage ) begin { @@ -2140,10 +2133,18 @@ function Test-RustProject { } else { Write-Verbose -Verbose "Testing rust projects: [$members]" } - if (-not [string]::IsNullOrEmpty($TestFilter)) { - cargo test @flags -- $TestFilter + if ($CodeCoverage) { + if (-not [string]::IsNullOrEmpty($TestFilter)) { + cargo llvm-cov test --no-report @flags -- $TestFilter + } else { + cargo llvm-cov test --no-report @flags + } } else { - cargo test @flags + if (-not [string]::IsNullOrEmpty($TestFilter)) { + cargo test @flags -- $TestFilter + } else { + cargo test @flags + } } if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { From f48470d897c0ae552a16c54e5d3681a832e41f13 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 20:53:52 -0700 Subject: [PATCH 26/30] Make -CodeCoverage imply -Test in build.ps1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.ps1 b/build.ps1 index c00837112..f3661bcb5 100755 --- a/build.ps1 +++ b/build.ps1 @@ -248,6 +248,8 @@ process { #region Code coverage instrumentation if ($CodeCoverage) { + # Code coverage requires tests to run + $Test = $true $progressParams.Activity = 'Setting up code coverage' Write-BuildProgress @progressParams From 7300ab3cd5d95f0baba9be79b56f9c296b7b005f Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 21:01:29 -0700 Subject: [PATCH 27/30] fix tests to skip on non-windows --- .../tests/windowsupdate.schema.tests.ps1 | 10 +-- .../tests/windowsupdate_export.tests.ps1 | 66 +++++++++--------- .../tests/windowsupdate_get.tests.ps1 | 68 +++++++++---------- .../tests/windowsupdate_set.tests.ps1 | 36 +++++----- .../personalization_get.tests.ps1 | 8 +-- 5 files changed, 94 insertions(+), 94 deletions(-) diff --git a/resources/WindowsUpdate/tests/windowsupdate.schema.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate.schema.tests.ps1 index 3fb55770f..4ac954116 100644 --- a/resources/WindowsUpdate/tests/windowsupdate.schema.tests.ps1 +++ b/resources/WindowsUpdate/tests/windowsupdate.schema.tests.ps1 @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'Windows Update resource schema validation' { +Describe 'Windows Update resource schema validation' -skip:(!$IsWindows) { BeforeAll { $resourceType = 'Microsoft.Windows/UpdateList' $manifestPath = Join-Path $PSScriptRoot "..\windowsupdate.dsc.resource.json" @@ -72,7 +72,7 @@ Describe 'Windows Update resource schema validation' { It 'schema should define all expected properties in updates items' { $manifest = Get-Content $manifestPath | ConvertFrom-Json $itemProperties = $manifest.schema.embedded.properties.updates.items.properties - + $expectedProperties = @( 'title', 'isInstalled', @@ -86,7 +86,7 @@ Describe 'Windows Update resource schema validation' { 'updateType', 'installationBehavior' ) - + foreach ($prop in $expectedProperties) { $itemProperties.$prop | Should -Not -BeNullOrEmpty -Because "Property '$prop' should be defined" } @@ -177,7 +177,7 @@ Describe 'Windows Update resource schema validation' { It 'all properties should have descriptions' { $manifest = Get-Content $manifestPath | ConvertFrom-Json $properties = $manifest.schema.embedded.properties - + foreach ($propName in $properties.PSObject.Properties.Name) { $prop = $properties.$propName $prop.description | Should -Not -BeNullOrEmpty -Because "Property '$propName' should have a description" @@ -187,7 +187,7 @@ Describe 'Windows Update resource schema validation' { It 'all properties should have titles' { $manifest = Get-Content $manifestPath | ConvertFrom-Json $properties = $manifest.schema.embedded.properties - + foreach ($propName in $properties.PSObject.Properties.Name) { $prop = $properties.$propName $prop.title | Should -Not -BeNullOrEmpty -Because "Property '$propName' should have a title" diff --git a/resources/WindowsUpdate/tests/windowsupdate_export.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate_export.tests.ps1 index e043c384c..307761bd9 100644 --- a/resources/WindowsUpdate/tests/windowsupdate_export.tests.ps1 +++ b/resources/WindowsUpdate/tests/windowsupdate_export.tests.ps1 @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'Windows Update Export operation tests' { +Describe 'Windows Update Export operation tests' -Skip:(!$IsWindows) { BeforeAll { $resourceType = 'Microsoft.Windows/UpdateList' } @@ -9,7 +9,7 @@ Describe 'Windows Update Export operation tests' { Context 'Export operation' { It 'should return UpdateList with array of updates' -Skip:(!$IsWindows) { $out = '{"updates":[{}]}' | dsc resource export -r $resourceType -f - -o json 2>&1 - + $LASTEXITCODE | Should -Be 0 $config = $out | ConvertFrom-Json $result = $config.resources[0].properties @@ -19,7 +19,7 @@ Describe 'Windows Update Export operation tests' { It 'should work without input filter' -Skip:(!$IsWindows) { $out = dsc resource export -r $resourceType -o json 2>&1 - + $LASTEXITCODE | Should -Be 0 $config = $out | ConvertFrom-Json $result = $config.resources[0].properties @@ -29,7 +29,7 @@ Describe 'Windows Update Export operation tests' { It 'should filter by isInstalled=true' -Skip:(!$IsWindows) { $json = '{"updates":[{"isInstalled": true}]}' $out = $json | dsc resource export -r $resourceType -f - -o json 2>&1 - + $LASTEXITCODE | Should -Be 0 $config = $out | ConvertFrom-Json $result = $config.resources[0].properties @@ -43,7 +43,7 @@ Describe 'Windows Update Export operation tests' { It 'should filter by isInstalled=false' -Skip:(!$IsWindows) { $json = '{"updates":[{"isInstalled": false}]}' $out = $json | dsc resource export -r $resourceType -f - -o json 2>&1 - + $LASTEXITCODE | Should -Be 0 $config = $out | ConvertFrom-Json $result = $config.resources[0].properties @@ -57,7 +57,7 @@ Describe 'Windows Update Export operation tests' { It 'should filter by title with wildcard in middle' -Skip:(!$IsWindows) { $json = '{"updates":[{"title": "*Windows*"}]}' $out = $json | dsc resource export -r $resourceType -f - -o json 2>&1 - + if ($LASTEXITCODE -eq 0) { $config = $out | ConvertFrom-Json $result = $config.resources[0].properties @@ -71,7 +71,7 @@ Describe 'Windows Update Export operation tests' { It 'should return proper structure for each update' -Skip:(!$IsWindows) { $out = '{"updates":[{}]}' | dsc resource export -r $resourceType -f - -o json 2>&1 - + $LASTEXITCODE | Should -Be 0 $config = $out | ConvertFrom-Json $result = $config.resources[0].properties @@ -93,10 +93,10 @@ Describe 'Windows Update Export operation tests' { It 'should fail when wildcard filter has no matches' -Skip:(!$IsWindows) { $json = '{"updates":[{"title": "ThisUpdateShouldNeverExist99999*"}]}' $stderr = $json | dsc resource export -r $resourceType -f - -o json 2>&1 - + # Should fail because the filter has criteria but no matches $LASTEXITCODE | Should -Not -Be 0 - + # Check for error message in stderr $errorText = $stderr | Out-String $errorText | Should -Match 'No matching update found' @@ -112,10 +112,10 @@ Describe 'Windows Update Export operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $stderr = $json | dsc resource export -r $resourceType -f - 2>&1 - + # Should fail because the filter has criteria but no matches $LASTEXITCODE | Should -Not -Be 0 - + # Check for error message in stderr $errorText = $stderr | Out-String $errorText | Should -Match 'No matching update found' @@ -129,7 +129,7 @@ Describe 'Windows Update Export operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource export -r $resourceType -f - -o json 2>&1 - + $LASTEXITCODE | Should -Be 0 $config = $out | ConvertFrom-Json $result = $config.resources[0].properties @@ -139,13 +139,13 @@ Describe 'Windows Update Export operation tests' { It 'should fail if any filter with criteria has no matches' -Skip:(!$IsWindows) { # Get an actual update $allOut = '{"updates":[{}]}' | dsc resource export -r $resourceType -f - -o json 2>&1 - + if ($LASTEXITCODE -eq 0) { $allConfig = $allOut | ConvertFrom-Json $allResult = $allConfig.resources[0].properties if ($allResult.updates.Count -gt 0) { $update1 = $allResult.updates[0] - + # One valid filter, one invalid filter $json = @{ updates = @( @@ -158,10 +158,10 @@ Describe 'Windows Update Export operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $stderr = $json | dsc resource export -r $resourceType -f - 2>&1 - + # Should fail because second filter has no matches $LASTEXITCODE | Should -Not -Be 0 - + # Check for error message in stderr $errorText = $stderr | Out-String $errorText | Should -Match 'No matching update found' @@ -172,14 +172,14 @@ Describe 'Windows Update Export operation tests' { It 'should return results when all filters find matches' -Skip:(!$IsWindows) { # Get actual updates $allOut = '{"updates":[{}]}' | dsc resource export -r $resourceType -f - -o json 2>&1 - + if ($LASTEXITCODE -eq 0) { $allConfig = $allOut | ConvertFrom-Json $allResult = $allConfig.resources[0].properties if ($allResult.updates.Count -ge 2) { $update1 = $allResult.updates[0] $update2 = $allResult.updates[1] - + $json = @{ updates = @( @{ @@ -191,7 +191,7 @@ Describe 'Windows Update Export operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource export -r $resourceType -f - -o json 2>&1 - + $LASTEXITCODE | Should -Be 0 $config = $out | ConvertFrom-Json $result = $config.resources[0].properties @@ -206,7 +206,7 @@ Describe 'Windows Update Export operation tests' { It 'should filter by msrcSeverity' -Skip:(!$IsWindows) { $json = '{"updates":[{"msrcSeverity": "Critical"}]}' $out = $json | dsc resource export -r $resourceType -f - -o json 2>&1 - + if ($LASTEXITCODE -eq 0) { $config = $out | ConvertFrom-Json $result = $config.resources[0].properties @@ -221,7 +221,7 @@ Describe 'Windows Update Export operation tests' { It 'should filter by updateType Software' -Skip:(!$IsWindows) { $json = '{"updates":[{"updateType": "Software"}]}' $out = $json | dsc resource export -r $resourceType -f - -o json 2>&1 - + if ($LASTEXITCODE -eq 0) { $config = $out | ConvertFrom-Json $result = $config.resources[0].properties @@ -236,7 +236,7 @@ Describe 'Windows Update Export operation tests' { It 'should support OR logic with multiple filters in array' -Skip:(!$IsWindows) { # Get some updates to use as filters $allOut = '{"updates":[{}]}' | dsc resource export -r $resourceType -f - -o json 2>&1 - + if ($LASTEXITCODE -eq 0) { $allConfig = $allOut | ConvertFrom-Json $allResult = $allConfig.resources[0].properties @@ -246,11 +246,11 @@ Describe 'Windows Update Export operation tests' { $id2 = $allResult.updates[1].id $json = "{`"updates`":[{`"id`": `"$id1`"}, {`"id`": `"$id2`"}]}" $out = $json | dsc resource export -r $resourceType -f - -o json 2>&1 - + $LASTEXITCODE | Should -Be 0 $config = $out | ConvertFrom-Json $result = $config.resources[0].properties - + # Should return both updates (OR logic) $result.updates.Count | Should -BeGreaterOrEqual 2 $foundIds = $result.updates.id @@ -268,7 +268,7 @@ Describe 'Windows Update Export operation tests' { # Multiple properties in one filter = AND logic $json = '{"updates":[{"isInstalled": true, "updateType": "Software"}]}' $out = $json | dsc resource export -r $resourceType -f - -o json 2>&1 - + if ($LASTEXITCODE -eq 0) { $config = $out | ConvertFrom-Json $result = $config.resources[0].properties @@ -285,7 +285,7 @@ Describe 'Windows Update Export operation tests' { It 'should not return duplicates when multiple filters match same update' -Skip:(!$IsWindows) { # Get an update with known properties $allOut = '{"updates":[{}]}' | dsc resource export -r $resourceType -f - -o json 2>&1 - + if ($LASTEXITCODE -eq 0) { $allConfig = $allOut | ConvertFrom-Json $allResult = $allConfig.resources[0].properties @@ -295,11 +295,11 @@ Describe 'Windows Update Export operation tests' { # Even though technically both filters specify the same criteria $json = "{`"updates`":[{`"id`": `"$($testUpdate.id)`"}, {`"id`": `"$($testUpdate.id)`"}]}" $out = $json | dsc resource export -r $resourceType -f - -o json 2>&1 - + $LASTEXITCODE | Should -Be 0 -Because $out $config = $out | ConvertFrom-Json $result = $config.resources[0].properties - + # Should return the update only once (no duplicates) $matchingUpdates = $result.updates | Where-Object { $_.id -eq $testUpdate.id } $matchingUpdates.Count | Should -Be 1 @@ -309,15 +309,15 @@ Describe 'Windows Update Export operation tests' { It 'should return installationBehavior property when present' -Skip:(!$IsWindows) { $out = '{"updates":[{}]}' | dsc resource export -r $resourceType -f - -o json 2>&1 - + $LASTEXITCODE | Should -Be 0 $config = $out | ConvertFrom-Json $result = $config.resources[0].properties - + if ($result.updates.Count -gt 0) { # Check if any update has installationBehavior property $updateWithBehavior = $result.updates | Where-Object { $null -ne $_.installationBehavior } | Select-Object -First 1 - + if ($updateWithBehavior) { # Verify the value is one of the valid enum values $updateWithBehavior.installationBehavior | Should -BeIn @('NeverReboots', 'AlwaysRequiresReboot', 'CanRequestReboot') @@ -327,11 +327,11 @@ Describe 'Windows Update Export operation tests' { It 'should return valid installationBehavior enum values for all updates' -Skip:(!$IsWindows) { $out = '{"updates":[{}]}' | dsc resource export -r $resourceType -f - -o json 2>&1 - + $LASTEXITCODE | Should -Be 0 $config = $out | ConvertFrom-Json $result = $config.resources[0].properties - + foreach ($update in $result.updates) { if ($null -ne $update.installationBehavior) { $update.installationBehavior | Should -BeIn @('NeverReboots', 'AlwaysRequiresReboot', 'CanRequestReboot') -Because "Update '$($update.title)' has invalid installationBehavior" diff --git a/resources/WindowsUpdate/tests/windowsupdate_get.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate_get.tests.ps1 index cb9f8b41c..4ee519698 100644 --- a/resources/WindowsUpdate/tests/windowsupdate_get.tests.ps1 +++ b/resources/WindowsUpdate/tests/windowsupdate_get.tests.ps1 @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'Windows Update Get operation tests' { +Describe 'Windows Update Get operation tests' -Skip:(!$IsWindows) { BeforeAll { $resourceType = 'Microsoft.Windows/UpdateList' $result = dsc resource export -r $resourceType | ConvertFrom-Json @@ -21,7 +21,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - + $LASTEXITCODE | Should -Be 0 $getResult = $out | ConvertFrom-Json $getResult.actualState | Should -Not -BeNullOrEmpty @@ -36,7 +36,7 @@ Describe 'Windows Update Get operation tests' { It 'should handle case-insensitive exact title match' -Skip:(!$IsWindows) { $exactTitle = $exportOut.updates[0].title - + # Test with lowercase version $jsonLower = @{ updates = @( @@ -46,7 +46,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $outLower = $jsonLower | dsc resource get -r $resourceType -f - 2>&1 - + # Test with uppercase version $jsonUpper = @{ updates = @( @@ -56,7 +56,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $outUpper = $jsonUpper | dsc resource get -r $resourceType -f - 2>&1 - + # Both should succeed if ($outLower -and $outUpper) { $resultLower = $outLower | ConvertFrom-Json @@ -104,7 +104,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - + $LASTEXITCODE | Should -Be 0 $result = $out | ConvertFrom-Json $result.actualState.updates[0].title | Should -Be $testUpdate.title @@ -122,7 +122,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $null = $json | dsc resource get -r $resourceType -f - 2>&1 - + # Should fail because id doesn't match $LASTEXITCODE | Should -Not -Be 0 } @@ -138,7 +138,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $null = $json | dsc resource get -r $resourceType -f - 2>&1 - + # Should fail because title doesn't match $LASTEXITCODE | Should -Not -Be 0 } @@ -152,7 +152,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 $result = $out | ConvertFrom-Json $result.actualState.updates[0].isInstalled | Should -BeOfType [bool] } @@ -166,7 +166,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 $result = $out | ConvertFrom-Json $result.actualState.updates[0].recommendedHardDiskSpace | Should -BeGreaterOrEqual 0 } @@ -180,7 +180,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 $result = $out | ConvertFrom-Json $result.actualState.updates[0].kbArticleIds.GetType().BaseType.Name | Should -Be 'Array' } @@ -194,14 +194,14 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 $result = $out | ConvertFrom-Json $result.actualState.updates[0].updateType | Should -BeIn @('Software', 'Driver') } It 'should return valid enum value for msrcSeverity when present' -Skip:(!$IsWindows) { $updateWithSeverity = $exportOut.updates | Where-Object { $null -ne $_.msrcSeverity } | Select-Object -First 1 - + if ($updateWithSeverity) { $json = @{ updates = @( @@ -226,7 +226,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - + $LASTEXITCODE | Should -Be 0 $result = $out | ConvertFrom-Json # Basic GUID format check (8-4-4-4-12 hex digits) @@ -243,7 +243,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - + $LASTEXITCODE | Should -Be 0 $result = $out | ConvertFrom-Json $result.actualState.updates[0].id | Should -Be $updateId @@ -254,7 +254,7 @@ Describe 'Windows Update Get operation tests' { if ($exportOut.updates.Count -ge 2) { $update1 = $exportOut.updates[0] $update2 = $exportOut.updates[1] - + $json = @{ updates = @( @{ @@ -266,7 +266,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - + $LASTEXITCODE | Should -Be 0 $getResult = $out | ConvertFrom-Json $getResult.actualState.updates.Count | Should -Be 2 @@ -279,7 +279,7 @@ Describe 'Windows Update Get operation tests' { It 'should fail if any input object does not have a match' -Skip:(!$IsWindows) { $update1 = $exportOut.updates[0] - + $json = @{ updates = @( @{ @@ -291,10 +291,10 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $stderr = $json | dsc resource get -r $resourceType -f - 2>&1 - + # Should fail because second input has no match $LASTEXITCODE | Should -Not -Be 0 - + # Check for error message in stderr $errorText = $stderr | Out-String $errorText | Should -Match 'No matching update found' @@ -303,7 +303,7 @@ Describe 'Windows Update Get operation tests' { It 'should support filtering by KB article IDs' -Skip:(!$IsWindows) { # Find an update with KB article IDs $updateWithKB = $exportOut.updates | Where-Object { $_.kbArticleIds.Count -gt 0 } | Select-Object -First 1 - + if ($updateWithKB) { $json = @{ updates = @( @@ -313,7 +313,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - + $LASTEXITCODE | Should -Be 0 $getResult = $out | ConvertFrom-Json $getResult.actualState.updates[0].kbArticleIds | Should -Contain $updateWithKB.kbArticleIds[0] @@ -324,7 +324,7 @@ Describe 'Windows Update Get operation tests' { It 'should support filtering by update type' -Skip:(!$IsWindows) { $softwareUpdate = $exportOut.updates | Where-Object { $_.updateType -eq 'Software' } | Select-Object -First 1 - + if ($softwareUpdate) { $json = @{ updates = @( @@ -335,7 +335,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - + $LASTEXITCODE | Should -Be 0 $getResult = $out | ConvertFrom-Json $getResult.actualState.updates[0].updateType | Should -Be 'Software' @@ -346,7 +346,7 @@ Describe 'Windows Update Get operation tests' { It 'should support filtering by MSRC severity with AND logic' -Skip:(!$IsWindows) { $updateWithSeverity = $exportOut.updates | Where-Object { $null -ne $_.msrcSeverity } | Select-Object -First 1 - + if ($updateWithSeverity) { $json = @{ updates = @( @@ -357,7 +357,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - + $LASTEXITCODE | Should -Be 0 $getResult = $out | ConvertFrom-Json $getResult.actualState.updates[0].msrcSeverity | Should -Be $updateWithSeverity.msrcSeverity @@ -375,7 +375,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - + $LASTEXITCODE | Should -Be 0 $result = $out | ConvertFrom-Json # installationBehavior should be one of the valid enum values if present @@ -387,7 +387,7 @@ Describe 'Windows Update Get operation tests' { It 'should return valid enum value for installationBehavior' -Skip:(!$IsWindows) { # Find an update that has installationBehavior set $updateWithBehavior = $exportOut.updates | Where-Object { $null -ne $_.installationBehavior } | Select-Object -First 1 - + if ($updateWithBehavior) { $json = @{ updates = @( @@ -397,7 +397,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 - + $LASTEXITCODE | Should -Be 0 $getResult = $out | ConvertFrom-Json $getResult.actualState.updates[0].installationBehavior | Should -BeIn @('NeverReboots', 'AlwaysRequiresReboot', 'CanRequestReboot') @@ -410,10 +410,10 @@ Describe 'Windows Update Get operation tests' { # Find a title pattern that might match multiple updates # Using isInstalled filter with a common partial title like 'Windows' could match multiple # This test verifies the new multiple-match detection behavior - + # First, check if there are multiple updates with similar titles $windowsUpdates = $exportOut.updates | Where-Object { $_.title -like '*Windows*' } - + if ($windowsUpdates.Count -ge 2) { # Find a common substring that appears in multiple update titles # Try to use a very generic criteria that would match multiple @@ -425,7 +425,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $stderr = $json | dsc resource get -r $resourceType -f - 2>&1 - + # If multiple updates match isInstalled=true, it should error $installedCount = ($exportOut.updates | Where-Object { $_.isInstalled -eq $true }).Count if ($installedCount -gt 1) { @@ -445,7 +445,7 @@ Describe 'Windows Update Get operation tests' { # Find a case where using title-only might match multiple updates # Group updates by similar starting titles $titleGroups = $exportOut.updates | Group-Object { ($_.title -split ' ')[0..2] -join ' ' } | Where-Object { $_.Count -gt 1 } - + if ($titleGroups.Count -gt 0) { # Use the first duplicate-ish title group $firstGroup = $titleGroups[0].Group @@ -461,7 +461,7 @@ Describe 'Windows Update Get operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $stderr = $json | dsc resource get -r $resourceType -f - 2>&1 - + # This may or may not fail depending on uniqueness if ($LASTEXITCODE -ne 0) { $errorText = $stderr | Out-String diff --git a/resources/WindowsUpdate/tests/windowsupdate_set.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate_set.tests.ps1 index a452d0210..4acbdc635 100644 --- a/resources/WindowsUpdate/tests/windowsupdate_set.tests.ps1 +++ b/resources/WindowsUpdate/tests/windowsupdate_set.tests.ps1 @@ -1,10 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'Windows Update Set operation tests' { +Describe 'Windows Update Set operation tests' -skip:(!$IsWindows) { BeforeDiscovery { $resourceType = 'Microsoft.Windows/UpdateList' - + $isAdmin = if ($IsWindows) { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]$identity @@ -18,7 +18,7 @@ Describe 'Windows Update Set operation tests' { It 'should match when both title and id are correct' { # Get an actual installed update with both title and id $exportOut = '{"updates": [{"isInstalled": true}]}' | dsc resource export -r $resourceType -f - 2>&1 - + if ($LASTEXITCODE -eq 0) { $result = $exportOut | ConvertFrom-Json if ($result.updates.Count -gt 0) { @@ -33,7 +33,7 @@ Describe 'Windows Update Set operation tests' { } | ConvertTo-Json -Depth 10 -Compress # Try to set (should detect already installed) $out = $json | dsc resource set -r $resourceType -f - 2>&1 - + if ($LASTEXITCODE -eq 0) { $result = $out | ConvertFrom-Json $result.afterState.updates[0].title | Should -Be $testUpdate.title @@ -51,7 +51,7 @@ Describe 'Windows Update Set operation tests' { It 'should fail when title matches but id does not' { # Get an actual update $exportOut = '{"updates": []}' | dsc resource export -r $resourceType -f - 2>&1 - + if ($LASTEXITCODE -eq 0) { $result = $exportOut | ConvertFrom-Json if ($result.updates.Count -gt 0) { @@ -65,7 +65,7 @@ Describe 'Windows Update Set operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource set -r $resourceType -f - 2>&1 - + # Should fail because id doesn't match $LASTEXITCODE | Should -Not -Be 0 } @@ -79,7 +79,7 @@ Describe 'Windows Update Set operation tests' { It 'should fail when id matches but title does not' { # Get an actual update $exportOut = '{"updates": []}' | dsc resource export -r $resourceType -f - 2>&1 - + if ($LASTEXITCODE -eq 0) { $result = $exportOut | ConvertFrom-Json if ($result.updates.Count -gt 0) { @@ -93,7 +93,7 @@ Describe 'Windows Update Set operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource set -r $resourceType -f - 2>&1 - + # Should fail because title doesn't match $LASTEXITCODE | Should -Not -Be 0 } @@ -107,12 +107,12 @@ Describe 'Windows Update Set operation tests' { It 'should verify all inputs have matches before installing' { # Get an actual update $exportOut = '{"updates": []}' | dsc resource export -r $resourceType -f - 2>&1 - + if ($LASTEXITCODE -eq 0) { $result = $exportOut | ConvertFrom-Json if ($result.updates.Count -gt 0) { $update1 = $result.updates[0] - + # One valid, one invalid - should fail before attempting any installation $json = @{ updates = @( @@ -125,10 +125,10 @@ Describe 'Windows Update Set operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $stderr = $json | dsc resource set -r $resourceType -f - 2>&1 - + # Should fail before attempting any installation $LASTEXITCODE | Should -Not -Be 0 - + # Check for error message in stderr $errorText = $stderr | Out-String $errorText | Should -Match 'No matching update found' @@ -143,12 +143,12 @@ Describe 'Windows Update Set operation tests' { It 'should process multiple valid input objects' { # Get an actual update $exportOut = '{"updates": [{"isInstalled": true}]}' | dsc resource export -r $resourceType -f - 2>&1 - + if ($LASTEXITCODE -eq 0) { $result = $exportOut | ConvertFrom-Json # Get two installed updates $installedUpdates = $result.updates | Where-Object { $_.isInstalled -eq $true } | Select-Object -First 2 - + if ($installedUpdates.Count -ge 2) { $json = @{ updates = @( @@ -161,7 +161,7 @@ Describe 'Windows Update Set operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource set -r $resourceType -f - 2>&1 - + if ($LASTEXITCODE -eq 0) { $setResult = $out | ConvertFrom-Json $setResult.afterState.updates.Count | Should -Be 2 @@ -176,11 +176,11 @@ Describe 'Windows Update Set operation tests' { It 'should apply logical AND for all criteria in each input' { # Get an actual update $exportOut = '{"updates": [{"isInstalled": true}]}' | dsc resource export -r $resourceType -f - 2>&1 - + if ($LASTEXITCODE -eq 0) { $result = $exportOut | ConvertFrom-Json $installedUpdate = $result.updates | Where-Object { $_.isInstalled -eq $true } | Select-Object -First 1 - + if ($installedUpdate) { # Multiple criteria - all must match $json = @{ @@ -193,7 +193,7 @@ Describe 'Windows Update Set operation tests' { ) } | ConvertTo-Json -Depth 10 -Compress $out = $json | dsc resource set -r $resourceType -f - 2>&1 - + if ($LASTEXITCODE -eq 0) { $setResult = $out | ConvertFrom-Json $setResult.afterState.updates[0].title | Should -Be $installedUpdate.title diff --git a/resources/windows_personalization/personalization_get.tests.ps1 b/resources/windows_personalization/personalization_get.tests.ps1 index e211b432e..6fb03efd6 100644 --- a/resources/windows_personalization/personalization_get.tests.ps1 +++ b/resources/windows_personalization/personalization_get.tests.ps1 @@ -1,8 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'Windows Personalization get tests' { - It 'Convert dword to boolean' -Skip:(!$IsWindows) { +Describe 'Windows Personalization get tests' -Skip:(!$IsWindows) { + It 'Convert dword to boolean' { $json = @{ "appsUseLightTheme" = $true } | ConvertTo-Json -Compress @@ -11,7 +11,7 @@ Describe 'Windows Personalization get tests' { $out.actualState.appsUseLightTheme | Should -BeIn @($true, $false) } - It 'Convert binary to string array' -Skip:(!$IsWindows) { + It 'Convert binary to string array' { $json = @{ "startMenuVisiblePlaces" = @() } | ConvertTo-Json -Compress @@ -33,7 +33,7 @@ Describe 'Windows Personalization get tests' { } } - It 'Convert dword to string enum' -Skip:(!$IsWindows) { + It 'Convert dword to string enum' { $json = @{ "multimonitorTaskbarGroupingMode" = 'NeverCombine' } | ConvertTo-Json -Compress From fd1b21ca26de6e601ab664008c6992cc41979a91 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 21:54:14 -0700 Subject: [PATCH 28/30] some fixes to output and deleting the old coverage data file --- build.ps1 | 7 ++++--- helpers.build.psm1 | 9 ++------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/build.ps1 b/build.ps1 index f3661bcb5..bd15dec2d 100755 --- a/build.ps1 +++ b/build.ps1 @@ -179,13 +179,13 @@ begin { $params.Remove('Quiet') > $null Write-Progress @params } elseif ($Completed) { - Write-Information "Finished build script" + Write-Host "Finished build script" -ForegroundColor Green } else { $message = "BUILD: $Activity" if (-not [string]::IsNullOrEmpty($Status)) { $message += "::$Status" } - Write-Information $message + Write-Host $message -ForegroundColor Cyan } } } @@ -257,12 +257,13 @@ process { Write-BuildProgress @progressParams -Status 'Checking for changed Rust files' $changedRustFiles = Get-ChangedRustFile -BaseSha $CodeCoverageBaseSha -HeadSha $CodeCoverageHeadSha @VerboseParam if ($changedRustFiles.Count -eq 0) { - Write-Information 'No Rust files changed between the specified commits. Skipping code coverage.' + Write-Warning 'No Rust files changed between the specified commits. Skipping code coverage.' return } } Write-BuildProgress @progressParams -Status 'Configuring cargo-llvm-cov environment' + Remove-Item $CodeCoverageOutputPath -Force -ErrorAction Ignore Initialize-CodeCoverage -UseCFS:$UseCFS @VerboseParam } #endregion Code coverage instrumentation diff --git a/helpers.build.psm1 b/helpers.build.psm1 index f302e6656..0ddc30a83 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1664,13 +1664,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" From b6b1d1af463507337e70467b63bf1347a7f5aea7 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 26 Jun 2026 21:55:16 -0700 Subject: [PATCH 29/30] Handle LLVM sentinel hit counts that overflow Int64 LLVM coverage emits values near UInt64.MaxValue (e.g. 18446744073709551613) as sentinels for uninstrumented lines. Parse as [decimal] and treat any value exceeding Int64.MaxValue as 0 (uncovered). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- helpers.build.psm1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 0ddc30a83..b2a5c5261 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1994,7 +1994,9 @@ function Get-CodeCoverageReport { $lcovData[$currentFile] = @{} } elseif ($line -match '^DA:(\d+),(\d+)' -and $currentFile) { $lineNum = [int]$Matches[1] - $hitCount = [long]$Matches[2] + # LLVM emits sentinel values near UInt64.MaxValue for uninstrumented lines + $rawHit = [decimal]$Matches[2] + $hitCount = if ($rawHit -gt [long]::MaxValue) { 0 } else { [long]$rawHit } $lcovData[$currentFile][$lineNum] = $hitCount } elseif ($line -eq 'end_of_record') { $currentFile = $null From 8a00509c4bde7c21fadafca99441d8ba51c7ae7b Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Sat, 27 Jun 2026 06:39:27 -0700 Subject: [PATCH 30/30] Add Show-CodeCoverageReport for colorized coverage visualization Displays changed executable lines with green (covered) and red underlined (uncovered) formatting using $PSStyle ANSI sequences. Called automatically by Get-CodeCoverageReport to show per-file line-level detail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- helpers.build.psm1 | 78 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/helpers.build.psm1 b/helpers.build.psm1 index b2a5c5261..db3431743 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1947,6 +1947,62 @@ function Export-CodeCoverageReport { } } +function Show-CodeCoverageReport { + <# + .SYNOPSIS + Displays a colorized visualization of code coverage on changed lines. + + .DESCRIPTION + Reads source files and displays changed executable lines with green for covered + and red with underline for uncovered, using $PSStyle for ANSI formatting. + + .PARAMETER FileDetails + Array of objects with File (path) and LineCoverageMap (hashtable of line number to bool). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [PSCustomObject[]]$FileDetails + ) + + process { + foreach ($detail in $FileDetails) { + $filePath = $detail.File + $lineCoverageMap = $detail.LineCoverageMap + + if ($lineCoverageMap.Count -eq 0) { + continue + } + + $fileContent = Get-Content -Path $filePath -ErrorAction SilentlyContinue + if (-not $fileContent) { + continue + } + + Write-Host "" + Write-Host "$($PSStyle.Bold)$filePath$($PSStyle.BoldOff)" -ForegroundColor Cyan + + $sortedLines = $lineCoverageMap.Keys | Sort-Object + $lineNumWidth = ($sortedLines[-1]).ToString().Length + + foreach ($lineNum in $sortedLines) { + $lineIndex = $lineNum - 1 + $lineText = if ($lineIndex -lt $fileContent.Count) { $fileContent[$lineIndex] } else { '' } + $prefix = $lineNum.ToString().PadLeft($lineNumWidth) + + if ($lineCoverageMap[$lineNum]) { + # Covered - green + Write-Host "$($PSStyle.Foreground.Green) $prefix | $lineText$($PSStyle.Reset)" + } else { + # Uncovered - red with underline + Write-Host "$($PSStyle.Foreground.Red)$($PSStyle.Underline) $prefix | $lineText$($PSStyle.UnderlineOff)$($PSStyle.Reset)" + } + } + } + Write-Host "" + } +} + function Get-CodeCoverageReport { <# .SYNOPSIS @@ -2008,6 +2064,8 @@ function Get-CodeCoverageReport { $totalChangedLines = 0 $coveredLines = 0 + # Collect per-file coverage detail for visualization + $fileDetails = @() foreach ($file in $changedFiles) { if (-not $file -or -not (Test-Path $file)) { @@ -2042,19 +2100,32 @@ function Get-CodeCoverageReport { } } + # Build per-line coverage map for this file (only added executable lines) + $lineCoverageMap = @{} if ($fileCoverage) { foreach ($lineNum in $addedLineNumbers) { - # Only count lines that LCOV recognizes as executable (have a DA entry) if ($fileCoverage.ContainsKey($lineNum)) { $totalChangedLines++ - if ($fileCoverage[$lineNum] -gt 0) { + $isCovered = $fileCoverage[$lineNum] -gt 0 + if ($isCovered) { $coveredLines++ } + $lineCoverageMap[$lineNum] = $isCovered } } } else { # File not in coverage report - count added lines as uncovered $totalChangedLines += $addedLineNumbers.Count + foreach ($lineNum in $addedLineNumbers) { + $lineCoverageMap[$lineNum] = $false + } + } + + if ($lineCoverageMap.Count -gt 0) { + $fileDetails += [PSCustomObject]@{ + File = $file + LineCoverageMap = $lineCoverageMap + } } } @@ -2079,6 +2150,9 @@ function Get-CodeCoverageReport { Write-Verbose -Verbose "Coverage: $percentage% ($coveredLines/$totalChangedLines executable lines covered)" + # Show visual report + Show-CodeCoverageReport -FileDetails $fileDetails + [PSCustomObject]@{ Percentage = $percentage CoveredLines = $coveredLines