From b24a455a463edf91291d5bcb75b326c7685e3007 Mon Sep 17 00:00:00 2001 From: "Sean C. Weeks" <112424278+scweeks@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:58:01 -0400 Subject: [PATCH] Fix coverage badge showing the wrong percentage; raise NetClean.psm1 coverage The "Generate GitHub Pages coverage badge payload" step in ci.yml selected its LINE counter via a bare `//counter` XPath search, which matches every nested per-package/per-sourcefile/per-class/per-method counter in the JaCoCo report, then took the first LINE-type match in document order. That's whichever function happens to appear first in the report, not the overall total - confirmed live: the deployed badge read 46.43% against an actual ~96% overall. Fixed by selecting /report/counter[@type="LINE"], the report-level aggregate, and added a regression test asserting the fix stays in place. Also add 8 tests closing real coverage gaps in NetClean.psm1 found via the PR's changed-files coverage report (94.28%, below the 95% gate): Invoke-InParallel's single-input-item fast path (success and the failing case) had zero coverage, and Get-NetCleanDeviceManagementState was missing its three failure-warning branches (dsregcmd throwing outright, the domain fallback also failing, MDM task detection throwing) plus two join-type branches (Entra-only and Enterprise-only, as opposed to hybrid). NetClean.psm1 coverage: 94.28% -> 95.48%. Full suite: 496/496 passing, 95.87% overall coverage, 0 PSScriptAnalyzer findings. --- .github/workflows/ci.yml | 9 +- tests/Unit/NetClean.Core.Unit.Tests.ps1 | 111 ++++++++++++++++++++ tests/Unit/NetClean.Coverage.Unit.Tests.ps1 | 11 ++ 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a08364a..26c0930 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,9 +161,14 @@ jobs: } [xml]$doc = Get-Content -LiteralPath $coverageXmlPath - $lineCounter = $doc.SelectNodes('//counter') | Where-Object { $_.GetAttribute('type') -eq 'LINE' } | Select-Object -First 1 + # Must be the that is a direct child of the root element - + # the report-level aggregate. A plain "//counter" search matches every nested + # per-package/per-sourcefile/per-class/per-method counter too, and picking the + # first LINE-type match in document order silently reports whichever function + # happens to appear first in the report instead of the overall total. + $lineCounter = $doc.SelectSingleNode('/report/counter[@type="LINE"]') if (-not $lineCounter) { - throw 'No LINE counter found in coverage XML.' + throw 'No report-level LINE counter found in coverage XML.' } $missed = [int]$lineCounter.GetAttribute('missed') diff --git a/tests/Unit/NetClean.Core.Unit.Tests.ps1 b/tests/Unit/NetClean.Core.Unit.Tests.ps1 index 70dcbd2..9a3b0b8 100644 --- a/tests/Unit/NetClean.Core.Unit.Tests.ps1 +++ b/tests/Unit/NetClean.Core.Unit.Tests.ps1 @@ -338,6 +338,96 @@ Describe 'NetClean core/shared helper unit tests' { $result.JoinType | Should -Be 'DomainJoined' @($result.Warnings).Count | Should -BeGreaterThan 0 } + + It 'warns and continues when the dsregcmd capture itself throws' { + Mock Invoke-NetCleanNativeCapture { throw 'dsregcmd.exe not found' } + Mock Get-CimInstance { + [pscustomobject]@{ PartOfDomain = $false } + } -ParameterFilter { $ClassName -eq 'Win32_ComputerSystem' } + Mock Get-ScheduledTask { @() } + + $result = Get-NetCleanDeviceManagementState + + $result.JoinType | Should -Be 'Workgroup' + @($result.Warnings) | Should -Contain 'Microsoft Entra join-state detection was unavailable.' + } + + It 'warns and continues when both dsregcmd and the domain fallback are unavailable' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ Succeeded = $false; Output = @(); Error = 'dsregcmd failed' } + } + Mock Get-CimInstance { throw 'WMI unavailable' } -ParameterFilter { $ClassName -eq 'Win32_ComputerSystem' } + Mock Get-ScheduledTask { @() } + + $result = Get-NetCleanDeviceManagementState + + $result.IsManaged | Should -BeFalse + $result.JoinType | Should -Be 'Workgroup' + @($result.Warnings) | Should -Contain 'Active Directory domain-state fallback was unavailable.' + } + + It 'warns and continues when MDM enrollment-task detection throws' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Succeeded = $true + Output = @( + ' AzureAdJoined : NO', + ' DomainJoined : NO', + ' EnterpriseJoined : NO', + ' WorkplaceJoined : NO' + ) + Error = $null + } + } + Mock Get-ScheduledTask { throw 'access denied' } + + $result = Get-NetCleanDeviceManagementState + + $result.MdmEnrolled | Should -BeFalse + @($result.Warnings) | Should -Contain 'MDM enrollment-task detection was unavailable.' + } + + It 'classifies an Entra-only join (not hybrid) with the Entra join type' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Succeeded = $true + Output = @( + ' AzureAdJoined : YES', + ' DomainJoined : NO', + ' EnterpriseJoined : NO', + ' WorkplaceJoined : NO' + ) + Error = $null + } + } + Mock Get-ScheduledTask { @() } + + $result = Get-NetCleanDeviceManagementState + + $result.IsManaged | Should -BeTrue + $result.JoinType | Should -Be 'MicrosoftEntraJoined' + } + + It 'classifies an enterprise-joined device with the enterprise join type' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Succeeded = $true + Output = @( + ' AzureAdJoined : NO', + ' DomainJoined : NO', + ' EnterpriseJoined : YES', + ' WorkplaceJoined : NO' + ) + Error = $null + } + } + Mock Get-ScheduledTask { @() } + + $result = Get-NetCleanDeviceManagementState + + $result.IsManaged | Should -BeTrue + $result.JoinType | Should -Be 'EnterpriseJoined' + } } Context 'Invoke-InParallel' { @@ -376,6 +466,27 @@ Describe 'NetClean core/shared helper unit tests' { Invoke-InParallel -ScriptBlock { param($item) $item } -InputObjects @(1) -ThrottleLimit 0 } | Should -Throw } + + It 'runs a single input item without a runspace pool' { + $result = @( + Invoke-InParallel -ScriptBlock { + param($item) + $item * 2 + } -InputObjects @(5) + ) + + $result | Should -Be @(10) + } + + It 'returns an empty collection when the single input item throws' { + $result = @( + Invoke-InParallel -ScriptBlock { + throw 'single worker failed' + } -InputObjects @(1) + ) + + $result.Count | Should -Be 0 + } } Context 'New-DirectoryIfNotExist' { diff --git a/tests/Unit/NetClean.Coverage.Unit.Tests.ps1 b/tests/Unit/NetClean.Coverage.Unit.Tests.ps1 index 064d370..f227b56 100644 --- a/tests/Unit/NetClean.Coverage.Unit.Tests.ps1 +++ b/tests/Unit/NetClean.Coverage.Unit.Tests.ps1 @@ -39,6 +39,17 @@ Describe 'NetClean coverage runner' { $script:ciText | Should -Match 'min-coverage-overall:\s+94' } + It 'computes the coverage badge percentage from the report-level aggregate counter' { + # A bare "//counter" XPath match's every nested per-package/per-sourcefile/ + # per-class/per-method LINE counter too, not just the overall total - taking + # the first match in document order silently reports whichever function + # happens to appear first in the report (observed live: 46.43% instead of + # the real ~96%). The counter must be selected as a direct child of the + # root element. + $script:ciText | Should -Match '\$doc\.SelectSingleNode\(''/report/counter\[@type="LINE"\]''\)' + $script:ciText | Should -Not -Match '\$doc\.SelectNodes\(''//counter''\)' + } + It 'pins workflow actions and Pages deployment steps' { $script:ciText | Should -Match 'actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8\s+# v6\.0\.1' $script:ciText | Should -Match 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a\s+# v7\.0\.1'