Skip to content

Commit 2d98dc6

Browse files
committed
feat(#133): enhance test coverage and fix CLI help errors
- Fix unsupported `nova` help invocations to return structured CLI validation errors - Align manifest/package helper edge cases with intended behavior - Add tests for CLI helper functions and project manifest resolution
1 parent 3cb8f9d commit 2d98dc6

8 files changed

Lines changed: 275 additions & 5 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8181

8282
### Fixed
8383

84+
- Fix unsupported `nova` help invocations so they now return Nova's structured CLI validation error instead of a
85+
PowerShell parameter-binding failure.
86+
- Keep manifest/package helper edge cases aligned with their intended behavior.
87+
- Manifest settings resolution now accepts ordered dictionary metadata shapes in addition to plain hashtables.
88+
- `New-NovaPackageArtifacts` now accepts an empty metadata list and returns an empty artifact result instead of
89+
failing during parameter binding.
8490
- Centralize delivery configuration resolution so raw package upload, update notification settings, and PSGallery
8591
publishing now follow one explicit precedence model without surfacing configured secrets in error text.
8692
- Raw upload now resolves command overrides before named repository settings, then package defaults.

src/private/cli/GetNovaCliHelpRequest.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function Get-NovaCliRootHelpRequest {
4444
return Get-NovaCliResolvedHelpRequest -Command $Arguments[0] -View Long -TargetType Command
4545
}
4646

47-
Assert-NovaCliHelpUsageSupported -Tokens @('--help') + $Arguments
47+
Assert-NovaCliHelpUsageSupported -Tokens (@('--help') + $Arguments)
4848
}
4949

5050
function Get-NovaCliSubcommandHelpRequest {
@@ -63,7 +63,7 @@ function Get-NovaCliSubcommandHelpRequest {
6363
}
6464

6565
if (@($Arguments | Where-Object {Test-NovaCliHelpToken -Argument $_}).Count -gt 0) {
66-
Assert-NovaCliHelpUsageSupported -Tokens @($Command) + $Arguments
66+
Assert-NovaCliHelpUsageSupported -Tokens (@($Command) + $Arguments)
6767
}
6868

6969
return $null

src/private/package/NewNovaPackageArtifacts.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ function New-NovaPackageArtifacts {
44
[CmdletBinding()]
55
param(
66
[Parameter(Mandatory)][pscustomobject]$ProjectInfo,
7-
[Parameter(Mandatory)][object[]]$PackageMetadataList
7+
[Parameter(Mandatory)][AllowEmptyCollection()][object[]]$PackageMetadataList
88
)
99

1010
$resolvedPackageMetadataList = @($PackageMetadataList)

src/private/shared/GetNovaResolvedProjectManifestSettings.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ function Get-NovaResolvedProjectManifestSettings {
55
[Parameter(Mandatory)][hashtable]$ProjectData
66
)
77

8-
if ($ProjectData.ContainsKey('Manifest') -and $ProjectData['Manifest'] -is [hashtable]) {
8+
if ($ProjectData.ContainsKey('Manifest') -and $ProjectData['Manifest'] -is [System.Collections.IDictionary]) {
99
return [ordered]@{} + $ProjectData['Manifest']
1010
}
1111

tests/CliHelperCoverage.Tests.ps1

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
$script:coverageGapsCliTestSupportPath = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot 'CoverageGaps.Cli.TestSupport.ps1')).Path
2+
$global:cliHelperCoverageSupportFunctionNameList = @(
3+
'Assert-TestStructuredCliError'
4+
)
5+
. $script:coverageGapsCliTestSupportPath
6+
7+
foreach ($functionName in $global:cliHelperCoverageSupportFunctionNameList) {
8+
$scriptBlock = (Get-Command -Name $functionName -CommandType Function -ErrorAction Stop).ScriptBlock
9+
Set-Item -Path "function:global:$functionName" -Value $scriptBlock
10+
}
11+
12+
BeforeAll {
13+
$here = Split-Path -Parent $PSCommandPath
14+
$script:repoRoot = Split-Path -Parent $here
15+
$script:moduleName = (Get-Content -LiteralPath (Join-Path $script:repoRoot 'project.json') -Raw | ConvertFrom-Json).ProjectName
16+
$script:distModuleDir = Join-Path $script:repoRoot "dist/$script:moduleName"
17+
$coverageGapsCliTestSupportPath = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot 'CoverageGaps.Cli.TestSupport.ps1')).Path
18+
19+
if (-not (Test-Path -LiteralPath $script:distModuleDir)) {
20+
throw "Expected built $script:moduleName module at: $script:distModuleDir. Run Invoke-NovaBuild in the repo root first."
21+
}
22+
23+
. $coverageGapsCliTestSupportPath
24+
foreach ($functionName in $global:cliHelperCoverageSupportFunctionNameList) {
25+
$scriptBlock = (Get-Command -Name $functionName -CommandType Function -ErrorAction Stop).ScriptBlock
26+
Set-Item -Path "function:global:$functionName" -Value $scriptBlock
27+
}
28+
29+
Remove-Module $script:moduleName -ErrorAction SilentlyContinue
30+
Import-Module $script:distModuleDir -Force
31+
}
32+
33+
Describe 'Targeted coverage for smaller CLI helper internals' {
34+
It 'CLI help helpers expose the usage text, unsupported usage errors, empty examples, and direct option accumulation' {
35+
InModuleScope $script:moduleName {
36+
Get-NovaCliHelpUsageText | Should -Be "Use 'nova --help', 'nova -h', 'nova --help <command>', 'nova -h <command>', or 'nova <command> --help'/'nova <command> -h'."
37+
Get-NovaCliExampleText -Examples @() | Should -Be ' (none)'
38+
39+
$options = @{}
40+
Add-NovaCliOptionValue -Options $options -Name 'path' -Value '/tmp/one'
41+
Add-NovaCliOptionValue -Options $options -Name 'path' -Value '/tmp/two'
42+
43+
$options.path | Should -Be @('/tmp/one', '/tmp/two')
44+
45+
$directHelpUsageError = $null
46+
try {
47+
Assert-NovaCliHelpUsageSupported -Tokens @('--help', 'build')
48+
}
49+
catch {
50+
$directHelpUsageError = $_
51+
}
52+
53+
Assert-TestStructuredCliError -ThrownError $directHelpUsageError -ExpectedError ([pscustomobject]@{
54+
Message = "Unsupported help usage. Use 'nova --help', 'nova -h', 'nova --help <command>', 'nova -h <command>', or 'nova <command> --help'/'nova <command> -h'."
55+
ErrorId = 'Nova.Validation.UnsupportedCliHelpUsage'
56+
Category = [System.Management.Automation.ErrorCategory]::InvalidArgument
57+
TargetObject = '--help build'
58+
})
59+
60+
$rootHelpUsageError = $null
61+
try {
62+
Get-NovaCliHelpRequest -Command '--help' -Arguments @('--help')
63+
}
64+
catch {
65+
$rootHelpUsageError = $_
66+
}
67+
68+
Assert-TestStructuredCliError -ThrownError $rootHelpUsageError -ExpectedError ([pscustomobject]@{
69+
Message = "Unsupported help usage. Use 'nova --help', 'nova -h', 'nova --help <command>', 'nova -h <command>', or 'nova <command> --help'/'nova <command> -h'."
70+
ErrorId = 'Nova.Validation.UnsupportedCliHelpUsage'
71+
Category = [System.Management.Automation.ErrorCategory]::InvalidArgument
72+
TargetObject = '--help --help'
73+
})
74+
75+
$subcommandHelpUsageError = $null
76+
try {
77+
Get-NovaCliHelpRequest -Command 'build' -Arguments @('--help', 'extra')
78+
}
79+
catch {
80+
$subcommandHelpUsageError = $_
81+
}
82+
83+
Assert-TestStructuredCliError -ThrownError $subcommandHelpUsageError -ExpectedError ([pscustomobject]@{
84+
Message = "Unsupported help usage. Use 'nova --help', 'nova -h', 'nova --help <command>', 'nova -h <command>', or 'nova <command> --help'/'nova <command> -h'."
85+
ErrorId = 'Nova.Validation.UnsupportedCliHelpUsage'
86+
Category = [System.Management.Automation.ErrorCategory]::InvalidArgument
87+
TargetObject = 'build --help extra'
88+
})
89+
}
90+
}
91+
92+
It 'Get-NovaCliForwardingParameterSet includes verbose and should-process options directly when requested' {
93+
InModuleScope $script:moduleName {
94+
$previousWhatIfPreference = $WhatIfPreference
95+
try {
96+
$WhatIfPreference = $true
97+
$result = Get-NovaCliForwardingParameterSet -BoundParameters @{Verbose = $true; Confirm = $false} -IncludeShouldProcess
98+
99+
$result.Verbose | Should -BeTrue
100+
$result.WhatIf | Should -BeTrue
101+
$result.Confirm | Should -BeFalse
102+
}
103+
finally {
104+
$WhatIfPreference = $previousWhatIfPreference
105+
}
106+
}
107+
}
108+
109+
It 'Get-NovaCliInstalledVersion returns the stable version when prerelease metadata is blank or missing' {
110+
InModuleScope $script:moduleName {
111+
$blankPrereleaseModule = [pscustomobject]@{
112+
Version = [version]'2.0.0'
113+
PrivateData = [pscustomobject]@{
114+
PSData = [pscustomobject]@{
115+
Prerelease = ' '
116+
}
117+
}
118+
}
119+
$missingPrereleaseModule = [pscustomobject]@{
120+
Version = [version]'2.0.1'
121+
PrivateData = [pscustomobject]@{
122+
PSData = [pscustomobject]@{}
123+
}
124+
}
125+
126+
Get-NovaCliInstalledVersion -Module $blankPrereleaseModule | Should -Be '2.0.0'
127+
Get-NovaCliInstalledVersion -Module $missingPrereleaseModule | Should -Be '2.0.1'
128+
}
129+
}
130+
}

tests/CoverageGaps.ReleaseInternals.Tests.ps1

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,28 @@ Describe 'Coverage gaps for release and git internals' {
436436
}
437437
}
438438

439+
It 'Get-NovaInstalledProjectManifestPath resolves the default project info when ProjectInfo is omitted' {
440+
InModuleScope $script:moduleName {
441+
Mock Get-NovaProjectInfo {
442+
[pscustomobject]@{ProjectName = 'AzureDevOpsAgentInstaller'; ProjectRoot = '/tmp/project'}
443+
}
444+
Mock Resolve-NovaLocalPublishPath {'/tmp/default-modules'}
445+
Mock Get-NovaPublishedLocalManifestPath {
446+
$PublishInvocation.IsLocal | Should -BeTrue
447+
$PublishInvocation.Target | Should -Be '/tmp/default-modules'
448+
$PublishInvocation.Parameters.ProjectInfo.ProjectName | Should -Be 'AzureDevOpsAgentInstaller'
449+
return '/tmp/default-modules/AzureDevOpsAgentInstaller/AzureDevOpsAgentInstaller.psd1'
450+
}
451+
452+
$result = Get-NovaInstalledProjectManifestPath
453+
454+
$result | Should -Be '/tmp/default-modules/AzureDevOpsAgentInstaller/AzureDevOpsAgentInstaller.psd1'
455+
Assert-MockCalled Get-NovaProjectInfo -Times 1
456+
Assert-MockCalled Resolve-NovaLocalPublishPath -Times 1
457+
Assert-MockCalled Get-NovaPublishedLocalManifestPath -Times 1
458+
}
459+
}
460+
439461
It 'Get-NovaResolvedPublishParameterMap copies publish parameters and lets workflow values override matching keys' {
440462
InModuleScope $script:moduleName {
441463
$publishInvocation = [pscustomobject]@{

tests/CoverageGaps.Tests.ps1

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,28 @@ Describe 'Coverage gaps for scaffold internals' {
507507
}
508508
}
509509

510+
It 'Initialize-NovaModule defaults Path to the current location when Path is omitted' {
511+
InModuleScope $script:moduleName {
512+
Mock Get-Location {[pscustomobject]@{Path = '/tmp/default-scaffold-root'}}
513+
Mock Get-NovaModuleInitializationWorkflowContext {
514+
[pscustomobject]@{
515+
Target = '/tmp/default-scaffold-root/NovaDelegation'
516+
Action = 'Create Nova module scaffold'
517+
}
518+
}
519+
Mock Invoke-NovaModuleInitializationWorkflow {}
520+
521+
Initialize-NovaModule -Confirm:$false
522+
523+
Assert-MockCalled Get-NovaModuleInitializationWorkflowContext -Times 1 -ParameterFilter {
524+
$Path -eq '/tmp/default-scaffold-root' -and -not $Example
525+
}
526+
Assert-MockCalled Invoke-NovaModuleInitializationWorkflow -Times 1 -ParameterFilter {
527+
$WorkflowContext.Target -eq '/tmp/default-scaffold-root/NovaDelegation'
528+
}
529+
}
530+
}
531+
510532
It 'Initialize-NovaModule -Example creates the packaged example scaffold without asking about Pester' {
511533
InModuleScope $script:moduleName {
512534
$answer = @{

tests/RemainingHelperCoverage.Tests.ps1

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,72 @@ Describe 'Coverage for remaining manifest, JSON, and help-locale helpers' {
207207
}
208208
}
209209

210+
It 'Get-NovaModulePsDataValue returns null for null PSData and reads object properties when present' {
211+
InModuleScope $script:moduleName {
212+
$nullPsDataModule = [pscustomobject]@{
213+
PrivateData = [pscustomobject]@{PSData = $null}
214+
}
215+
$objectPsDataModule = [pscustomobject]@{
216+
PrivateData = [pscustomobject]@{
217+
PSData = [pscustomobject]@{
218+
Prerelease = 'preview'
219+
}
220+
}
221+
}
222+
223+
Get-NovaModulePsDataValue -Name 'Prerelease' -Module $nullPsDataModule | Should -BeNullOrEmpty
224+
Get-NovaModulePsDataValue -Name 'Prerelease' -Module $objectPsDataModule | Should -Be 'preview'
225+
Get-NovaModulePsDataValue -Name 'ReleaseNotes' -Module $objectPsDataModule | Should -BeNullOrEmpty
226+
}
227+
}
228+
229+
It 'Get-NovaResolvedProjectManifestSettings returns a copy for manifest hashtables and an empty ordered table otherwise' {
230+
InModuleScope $script:moduleName {
231+
$projectDataWithManifest = @{
232+
Manifest = [ordered]@{
233+
Author = 'Nova Author'
234+
Description = 'Manifest description'
235+
}
236+
}
237+
$resolvedManifest = Get-NovaResolvedProjectManifestSettings -ProjectData $projectDataWithManifest
238+
239+
$resolvedManifest['Author'] | Should -Be 'Nova Author'
240+
$resolvedManifest['Description'] | Should -Be 'Manifest description'
241+
$resolvedManifest.GetType().Name | Should -Be 'OrderedDictionary'
242+
243+
$resolvedManifest['Author'] = 'Changed'
244+
$projectDataWithManifest.Manifest.Author | Should -Be 'Nova Author'
245+
246+
(Get-NovaResolvedProjectManifestSettings -ProjectData @{}).Count | Should -Be 0
247+
(Get-NovaResolvedProjectManifestSettings -ProjectData @{Manifest = 'invalid'}).Count | Should -Be 0
248+
}
249+
}
250+
251+
It 'Get-NovaProjectPackageOutputDirectorySettingsTable returns dictionary copies and wraps scalar paths' {
252+
InModuleScope $script:moduleName {
253+
$dictionarySettings = [ordered]@{
254+
OutputDirectory = [ordered]@{
255+
Path = 'artifacts/packages'
256+
Clean = $true
257+
}
258+
}
259+
$resolvedDictionarySettings = Get-NovaProjectPackageOutputDirectorySettingsTable -PackageSettings $dictionarySettings
260+
261+
$resolvedDictionarySettings.Path | Should -Be 'artifacts/packages'
262+
$resolvedDictionarySettings.Clean | Should -BeTrue
263+
$resolvedDictionarySettings.GetType().Name | Should -Be 'OrderedDictionary'
264+
265+
$resolvedDictionarySettings['Path'] = 'changed'
266+
$dictionarySettings.OutputDirectory.Path | Should -Be 'artifacts/packages'
267+
268+
$resolvedStringSettings = Get-NovaProjectPackageOutputDirectorySettingsTable -PackageSettings @{OutputDirectory = 'dist/packages'}
269+
$resolvedStringSettings.Path | Should -Be 'dist/packages'
270+
271+
$resolvedMissingSettings = Get-NovaProjectPackageOutputDirectorySettingsTable -PackageSettings @{}
272+
$resolvedMissingSettings.Path | Should -BeNullOrEmpty
273+
}
274+
}
275+
210276
It 'Test-ProjectSchema validates the Build schema' {
211277
InModuleScope $script:moduleName {
212278
Mock Get-ResourceFilePath {
@@ -385,11 +451,20 @@ Describe 'Coverage for remaining manifest, JSON, and help-locale helpers' {
385451
It 'Get-NovaPackageAuthorList normalizes string and enumerable author values' {
386452
InModuleScope $script:moduleName {
387453
@(Get-NovaPackageAuthorList -AuthorValue $null) | Should -Be @()
454+
@(Get-NovaPackageAuthorList -AuthorValue ' ') | Should -Be @()
388455
@(Get-NovaPackageAuthorList -AuthorValue ' Nova Author ') | Should -Be @('Nova Author')
389456
@(Get-NovaPackageAuthorList -AuthorValue @(' Author A ', 'Author B', 'Author A', ' ')) | Should -Be @('Author A', 'Author B')
390457
}
391458
}
392459

460+
It 'Get-NovaManifestValue reads dictionaries, objects, and returns null for missing object properties' {
461+
InModuleScope $script:moduleName {
462+
Get-NovaManifestValue -Manifest @{Author = 'Dictionary Author'} -Name 'Author' | Should -Be 'Dictionary Author'
463+
Get-NovaManifestValue -Manifest ([pscustomobject]@{Author = 'Object Author'}) -Name 'Author' | Should -Be 'Object Author'
464+
Get-NovaManifestValue -Manifest ([pscustomobject]@{Author = 'Object Author'}) -Name 'Tags' | Should -BeNullOrEmpty
465+
}
466+
}
467+
393468
It 'Get-NovaPackageAuthorList rejects unsupported author value types' {
394469
InModuleScope $script:moduleName {
395470
$thrown = $null
@@ -575,7 +650,7 @@ Describe 'Coverage for remaining manifest, JSON, and help-locale helpers' {
575650
Get-NovaPackageArtifactType -PackagePath '/tmp/Nova.Package.nupkg' | Should -Be 'NuGet'
576651
Get-NovaPackageArtifactType -PackagePath '/tmp/Nova.Package.zip' | Should -Be 'Zip'
577652

578-
foreach ($packagePath in @('/tmp/Nova.Package', '/tmp/Nova.Package.tar.gz')) {
653+
foreach ($packagePath in @('/tmp/package', '/tmp/Nova.Package', '/tmp/Nova.Package.tar.gz')) {
579654
$thrown = $null
580655
try {
581656
Get-NovaPackageArtifactType -PackagePath $packagePath
@@ -590,6 +665,21 @@ Describe 'Coverage for remaining manifest, JSON, and help-locale helpers' {
590665
}
591666
}
592667

668+
It 'New-NovaPackageArtifacts returns an empty list when no package metadata was requested' {
669+
InModuleScope $script:moduleName {
670+
Mock Assert-NovaPackageMetadata {}
671+
Mock Initialize-NovaPackageOutputDirectory {}
672+
Mock New-NovaPackageArtifact {}
673+
674+
$result = @(New-NovaPackageArtifacts -ProjectInfo ([pscustomobject]@{ProjectName = 'NovaModuleTools'}) -PackageMetadataList @())
675+
676+
$result | Should -Be @()
677+
Assert-MockCalled Assert-NovaPackageMetadata -Times 0
678+
Assert-MockCalled Initialize-NovaPackageOutputDirectory -Times 0
679+
Assert-MockCalled New-NovaPackageArtifact -Times 0
680+
}
681+
}
682+
593683
It 'Get-NovaPackageOutputDirectory returns rooted paths unchanged and resolves relative paths from the project root' {
594684
InModuleScope $script:moduleName {
595685
$relativeProject = [pscustomobject]@{ProjectRoot = '/tmp/project'; Package = [pscustomobject]@{OutputDirectory = [pscustomobject]@{Path = 'artifacts/packages'}}}

0 commit comments

Comments
 (0)