From 75fcdb5d7a60e803bf9e6cc680f4a9dba1d13055 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Tue, 28 Apr 2026 15:42:57 +0200 Subject: [PATCH 1/2] feat(#133): enhance test coverage for NovaModuleTools - Add tests for CodeScene Cobertura remapping helpers - Implement coverage gap tests for quality helpers - Validate Nova package output directory initialization - Improve timeout handling in PowerShell script execution --- .../InitializeNovaPackageOutputDirectory.ps1 | 12 +- .../InvokeNovaPowerShellScriptWithTimeout.ps1 | 5 +- tests/CiCoverage.Tests.ps1 | 221 ++++++++++ tests/CoverageGaps.ReleaseInternals.Tests.ps1 | 128 ++++++ tests/RemainingHelperCoverage.Tests.ps1 | 399 ++++++++++++++++++ tests/UpdateNotification.Tests.ps1 | 99 +++++ 6 files changed, 856 insertions(+), 8 deletions(-) diff --git a/src/private/package/InitializeNovaPackageOutputDirectory.ps1 b/src/private/package/InitializeNovaPackageOutputDirectory.ps1 index 25dde969..e00706c9 100644 --- a/src/private/package/InitializeNovaPackageOutputDirectory.ps1 +++ b/src/private/package/InitializeNovaPackageOutputDirectory.ps1 @@ -3,19 +3,19 @@ function Initialize-NovaPackageOutputDirectory { [CmdletBinding()] param( [Parameter(Mandatory)][pscustomobject]$ProjectInfo, - [Alias('PackageMetadata')][Parameter(Mandatory)][object[]]$PackageMetadataList + [AllowEmptyCollection()][Alias('PackageMetadata')][Parameter(Mandatory)][object[]]$PackageMetadataList ) - $packageMetadata = @($PackageMetadataList)[0] + $packageMetadata = @($PackageMetadataList) | Select-Object -First 1 if ($null -eq $packageMetadata) { Stop-NovaOperation -Message 'Package metadata list cannot be empty.' -ErrorId 'Nova.Validation.PackageMetadataListEmpty' -Category InvalidArgument -TargetObject 'PackageMetadataList' } - if ($PackageMetadata.CleanOutputDirectory) { - Clear-NovaPackageOutputDirectory -ProjectInfo $ProjectInfo -OutputDirectory $PackageMetadata.OutputDirectory + if ($packageMetadata.CleanOutputDirectory) { + Clear-NovaPackageOutputDirectory -ProjectInfo $ProjectInfo -OutputDirectory $packageMetadata.OutputDirectory } - if (-not (Test-Path -LiteralPath $PackageMetadata.OutputDirectory)) { - $null = New-Item -ItemType Directory -Path $PackageMetadata.OutputDirectory -Force + if (-not (Test-Path -LiteralPath $packageMetadata.OutputDirectory)) { + $null = New-Item -ItemType Directory -Path $packageMetadata.OutputDirectory -Force } } diff --git a/src/private/update/InvokeNovaPowerShellScriptWithTimeout.ps1 b/src/private/update/InvokeNovaPowerShellScriptWithTimeout.ps1 index 3dd648ec..5ed0084a 100644 --- a/src/private/update/InvokeNovaPowerShellScriptWithTimeout.ps1 +++ b/src/private/update/InvokeNovaPowerShellScriptWithTimeout.ps1 @@ -3,10 +3,11 @@ function Invoke-NovaPowerShellScriptWithTimeout { param( [Parameter(Mandatory)][string]$Script, [object[]]$ArgumentList = @(), - [int]$TimeoutMilliseconds = 3000 + [int]$TimeoutMilliseconds = 3000, + [scriptblock]$PowerShellFactory = {[powershell]::Create()} ) - $powershell = [powershell]::Create() + $powershell = & $PowerShellFactory try { $null = $powershell.AddScript($Script) foreach ($argument in $ArgumentList) { diff --git a/tests/CiCoverage.Tests.ps1 b/tests/CiCoverage.Tests.ps1 index 71ba5faf..164472c9 100644 --- a/tests/CiCoverage.Tests.ps1 +++ b/tests/CiCoverage.Tests.ps1 @@ -2,6 +2,47 @@ BeforeAll { . (Join-Path $PSScriptRoot '..' 'scripts' 'build' 'ci' 'CodeSceneCoverageMap.ps1') . (Join-Path $PSScriptRoot '..' 'scripts' 'build' 'ci' 'CodeSceneCoverageXml.ps1') . (Join-Path $PSScriptRoot '..' 'scripts' 'build' 'ci' 'CoverageLowReport.ps1') + + foreach ($functionName in @( + 'ConvertTo-CoberturaRelativePath' + 'Get-CodeSceneCoverageErrorRecord' + 'Get-SourceSectionListFromBuiltModule' + 'Find-SourceSectionForLine' + 'Get-EmptyCoberturaLineBucket' + 'Add-CoberturaLineHit' + 'Get-CoberturaLineStat' + 'Get-CoberturaPackageName' + 'Get-CoberturaSourceLineRange' + 'Test-CoberturaLineOutsideSourceRange' + 'Add-CoberturaMappedLineHit' + 'Add-CoberturaLineNodeHit' + 'Get-CoberturaLineBucketMap' + 'Add-CoberturaAttribute' + 'Get-CoberturaClassElement' + 'Get-CoberturaPackageElement' + 'Get-CoberturaCoverageAttributeMap' + 'Get-CoberturaCoverageDocument' + 'Convert-CoberturaCoverageToSourcePath' + 'ConvertTo-CoverageLineRate' + 'Get-CoverageLowReportEntryList' + 'Format-CoverageLowReportLine' + 'Write-CoverageLowReport' + )) { + $scriptBlock = (Get-Command -Name $functionName -CommandType Function -ErrorAction Stop).ScriptBlock + Set-Item -Path "function:global:$functionName" -Value $scriptBlock + } + + $here = Split-Path -Parent $PSCommandPath + $script:repoRoot = Split-Path -Parent $here + $script:moduleName = (Get-Content -LiteralPath (Join-Path $script:repoRoot 'project.json') -Raw | ConvertFrom-Json).ProjectName + $script:distModuleDir = Join-Path $script:repoRoot "dist/$script:moduleName" + + if (-not (Test-Path -LiteralPath $script:distModuleDir)) { + throw "Expected built $script:moduleName module at: $script:distModuleDir. Run Invoke-NovaBuild in the repo root first." + } + + Remove-Module $script:moduleName -ErrorAction SilentlyContinue + Import-Module $script:distModuleDir -Force } Describe 'CodeScene Cobertura remapping helpers' { @@ -204,3 +245,183 @@ Describe 'CodeScene Cobertura remapping helpers' { ) } } + +Describe 'Coverage gaps for quality helpers' { + It 'Write-NovaPesterTestResultReport writes a success NUnit-style report with default suite name' { + $outputPath = Join-Path $TestDrive 'success-report.xml' + + InModuleScope $script:moduleName -Parameters @{OutputPath = $outputPath} { + param($OutputPath) + + $testResult = [pscustomobject]@{ + Tests = @( + [pscustomobject]@{Result = 'Passed'} + [pscustomobject]@{Result = 'Passed'} + [pscustomobject]@{Result = 'Skipped'} + [pscustomobject]@{Result = 'Inconclusive'} + ) + } + + Write-NovaPesterTestResultReport -TestResult $testResult -OutputPath $OutputPath + + [xml]$report = Get-Content -LiteralPath $OutputPath -Raw + $testResultsNode = $report.SelectSingleNode('/test-results') + $testSuiteNode = $report.SelectSingleNode('/test-results/test-suite') + + $testResultsNode.name | Should -Be 'NovaModuleTools' + $testResultsNode.total | Should -Be '4' + $testResultsNode.failures | Should -Be '0' + $testResultsNode.inconclusive | Should -Be '1' + $testResultsNode.skipped | Should -Be '1' + $testSuiteNode.result | Should -Be 'Success' + $testSuiteNode.success | Should -Be 'True' + $testSuiteNode.passed | Should -Be '2' + } + } + + It 'Write-NovaPesterTestResultReport writes a failure NUnit-style report when failed tests are present' { + $outputPath = Join-Path $TestDrive 'failure-report.xml' + + InModuleScope $script:moduleName -Parameters @{OutputPath = $outputPath} { + param($OutputPath) + + $testResult = [pscustomobject]@{ + Tests = @( + [pscustomobject]@{Result = 'Passed'} + [pscustomobject]@{Result = 'Failed'} + ) + } + + Write-NovaPesterTestResultReport -TestResult $testResult -OutputPath $OutputPath -TestSuiteName 'FocusedSuite' + + [xml]$report = Get-Content -LiteralPath $OutputPath -Raw + $testResultsNode = $report.SelectSingleNode('/test-results') + $testSuiteNode = $report.SelectSingleNode('/test-results/test-suite') + + $testResultsNode.name | Should -Be 'FocusedSuite' + $testResultsNode.failures | Should -Be '1' + $testSuiteNode.result | Should -Be 'Failure' + $testSuiteNode.success | Should -Be 'False' + $testSuiteNode.failed | Should -Be '1' + } + } + + It 'Write-NovaPesterTestResultArtifact returns without writing when the result has no Tests property' { + InModuleScope $script:moduleName { + Mock Get-Command {throw 'Get-Command should not be called when Tests is missing.'} + + {Write-NovaPesterTestResultArtifact -TestResult ([pscustomobject]@{Summary = 'no tests'}) -OutputPath '/tmp/unused.xml'} | Should -Not -Throw + + Assert-MockCalled Get-Command -Times 0 + } + } + + It 'Write-NovaPesterTestResultArtifact uses the provided report writer when one is supplied' { + InModuleScope $script:moduleName { + $calls = [System.Collections.Generic.List[object]]::new() + $reportWriter = { + param($TestResult, $OutputPath) + + $calls.Add([pscustomobject]@{ + TestResult = $TestResult + OutputPath = $OutputPath + }) | Out-Null + }.GetNewClosure() + $testResult = [pscustomobject]@{Tests = @([pscustomobject]@{Result = 'Passed'})} + + Write-NovaPesterTestResultArtifact -TestResult $testResult -OutputPath '/tmp/provided.xml' -ReportWriter $reportWriter + + $calls.Count | Should -Be 1 + $calls[0].OutputPath | Should -Be '/tmp/provided.xml' + $calls[0].TestResult.Tests[0].Result | Should -Be 'Passed' + } + } + + It 'Write-NovaPesterTestResultArtifact resolves the default report writer when none is supplied' { + InModuleScope $script:moduleName { + Mock Get-Command { + [pscustomobject]@{ + ScriptBlock = { + param($TestResult, $OutputPath) + + return [pscustomobject]@{ + TestCount = @($TestResult.Tests).Count + OutputPath = $OutputPath + } + } + } + } -ParameterFilter { + $Name -eq 'Write-NovaPesterTestResultReport' -and $CommandType -eq 'Function' + } + + $result = Write-NovaPesterTestResultArtifact -TestResult ([pscustomobject]@{Tests = @([pscustomobject]@{Result = 'Passed'})}) -OutputPath '/tmp/default.xml' + + $result.TestCount | Should -Be 1 + $result.OutputPath | Should -Be '/tmp/default.xml' + Assert-MockCalled Get-Command -Times 1 -ParameterFilter { + $Name -eq 'Write-NovaPesterTestResultReport' -and $CommandType -eq 'Function' + } + } + } + + It 'Initialize-NovaPesterExecutionConfiguration applies returned overrides when ' -ForEach @( + @{ + Name = 'both verbosity and render mode are provided' + Override = [pscustomobject]@{Verbosity = 'Detailed'; RenderMode = 'Plaintext'} + BoundParameters = @{OutputVerbosity = 'Detailed'; OutputRenderMode = 'Plaintext'} + ExpectedVerbosity = 'Detailed' + ExpectedRenderMode = 'Plaintext' + } + @{ + Name = 'only render mode is provided' + Override = [pscustomobject]@{Verbosity = $null; RenderMode = 'Ansi'} + BoundParameters = @{OutputRenderMode = 'Ansi'} + ExpectedVerbosity = 'Normal' + ExpectedRenderMode = 'Ansi' + } + ) { + $pesterConfig = [pscustomobject]@{ + Output = [pscustomobject]@{ + Verbosity = 'Normal' + RenderMode = 'Auto' + } + TestResult = [pscustomobject]@{Enabled = $true} + } + + InModuleScope $script:moduleName -Parameters @{TestCase = $_; PesterConfig = $pesterConfig} { + param($TestCase, $PesterConfig) + + Mock Get-NovaPesterOutputOptionOverride {$TestCase.Override} + + Initialize-NovaPesterExecutionConfiguration -PesterConfig $PesterConfig -BoundParameters $TestCase.BoundParameters + + $PesterConfig.Output.Verbosity | Should -Be $TestCase.ExpectedVerbosity + $PesterConfig.Output.RenderMode | Should -Be $TestCase.ExpectedRenderMode + $PesterConfig.TestResult.Enabled | Should -BeFalse + Assert-MockCalled Get-NovaPesterOutputOptionOverride -Times 1 + } + } + + It 'Initialize-NovaPesterExecutionConfiguration preserves unsupported settings when no overrides are returned' { + $pesterConfig = [pscustomobject]@{ + Output = [pscustomobject]@{ + Verbosity = 'Normal' + RenderMode = 'Auto' + } + TestResult = [pscustomobject]@{Summary = 'No Enabled property'} + } + + InModuleScope $script:moduleName -Parameters @{PesterConfig = $pesterConfig} { + param($PesterConfig) + + Mock Get-NovaPesterOutputOptionOverride {$null} + + Initialize-NovaPesterExecutionConfiguration -PesterConfig $PesterConfig -BoundParameters @{} + + $PesterConfig.Output.Verbosity | Should -Be 'Normal' + $PesterConfig.Output.RenderMode | Should -Be 'Auto' + $PesterConfig.TestResult.PSObject.Properties.Name | Should -Not -Contain 'Enabled' + Assert-MockCalled Get-NovaPesterOutputOptionOverride -Times 1 + } + } +} diff --git a/tests/CoverageGaps.ReleaseInternals.Tests.ps1 b/tests/CoverageGaps.ReleaseInternals.Tests.ps1 index e1bedfec..fea8fd5d 100644 --- a/tests/CoverageGaps.ReleaseInternals.Tests.ps1 +++ b/tests/CoverageGaps.ReleaseInternals.Tests.ps1 @@ -380,6 +380,134 @@ Describe 'Coverage gaps for release and git internals' { } } + It 'Get-NovaPublishedLocalManifestPath returns nothing for non-local publish invocations and resolves the local manifest path when enabled' { + InModuleScope $script:moduleName { + $projectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'} + $localInvocation = [pscustomobject]@{ + IsLocal = $true + Target = '/tmp/modules' + Parameters = @{ProjectInfo = $projectInfo} + } + + Get-NovaPublishedLocalManifestPath -PublishInvocation ([pscustomobject]@{IsLocal = $false; Target = '/tmp/ignored'; Parameters = @{ProjectInfo = $projectInfo}}) | Should -BeNullOrEmpty + Get-NovaPublishedLocalManifestPath -PublishInvocation $localInvocation | Should -Be (Join-Path '/tmp/modules/NovaModuleTools' 'NovaModuleTools.psd1') + } + } + + It 'Get-NovaLocalPublishActivation returns nothing for non-local publishes and resolves import details for local publishes' { + InModuleScope $script:moduleName { + $importAction = {'imported'} + + Mock Get-NovaPublishedLocalManifestPath {'/tmp/modules/NovaModuleTools/NovaModuleTools.psd1'} + Mock Get-Command {[pscustomobject]@{ScriptBlock = $importAction}} -ParameterFilter { + $Name -eq 'Import-NovaPublishedLocalModule' -and $CommandType -eq 'Function' + } + + Get-NovaLocalPublishActivation -PublishInvocation ([pscustomobject]@{IsLocal = $false}) | Should -BeNullOrEmpty + + $result = Get-NovaLocalPublishActivation -PublishInvocation ([pscustomobject]@{IsLocal = $true; Target = '/tmp/modules'; Parameters = @{ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'}}}) + + $result.ManifestPath | Should -Be '/tmp/modules/NovaModuleTools/NovaModuleTools.psd1' + $result.ImportAction | Should -Be $importAction + Assert-MockCalled Get-NovaPublishedLocalManifestPath -Times 1 + Assert-MockCalled Get-Command -Times 1 -ParameterFilter { + $Name -eq 'Import-NovaPublishedLocalModule' -and $CommandType -eq 'Function' + } + } + } + + It 'Get-NovaInstalledProjectManifestPath delegates through the local publish manifest helper with the resolved target path' { + InModuleScope $script:moduleName { + $projectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'; ProjectRoot = '/tmp/project'} + + Mock Resolve-NovaLocalPublishPath {'/tmp/modules'} + Mock Get-NovaPublishedLocalManifestPath { + $PublishInvocation.IsLocal | Should -BeTrue + $PublishInvocation.Target | Should -Be '/tmp/modules' + $PublishInvocation.Parameters.ProjectInfo | Should -Be $projectInfo + return '/tmp/modules/NovaModuleTools/NovaModuleTools.psd1' + } + + $result = Get-NovaInstalledProjectManifestPath -ProjectInfo $projectInfo -ModuleDirectoryPath '/tmp/custom-modules' + + $result | Should -Be '/tmp/modules/NovaModuleTools/NovaModuleTools.psd1' + Assert-MockCalled Resolve-NovaLocalPublishPath -Times 1 -ParameterFilter {$ModuleDirectoryPath -eq '/tmp/custom-modules'} + Assert-MockCalled Get-NovaPublishedLocalManifestPath -Times 1 + } + } + + It 'Get-NovaResolvedPublishParameterMap copies publish parameters and lets workflow values override matching keys' { + InModuleScope $script:moduleName { + $publishInvocation = [pscustomobject]@{ + Parameters = [ordered]@{ + ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'} + Repository = 'PSGallery' + ApiKey = 'initial-key' + } + } + + $result = Get-NovaResolvedPublishParameterMap -PublishInvocation $publishInvocation -WorkflowParams @{ApiKey = 'workflow-key'; Confirm = $false} + + $result.ProjectInfo.ProjectName | Should -Be 'NovaModuleTools' + $result.Repository | Should -Be 'PSGallery' + $result.ApiKey | Should -Be 'workflow-key' + $result.Confirm | Should -BeFalse + } + } + + It 'Import-NovaPublishedLocalModule fails clearly when the local manifest is missing' { + InModuleScope $script:moduleName { + Mock Test-Path {$false} -ParameterFilter {$LiteralPath -eq '/tmp/missing.psd1' -and $PathType -eq 'Leaf'} + Mock Stop-NovaOperation {throw [System.InvalidOperationException]::new($Message)} + + {Import-NovaPublishedLocalModule -ProjectName 'NovaModuleTools' -ManifestPath '/tmp/missing.psd1'} | Should -Throw 'Expected locally published module manifest at: /tmp/missing.psd1' + Assert-MockCalled Stop-NovaOperation -Times 1 -ParameterFilter { + $Message -eq 'Expected locally published module manifest at: /tmp/missing.psd1' -and + $ErrorId -eq 'Nova.Environment.LocalPublishedModuleManifestNotFound' -and + $Category -eq 'ObjectNotFound' -and + $TargetObject -eq '/tmp/missing.psd1' + } + } + } + + It 'Import-NovaPublishedLocalModule imports the requested local module manifest as a global module' { + InModuleScope $script:moduleName { + $importedModule = [pscustomobject]@{Path = '/tmp/modules/NovaModuleTools/NovaModuleTools.psd1'; Name = 'NovaModuleTools'} + + Mock Test-Path {$true} + Mock Get-Module {@()} + Mock Remove-Module {} + Mock Import-Module {$importedModule} + + $result = Import-NovaPublishedLocalModule -ProjectName 'NovaModuleTools' -ManifestPath $importedModule.Path + + $result | Should -Be $importedModule + Assert-MockCalled Import-Module -Times 1 + } + } + + It 'Import-NovaPublishedLocalModule removes matching and stale loaded module instances around the import' { + InModuleScope $script:moduleName { + $importedModule = [pscustomobject]@{Path = '/tmp/modules/NovaModuleTools/NovaModuleTools.psd1'; Name = 'NovaModuleTools'} + + Mock Test-Path {$true} -ParameterFilter {$LiteralPath -eq $importedModule.Path -and $PathType -eq 'Leaf'} + Mock Get-Module { + @( + [pscustomobject]@{Path = $importedModule.Path} + [pscustomobject]@{Path = '/tmp/modules/NovaModuleTools/legacy.psd1'} + ) + } -ParameterFilter {$Name -eq 'NovaModuleTools' -and $All} + Mock Remove-Module {} + Mock Import-Module {$importedModule} -ParameterFilter { + $Name -eq $importedModule.Path -and $Force -and $Global -and $PassThru -and $ErrorAction -eq 'Stop' + } + + $null = Import-NovaPublishedLocalModule -ProjectName 'NovaModuleTools' -ManifestPath $importedModule.Path + + Assert-MockCalled Remove-Module -Times 2 + } + } + It 'Get-GitCommitMessageForVersionBump returns empty when the project is not a git repository' { InModuleScope $script:moduleName { $projectRoot = Join-Path $TestDrive 'no-git-project' diff --git a/tests/RemainingHelperCoverage.Tests.ps1 b/tests/RemainingHelperCoverage.Tests.ps1 index 722846b4..c6ffe80a 100644 --- a/tests/RemainingHelperCoverage.Tests.ps1 +++ b/tests/RemainingHelperCoverage.Tests.ps1 @@ -330,6 +330,405 @@ Describe 'Coverage for remaining manifest, JSON, and help-locale helpers' { } } + It 'Get-NovaPackageSettingValue reads dictionary and object values and returns nothing for missing entries' { + InModuleScope $script:moduleName { + Get-NovaPackageSettingValue -InputObject $null -Name 'Id' | Should -BeNullOrEmpty + Get-NovaPackageSettingValue -InputObject @{Id = 'Nova.Package'} -Name 'Id' | Should -Be 'Nova.Package' + Get-NovaPackageSettingValue -InputObject @{Id = 'Nova.Package'} -Name 'Missing' | Should -BeNullOrEmpty + Get-NovaPackageSettingValue -InputObject ([pscustomobject]@{Id = 'Nova.Object.Package'}) -Name 'Id' | Should -Be 'Nova.Object.Package' + Get-NovaPackageSettingValue -InputObject ([pscustomobject]@{Id = 'Nova.Object.Package'}) -Name 'Missing' | Should -BeNullOrEmpty + } + } + + It 'Merge-NovaPackageSettingTable merges dictionary and object settings while letting overrides win' { + InModuleScope $script:moduleName { + $result = Merge-NovaPackageSettingTable -BaseSettings ([pscustomobject]@{Id = 'base'; Authors = 'Base'}) -OverrideSettings @{Authors = 'Override'; Description = 'Package description'} + + $result.Id | Should -Be 'base' + $result.Authors | Should -Be 'Override' + $result.Description | Should -Be 'Package description' + (Merge-NovaPackageSettingTable -BaseSettings $null -OverrideSettings $null).Count | Should -Be 0 + } + } + + It 'Get-NovaConfiguredPackageTypeList uses configured values and defaults to NuGet when none are set' { + InModuleScope $script:moduleName { + @(Get-NovaConfiguredPackageTypeList -PackageSettings @{Types = @('NuGet', '', $null, 'Zip')}) | Should -Be @('NuGet', 'Zip') + @(Get-NovaConfiguredPackageTypeList -PackageSettings ([pscustomobject]@{Types = @('Zip')})) | Should -Be @('Zip') + @(Get-NovaConfiguredPackageTypeList -PackageSettings ([pscustomobject]@{Types = @('', $null)})) | Should -Be @('NuGet') + } + } + + It 'Test-NovaPackageLatestEnabled reads dictionary and object latest flags and defaults to false otherwise' { + InModuleScope $script:moduleName { + Test-NovaPackageLatestEnabled -PackageSettings @{Latest = $true} | Should -BeTrue + Test-NovaPackageLatestEnabled -PackageSettings @{Types = @('NuGet')} | Should -BeFalse + Test-NovaPackageLatestEnabled -PackageSettings ([pscustomobject]@{Latest = $true}) | Should -BeTrue + Test-NovaPackageLatestEnabled -PackageSettings ([pscustomobject]@{Types = @('Zip')}) | Should -BeFalse + Test-NovaPackageLatestEnabled -PackageSettings $null | Should -BeFalse + } + } + + It 'Get-NovaPackageMetadataList returns one entry per package type and optional latest variants' { + InModuleScope $script:moduleName { + Mock Get-NovaConfiguredPackageTypeList {@('NuGet', 'Zip')} + Mock Test-NovaPackageLatestEnabled {$true} + Mock Get-NovaPackageMetadata {"$( $PackageType )-latest:$( [bool]$Latest )"} + + $result = @(Get-NovaPackageMetadataList -ProjectInfo ([pscustomobject]@{Package = @{}})) + + $result | Should -Be @('NuGet-latest:False', 'NuGet-latest:True', 'Zip-latest:False', 'Zip-latest:True') + Assert-MockCalled Get-NovaPackageMetadata -Times 4 + } + } + + It 'Get-NovaPackageAuthorList normalizes string and enumerable author values' { + InModuleScope $script:moduleName { + @(Get-NovaPackageAuthorList -AuthorValue $null) | Should -Be @() + @(Get-NovaPackageAuthorList -AuthorValue ' Nova Author ') | Should -Be @('Nova Author') + @(Get-NovaPackageAuthorList -AuthorValue @(' Author A ', 'Author B', 'Author A', ' ')) | Should -Be @('Author A', 'Author B') + } + } + + It 'Get-NovaPackageAuthorList rejects unsupported author value types' { + InModuleScope $script:moduleName { + $thrown = $null + try { + Get-NovaPackageAuthorList -AuthorValue 42 + } + catch { + $thrown = $_ + } + + $thrown | Should -Not -BeNullOrEmpty + $thrown.Exception.Message | Should -Be 'Package.Authors must be a string or an array of strings.' + $thrown.FullyQualifiedErrorId | Should -Be 'Nova.Configuration.PackageAuthorsInvalidType' + $thrown.CategoryInfo.Category | Should -Be ([System.Management.Automation.ErrorCategory]::InvalidData) + $thrown.TargetObject | Should -Be 42 + } + } + + It 'Get-NovaPackageMetadata resolves explicit package types, trims fields, and uses a zip content root for zip packages' { + InModuleScope $script:moduleName { + $projectInfo = [pscustomobject]@{ + ProjectName = 'NovaModuleTools' + Version = ' 1.2.3 ' + Manifest = [pscustomobject]@{} + Package = [pscustomobject]@{ + Id = ' Nova.Package ' + Authors = @(' Author A ', 'Author B') + Description = ' Package description ' + OutputDirectory = [pscustomobject]@{Clean = $true} + Types = @('zip') + } + } + Mock ConvertTo-NovaPackageType {'Zip'} + Mock Get-NovaPackageAuthorList {@('Author A', 'Author B')} + Mock Get-NovaManifestValue { + switch ($Name) { + 'Tags' { + @('tools', '', 'module') + } + 'ProjectUri' { + ' https://example.test/project ' + } + 'ReleaseNotes' { + ' https://example.test/release-notes ' + } + 'LicenseUri' { + ' https://example.test/license ' + } + default { + $null + } + } + } + Mock Get-NovaPackageFileName {'Nova.Package.latest.zip'} + Mock Get-NovaPackageOutputDirectory {'/tmp/packages'} + + $result = Get-NovaPackageMetadata -ProjectInfo $projectInfo -PackageType 'zip' -Latest + + $result.Type | Should -Be 'Zip' + $result.Latest | Should -BeTrue + $result.Id | Should -Be 'Nova.Package' + $result.Version | Should -Be '1.2.3' + $result.Authors | Should -Be @('Author A', 'Author B') + $result.Description | Should -Be 'Package description' + $result.Tags | Should -Be @('tools', 'module') + $result.ProjectUrl | Should -Be 'https://example.test/project' + $result.ReleaseNotes | Should -Be 'https://example.test/release-notes' + $result.LicenseUrl | Should -Be 'https://example.test/license' + $result.PackagePath | Should -Be ([System.IO.Path]::Join('/tmp/packages', 'Nova.Package.latest.zip')) + $result.ContentRoot | Should -Be 'NovaModuleTools' + } + } + + It 'Get-NovaPackageMetadata defaults to NuGet and content packages when no package type is configured' { + InModuleScope $script:moduleName { + $projectInfo = [pscustomobject]@{ + ProjectName = 'NovaModuleTools' + Version = '1.2.3' + Manifest = [pscustomobject]@{} + Package = [pscustomobject]@{ + Id = 'Nova.Package' + Authors = 'Author A' + Description = 'Package description' + OutputDirectory = [pscustomobject]@{Clean = $false} + Types = @('', $null) + } + } + Mock ConvertTo-NovaPackageType {throw 'ConvertTo-NovaPackageType should not be called when the default NuGet type is used.'} + Mock Get-NovaPackageAuthorList {@('Author A')} + Mock Get-NovaManifestValue { + if ($Name -eq 'Tags') { + return @() + } + + return '' + } + Mock Get-NovaPackageFileName {'Nova.Package.nupkg'} + Mock Get-NovaPackageOutputDirectory {'/tmp/packages'} + + $result = Get-NovaPackageMetadata -ProjectInfo $projectInfo + + $result.Type | Should -Be 'NuGet' + $result.ContentRoot | Should -Be 'content/NovaModuleTools' + $result.CleanOutputDirectory | Should -BeFalse + } + } + + It 'Assert-NovaPackageMetadata accepts complete metadata and rejects missing required fields and authors' { + InModuleScope $script:moduleName { + { + Assert-NovaPackageMetadata -PackageMetadata ([pscustomobject]@{ + Type = 'NuGet' + Id = 'Nova.Package' + Version = '1.2.3' + Description = 'Package description' + OutputDirectory = '/tmp/packages' + PackageFileName = 'Nova.Package.nupkg' + PackagePath = '/tmp/packages/Nova.Package.nupkg' + Authors = @('Author A') + }) + } | Should -Not -Throw + + $missingField = $null + try { + Assert-NovaPackageMetadata -PackageMetadata ([pscustomobject]@{ + Type = '' + Id = 'Nova.Package' + Version = '1.2.3' + Description = 'Package description' + OutputDirectory = '/tmp/packages' + PackageFileName = 'Nova.Package.nupkg' + PackagePath = '/tmp/packages/Nova.Package.nupkg' + Authors = @('Author A') + }) + } + catch { + $missingField = $_ + } + + $missingField.FullyQualifiedErrorId | Should -Be 'Nova.Configuration.PackageMetadataValueMissing' + $missingField.TargetObject | Should -Be 'Type' + + $missingAuthors = $null + try { + Assert-NovaPackageMetadata -PackageMetadata ([pscustomobject]@{ + Type = 'NuGet' + Id = 'Nova.Package' + Version = '1.2.3' + Description = 'Package description' + OutputDirectory = '/tmp/packages' + PackageFileName = 'Nova.Package.nupkg' + PackagePath = '/tmp/packages/Nova.Package.nupkg' + Authors = @() + }) + } + catch { + $missingAuthors = $_ + } + + $missingAuthors.FullyQualifiedErrorId | Should -Be 'Nova.Configuration.PackageMetadataValueMissing' + $missingAuthors.TargetObject | Should -Be 'Authors' + } + } + + It 'Get-NovaPackageArtifactPatternInfo uses the configured pattern or defaults to the package id wildcard' { + InModuleScope $script:moduleName { + $defaultPattern = Get-NovaPackageArtifactPatternInfo -ProjectInfo ([pscustomobject]@{Package = [pscustomobject]@{Id = 'Nova.Package'; FileNamePattern = ' '}}) + + $defaultPattern.Pattern | Should -Be 'Nova.Package*' + $defaultPattern.ExplicitPackageType | Should -BeNullOrEmpty + + Mock ConvertTo-NovaPackageType {'Zip'} + + $zipPattern = Get-NovaPackageArtifactPatternInfo -ProjectInfo ([pscustomobject]@{Package = [pscustomobject]@{Id = 'Nova.Package'; FileNamePattern = 'artifacts/*.zip'}}) + + $zipPattern.Pattern | Should -Be 'artifacts/*.zip' + $zipPattern.ExplicitPackageType | Should -Be 'Zip' + } + } + + It 'Get-NovaPackageArtifactType resolves supported extensions and rejects unsupported upload file names' { + InModuleScope $script:moduleName { + Get-NovaPackageArtifactType -PackagePath '/tmp/Nova.Package.nupkg' | Should -Be 'NuGet' + Get-NovaPackageArtifactType -PackagePath '/tmp/Nova.Package.zip' | Should -Be 'Zip' + + foreach ($packagePath in @('/tmp/Nova.Package', '/tmp/Nova.Package.tar.gz')) { + $thrown = $null + try { + Get-NovaPackageArtifactType -PackagePath $packagePath + } + catch { + $thrown = $_ + } + + $thrown.FullyQualifiedErrorId | Should -Be 'Nova.Validation.UnsupportedPackageUploadFileType' + $thrown.TargetObject | Should -Be $packagePath + } + } + } + + It 'Get-NovaPackageOutputDirectory returns rooted paths unchanged and resolves relative paths from the project root' { + InModuleScope $script:moduleName { + $relativeProject = [pscustomobject]@{ProjectRoot = '/tmp/project'; Package = [pscustomobject]@{OutputDirectory = [pscustomobject]@{Path = 'artifacts/packages'}}} + $absoluteProject = [pscustomobject]@{ProjectRoot = '/tmp/project'; Package = [pscustomobject]@{OutputDirectory = '/tmp/packages'}} + + Get-NovaPackageOutputDirectory -ProjectInfo $relativeProject | Should -Be ([System.IO.Path]::Join('/tmp/project', 'artifacts/packages')) + Get-NovaPackageOutputDirectory -ProjectInfo $absoluteProject | Should -Be '/tmp/packages' + } + } + + It 'Get-NovaPackageBaseFileName and Get-NovaPackageFileName respect version suffix and latest naming rules' { + InModuleScope $script:moduleName { + $projectInfo = [pscustomobject]@{ + Version = '1.2.3' + Package = [pscustomobject]@{ + PackageFileName = ' Custom.Package.zip ' + AddVersionToFileName = $true + } + } + + Get-NovaPackageBaseFileName -ProjectInfo ([pscustomobject]@{Version = '1.2.3'; Package = [pscustomobject]@{PackageFileName = ''; AddVersionToFileName = $false}}) -PackageId 'Nova.Package' | Should -Be 'Nova.Package.1.2.3' + Get-NovaPackageBaseFileName -ProjectInfo $projectInfo -PackageId 'Nova.Package' | Should -Be 'Custom.Package.1.2.3' + Add-NovaPackageVersionSuffix -PackageFileName 'Custom.Package.1.2.3' -Version '1.2.3' | Should -Be 'Custom.Package.1.2.3' + ConvertTo-NovaLatestPackageFileName -PackageFileName 'Custom.Package.1.2.3' -Version '1.2.3' | Should -Be 'Custom.Package.latest' + ConvertTo-NovaLatestPackageFileName -PackageFileName 'Custom.Package' -Version '1.2.3' | Should -Be 'Custom.Package.latest' + ConvertTo-NovaLatestPackageFileName -PackageFileName 'Custom.Package.latest' -Version '1.2.3' | Should -Be 'Custom.Package.latest' + Get-NovaPackageFileName -ProjectInfo $projectInfo -PackageId 'Nova.Package' -PackageType 'zip' -Latest | Should -Be 'Custom.Package.latest.zip' + } + } + + It 'Get-NovaPackageUploadStatusCode returns integer status codes and nothing for null or missing responses' { + InModuleScope $script:moduleName { + Get-NovaPackageUploadStatusCode -Response $null | Should -BeNullOrEmpty + Get-NovaPackageUploadStatusCode -Response ([pscustomobject]@{StatusCode = '201'}) | Should -Be 201 + Get-NovaPackageUploadStatusCode -Response ([pscustomobject]@{Body = 'ok'}) | Should -BeNullOrEmpty + } + } + + It 'Get-NovaPackageUploadAuthHeaderValue returns raw tokens for non-authorization headers or None schemes and defaults authorization to Bearer' { + InModuleScope $script:moduleName { + Get-NovaPackageUploadAuthHeaderValue -AuthSettings $null -HeaderName 'X-Api-Key' -Token 'secret-token' | Should -Be 'secret-token' + Get-NovaPackageUploadAuthHeaderValue -AuthSettings $null -HeaderName 'Authorization' -Token 'secret-token' | Should -Be 'Bearer secret-token' + Get-NovaPackageUploadAuthHeaderValue -AuthSettings ([pscustomobject]@{Scheme = 'None'}) -HeaderName 'Authorization' -Token 'secret-token' | Should -Be 'secret-token' + Get-NovaPackageUploadAuthHeaderValue -AuthSettings $null -AuthenticationScheme ' Basic ' -HeaderName 'Authorization' -Token 'secret-token' | Should -Be 'Basic secret-token' + } + } + + It 'Initialize-NovaPackageOutputDirectory validates non-empty metadata, clears when requested, and creates missing directories' { + InModuleScope $script:moduleName { + $thrown = $null + try { + Initialize-NovaPackageOutputDirectory -ProjectInfo ([pscustomobject]@{}) -PackageMetadataList @() + } + catch { + $thrown = $_ + } + + $thrown.FullyQualifiedErrorId | Should -Be 'Nova.Validation.PackageMetadataListEmpty' + $thrown.TargetObject | Should -Be 'PackageMetadataList' + + $metadata = [pscustomobject]@{OutputDirectory = '/tmp/packages'; CleanOutputDirectory = $true} + Mock Clear-NovaPackageOutputDirectory {} + Mock Test-Path {$false} -ParameterFilter {$LiteralPath -eq '/tmp/packages'} + Mock New-Item {[pscustomobject]@{FullName = '/tmp/packages'}} -ParameterFilter {$ItemType -eq 'Directory' -and $Path -eq '/tmp/packages' -and $Force} + + Initialize-NovaPackageOutputDirectory -ProjectInfo ([pscustomobject]@{ProjectRoot = '/tmp/project'}) -PackageMetadataList @($metadata) + + Assert-MockCalled Clear-NovaPackageOutputDirectory -Times 1 -ParameterFilter {$OutputDirectory -eq '/tmp/packages'} + Assert-MockCalled New-Item -Times 1 -ParameterFilter {$ItemType -eq 'Directory' -and $Path -eq '/tmp/packages' -and $Force} + } + } + + It 'Join-NovaPackageUploadUrl trims separators and escapes package file names' { + InModuleScope $script:moduleName { + Join-NovaPackageUploadUrl -Url 'https://example.test/api/' -UploadPath ' uploads /packages/ ' -PackageFileName 'Nova Package 1.2.3.nupkg' | Should -Be 'https://example.test/api/uploads /packages//Nova%20Package%201.2.3.nupkg' + Join-NovaPackageUploadUrl -Url 'https://example.test/api' -PackageFileName 'Nova.Package.zip' | Should -Be 'https://example.test/api/Nova.Package.zip' + } + } + + It 'Resolve-NovaPackageUploadTypeList uses requested types, explicit artifact types, or metadata types depending on the inputs' { + InModuleScope $script:moduleName { + Mock Get-NovaPackageArtifactPatternInfo {[pscustomobject]@{Pattern = 'artifacts/*'; ExplicitPackageType = $null}} + Mock ConvertTo-NovaPackageType { + switch ($Type) { + '.zip' { + 'Zip' + } + '.nupkg' { + 'NuGet' + } + 'zip' { + 'Zip' + } + 'nupkg' { + 'NuGet' + } + default { + throw "Unsupported: $Type" + } + } + } + Mock Get-NovaPackageMetadataList {@([pscustomobject]@{Type = 'NuGet'}, [pscustomobject]@{Type = 'Zip'}, [pscustomobject]@{Type = 'NuGet'})} + + @(Resolve-NovaPackageUploadTypeList -ProjectInfo ([pscustomobject]@{}) -PackageType @('zip', 'ZIP', 'nupkg')) | Should -Be @('Zip', 'NuGet') + + Mock Get-NovaPackageArtifactPatternInfo {[pscustomobject]@{Pattern = 'artifacts/*.zip'; ExplicitPackageType = 'Zip'}} + @(Resolve-NovaPackageUploadTypeList -ProjectInfo ([pscustomobject]@{}) -PackageType @('zip', 'nupkg')) | Should -Be @('Zip') + + Mock Get-NovaPackageArtifactPatternInfo {[pscustomobject]@{Pattern = 'artifacts/*'; ExplicitPackageType = $null}} + @(Resolve-NovaPackageUploadTypeList -ProjectInfo ([pscustomobject]@{})) | Should -Be @('NuGet', 'Zip') + } + } + + It 'Resolve-NovaPackageUploadTypeList exposes a structured conflict when requested types disagree with an explicit artifact pattern' { + InModuleScope $script:moduleName { + Mock Get-NovaPackageArtifactPatternInfo {[pscustomobject]@{Pattern = 'artifacts/*.zip'; ExplicitPackageType = 'Zip'}} + Mock ConvertTo-NovaPackageType {'NuGet'} + + $thrown = $null + try { + Resolve-NovaPackageUploadTypeList -ProjectInfo ([pscustomobject]@{}) -PackageType @('nupkg') + } + catch { + $thrown = $_ + } + + $thrown.FullyQualifiedErrorId | Should -Be 'Nova.Validation.PackageUploadPatternConflict' + $thrown.TargetObject | Should -Be 'artifacts/*.zip' + } + } + + It 'Test-NovaPathContainsPath returns true for identical or nested paths and false for siblings' { + InModuleScope $script:moduleName { + Test-NovaPathContainsPath -ParentPath '/tmp/project' -ChildPath '/tmp/project' | Should -BeTrue + Test-NovaPathContainsPath -ParentPath '/tmp/project' -ChildPath '/tmp/project/dist/module' | Should -BeTrue + Test-NovaPathContainsPath -ParentPath '/tmp/project' -ChildPath '/tmp/another' | Should -BeFalse + } + } + It 'Get-AliasInFunctionFromFile returns aliases declared on the function' { $filePath = Join-Path $script:repoRoot 'src/public/UpdateNovaModuleTools.ps1' diff --git a/tests/UpdateNotification.Tests.ps1 b/tests/UpdateNotification.Tests.ps1 index 0e5eb0a2..bbf50359 100644 --- a/tests/UpdateNotification.Tests.ps1 +++ b/tests/UpdateNotification.Tests.ps1 @@ -5,6 +5,10 @@ $global:updateNotificationTestSupportFunctionNameList = @( 'Invoke-TestNotificationPreferenceToggle' 'Assert-TestNotificationPreferenceToggleResult' 'Invoke-TestNovaSelfUpdate' + 'New-TestPowerShellRunnerState' + 'New-TestPowerShellWaitHandle' + 'Add-TestPowerShellRunnerMethods' + 'New-TestPowerShellRunner' ) . $script:updateNotificationTestSupportPath @@ -38,6 +42,7 @@ BeforeAll { Import-Module $script:distModuleDir -Force } + Describe 'Update notification behavior' { It 'Get-NovaUpdateNotificationPreferenceStatus shapes the stored preference and settings path' { InModuleScope $script:moduleName { @@ -637,6 +642,100 @@ throw 'offline' } } + It 'Get-NovaPrereleaseModuleUpdateConfirmationPrompt returns the expected caption and message text' { + InModuleScope $script:moduleName { + $prompt = Get-NovaPrereleaseModuleUpdateConfirmationPrompt -CurrentVersion '1.2.3' -TargetVersion '1.3.0-preview1' + + $prompt.Caption | Should -Be 'Confirm prerelease NovaModuleTools update' + $prompt.Message | Should -Be @" +NovaModuleTools would update from 1.2.3 to prerelease 1.3.0-preview1. + +Prerelease updates may be less stable than released versions. +Continue with the prerelease update? +"@ + } + } + + It 'Confirm-NovaPrereleaseModuleUpdate delegates the generated prompt to ShouldContinue and returns the decision' { + InModuleScope $script:moduleName { + $calls = [System.Collections.Generic.List[object]]::new() + $cmdlet = [pscustomobject]@{ + Calls = $calls + ShouldContinueResult = $false + } + $cmdlet | Add-Member -MemberType ScriptMethod -Name ShouldContinue -Value { + param($Message, $Caption) + + $this.Calls.Add([pscustomobject]@{ + Message = $Message + Caption = $Caption + }) | Out-Null + return $this.ShouldContinueResult + } + + $result = Confirm-NovaPrereleaseModuleUpdate -Cmdlet $cmdlet -CurrentVersion '1.2.3' -TargetVersion '2.0.0-preview2' + + $result | Should -BeFalse + $calls.Count | Should -Be 1 + $calls[0].Caption | Should -Be 'Confirm prerelease NovaModuleTools update' + $calls[0].Message | Should -Match '1.2.3' + $calls[0].Message | Should -Match '2.0.0-preview2' + } + } + + It 'Invoke-NovaPowerShellScriptWithTimeout returns the first pipeline result and forwards arguments when the script completes' { + $runner = New-TestPowerShellRunner -ShouldComplete:$true -EndInvokeResult @('first result', 'second result') + + InModuleScope $script:moduleName -Parameters @{Runner = $runner} { + param($Runner) + + $result = Invoke-NovaPowerShellScriptWithTimeout -Script 'param($name, $flag)' -ArgumentList @('NovaModuleTools', $true) -TimeoutMilliseconds 321 -PowerShellFactory {$runner} + + $result | Should -Be 'first result' + $Runner.State.Script | Should -Be 'param($name, $flag)' + @($Runner.State.Arguments) | Should -Be @('NovaModuleTools', $true) + $Runner.State.LastTimeoutMilliseconds | Should -Be 321 + $Runner.State.EndInvokeCalls | Should -Be 1 + $Runner.State.StopCalls | Should -Be 0 + $Runner.State.DisposeCalls | Should -Be 1 + } + } + + It 'Invoke-NovaPowerShellScriptWithTimeout returns nothing and stops the pipeline when the timeout is exceeded' { + $runner = New-TestPowerShellRunner -ShouldComplete:$false + + InModuleScope $script:moduleName -Parameters @{Runner = $runner} { + param($Runner) + + $result = Invoke-NovaPowerShellScriptWithTimeout -Script 'Start-Sleep -Seconds 30' -TimeoutMilliseconds 25 -PowerShellFactory {$Runner} + + $result | Should -BeNullOrEmpty + $Runner.State.LastTimeoutMilliseconds | Should -Be 25 + $Runner.State.StopCalls | Should -Be 1 + $Runner.State.EndInvokeCalls | Should -Be 0 + $Runner.State.DisposeCalls | Should -Be 1 + } + } + + It 'Invoke-NovaPowerShellScriptWithTimeout swallows stop and EndInvoke failures and still disposes the pipeline' { + $stopFailureRunner = New-TestPowerShellRunner -ShouldComplete:$false -ThrowOnStop + $endFailureRunner = New-TestPowerShellRunner -ShouldComplete:$true -ThrowOnEndInvoke + + InModuleScope $script:moduleName -Parameters @{StopFailureRunner = $stopFailureRunner; EndFailureRunner = $endFailureRunner} { + param($StopFailureRunner, $EndFailureRunner) + + $timedOutResult = Invoke-NovaPowerShellScriptWithTimeout -Script 'Start-Sleep -Seconds 30' -TimeoutMilliseconds 25 -PowerShellFactory {$StopFailureRunner} + $endFailureResult = Invoke-NovaPowerShellScriptWithTimeout -Script 'throw' -PowerShellFactory {$EndFailureRunner} + + $timedOutResult | Should -BeNullOrEmpty + $endFailureResult | Should -BeNullOrEmpty + $StopFailureRunner.State.StopCalls | Should -Be 1 + $StopFailureRunner.State.DisposeCalls | Should -Be 1 + $EndFailureRunner.State.EndInvokeCalls | Should -Be 1 + $EndFailureRunner.State.DisposeCalls | Should -Be 1 + } + } + It 'Invoke-NovaBuildUpdateNotification warns about a newer stable release even when prerelease notifications are disabled' { $result = Invoke-TestBuildUpdateNotification -PrereleaseNotificationsEnabled:$false -LookupResult ([pscustomobject]@{ Stable = [pscustomobject]@{Version = '1.1.0'} From 3cb8f9dece26f0324dce24f92666b7838f9cba1a Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Tue, 28 Apr 2026 15:43:51 +0200 Subject: [PATCH 2/2] feat(#133): enhance test coverage for NovaModuleTools - Add tests for CodeScene Cobertura remapping helpers - Implement coverage gap tests for quality helpers - Validate Nova package output directory initialization - Improve timeout handling in PowerShell script execution --- tests/UpdateNotification.TestSupport.ps1 | 102 +++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/tests/UpdateNotification.TestSupport.ps1 b/tests/UpdateNotification.TestSupport.ps1 index f2d1c7f3..d0c00ba1 100644 --- a/tests/UpdateNotification.TestSupport.ps1 +++ b/tests/UpdateNotification.TestSupport.ps1 @@ -180,3 +180,105 @@ function Invoke-TestNovaSelfUpdate { } } } + +function New-TestPowerShellRunnerState { + [CmdletBinding()] + param() + + return [pscustomobject]@{ + Script = $null + Arguments = [System.Collections.Generic.List[object]]::new() + LastTimeoutMilliseconds = $null + StopCalls = 0 + EndInvokeCalls = 0 + DisposeCalls = 0 + } +} + +function New-TestPowerShellWaitHandle { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$State, + [Parameter(Mandatory)][bool]$ShouldComplete + ) + + $waitHandle = [pscustomobject]@{ + ShouldComplete = $ShouldComplete + State = $State + } + + $waitHandle | Add-Member -MemberType ScriptMethod -Name WaitOne -Value { + param($TimeoutMilliseconds) + + $this.State.LastTimeoutMilliseconds = $TimeoutMilliseconds + return $this.ShouldComplete + } + + return $waitHandle +} + +function Add-TestPowerShellRunnerMethods { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$Runner + ) + + $runner | Add-Member -MemberType ScriptMethod -Name AddScript -Value { + param($ScriptText) + + $this.State.Script = $ScriptText + return $this + } + $runner | Add-Member -MemberType ScriptMethod -Name AddArgument -Value { + param($Argument) + + $this.State.Arguments.Add($Argument) | Out-Null + return $this + } + $runner | Add-Member -MemberType ScriptMethod -Name BeginInvoke -Value { + return $this.AsyncResult + } + $runner | Add-Member -MemberType ScriptMethod -Name Stop -Value { + $this.State.StopCalls++ + if ($this.ThrowOnStop) { + throw 'stop failed' + } + } + $runner | Add-Member -MemberType ScriptMethod -Name EndInvoke -Value { + param($InvocationResult) + + $this.State.EndInvokeCalls++ + if ($this.ThrowOnEndInvoke) { + throw 'end failed' + } + + return $this.EndInvokeResult + } + $runner | Add-Member -MemberType ScriptMethod -Name Dispose -Value { + $this.State.DisposeCalls++ + } + + return $Runner +} + +function New-TestPowerShellRunner { + [CmdletBinding()] + param( + [bool]$ShouldComplete, + [object]$EndInvokeResult = $null, + [switch]$ThrowOnStop, + [switch]$ThrowOnEndInvoke + ) + + $state = New-TestPowerShellRunnerState + $waitHandle = New-TestPowerShellWaitHandle -State $state -ShouldComplete $ShouldComplete + $runner = [pscustomobject]@{ + State = $state + AsyncResult = [pscustomobject]@{AsyncWaitHandle = $waitHandle} + EndInvokeResult = $EndInvokeResult + ThrowOnStop = $ThrowOnStop.IsPresent + ThrowOnEndInvoke = $ThrowOnEndInvoke.IsPresent + } + + return Add-TestPowerShellRunnerMethods -Runner $runner +}