diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml new file mode 100644 index 000000000..cb02d9b23 --- /dev/null +++ b/.github/workflows/pr-coverage.yml @@ -0,0 +1,155 @@ +name: PR Code Coverage + +on: + pull_request: + branches: [ "main", "release/*" ] + paths-ignore: + - "docs/**" + - "*.md" + - ".vscode/*.json" + - ".github/ISSUE_TEMPLATE/**" + +env: + CARGO_TERM_COLOR: always + +defaults: + run: + shell: pwsh + +jobs: + coverage: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Build and test with coverage + id: build-test + 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 + + $testsFailed = $false + try { + ./build.ps1 -Test -CodeCoverage -CodeCoverageBaseSha $baseSha -CodeCoverageHeadSha $headSha -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 + } + + # 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' && 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 }}' + + $report = Get-CodeCoverageReport -LcovPath 'lcov.info' -BaseSha $baseSha -HeadSha $headSha -Verbose + + "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: >- + 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 + 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.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 + 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 | + |--------|-------| + | 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 (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' + && github.event.pull_request.head.repo.full_name == github.repository + 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. 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 diff --git a/build.ps1 b/build.ps1 index 63288b54f..bd15dec2d 100755 --- a/build.ps1 +++ b/build.ps1 @@ -44,6 +44,28 @@ 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. 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-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, + 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. @@ -87,6 +109,10 @@ param( )] $PackageType, [switch]$Test, + [switch]$CodeCoverage, + [string]$CodeCoverageOutputPath = (Join-Path $PSScriptRoot 'lcov.info'), + [string]$CodeCoverageBaseSha, + [string]$CodeCoverageHeadSha, [string[]]$Project, [switch]$ExcludeRustTests, [string]$RustTestFilter, @@ -153,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 } } } @@ -220,6 +246,28 @@ process { #endregion Setup + #region Code coverage instrumentation + if ($CodeCoverage) { + # Code coverage requires tests to run + $Test = $true + $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-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 + if (!$SkipBuild) { if ($UpdateLockFile) { $lockFile = Join-Path $PSScriptRoot "Cargo.lock" @@ -249,7 +297,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 } @@ -276,7 +324,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 = @{ @@ -305,6 +353,44 @@ 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 + + # 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-Host "$($report.Emoji) Changed code coverage: $($report.Percentage)% ($($report.Label))" + Write-Host " Lines analyzed: $($report.TotalLines) | Lines covered: $($report.CoveredLines)" + } + } + #endregion Code coverage report + if (-not [string]::IsNullOrEmpty($PackageType)) { $progressParams.Activity = "Packaging" $packageParams = @{ diff --git a/dsc/src/main.rs b/dsc/src/main.rs index 928556e4d..6e5c8c0fd 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().div_ceil(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=="); + } +} diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 32d007a27..db3431743 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -483,6 +483,60 @@ 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 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( + [switch]$UseCFS + ) + + process { + if (Test-CommandAvailable -Name 'cargo-llvm-cov') { + Write-Verbose -Verbose 'cargo-llvm-cov already installed.' + } else { + $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 (-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 -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' + } + } +} + function Install-TreeSitter { <# .SYNOPSIS @@ -1548,7 +1602,8 @@ function Build-RustProject { [switch]$Clean, [switch]$UpdateLockFile, [switch]$Audit, - [switch]$Clippy + [switch]$Clippy, + [switch]$CodeCoverage ) begin { @@ -1790,6 +1845,325 @@ 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 + Prepares the workspace for code coverage instrumentation using cargo-llvm-cov. + + .DESCRIPTION + Installs cargo-llvm-cov if needed and cleans any prior coverage artifacts from the + workspace. When coverage is enabled, Build-RustProject and Test-RustProject use + `cargo llvm-cov build` and `cargo llvm-cov test --no-report` respectively, which + handle all instrumentation and profraw management internally. + #> + [CmdletBinding()] + param( + [switch]$UseCFS + ) + + process { + $verboseFlag = @{} + if ($VerbosePreference -eq 'Continue') { + $verboseFlag.Verbose = $true + } + + Install-CargoLlvmCov -UseCFS:$UseCFS @verboseFlag + + 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' + } + } +} + +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 { + 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 -Verbose "Code coverage report written to: $OutputPath" + } +} + +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 + 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] + # 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 + } + } + + # Get changed Rust files + $changedFiles = Get-ChangedRustFile -BaseSha $BaseSha -HeadSha $HeadSha + + $totalChangedLines = 0 + $coveredLines = 0 + # Collect per-file coverage detail for visualization + $fileDetails = @() + + 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 + } + } + + # Build per-line coverage map for this file (only added executable lines) + $lineCoverageMap = @{} + if ($fileCoverage) { + foreach ($lineNum in $addedLineNumbers) { + if ($fileCoverage.ContainsKey($lineNum)) { + $totalChangedLines++ + $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 + } + } + } + + 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)" + + # Show visual report + Show-CodeCoverageReport -FileDetails $fileDetails + + [PSCustomObject]@{ + Percentage = $percentage + CoveredLines = $coveredLines + TotalLines = $totalChangedLines + Emoji = $emoji + Label = $label + } + } +} +#endregion Code coverage functions + #region Test project functions function Test-RustProject { [CmdletBinding()] @@ -1799,7 +2173,8 @@ function Test-RustProject { $Architecture = 'current', [switch]$Release, [switch]$Docs, - [string]$TestFilter + [string]$TestFilter, + [switch]$CodeCoverage ) begin { @@ -1829,14 +2204,22 @@ 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) { - Write-Error "Last exit code is $LASTEXITCODE, rust tests failed" + throw "Last exit code is $LASTEXITCODE, rust tests failed" } } 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