diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c87d2bece..8337230f3 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -52,12 +52,10 @@ jobs: fetch-depth: 0 - name: Install prerequisites run: ./build.ps1 -SkipBuild -Clippy -Verbose - - name: Build - run: ./build.ps1 -Clippy -Verbose - - name: Run rust tests with code coverage + - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -SkipBuild -Test -CodeCoverage -ExcludePesterTests -Verbose + run: ./build.ps1 -Clippy -Test -CodeCoverage -Verbose -PesterTestGroup resources - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -110,12 +108,10 @@ jobs: fetch-depth: 0 - name: Install prerequisites run: ./build.ps1 -SkipBuild -Clippy -Verbose - - name: Build - run: ./build.ps1 -Clippy -Verbose - - name: Run rust tests with code coverage + - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -SkipBuild -Test -CodeCoverage -ExcludePesterTests -Verbose + run: ./build.ps1 -Clippy -Test -CodeCoverage -Verbose -PesterTestGroup resources - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -172,12 +168,10 @@ jobs: fetch-depth: 0 - name: Install prerequisites run: ./build.ps1 -SkipBuild -Clippy -Verbose - - name: Build - run: ./build.ps1 -Clippy -Verbose - - name: Run rust tests with code coverage + - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -SkipBuild -Test -CodeCoverage -ExcludePesterTests -Verbose + run: ./build.ps1 -Clippy -Test -CodeCoverage -Verbose -PesterTestGroup resources - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -251,8 +245,18 @@ jobs: $baseSha = '${{ github.event.pull_request.base.sha }}' $headSha = '${{ github.event.pull_request.head.sha }}' + # Compute the merge-base to get an accurate diff of only PR changes. + # Using base.sha directly is unreliable after rebases or when the base + # branch has advanced, since it points to the tip of the base branch at + # event time rather than the common ancestor. + $mergeBase = git merge-base $baseSha $headSha 2>$null + if ($LASTEXITCODE -eq 0 -and $mergeBase) { + Write-Verbose -Verbose "Using merge-base $mergeBase (base=$baseSha, head=$headSha)" + $baseSha = $mergeBase + } + # Determine if any Rust files changed from git diff - $changedFiles = git diff --name-only --diff-filter=ACMR "$baseSha...$headSha" -- '*.rs' | Where-Object { $_ } + $changedFiles = git diff --name-only --diff-filter=ACMR "$baseSha..$headSha" -- '*.rs' | Where-Object { $_ } if (-not $changedFiles) { "has_rust_changes=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT return @@ -322,3 +326,12 @@ jobs: ## Code Coverage Report No Rust files were changed in this PR. Coverage analysis skipped. + + - name: Fail if coverage is below 70% + if: >- + steps.coverage.outputs.has_rust_changes == 'true' + && steps.coverage.outputs.coverage_failed != 'true' + && steps.coverage.outputs.percentage < 70 + run: | + Write-Error "Code coverage is ${{ steps.coverage.outputs.percentage }}%, which is below the 70% minimum threshold." + exit 1 diff --git a/Cargo.lock b/Cargo.lock index 20d1f0ca2..7e1466916 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -847,6 +847,7 @@ dependencies = [ "os_info", "serde", "serde_json", + "version-compare", ] [[package]] @@ -3754,6 +3755,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 783faad7c..8be02844d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -186,6 +186,8 @@ nix = { version = "0.31.3" } num-traits = { version = "0.2" } # dsc-lib-osinfo os_info = { version = "3.15" } +# dsc-lib-osinfo +version-compare = { version = "0.2" } # dsc, dsc-lib path-absolutize = { version = "3.1" } # dsc-bicep-ext diff --git a/build.ps1 b/build.ps1 index bd15dec2d..84cf5323e 100755 --- a/build.ps1 +++ b/build.ps1 @@ -265,6 +265,9 @@ process { Write-BuildProgress @progressParams -Status 'Configuring cargo-llvm-cov environment' Remove-Item $CodeCoverageOutputPath -Force -ErrorAction Ignore Initialize-CodeCoverage -UseCFS:$UseCFS @VerboseParam + + Write-BuildProgress @progressParams -Status 'Setting llvm-cov environment variables' + $priorLlvmCovEnv = Set-LlvmCovEnvironment @VerboseParam } #endregion Code coverage instrumentation @@ -297,7 +300,7 @@ process { Clean = $Clean } Write-BuildProgress @progressParams -Status 'Compiling Rust' - Build-RustProject @buildParams -Audit:$Audit -Clippy:$Clippy -CodeCoverage:$CodeCoverage @VerboseParam + Build-RustProject @buildParams -Audit:$Audit -Clippy:$Clippy @VerboseParam Write-BuildProgress @progressParams -Status "Copying build artifacts" Copy-BuildArtifact @buildParams -ExecutableFile $BuildData.PackageFiles.Executable @VerboseParam } @@ -324,7 +327,7 @@ process { $rustTestParams.TestFilter = $RustTestFilter } Write-BuildProgress @progressParams -Status "Testing Rust projects" - Test-RustProject @rustTestParams -CodeCoverage:$CodeCoverage @VerboseParam + Test-RustProject @rustTestParams @VerboseParam } if ($RustDocs) { $docTestParams = @{ @@ -360,6 +363,9 @@ process { Write-BuildProgress @progressParams -Status "Writing LCOV report to $CodeCoverageOutputPath" Export-CodeCoverageReport -OutputPath $CodeCoverageOutputPath @VerboseParam + Write-BuildProgress @progressParams -Status 'Restoring environment variables' + Reset-LlvmCovEnvironment -PriorValues $priorLlvmCovEnv @VerboseParam + # Determine base and head SHAs for analysis $baseSha = $CodeCoverageBaseSha $headSha = $CodeCoverageHeadSha diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 15bae8654..1a62b8c51 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -1602,8 +1602,7 @@ function Build-RustProject { [switch]$Clean, [switch]$UpdateLockFile, [switch]$Audit, - [switch]$Clippy, - [switch]$CodeCoverage + [switch]$Clippy ) begin { @@ -1875,14 +1874,14 @@ function Get-ChangedRustFile { ) process { - $changedFiles = git diff --name-only --diff-filter=ACMR "$BaseSha...$HeadSha" -- '*.rs' + $changedFiles = git diff --name-only --diff-filter=ACMR "$BaseSha..$HeadSha" -- '*.rs' if ($LASTEXITCODE -ne 0) { Write-Warning "Failed to detect changed files between $BaseSha and $HeadSha" return @() } $result = @($changedFiles | Where-Object { $_ }) - Write-Verbose "Found $($result.Count) changed Rust file(s)" + Write-Verbose -Verbose "Found $($result.Count) changed Rust file(s)" return $result } } @@ -1894,9 +1893,9 @@ function Initialize-CodeCoverage { .DESCRIPTION Installs cargo-llvm-cov if needed and cleans any prior coverage artifacts from the - workspace. When coverage is enabled, Build-RustProject and Test-RustProject use - `cargo llvm-cov build` and `cargo llvm-cov test --no-report` respectively, which - handle all instrumentation and profraw management internally. + workspace. After initialization, call Set-LlvmCovEnvironment to set the environment + variables that make normal `cargo build` and `cargo test` invocations produce + instrumented binaries and write profraw data. #> [CmdletBinding()] param( @@ -1919,6 +1918,77 @@ function Initialize-CodeCoverage { } } +function Set-LlvmCovEnvironment { + <# + .SYNOPSIS + Sets the environment variables required by cargo-llvm-cov for instrumented builds. + + .DESCRIPTION + Parses the output of `cargo llvm-cov show-env` and sets the corresponding + environment variables (LLVM_PROFILE_FILE, RUSTC_WRAPPER, CARGO_LLVM_COV, etc.) + in the current process. This enables a normal `cargo build` to produce + instrumented binaries, and allows externally invoked instrumented binaries + (such as during Pester tests) to write profraw data to a location that + `cargo llvm-cov report` can discover. + + .OUTPUTS + System.Collections.Hashtable — Prior values of the modified environment variables + so they can be restored with Reset-LlvmCovEnvironment. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param() + + process { + $showEnvOutput = cargo llvm-cov show-env 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to retrieve cargo-llvm-cov environment: $showEnvOutput" + } + + $priorValues = @{} + foreach ($line in $showEnvOutput) { + if ($line -match '^([A-Z_][A-Z0-9_]+)=(.*)$') { + $name = $Matches[1] + # CARGO_LLVM_COV_SHOW_ENV is an output-only flag that tells + # cargo-llvm-cov to print env and exit; do not propagate it. + if ($name -eq 'CARGO_LLVM_COV_SHOW_ENV') { + continue + } + # Strip optional surrounding single quotes from the value + $value = ($Matches[2] -replace "^'", '') -replace "'$", '' + $priorValues[$name] = [System.Environment]::GetEnvironmentVariable($name) + [System.Environment]::SetEnvironmentVariable($name, $value) + Write-Verbose "Set $name=$value" + } + } + + Write-Verbose -Verbose "Set $($priorValues.Count) cargo-llvm-cov environment variables" + $priorValues + } +} + +function Reset-LlvmCovEnvironment { + <# + .SYNOPSIS + Restores environment variables modified by Set-LlvmCovEnvironment. + + .PARAMETER PriorValues + The hashtable returned by Set-LlvmCovEnvironment containing original values. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$PriorValues + ) + + process { + foreach ($entry in $PriorValues.GetEnumerator()) { + [System.Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) + } + Write-Verbose -Verbose "Restored $($PriorValues.Count) environment variables" + } +} + function Export-CodeCoverageReport { <# .SYNOPSIS @@ -2077,7 +2147,7 @@ function Get-CodeCoverageReport { } # Parse diff to get added line numbers in the new file - $diffOutput = git diff "$BaseSha...$HeadSha" -- $file + $diffOutput = git diff "$BaseSha..$HeadSha" -- $file $addedLineNumbers = @() $currentLineNum = 0 @@ -2096,14 +2166,23 @@ function Get-CodeCoverageReport { # Find matching LCOV entry for this file $absPath = (Resolve-Path $file).Path + $normalizedFile = $file.Replace('\', '/') $fileCoverage = $null foreach ($key in $lcovData.Keys) { - if ($key -eq $absPath -or $key.EndsWith("/$file") -or $key.EndsWith("\$file")) { + $normalizedKey = $key.Replace('\', '/') + if ($normalizedKey -eq $absPath -or + $normalizedKey -eq $normalizedFile -or + $normalizedKey.EndsWith("/$normalizedFile") -or + $normalizedKey.EndsWith("\$normalizedFile")) { $fileCoverage = $lcovData[$key] break } } + if (-not $fileCoverage) { + Write-Verbose -Verbose "No LCOV match for '$file' (absPath='$absPath'). LCOV keys: $($lcovData.Keys -join ', ')" + } + # Build per-line coverage map for this file (only added executable lines) $lineCoverageMap = @{} if ($fileCoverage) { @@ -2177,8 +2256,7 @@ function Test-RustProject { $Architecture = 'current', [switch]$Release, [switch]$Docs, - [string]$TestFilter, - [switch]$CodeCoverage + [string]$TestFilter ) begin { @@ -2208,18 +2286,10 @@ function Test-RustProject { } else { Write-Verbose -Verbose "Testing rust projects: [$members]" } - if ($CodeCoverage) { - if (-not [string]::IsNullOrEmpty($TestFilter)) { - cargo llvm-cov test --no-report @flags -- $TestFilter - } else { - cargo llvm-cov test --no-report @flags - } + if (-not [string]::IsNullOrEmpty($TestFilter)) { + cargo test @flags -- $TestFilter } else { - if (-not [string]::IsNullOrEmpty($TestFilter)) { - cargo test @flags -- $TestFilter - } else { - cargo test @flags - } + cargo test @flags } if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { diff --git a/lib/dsc-lib-osinfo/Cargo.toml b/lib/dsc-lib-osinfo/Cargo.toml index 8d9ce0135..ba4daa65e 100644 --- a/lib/dsc-lib-osinfo/Cargo.toml +++ b/lib/dsc-lib-osinfo/Cargo.toml @@ -10,3 +10,4 @@ doctest = false os_info = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +version-compare = { workspace = true } diff --git a/lib/dsc-lib-osinfo/src/lib.rs b/lib/dsc-lib-osinfo/src/lib.rs index c20c63f6a..5f4e0bec1 100644 --- a/lib/dsc-lib-osinfo/src/lib.rs +++ b/lib/dsc-lib-osinfo/src/lib.rs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::fmt::Display; use std::string::ToString; +use version_compare::Cmp; /// Returns information about the operating system. #[derive(Debug, Clone, PartialEq, Serialize)] @@ -22,12 +23,30 @@ pub struct OsInfo { /// Defines the processor architecture as reported by `uname -m` on the operating system. #[serde(skip_serializing_if = "Option::is_none")] architecture: Option, + /// Support returning generated name for the OSInfo instance for export. #[serde(rename = "_name", skip_serializing_if = "Option::is_none")] name: Option, + /// Indicates whether the resource is in the desired state. Only emitted by the test operation. + #[serde(rename = "_inDesiredState", skip_serializing_if = "Option::is_none")] + in_desired_state: Option, +} + +/// Desired state for the test operation. All fields are optional. +/// The `version` field may include a comparison operator prefix: `>`, `<`, `=`, `>=`, or `<=`. +#[derive(Debug, Default, Deserialize)] +pub struct OsTestInput { + pub family: Option, + pub version: Option, + pub edition: Option, + pub codename: Option, + pub bitness: Option, + pub architecture: Option, + #[serde(rename = "_name")] + pub name: Option, } /// Defines whether the operating system is Linux, macOS, or Windows. -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Family { Linux, #[serde(rename = "macOS")] @@ -80,6 +99,133 @@ impl OsInfo { bitness: bits, architecture, name, + in_desired_state: None, + } + } +} + +/// Parse the optional comparison operator prefix from a version constraint string. +/// +/// Returns a tuple of `(operator, version_str)` where `operator` is one of +/// `">"`, `"<"`, `"="`, `">="`, `"<="`, and `version_str` is the version +/// string with whitespace trimmed. When no operator prefix is present, the +/// operator defaults to `"="` (exact match). +/// +/// An operator is only recognised when the remainder after stripping it starts +/// with an ASCII digit. Strings like `">> 1.0"` (double operator) are +/// therefore treated as literal exact-match strings rather than producing a +/// misleading parsed operator. +fn parse_version_constraint(constraint: &str) -> (&str, &str) { + let constraint = constraint.trim(); + // Check two-character operators before single-character ones. + if let Some(rest) = constraint.strip_prefix(">=") { + let rest = rest.trim(); + if rest.starts_with(|c: char| c.is_ascii_digit()) { + return (">=", rest); + } + } else if let Some(rest) = constraint.strip_prefix("<=") { + let rest = rest.trim(); + if rest.starts_with(|c: char| c.is_ascii_digit()) { + return ("<=", rest); + } + } else if let Some(rest) = constraint.strip_prefix('>') { + let rest = rest.trim(); + if rest.starts_with(|c: char| c.is_ascii_digit()) { + return (">", rest); } + } else if let Some(rest) = constraint.strip_prefix('<') { + let rest = rest.trim(); + if rest.starts_with(|c: char| c.is_ascii_digit()) { + return ("<", rest); + } + } else if let Some(rest) = constraint.strip_prefix('=') { + let rest = rest.trim(); + if rest.starts_with(|c: char| c.is_ascii_digit()) { + return ("=", rest); + } + } + // No recognised operator, or the remainder does not look like a version. + // Treat the entire string as a literal exact-match value. + ("=", constraint) +} + +/// Returns `true` when `actual` satisfies the `constraint`. +/// +/// `constraint` may be a plain version string (exact match) or a version +/// string prefixed with one of the comparison operators `>`, `<`, `=`, `>=`, +/// or `<=`. Operator and version string may be separated by optional +/// whitespace. Comparison is performed by the `version_compare` crate, which +/// handles version strings that are not strict semver (e.g. `"22.04"`, +/// `"10.15.7"`, `"11"`). Returns `false` when either version string cannot +/// be parsed. +fn version_matches(constraint: &str, actual: &str) -> bool { + let (operator, desired_ver) = parse_version_constraint(constraint); + + if operator == "=" { + return desired_ver == actual; + } + + match version_compare::compare(actual, desired_ver) { + Ok(cmp) => match operator { + ">" => cmp == Cmp::Gt, + "<" => cmp == Cmp::Lt, + ">=" => matches!(cmp, Cmp::Gt | Cmp::Eq), + "<=" => matches!(cmp, Cmp::Lt | Cmp::Eq), + _ => false, + }, + Err(()) => false, } } + +/// Perform the test operation against the current OS state. +/// +/// Parses `input_json` as the desired state (`OsTestInput`), retrieves the +/// actual OS information, and evaluates each specified field. For `version`, +/// an optional comparison operator prefix is supported (see +/// `version_matches`). Returns the actual `OsInfo` with `_inDesiredState` +/// set to indicate whether all specified fields are satisfied. +/// +/// # Errors +/// +/// Returns an error string when `input_json` cannot be parsed as valid JSON. +pub fn perform_test(input_json: &str) -> Result { + let desired: OsTestInput = serde_json::from_str(input_json) + .map_err(|e| format!("Failed to parse test input as JSON: {e}"))?; + + // name is ignored for test since it's only generated for export and not a property of the actual OS state. + let actual = OsInfo::new(false); + + let mut in_desired_state = true; + + if let Some(desired_family) = desired.family + && desired_family != actual.family { + in_desired_state = false; + } + + if let Some(ref desired_version) = desired.version + && !version_matches(desired_version, &actual.version) { + in_desired_state = false; + } + + if let Some(ref desired_edition) = desired.edition + && actual.edition.as_deref() != Some(desired_edition.as_str()) { + in_desired_state = false; + } + + if let Some(ref desired_codename) = desired.codename + && actual.codename.as_deref() != Some(desired_codename.as_str()) { + in_desired_state = false; + } + + if let Some(desired_bitness) = desired.bitness + && actual.bitness != Some(desired_bitness) { + in_desired_state = false; + } + + if let Some(ref desired_architecture) = desired.architecture + && actual.architecture.as_deref() != Some(desired_architecture.as_str()) { + in_desired_state = false; + } + + Ok(OsInfo { in_desired_state: Some(in_desired_state), ..actual }) +} diff --git a/resources/osinfo/osinfo.dsc.resource.json b/resources/osinfo/osinfo.dsc.resource.json index 25cb6464d..db064363e 100644 --- a/resources/osinfo/osinfo.dsc.resource.json +++ b/resources/osinfo/osinfo.dsc.resource.json @@ -12,6 +12,14 @@ "get": { "executable": "osinfo" }, + "test": { + "executable": "osinfo", + "args": [ + "test" + ], + "input": "stdin", + "return": "state" + }, "export": { "executable": "osinfo", "args": [ @@ -43,6 +51,12 @@ "description": "Returns the name of the OSInfo instance.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#name\n", "markdownDescription": "Returns the name of the OSInfo instance.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#name\n" }, + "_inDesiredState": { + "type": "boolean", + "readOnly": true, + "title": "In desired state", + "description": "Indicates whether the resource instance is in the desired state. Only present in the output of the test operation.\n" + }, "architecture": { "type": "string", "title": "Processor architecture", @@ -81,8 +95,8 @@ "version": { "type": "string", "title": "Operating system version", - "description": "Defines the version of the operating system as a string.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#version\n", - "markdownDescription": "Defines the version of the operating system as a string.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#version\n" + "description": "Defines the version of the operating system as a string.\n\nWhen used with the test operation, the version string may include a comparison operator prefix to compare the actual version against a constraint. Supported operators are: `>`, `<`, `=`, `>=`, and `<=`. The operator and version may be separated by optional whitespace, for example `>= 10.0`. Without an operator, the comparison is an exact string match.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#version\n", + "markdownDescription": "Defines the version of the operating system as a string.\n\nWhen used with the **test** operation, the version string may include a comparison operator prefix to compare the actual version against a constraint. Supported operators are: `>`, `<`, `=`, `>=`, and `<=`. The operator and version may be separated by optional whitespace, for example `>= 10.0`. Without an operator, the comparison is an exact string match.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft/osinfo/resource#version\n" } } } diff --git a/resources/osinfo/src/main.rs b/resources/osinfo/src/main.rs index b00c3b07e..ab4e4f464 100644 --- a/resources/osinfo/src/main.rs +++ b/resources/osinfo/src/main.rs @@ -1,11 +1,45 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use dsc_lib_osinfo::OsInfo; +use dsc_lib_osinfo::{perform_test, OsInfo}; +use std::io::Read; fn main() { let args: Vec = std::env::args().collect(); - let include_name = args.len() > 1 && args[1] == "export"; - let json = serde_json::to_string(&OsInfo::new(include_name)).unwrap(); - println!("{json}"); + match args.get(1).map(String::as_str) { + Some("export") => { + let json = serde_json::to_string(&OsInfo::new(true)).unwrap_or_else(|e| { + eprintln!("Failed to serialize OS info as JSON: {e}"); + std::process::exit(1); + }); + println!("{json}"); + }, + Some("test") => { + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut input) { + eprintln!("Failed to read stdin: {e}"); + std::process::exit(1); + } + match perform_test(&input) { + Ok(result) => { + let json = serde_json::to_string(&result).unwrap_or_else(|e| { + eprintln!("Failed to serialize test result as JSON: {e}"); + std::process::exit(1); + }); + println!("{json}"); + }, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + }, + } + }, + _ => { + let json = serde_json::to_string(&OsInfo::new(false)).unwrap_or_else(|e| { + eprintln!("Failed to serialize OS info as JSON: {e}"); + std::process::exit(1); + }); + println!("{json}"); + }, + } } diff --git a/resources/osinfo/tests/osinfo.tests.ps1 b/resources/osinfo/tests/osinfo.tests.ps1 index b07e81ced..f7b5ed262 100644 --- a/resources/osinfo/tests/osinfo.tests.ps1 +++ b/resources/osinfo/tests/osinfo.tests.ps1 @@ -57,3 +57,132 @@ Describe 'osinfo resource tests' { $out.resources[0].name | Should -BeExactly "$($out.resources[0].properties.family) $($out.resources[0].properties.version) $($out.resources[0].properties.architecture)" } } + +Describe 'osinfo test subcommand version operator tests' { + BeforeDiscovery { + $osGetResult = dsc resource get -r Microsoft/OSInfo | ConvertFrom-Json + $currentVersion = $osGetResult.actualState.version + + $versionTestCases = @( + @{ constraint = $currentVersion; expectedState = $true; description = 'exact version without operator' } + @{ constraint = "= $currentVersion"; expectedState = $true; description = 'exact version with = operator' } + @{ constraint = ">= $currentVersion"; expectedState = $true; description = '>= current version' } + @{ constraint = "<= $currentVersion"; expectedState = $true; description = '<= current version' } + @{ constraint = "> $currentVersion"; expectedState = $false; description = '> current version' } + @{ constraint = "< $currentVersion"; expectedState = $false; description = '< current version' } + ) + + $invalidSyntaxCases = @( + @{ constraint = '?? 1.0'; description = 'unknown ?? operator treated as exact match' } + @{ constraint = '~= 1.0'; description = 'unsupported ~= operator treated as exact match' } + @{ constraint = '>> 1.0'; description = 'unsupported >> operator treated as exact match' } + ) + } + + Context 'valid version constraints' { + It 'version constraint "" () should report inDesiredState = ' -ForEach $versionTestCases { + $json = "{`"version`": `"$constraint`"}" + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -Be $expectedState + } + } + + Context 'unsupported version syntax' { + It 'version "" () should not be in desired state' -ForEach $invalidSyntaxCases { + $json = "{`"version`": `"$constraint`"}" + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -Be $false + } + } +} + +Describe 'osinfo test subcommand property tests' { + BeforeAll { + $actual = (dsc resource get -r Microsoft/OSInfo | ConvertFrom-Json).actualState + } + + Context 'edition property' -Skip:(-not $IsWindows) { + It 'should be in desired state when edition matches actual' { + $json = @{ edition = $actual.edition } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeTrue + } + + It 'should not be in desired state when edition does not match' { + $json = @{ edition = 'NonExistentEdition' } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeFalse + } + } + + Context 'codename property' -Skip:(-not $IsLinux) { + It 'should be in desired state when codename matches actual' { + $json = @{ codename = $actual.codename } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeTrue + } + + It 'should not be in desired state when codename does not match' { + $json = @{ codename = 'nonexistentcodename' } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeFalse + } + } + + Context 'bitness property' { + It 'should be in desired state when bitness matches actual' { + $json = @{ bitness = [int]$actual.bitness } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeTrue + } + + It 'should not be in desired state when bitness does not match' { + $wrongBitness = if ([int]$actual.bitness -eq 64) { 32 } else { 64 } + $json = @{ bitness = $wrongBitness } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeFalse + } + } + + Context 'architecture property' { + It 'should be in desired state when architecture matches actual' { + $json = @{ architecture = $actual.architecture } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeTrue + } + + It 'should not be in desired state when architecture does not match' { + $json = @{ architecture = 'mips' } | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeFalse + } + } + + Context 'multiple properties combined' { + It 'should be in desired state when all specified properties match' { + $desiredState = @{ family = $actual.family; bitness = [int]$actual.bitness; architecture = $actual.architecture } + $json = $desiredState | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeTrue + } + + It 'should not be in desired state when one of multiple properties does not match' { + $desiredState = @{ family = $actual.family; bitness = [int]$actual.bitness; architecture = 'mips' } + $json = $desiredState | ConvertTo-Json -Compress + $out = $json | dsc resource test -r Microsoft/OSInfo -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -BeFalse + } + } +}