From 4afc53d6b83d623258d05d9fe7965638954187bb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:28:34 +0000 Subject: [PATCH 1/7] chore: prepare develop for next prerelease --- project.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.json b/project.json index e754abc..1ba070d 100644 --- a/project.json +++ b/project.json @@ -1,7 +1,7 @@ { "ProjectName": "NovaModuleTools", "Description": "NovaModuleTools is an enterprise-focused build tool for Agentic Copilot PowerShell module development, with a strong emphasis on structure, maintainability, and automated CI/CD pipelines.", - "Version": "3.3.0", + "Version": "3.3.1-preview", "Preamble": [ "Set-StrictMode -Version Latest", "$ErrorActionPreference = 'Stop'" From 9c5f31ee07f8fe91e9d50aba848f28f2c922ddfb Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Sun, 28 Jun 2026 18:07:21 +0200 Subject: [PATCH 2/7] fix: update project description for clarity and consistency --- README.md | 2 +- project.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ecfbf39..35974d7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![WorkFlow Status][WorkFlowStatus] [![Keep a Changelog][changelog-badge]][changelog] -NovaModuleTools is an enterprise-focused build tool for Agentic Copilot PowerShell module development, repository automation, and maintainable Nova workflows. +NovaModuleTools is an enterprise-focused build tool for PowerShell module development, repository automation, and maintainable Nova workflows. This README is the single developer-documentation entry point for the repository. diff --git a/project.json b/project.json index 1ba070d..44ace28 100644 --- a/project.json +++ b/project.json @@ -1,6 +1,6 @@ { "ProjectName": "NovaModuleTools", - "Description": "NovaModuleTools is an enterprise-focused build tool for Agentic Copilot PowerShell module development, with a strong emphasis on structure, maintainability, and automated CI/CD pipelines.", + "Description": "NovaModuleTools is an enterprise-focused build tool for PowerShell module development, with a strong emphasis on structure, maintainability, and build for automated CI/CD pipelines.", "Version": "3.3.1-preview", "Preamble": [ "Set-StrictMode -Version Latest", From 5a5a56a25786e0e5c24c9240ae51fab391a974a3 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Sun, 12 Jul 2026 01:10:02 +0200 Subject: [PATCH 3/7] fix: update MaximumVersion for Pester module in project.json --- project.json | 3 ++- scripts/build/ci/Install-CiPowerShellModules.ps1 | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/project.json b/project.json index 44ace28..9e9959a 100644 --- a/project.json +++ b/project.json @@ -14,7 +14,8 @@ "RequiredModules": [ { "ModuleName": "Pester", - "ModuleVersion": "5.7.1" + "ModuleVersion": "5.7.1", + "MaximumVersion": "5.10.0" }, { "ModuleName": "Microsoft.PowerShell.PlatyPS", diff --git a/scripts/build/ci/Install-CiPowerShellModules.ps1 b/scripts/build/ci/Install-CiPowerShellModules.ps1 index e3bf041..6f96c8c 100644 --- a/scripts/build/ci/Install-CiPowerShellModules.ps1 +++ b/scripts/build/ci/Install-CiPowerShellModules.ps1 @@ -16,6 +16,7 @@ function Get-CiModuleInstallOption { if ($Name -eq 'Pester') { return [pscustomobject]@{ RequiredVersion = '5.7.1' + MaximumVersion = '5.10.0' AllowPrerelease = $false } } From 97003e67c916df54e78c1d12357965f16bc4537b Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Wed, 15 Jul 2026 22:04:22 +0200 Subject: [PATCH 4/7] Bug/260 setting max pester version below 6 (#261) * fix(#260): enforce supported Pester version range and improve error handling - `Invoke-NovaTest`, `Test-NovaBuild`, and `% nova test` now only use Pester versions from `5.7.1` to `5.10.0`, failing with a clear error for unsupported `Pester 6.x`. - Updated documentation to reflect the new Pester version requirements. * fix(#260): improve Pester version handling in Nova test workflows - Reuse already loaded supported Pester 5.x version before switching - Ensure early failure with clear error when unsupported Pester 6.x is present * fix(#260): enhance Nova module validation commands and logging - Introduced new functions for command generation and execution - Improved error handling for validation command failures - Updated logging to capture command execution details * fix(#260): improve run.ps1 script content handling in tests - resolve script path and load content once for efficiency - update test assertions to use loaded script content * fix(#260): improve run.ps1 script content handling in tests - resolve script path and load content once for efficiency - update test assertions to use loaded script content * fix(#260): enhance output handling in integration tests - replace output joining with Get-NovaPublicCommandIntegrationOutputText for better formatting - normalize whitespace in output assertions for consistency * fix(#260): add tests for Get-NovaPesterVersionText and Test-NovaPesterModuleVersionSupported - implement default value handling in Get-NovaPesterVersionText - validate null candidate version in Test-NovaPesterModuleVersionSupported --- CHANGELOG.md | 5 + README.md | 7 +- RELEASE_NOTE.md | 2 + docs/NovaModuleTools/en-US/Invoke-NovaTest.md | 2 + docs/NovaModuleTools/en-US/Test-NovaBuild.md | 8 +- scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 | 55 +++- .../quality/GetNovaTestWorkflowContext.ps1 | 188 ++++++++++++- .../quality/InvokeNovaTestWorkflow.ps1 | 22 +- tests/Invoke-NovaModuleToolsCI.Tests.ps1 | 56 ++-- .../TestHelpers/PublicCommandIntegration.ps1 | 68 +++++ ...GetNovaTestWorkflowContext.TestSupport.ps1 | 58 +++- .../GetNovaTestWorkflowContext.Tests.ps1 | 256 ++++++++++++++++-- .../quality/InvokeNovaTestWorkflow.Tests.ps1 | 28 +- .../InvokeNovaTest.Integration.Tests.ps1 | 79 ++++-- .../TestNovaBuild.Integration.Tests.ps1 | 52 ++-- ...pdateNovaModuleTools.Integration.Tests.ps1 | 14 +- 16 files changed, 770 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0cb04..b542f55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- `Invoke-NovaTest`, `Test-NovaBuild`, and `% nova test` now resolve and import a supported installed `Pester` version from `5.7.1` through `5.10.0` instead of using an unsupported `Pester 6.x` installation automatically. + - Nova test workflows now fail early with a clear dependency error when only unsupported `Pester 6.x` versions are available. +- Nova test workflows now reuse an already loaded supported `Pester 5.x` version in the current PowerShell session before selecting a different installed version. + - Repository CI no longer trips the `Pester` assembly loader by importing a gallery-installed `NovaModuleTools` module, building the local module, and then switching to another supported `Pester` version in the same session. + ### Security ## [3.3.0] - 2026-06-16 diff --git a/README.md b/README.md index 35974d7..a821b94 100644 --- a/README.md +++ b/README.md @@ -298,8 +298,9 @@ Notes: - if `project.json` sets `Pester.CodeCoverage.CoveragePercentTarget`, `Invoke-NovaTest` fails when the measured coverage percentage is lower than that configured target - this repository currently enables coverage with a `99` percent target; the template and packaged example `project.json` files ship the same JaCoCo configuration shape with `Enabled=false` and a `90` percent opt-in target -- make sure `Pester 5.7.1` is available before running `Invoke-NovaTest` or `Test-NovaBuild` -- the published `NovaModuleTools` manifest also declares `Pester 5.7.1`, so installed end-user workflows can still resolve that dependency automatically +- make sure a supported `Pester` version from `5.7.1` through `5.10.0` is available before running `Invoke-NovaTest` or `Test-NovaBuild` +- Nova resolves and imports a compatible installed `Pester 5.x` version for the managed test workflow and does not run tests with `Pester 6.x` +- the published `NovaModuleTools` manifest declares `Pester` with `ModuleVersion = 5.7.1` and `MaximumVersion = 5.10.0`, so installed end-user workflows can still resolve that supported dependency range automatically ### Create a package artifact @@ -640,7 +641,7 @@ Responsibilities currently covered by the release pipeline include: - publishing to PowerShell Gallery - preparing the next prerelease version on `develop` -The workflow now uses `KeepAChangelog` for changelog release moves, creates annotated git tags named directly from the release version, and bootstraps the local PSResourceGet repository store before calling `Publish-NovaModule`. The shared CI installer also installs `Pester 5.7.1` explicitly before it installs prerelease gallery modules so test workflows do not rely on transitive manifest dependency resolution. +The workflow now uses `KeepAChangelog` for changelog release moves, creates annotated git tags named directly from the release version, and bootstraps the local PSResourceGet repository store before calling `Publish-NovaModule`. The shared CI installer also installs `Pester 5.7.1` explicitly before it installs prerelease gallery modules so test workflows stay on Nova's supported `Pester 5.x` range and do not rely on transitive manifest dependency resolution. ### Where NovaModuleTools cmdlets fit diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index 5b2aa48..676fd3f 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -14,6 +14,8 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang ### Fixed +- `Invoke-NovaTest`, `Test-NovaBuild`, and `% nova test` now stay on Nova's supported `Pester 5.x` range and stop with a clear error instead of trying to run with an installed `Pester 6.x` version. + ### Security ## [3.3.0] - 2026-06-16 diff --git a/docs/NovaModuleTools/en-US/Invoke-NovaTest.md b/docs/NovaModuleTools/en-US/Invoke-NovaTest.md index 9c496e5..1a96d2e 100644 --- a/docs/NovaModuleTools/en-US/Invoke-NovaTest.md +++ b/docs/NovaModuleTools/en-US/Invoke-NovaTest.md @@ -32,6 +32,8 @@ PS> Invoke-NovaTest [[-TagFilter] ] [[-ExcludeTagFilter] ] `Invoke-NovaTest` reads the Pester configuration from `project.json`, discovers the repository unit-test files, and runs the managed Nova test workflow without rebuilding the project first. +Nova resolves a supported installed `Pester` version from `5.7.1` through `5.10.0` for the managed test workflow and stops with a clear dependency error when only unsupported `Pester 6.x` versions are available. + The unit-test workflow writes NUnit XML to `artifacts/UnitTestResults.xml`. When `Pester.CodeCoverage.Enabled` is `true`, Nova also writes JaCoCo coverage to `artifacts/coverage.xml` and fails the run when the measured percentage is lower than `Pester.CodeCoverage.CoveragePercentTarget`. diff --git a/docs/NovaModuleTools/en-US/Test-NovaBuild.md b/docs/NovaModuleTools/en-US/Test-NovaBuild.md index 338c8ce..d1988d1 100644 --- a/docs/NovaModuleTools/en-US/Test-NovaBuild.md +++ b/docs/NovaModuleTools/en-US/Test-NovaBuild.md @@ -31,11 +31,13 @@ PS> Test-NovaBuild [[-TagFilter] ] [[-ExcludeTagFilter] ] `Test-NovaBuild` reads the Pester configuration from `project.json`, discovers the build-validation integration tests for the current project, and runs the managed Nova test workflow against the built-module validation surface. +Nova resolves a supported installed `Pester` version from `5.7.1` through `5.10.0` for the managed test workflow and stops with a clear dependency error when only unsupported `Pester 6.x` versions are available. + This build-validation flow writes NUnit XML to `artifacts/TestResults.xml`. Unlike `Invoke-NovaTest`, this command does not enforce source-coverage targets. Use `Invoke-NovaTest` for the unit-test and code-coverage workflow, and use `Test-NovaBuild` when you need build-validation integration coverage that reflects the built module path. -If the current project does not contain any `*.Integration.Tests.ps1` files, `Test-NovaBuild` stops with a Nova-native error that explains the expected naming and reminds you to use `Invoke-NovaTest` for unit tests. +If the current project does not contain any `*.Integration.Tests.ps1` files, `Test-NovaBuild` writes an actionable warning that explains the expected naming and reminds you to use `Invoke-NovaTest` for unit tests. `-OverrideWarning` lets the nested build-validation flow continue even if the `src/public` layout guard reports zero or multiple top-level functions in a public file. @@ -271,5 +273,5 @@ Returns the Pester result object from the managed build-validation run. ## RELATED LINKS -[Invoke-NovaTest](Invoke-NovaTest.md) -[Invoke-NovaBuild](Invoke-NovaBuild.md) +- [Invoke-NovaTest](./Invoke-NovaTest.md) +- [Invoke-NovaBuild](./Invoke-NovaBuild.md) diff --git a/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 b/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 index 81b38cd..66e3cc3 100644 --- a/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 +++ b/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 @@ -16,29 +16,58 @@ function Copy-NovaModuleToolsArtifactIfPresent { } } +function ConvertTo-NovaSingleQuotedPowerShellLiteral { + param( + [Parameter(Mandatory)][string]$Value + ) + + return "'$($Value.Replace("'", "''") )'" +} + +function Get-NovaModuleToolsValidationCommand { + param( + [Parameter(Mandatory)][string]$BuiltModulePath, + [Parameter(Mandatory)][string]$CommandName, + [string[]]$ExcludeTag = @() + ) + + $commandLine = $CommandName + if (@($ExcludeTag).Count -gt 0) { + $excludeTagLiteral = @($ExcludeTag | ForEach-Object {ConvertTo-NovaSingleQuotedPowerShellLiteral -Value ([string]$_)}) -join ', ' + $commandLine += " -ExcludeTagFilter @($excludeTagLiteral)" + } + + return "Import-Module $( ConvertTo-NovaSingleQuotedPowerShellLiteral -Value $BuiltModulePath ) -Force -ErrorAction Stop; $commandLine" +} + +function Invoke-NovaModuleToolsFreshValidationCommand { + param( + [Parameter(Mandatory)][string]$Command + ) + + & pwsh -NoLogo -NoProfile -Command $Command + if ($LASTEXITCODE -ne 0) { + throw "Validation command failed: $Command" + } +} + $repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..' '..' '..')).Path Set-Location $repoRoot New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null Import-Module NovaModuleTools -ErrorAction Stop -Invoke-NovaBuild - -$projectInfo = Get-NovaProjectInfo -$builtModulePath = $projectInfo.OutputModuleDir -Remove-Module $projectInfo.ProjectName -ErrorAction SilentlyContinue -Import-Module $builtModulePath -Force $projectInfo = Get-NovaProjectInfo +Invoke-NovaBuild +$builtModulePath = Join-Path $projectInfo.OutputModuleDir "$( $projectInfo.ProjectName ).psd1" $novaModuleToolsTestFailed = $false try { - if (@($ExcludeTag).Count -gt 0) { - Invoke-NovaTest -ExcludeTagFilter $ExcludeTag - Test-NovaBuild -ExcludeTagFilter $ExcludeTag - } else { - Invoke-NovaTest - Test-NovaBuild - } + $unitTestCommand = Get-NovaModuleToolsValidationCommand -BuiltModulePath $builtModulePath -CommandName 'Invoke-NovaTest' -ExcludeTag $ExcludeTag + Invoke-NovaModuleToolsFreshValidationCommand -Command $unitTestCommand + + $buildValidationCommand = Get-NovaModuleToolsValidationCommand -BuiltModulePath $builtModulePath -CommandName 'Test-NovaBuild' -ExcludeTag $ExcludeTag + Invoke-NovaModuleToolsFreshValidationCommand -Command $buildValidationCommand } catch { $novaModuleToolsTestFailed = $true Write-Warning "Nova test workflow failed: $( $_.Exception.Message )" diff --git a/src/private/quality/GetNovaTestWorkflowContext.ps1 b/src/private/quality/GetNovaTestWorkflowContext.ps1 index 5741ca2..65f1d8f 100644 --- a/src/private/quality/GetNovaTestWorkflowContext.ps1 +++ b/src/private/quality/GetNovaTestWorkflowContext.ps1 @@ -12,14 +12,192 @@ function Get-NovaTestWorkflowOperation { } function Assert-NovaPesterAvailable { + [CmdletBinding()] + param( + [AllowNull()][pscustomobject]$ProjectInfo + ) + + $moduleSpecification = Get-NovaSupportedPesterModuleSpecification -ProjectInfo $ProjectInfo + Import-NovaSupportedPesterModule -ModuleSpecification $moduleSpecification + return $moduleSpecification +} + +function Get-NovaSupportedPesterModuleSpecification { + [CmdletBinding()] + param( + [AllowNull()][pscustomobject]$ProjectInfo + ) + + $moduleRequirement = Get-NovaPesterModuleRequirement -ProjectInfo $ProjectInfo + $availableModule = @(Get-LoadedNovaPesterModule -ModuleRequirement $moduleRequirement) + if ($availableModule.Count -eq 0) { + $availableModule = @(Get-AvailableNovaPesterModule -ModuleRequirement $moduleRequirement) + } + + if ($availableModule.Count -eq 0) { + Stop-NovaOperation -Message (Get-NovaPesterDependencyMessage -ModuleRequirement $moduleRequirement) -ErrorId 'Nova.Dependency.PesterDependencyMissing' -Category ResourceUnavailable -TargetObject 'Pester' + } + + $selectedVersion = [version]$availableModule[0].Version + return [pscustomobject]@{ + Name = 'Pester' + MinimumVersion = $moduleRequirement.MinimumVersion + MaximumVersion = $moduleRequirement.MaximumVersion + SelectedVersion = $selectedVersion + FullyQualifiedName = @{ + ModuleName = 'Pester' + RequiredVersion = [string]$selectedVersion + } + } +} + +function Get-NovaSupportedPesterRange { [CmdletBinding()] param() - if (-not (Get-Module -Name Pester -ListAvailable)) { - Stop-NovaOperation -Message 'The module Pester must be installed to run Nova tests. Install Pester 5.7.1 and try again.' -ErrorId 'Nova.Dependency.PesterDependencyMissing' -Category ResourceUnavailable -TargetObject 'Pester' + return [pscustomobject]@{ + Name = 'Pester' + MinimumVersion = [version]'5.7.1' + MaximumVersion = [version]'5.10.0' } } +function Get-NovaPesterModuleRequirement { + [CmdletBinding()] + param( + [AllowNull()][pscustomobject]$ProjectInfo + ) + + $supportedRequirement = Get-NovaSupportedPesterRange + $manifestSettings = Get-NovaPesterSettingValue -InputObject $ProjectInfo -Name 'Manifest' + $requiredModules = @(Get-NovaPesterSettingValue -InputObject $manifestSettings -Name 'RequiredModules') + $pesterModule = $requiredModules | + Where-Object {(Get-NovaPesterSettingValue -InputObject $_ -Name 'ModuleName') -eq 'Pester'} | + Select-Object -First 1 + + if ($null -ne $pesterModule) { + $manifestRequirement = [pscustomobject]@{ + Name = 'Pester' + MinimumVersion = [version](Get-NovaPesterVersionText -InputObject $pesterModule -Name 'ModuleVersion' -DefaultValue '5.7.1') + MaximumVersion = [version](Get-NovaPesterVersionText -InputObject $pesterModule -Name 'MaximumVersion' -DefaultValue '5.10.0') + } + Assert-NovaPesterModuleRequirementSupported -ModuleRequirement $manifestRequirement -SupportedRequirement $supportedRequirement + } + + return $supportedRequirement +} + +function Assert-NovaPesterModuleRequirementSupported { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$ModuleRequirement, + [Parameter(Mandatory)][pscustomobject]$SupportedRequirement + ) + + if ($ModuleRequirement.MinimumVersion -eq $SupportedRequirement.MinimumVersion -and $ModuleRequirement.MaximumVersion -eq $SupportedRequirement.MaximumVersion) { + return + } + + $supportedVersionText = Get-NovaPesterVersionRangeText -ModuleRequirement $SupportedRequirement + $declaredVersionText = Get-NovaPesterVersionRangeText -ModuleRequirement $ModuleRequirement + Stop-NovaOperation -Message "Nova tests support Pester only from $supportedVersionText. project.json declares Pester from $declaredVersionText. Update project.json to match Nova's supported range and try again." -ErrorId 'Nova.Dependency.UnsupportedPesterVersionRequirement' -Category InvalidData -TargetObject 'project.json' +} + +function Get-NovaPesterVersionText { + [CmdletBinding()] + param( + [AllowNull()][object]$InputObject, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$DefaultValue + ) + + $value = [string](Get-NovaPesterSettingValue -InputObject $InputObject -Name $Name) + if ( [string]::IsNullOrWhiteSpace($value)) { + return $DefaultValue + } + + return $value +} + +function Get-NovaPesterVersionRangeText { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$ModuleRequirement + ) + + return "$( $ModuleRequirement.MinimumVersion ) through $( $ModuleRequirement.MaximumVersion )" +} + +function Get-AvailableNovaPesterModule { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$ModuleRequirement + ) + + return @( + Get-Module -Name Pester -ListAvailable | + Where-Object {Test-NovaPesterModuleVersionSupported -Version $_.Version -ModuleRequirement $ModuleRequirement} | + Sort-Object Version -Descending + ) +} + +function Get-LoadedNovaPesterModule { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$ModuleRequirement + ) + + return @( + Get-Module -Name Pester | + Where-Object {Test-NovaPesterModuleVersionSupported -Version $_.Version -ModuleRequirement $ModuleRequirement} | + Sort-Object Version -Descending + ) +} + +function Test-NovaPesterModuleVersionSupported { + [CmdletBinding()] + param( + [AllowNull()][object]$Version, + [Parameter(Mandatory)][pscustomobject]$ModuleRequirement + ) + + if ($null -eq $Version) { + return $false + } + + $resolvedVersion = [version]$Version + return $resolvedVersion -ge $ModuleRequirement.MinimumVersion -and $resolvedVersion -le $ModuleRequirement.MaximumVersion +} + +function Get-NovaPesterDependencyMessage { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$ModuleRequirement + ) + + $supportedVersionText = Get-NovaPesterVersionRangeText -ModuleRequirement $ModuleRequirement + $installedVersion = @( + Get-Module -Name Pester -ListAvailable | + Sort-Object Version -Descending | + ForEach-Object {[string]$_.Version} | + Select-Object -Unique + ) + if ($installedVersion.Count -eq 0) { + return "The module Pester must be installed to run Nova tests. Install a supported Pester version from $supportedVersionText and try again." + } + + return "Nova tests require Pester from $supportedVersionText. Installed versions: $( $installedVersion -join ', ' ). Install a supported Pester 5.x version and try again." +} + +function Import-NovaSupportedPesterModule { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$ModuleSpecification + ) + + Import-Module -FullyQualifiedName $ModuleSpecification.FullyQualifiedName -Force -ErrorAction Stop | Out-Null +} + function Get-NovaTestWorkflowContext { [CmdletBinding()] param( @@ -27,11 +205,12 @@ function Get-NovaTestWorkflowContext { [Parameter(Mandatory)][hashtable]$BoundParameters ) - Test-ProjectSchema | Out-Null - Assert-NovaPesterAvailable $projectInfo = Get-NovaProjectInfo + Test-ProjectSchema | Out-Null + $pesterModuleSpecification = Assert-NovaPesterAvailable -ProjectInfo $projectInfo $workflowProfile = Get-NovaTestWorkflowProfile -TestOption $TestOption $pesterConfig = New-PesterConfiguration -Hashtable $projectInfo.Pester + Add-Member -InputObject $pesterConfig -MemberType NoteProperty -Name PesterModuleSpecification -Value $pesterModuleSpecification -Force $coverageConfiguration = Get-NovaPesterCoverageConfigurationState -ProjectInfo $projectInfo -CoverageEnabled:$workflowProfile.CoverageEnabled $pesterConfig.CodeCoverage.Enabled = $coverageConfiguration.Enabled $pesterConfig.CodeCoverage.Path = $coverageConfiguration.Path @@ -62,6 +241,7 @@ function Get-NovaTestWorkflowContext { CommandName = $workflowProfile.CommandName OverrideWarningRequested = $BoundParameters.ContainsKey('OverrideWarning') -and [bool]$BoundParameters.OverrideWarning ProjectInfo = $projectInfo + PesterModuleSpecification = $pesterModuleSpecification PesterSettings = Get-NovaTestWorkflowPesterConfiguration -ProjectPesterSettings $projectInfo.Pester -CoverageEnabled:$workflowProfile.CoverageEnabled PesterConfig = $pesterConfig TestResultPath = $testResultPath diff --git a/src/private/quality/InvokeNovaTestWorkflow.ps1 b/src/private/quality/InvokeNovaTestWorkflow.ps1 index 95be1bb..dd171fb 100644 --- a/src/private/quality/InvokeNovaTestWorkflow.ps1 +++ b/src/private/quality/InvokeNovaTestWorkflow.ps1 @@ -153,7 +153,8 @@ function Invoke-NovaPesterWithSuppressedProgress { $heartbeatMilliseconds = 2000 } - $execution = Get-NovaPesterExecution -Configuration $Configuration + $moduleSpecification = Get-NovaPropertyValue -InputObject $Configuration -Name 'PesterModuleSpecification' + $execution = Get-NovaPesterExecution -Configuration $Configuration -ModuleSpecification $moduleSpecification try { Write-NovaTestWorkflowPesterProgress -Execution $execution -ProgressContext $ProgressContext while (-not (Wait-NovaPesterExecution -Execution $execution -TimeoutMilliseconds $HeartbeatMilliseconds)) { @@ -323,13 +324,19 @@ function Write-NovaPesterHostInformationMessage { function Get-NovaPesterExecution { [CmdletBinding()] param( - [Parameter(Mandatory)][object]$Configuration + [Parameter(Mandatory)][object]$Configuration, + [AllowNull()][object]$ModuleSpecification ) $powershell = [powershell]::Create() $command = @' -param($Configuration) -Import-Module Pester -ErrorAction Stop +param($Configuration, $ModuleSpecification) +if ($null -ne $ModuleSpecification) { + Import-Module -FullyQualifiedName $ModuleSpecification -Force -ErrorAction Stop +} +else { + Import-Module Pester -ErrorAction Stop +} $previousProgressPreference = $global:ProgressPreference $global:ProgressPreference = 'SilentlyContinue' try { @@ -338,7 +345,12 @@ try { $global:ProgressPreference = $previousProgressPreference } '@ - $null = $powershell.AddScript($command).AddArgument($Configuration) + $moduleImportSpecification = $null + if ($null -ne $ModuleSpecification) { + $moduleImportSpecification = Get-NovaPropertyValue -InputObject $ModuleSpecification -Name 'FullyQualifiedName' + } + + $null = $powershell.AddScript($command).AddArgument($Configuration).AddArgument($moduleImportSpecification) return [pscustomobject]@{ PowerShell = $powershell diff --git a/tests/Invoke-NovaModuleToolsCI.Tests.ps1 b/tests/Invoke-NovaModuleToolsCI.Tests.ps1 index 848f5fb..a2b4c4a 100644 --- a/tests/Invoke-NovaModuleToolsCI.Tests.ps1 +++ b/tests/Invoke-NovaModuleToolsCI.Tests.ps1 @@ -37,7 +37,7 @@ Describe 'Invoke-NovaModuleToolsCI' { $projectRoot = Join-Path $TestDrive 'project' $outputDirectory = Join-Path $TestDrive 'artifacts-out' $callLogPath = Join-Path $TestDrive 'call-log.txt' - $excludeTagLogPath = Join-Path $TestDrive 'exclude-tags.txt' + $commandLogPath = Join-Path $TestDrive 'commands.txt' $unitResultPath = Join-Path $projectRoot 'artifacts/UnitTestResults.xml' $integrationResultPath = Join-Path $projectRoot 'artifacts/TestResults.xml' @@ -64,24 +64,24 @@ function Get-NovaProjectInfo { } } -function Remove-Module { +function pwsh { [CmdletBinding()] - param([string]`$Name) -} - -function Invoke-NovaTest { - [CmdletBinding()] - param([string[]]`$ExcludeTagFilter) + param( + [switch]`$NoLogo, + [switch]`$NoProfile, + [string]`$Command + ) - Add-Content -LiteralPath '$callLogPath' -Value 'Invoke-NovaTest' - Set-Content -LiteralPath '$excludeTagLogPath' -Value (`$ExcludeTagFilter -join ',') -Encoding utf8 -} + Add-Content -LiteralPath '$commandLogPath' -Value `$Command + if (`$Command -match 'Invoke-NovaTest') { + Add-Content -LiteralPath '$callLogPath' -Value 'Invoke-NovaTest' + } -function Test-NovaBuild { - [CmdletBinding()] - param([string[]]`$ExcludeTagFilter) + if (`$Command -match 'Test-NovaBuild') { + Add-Content -LiteralPath '$callLogPath' -Value 'Test-NovaBuild' + } - Add-Content -LiteralPath '$callLogPath' -Value 'Test-NovaBuild' + `$global:LASTEXITCODE = 0 } & '$script:novaModuleToolsCiScriptPath' -OutputDirectory '$outputDirectory' -ExcludeTag 'slow','integration' @@ -90,7 +90,10 @@ function Test-NovaBuild { $result.ExitCode | Should -Be 0 -Because ($result.Output -join [Environment]::NewLine) (Get-Content -LiteralPath $callLogPath) | Should -Be @('Invoke-NovaTest', 'Test-NovaBuild') - (Get-Content -LiteralPath $excludeTagLogPath -Raw).Trim() | Should -Be 'slow,integration' + $commands = Get-Content -LiteralPath $commandLogPath + $commands | Should -HaveCount 2 + $commands[0] | Should -Match "Import-Module '.*/dist/NovaModuleTools/NovaModuleTools\.psd1' -Force -ErrorAction Stop; Invoke-NovaTest -ExcludeTagFilter @\('slow', 'integration'\)" + $commands[1] | Should -Match "Import-Module '.*/dist/NovaModuleTools/NovaModuleTools\.psd1' -Force -ErrorAction Stop; Test-NovaBuild -ExcludeTagFilter @\('slow', 'integration'\)" (Get-Content -LiteralPath (Join-Path $outputDirectory 'novamoduletools-unit-nunit.xml') -Raw).Trim() | Should -Be '' (Get-Content -LiteralPath (Join-Path $outputDirectory 'novamoduletools-integration-nunit.xml') -Raw).Trim() | Should -Be '' } @@ -122,16 +125,21 @@ function Get-NovaProjectInfo { } } -function Remove-Module { +function pwsh { [CmdletBinding()] - param([string]`$Name) -} + param( + [switch]`$NoLogo, + [switch]`$NoProfile, + [string]`$Command + ) -function Invoke-NovaTest { - throw 'boom' -} + if (`$Command -match 'Invoke-NovaTest') { + `$global:LASTEXITCODE = 1 + return + } -function Test-NovaBuild {} + `$global:LASTEXITCODE = 0 +} & '$script:novaModuleToolsCiScriptPath' -OutputDirectory '$outputDirectory' "@ @@ -139,7 +147,7 @@ function Test-NovaBuild {} $outputText = $result.Output -join [Environment]::NewLine $result.ExitCode | Should -Be 1 - $outputText | Should -Match 'Nova test workflow failed: boom' + $outputText | Should -Match 'Nova test workflow failed: Validation command failed: .*Invoke-NovaTest' (Get-Content -LiteralPath (Join-Path $outputDirectory 'novamoduletools-unit-nunit.xml') -Raw).Trim() | Should -Be '' Test-Path -LiteralPath (Join-Path $outputDirectory 'novamoduletools-integration-nunit.xml') | Should -BeFalse } diff --git a/tests/TestHelpers/PublicCommandIntegration.ps1 b/tests/TestHelpers/PublicCommandIntegration.ps1 index e1587ec..f9b56b1 100644 --- a/tests/TestHelpers/PublicCommandIntegration.ps1 +++ b/tests/TestHelpers/PublicCommandIntegration.ps1 @@ -46,3 +46,71 @@ function Invoke-NovaPublicCommandIntegrationInProjectRoot { return Invoke-NovaPublicCommandIntegrationInLocation -Path $ProjectRoot -ScriptBlock $ScriptBlock } + +function Get-NovaPublicCommandIntegrationOutputText { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object[]]$Output, + [switch]$NormalizeWhitespace + ) + + $text = @($Output) -join [Environment]::NewLine + $text = [regex]::Replace($text, '\x1B\[[0-?]*[ -/]*[@-~]', '') + if ($NormalizeWhitespace) { + $text = [regex]::Replace($text, '\s+', ' ') + } + + return $text.Trim() +} + +function Invoke-NovaPublicCommandIntegrationInIsolatedSession { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ProjectRoot, + [string]$Path = $ProjectRoot, + [Parameter(Mandatory)][scriptblock]$ScriptBlock + ) + + $runnerPath = Join-Path $TestDrive 'Run-NovaPublicCommandIntegration.ps1' + $moduleManifestPath = Join-Path $ProjectRoot 'dist/NovaModuleTools/NovaModuleTools.psd1' + $escapedManifestPath = $moduleManifestPath.Replace("'", "''") + $escapedLocationPath = $Path.Replace("'", "''") + $runnerContent = @" +function New-NovaPublicCommandIntegrationPesterModule { + param( + [Parameter(Mandatory)][string]`$BasePath, + [Parameter(Mandatory)][string]`$Version + ) + + `$moduleRoot = Join-Path `$BasePath "Pester/`$Version" + `$null = New-Item -ItemType Directory -Path `$moduleRoot -Force + Set-Content -LiteralPath (Join-Path `$moduleRoot 'Pester.psm1') -Value '' -Encoding utf8 + `$manifestContent = @( + '@{' + " RootModule = 'Pester.psm1'" + " ModuleVersion = '`$Version'" + " GUID = '`$([guid]::NewGuid().Guid)'" + ' FunctionsToExport = @()' + ' CmdletsToExport = @()' + ' VariablesToExport = @()' + ' AliasesToExport = @()' + '}' + ) -join [Environment]::NewLine + Set-Content -LiteralPath (Join-Path `$moduleRoot 'Pester.psd1') -Value `$manifestContent -Encoding utf8 +} + +Import-Module '$escapedManifestPath' -Force -ErrorAction Stop +Set-Location -LiteralPath '$escapedLocationPath' +& { +$($ScriptBlock.ToString() ) +} +"@ + Set-Content -LiteralPath $runnerPath -Value $runnerContent -Encoding utf8 + + $output = & pwsh -NoLogo -NoProfile -File $runnerPath 2>&1 + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = @($output) + } +} + diff --git a/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 b/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 index 3760e10..b2ac60b 100644 --- a/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 +++ b/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 @@ -1,7 +1,20 @@ -function Test-ProjectSchema {param($Name) } -function Stop-NovaOperation {param($Message, $ErrorId, $Category, $TargetObject) throw $Message} -function Get-NovaProjectInfo {} -function New-PesterConfiguration {param($Hashtable)} +function Test-ProjectSchema { + param($Name) +} + +function Stop-NovaOperation { + param($Message, $ErrorId, $Category, $TargetObject) + + throw $Message +} + +function Get-NovaProjectInfo { +} + +function New-PesterConfiguration { + param($Hashtable) +} + function Get-NovaPesterRunPath { param($ProjectInfo, $IncludePattern, $ExcludePattern) @@ -32,12 +45,20 @@ function Initialize-NovaPesterExecutionConfiguration { ExecutionOption = $ExecutionOption } } -function Get-NovaShouldProcessForwardingParameter {param([switch]$WhatIfEnabled) return @{}} -function Write-NovaPesterTestResultArtifact {} -function Write-NovaPesterTestResultReport {} +function Get-NovaShouldProcessForwardingParameter { + param([switch]$WhatIfEnabled) -$script:getPesterConfig = { - [pscustomobject]@{ + return @{} +} + +function Write-NovaPesterTestResultArtifact { +} + +function Write-NovaPesterTestResultReport { +} + +function New-TestPesterConfig { + return [pscustomobject]@{ Run = [pscustomobject]@{Path = $null; PassThru = $false; Exit = $false; Throw = $false} Filter = [pscustomobject]@{Tag = @(); ExcludeTag = @()} Output = [pscustomobject]@{Verbosity = 'Detailed'; RenderMode = 'Auto'} @@ -46,14 +67,29 @@ $script:getPesterConfig = { } } -$script:getProjectInfo = { +function New-TestProjectInfo { param( [Parameter(Mandatory)][object]$PesterSettings, + [AllowNull()][object[]]$ManifestRequiredModules = $null, [string]$ProjectRoot = (Join-Path $TestDrive 'nova-project') ) - [pscustomobject]@{ + $requiredModules = $ManifestRequiredModules + if ($null -eq $requiredModules) { + $requiredModules = @( + [ordered]@{ + ModuleName = 'Pester' + ModuleVersion = '5.7.1' + MaximumVersion = '5.10.0' + } + ) + } + + return [pscustomobject]@{ Pester = $PesterSettings + Manifest = [ordered]@{ + RequiredModules = @($requiredModules) + } BuildRecursiveFolders = $true TestsDir = (Join-Path $ProjectRoot 'tests') ProjectRoot = $ProjectRoot diff --git a/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 b/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 index 4b1998d..61ba5a4 100644 --- a/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 +++ b/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 @@ -10,15 +10,29 @@ Describe 'Get-NovaTestWorkflowContext' { $script:lastRunPathRequest = $null $script:lastResultPathRequest = $null $script:lastExecutionConfigurationRequest = $null + $script:lastImportModuleRequest = $null Mock Test-ProjectSchema {} - Mock Get-Module {[pscustomobject]@{Name = 'Pester'}} -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} + Mock Get-Module { + @() + } -ParameterFilter {$Name -eq 'Pester' -and -not $ListAvailable} + Mock Get-Module { + @( + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.10.0'} + ) + } -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} + Mock Import-Module { + $script:lastImportModuleRequest = [pscustomobject]@{ + FullyQualifiedName = $PSBoundParameters.FullyQualifiedName + Force = $PSBoundParameters.Force + } + } Mock Get-Command {[pscustomobject]@{ScriptBlock = {}}} -ParameterFilter {$CommandType -eq 'Function'} } It 'configures unit-test execution with coverage enabled and integration tests excluded' { - $pesterConfig = & $script:getPesterConfig - $projectInfo = & $script:getProjectInfo -PesterSettings ([ordered]@{ + $pesterConfig = New-TestPesterConfig + $projectInfo = New-TestProjectInfo -PesterSettings ([ordered]@{ CodeCoverage = [ordered]@{ Enabled = $true CoveragePercentTarget = 99 @@ -33,16 +47,66 @@ Describe 'Get-NovaTestWorkflowContext' { $result.BuildRequested | Should -BeFalse $result.CommandName | Should -Be 'Invoke-NovaTest' $result.PesterConfig.CodeCoverage.CoveragePercentTarget | Should -Be 99 + $result.PesterConfig.PesterModuleSpecification.SelectedVersion | Should -Be ([version]'5.10.0') + $result.PesterModuleSpecification.SelectedVersion | Should -Be ([version]'5.10.0') $result.PesterSettings.CodeCoverage.Enabled | Should -BeTrue $script:lastRunPathRequest.IncludePattern | Should -Be '*.Tests.ps1' $script:lastRunPathRequest.ExcludePattern | Should -Be @('*.Integration.Tests.ps1') $script:lastResultPathRequest.FileName | Should -Be 'UnitTestResults.xml' + $result.PesterModuleSpecification.FullyQualifiedName.ModuleName | Should -Be 'Pester' + $result.PesterModuleSpecification.FullyQualifiedName.RequiredVersion | Should -Be '5.10.0' $result.Operation | Should -Be 'Run unit tests and write test results' } + It 'selects the highest supported Pester 5.x version when Pester 6 is also installed' { + $pesterConfig = New-TestPesterConfig + $projectInfo = New-TestProjectInfo -PesterSettings ([ordered]@{}) + + Mock Get-NovaProjectInfo {$projectInfo} + Mock New-PesterConfiguration {$pesterConfig} + Mock Get-Module { + @( + [pscustomobject]@{Name = 'Pester'; Version = [version]'6.0.0'} + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.10.0'} + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.7.1'} + ) + } -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} + + $result = Get-NovaTestWorkflowContext -TestOption @{TestMode = 'Unit'} -BoundParameters @{} + + $result.PesterModuleSpecification.SelectedVersion | Should -Be ([version]'5.10.0') + $result.PesterConfig.PesterModuleSpecification.SelectedVersion | Should -Be ([version]'5.10.0') + $result.PesterModuleSpecification.FullyQualifiedName.RequiredVersion | Should -Be '5.10.0' + } + + It 'reuses the already loaded supported Pester version instead of switching versions mid-session' { + $pesterConfig = New-TestPesterConfig + $projectInfo = New-TestProjectInfo -PesterSettings ([ordered]@{}) + + Mock Get-NovaProjectInfo {$projectInfo} + Mock New-PesterConfiguration {$pesterConfig} + Mock Get-Module { + @( + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.7.1'} + ) + } -ParameterFilter {$Name -eq 'Pester' -and -not $ListAvailable} + Mock Get-Module { + @( + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.8.0'} + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.7.1'} + ) + } -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} + + $result = Get-NovaTestWorkflowContext -TestOption @{TestMode = 'Unit'} -BoundParameters @{} + + $result.PesterModuleSpecification.SelectedVersion | Should -Be ([version]'5.7.1') + $result.PesterModuleSpecification.FullyQualifiedName.RequiredVersion | Should -Be '5.7.1' + Assert-MockCalled Import-Module -Times 1 -ParameterFilter {$FullyQualifiedName.RequiredVersion -eq '5.7.1' -and $Force} + } + It 'configures build-validation execution with coverage disabled and integration-only test discovery' { - $pesterConfig = & $script:getPesterConfig - $projectInfo = & $script:getProjectInfo -PesterSettings ([ordered]@{ + $pesterConfig = New-TestPesterConfig + $projectInfo = New-TestProjectInfo -PesterSettings ([ordered]@{ CodeCoverage = [ordered]@{ Enabled = $true CoveragePercentTarget = 99 @@ -67,8 +131,8 @@ Describe 'Get-NovaTestWorkflowContext' { } It 'returns a skip state with actionable guidance when build-validation tests are missing' { - $pesterConfig = & $script:getPesterConfig - $projectInfo = & $script:getProjectInfo -PesterSettings ([ordered]@{}) + $pesterConfig = New-TestPesterConfig + $projectInfo = New-TestProjectInfo -PesterSettings ([ordered]@{}) Mock Get-NovaProjectInfo {$projectInfo} Mock New-PesterConfiguration {$pesterConfig} @@ -96,8 +160,8 @@ Describe 'Get-NovaTestWorkflowContext' { Set-Content -LiteralPath $filePath -Value '# test' } - $pesterConfig = & $script:getPesterConfig - $projectInfo = & $script:getProjectInfo -ProjectRoot $projectRoot -PesterSettings ([ordered]@{ + $pesterConfig = New-TestPesterConfig + $projectInfo = New-TestProjectInfo -ProjectRoot $projectRoot -PesterSettings ([ordered]@{ CodeCoverage = [ordered]@{ Enabled = $true Path = @( @@ -123,8 +187,8 @@ Describe 'Get-NovaTestWorkflowContext' { } It 'forwards the guarded Pester configuration override to the execution configuration initializer' { - $pesterConfig = & $script:getPesterConfig - $projectInfo = & $script:getProjectInfo -PesterSettings ([ordered]@{}) + $pesterConfig = New-TestPesterConfig + $projectInfo = New-TestProjectInfo -PesterSettings ([ordered]@{}) $containerOverride = [pscustomobject]@{ Type = 'File' Item = (Join-Path $projectInfo.ProjectRoot 'tests/Example.Tests.ps1') @@ -212,14 +276,172 @@ Describe 'Get-NovaDiscoveredTestPathState' { } Describe 'Assert-NovaPesterAvailable' { + BeforeEach { + $script:lastImportModuleRequest = $null + Mock Get-Module { + @() + } -ParameterFilter {$Name -eq 'Pester' -and -not $ListAvailable} + Mock Import-Module { + $script:lastImportModuleRequest = [pscustomobject]@{ + FullyQualifiedName = $PSBoundParameters.FullyQualifiedName + Force = $PSBoundParameters.Force + } + } + } + It 'stops with Nova.Dependency.PesterDependencyMissing when Pester is missing' { Mock Get-Module {@()} -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} - {Assert-NovaPesterAvailable} | Should -Throw + {Assert-NovaPesterAvailable -ProjectInfo (New-TestProjectInfo -PesterSettings ([ordered]@{}))} | Should -Throw + } + + It 'stops when only unsupported Pester 6.x versions are installed' { + Mock Get-Module { + @( + [pscustomobject]@{Name = 'Pester'; Version = [version]'6.0.0'} + ) + } -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} + + $thrown = $null + try { + Assert-NovaPesterAvailable -ProjectInfo (New-TestProjectInfo -PesterSettings ([ordered]@{})) + } catch { + $thrown = $_ + } + + $thrown | Should -Not -BeNullOrEmpty + $thrown | Should -Match '5.7.1 through 5.10.0' + $thrown | Should -Match '6.0.0' + } + + It 'returns the selected supported Pester specification when Pester is available' { + Mock Get-Module { + @( + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.10.0'} + ) + } -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} + + $result = Assert-NovaPesterAvailable -ProjectInfo (New-TestProjectInfo -PesterSettings ([ordered]@{})) + + $result.SelectedVersion | Should -Be ([version]'5.10.0') + $result.FullyQualifiedName.RequiredVersion | Should -Be '5.10.0' + } +} + +Describe 'Get-NovaSupportedPesterModuleSpecification' { + It 'reuses the loaded supported Pester version before considering higher installed versions' { + $projectInfo = New-TestProjectInfo -PesterSettings ([ordered]@{}) + Mock Get-Module { + @( + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.7.1'} + ) + } -ParameterFilter {$Name -eq 'Pester' -and -not $ListAvailable} + Mock Get-Module { + @( + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.10.0'} + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.7.1'} + ) + } -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} + + $result = Get-NovaSupportedPesterModuleSpecification -ProjectInfo $projectInfo + + $result.SelectedVersion | Should -Be ([version]'5.7.1') + } + + It 'selects the highest installed version inside Nova''s supported Pester range' { + $projectInfo = New-TestProjectInfo -PesterSettings ([ordered]@{}) + Mock Get-Module {@()} -ParameterFilter {$Name -eq 'Pester' -and -not $ListAvailable} + Mock Get-Module { + @( + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.10.1'} + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.8.0'} + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.10.0'} + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.7.1'} + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.7.0'} + ) + } -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} + + $result = Get-NovaSupportedPesterModuleSpecification -ProjectInfo $projectInfo + + $result.MinimumVersion | Should -Be ([version]'5.7.1') + $result.MaximumVersion | Should -Be ([version]'5.10.0') + $result.SelectedVersion | Should -Be ([version]'5.10.0') + } + + It 'falls back to the default supported Pester range when the manifest does not declare Pester' { + $projectInfo = [pscustomobject]@{ + Pester = [ordered]@{} + Manifest = [ordered]@{RequiredModules = @()} + } + Mock Get-Module {@()} -ParameterFilter {$Name -eq 'Pester' -and -not $ListAvailable} + Mock Get-Module { + @( + [pscustomobject]@{Name = 'Pester'; Version = [version]'5.7.1'} + ) + } -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} + + $result = Get-NovaSupportedPesterModuleSpecification -ProjectInfo $projectInfo + + $result.MinimumVersion | Should -Be ([version]'5.7.1') + $result.MaximumVersion | Should -Be ([version]'5.10.0') + $result.SelectedVersion | Should -Be ([version]'5.7.1') + } + + It 'stops when project.json declares a wider Pester range than Nova supports' { + $projectInfo = New-TestProjectInfo -PesterSettings ([ordered]@{}) -ManifestRequiredModules @( + [ordered]@{ + ModuleName = 'Pester' + ModuleVersion = '5.7.1' + MaximumVersion = '6.0.0' + } + ) + + $thrown = $null + try { + $null = Get-NovaSupportedPesterModuleSpecification -ProjectInfo $projectInfo + } catch { + $thrown = $_ + } + + $thrown | Should -Not -BeNullOrEmpty + $thrown | Should -Match '5.7.1 through 5.10.0' + $thrown | Should -Match 'project.json declares Pester from 5.7.1 through 6.0.0' + } +} + +Describe 'Get-NovaPesterVersionText' { + It 'returns the default value when the requested setting is empty' { + $moduleInfo = [pscustomobject]@{ + ModuleVersion = '' + } + + $result = Get-NovaPesterVersionText -InputObject $moduleInfo -Name 'ModuleVersion' -DefaultValue '5.7.1' + + $result | Should -Be '5.7.1' } +} + +Describe 'Test-NovaPesterModuleVersionSupported' { + It 'accepts only versions inside Nova''s supported Pester range' { + $moduleRequirement = Get-NovaPesterModuleRequirement -ProjectInfo (New-TestProjectInfo -PesterSettings ([ordered]@{})) + $cases = @( + [pscustomobject]@{Version = '5.7.0'; Expected = $false} + [pscustomobject]@{Version = '5.7.1'; Expected = $true} + [pscustomobject]@{Version = '5.10.0'; Expected = $true} + [pscustomobject]@{Version = '5.10.1'; Expected = $false} + ) + + foreach ($case in $cases) { + $actual = Test-NovaPesterModuleVersionSupported -Version ([version]$case.Version) -ModuleRequirement $moduleRequirement + $actual | Should -Be $case.Expected -Because $case.Version + } + } + + It 'returns false when the candidate version is null' { + $moduleRequirement = Get-NovaPesterModuleRequirement -ProjectInfo (New-TestProjectInfo -PesterSettings ([ordered]@{})) + + $result = Test-NovaPesterModuleVersionSupported -Version $null -ModuleRequirement $moduleRequirement - It 'returns silently when Pester is available' { - Mock Get-Module {@([pscustomobject]@{Name = 'Pester'})} -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} - {Assert-NovaPesterAvailable} | Should -Not -Throw + $result | Should -BeFalse } } @@ -304,7 +526,7 @@ Describe 'Get-NovaDisabledPesterCoverageConfiguration' { Describe 'Get-NovaPesterCoverageConfigurationState' { It 'returns disabled coverage settings when coverage is disabled for the workflow' { - $projectInfo = & $script:getProjectInfo -PesterSettings ([ordered]@{ + $projectInfo = New-TestProjectInfo -PesterSettings ([ordered]@{ CodeCoverage = [ordered]@{ Enabled = $true CoveragePercentTarget = 99 @@ -324,7 +546,7 @@ Describe 'Get-NovaPesterCoverageConfigurationState' { $filePath = Join-Path $projectRoot 'src/public/GetAlpha.ps1' New-Item -ItemType Directory -Path (Split-Path -Parent $filePath) -Force | Out-Null Set-Content -LiteralPath $filePath -Value '# test' - $projectInfo = & $script:getProjectInfo -ProjectRoot $projectRoot -PesterSettings ([ordered]@{ + $projectInfo = New-TestProjectInfo -ProjectRoot $projectRoot -PesterSettings ([ordered]@{ CodeCoverage = [ordered]@{ Enabled = $true CoveragePercentTarget = 99 diff --git a/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 b/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 index 8e625f4..cda0718 100644 --- a/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 +++ b/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 @@ -354,7 +354,16 @@ Describe 'Invoke-NovaPesterWithSuppressedProgress' { $script:outputCallCount += 1 } - $result = Invoke-NovaPesterWithSuppressedProgress -Configuration ([pscustomobject]@{}) -ProgressContext ([pscustomobject]@{ + $configuration = [pscustomobject]@{ + PesterModuleSpecification = [pscustomobject]@{ + FullyQualifiedName = @{ + ModuleName = 'Pester' + RequiredVersion = '5.10.0' + } + } + } + + $result = Invoke-NovaPesterWithSuppressedProgress -Configuration $configuration -ProgressContext ([pscustomobject]@{ Activity = 'Running Nova test workflow' StartPercentComplete = 70 EndPercentComplete = 94 @@ -362,7 +371,10 @@ Describe 'Invoke-NovaPesterWithSuppressedProgress' { }) $result.Result | Should -Be 'Passed' - Should -Invoke Get-NovaPesterExecution -Times 1 + Should -Invoke Get-NovaPesterExecution -Times 1 -ParameterFilter { + $ModuleSpecification.FullyQualifiedName.ModuleName -eq 'Pester' -and + $ModuleSpecification.FullyQualifiedName.RequiredVersion -eq '5.10.0' + } Should -Invoke Wait-NovaPesterExecution -Times 3 Should -Invoke Write-NovaPesterExecutionOutput -Times 3 Should -Invoke Receive-NovaPesterExecutionResult -Times 1 @@ -565,7 +577,12 @@ Describe 'Get-NovaPesterExecution' { It 'returns an execution object with the expected initial properties' { $execution = $null try { - $execution = Get-NovaPesterExecution -Configuration ([pscustomobject]@{}) + $execution = Get-NovaPesterExecution -Configuration ([pscustomobject]@{}) -ModuleSpecification ([pscustomobject]@{ + FullyQualifiedName = @{ + ModuleName = 'Pester' + RequiredVersion = '5.10.0' + } + }) $execution.PowerShell | Should -Not -BeNullOrEmpty $execution.AsyncResult | Should -Not -BeNullOrEmpty $execution.CompletedTestCount | Should -Be 0 @@ -573,6 +590,11 @@ Describe 'Get-NovaPesterExecution' { $execution.TotalTestCount | Should -BeNullOrEmpty $execution.LastProgressStatus | Should -BeNullOrEmpty $execution.LastProgressPercentComplete | Should -BeNullOrEmpty + + $commands = $execution.PowerShell.Commands.Commands + $commands.Count | Should -Be 1 + $commands[0].Parameters[1].Value.ModuleName | Should -Be 'Pester' + $commands[0].Parameters[1].Value.RequiredVersion | Should -Be '5.10.0' } finally { if ($null -ne $execution -and $null -ne $execution.PowerShell) { $execution.PowerShell.Dispose() diff --git a/tests/public/InvokeNovaTest.Integration.Tests.ps1 b/tests/public/InvokeNovaTest.Integration.Tests.ps1 index f260407..07899bc 100644 --- a/tests/public/InvokeNovaTest.Integration.Tests.ps1 +++ b/tests/public/InvokeNovaTest.Integration.Tests.ps1 @@ -6,48 +6,75 @@ BeforeAll { Describe 'Invoke-NovaTest integration' { It 'supports WhatIf from the built module' { - { - Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock { + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -ScriptBlock { + Invoke-NovaTest -WhatIf + } + + $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) + } + + It 'fails early when the isolated session cannot resolve a supported Pester 5.x module' { + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -ScriptBlock { + $temporaryModulePath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().Guid) + $originalModulePath = $env:PSModulePath + try { + New-NovaPublicCommandIntegrationPesterModule -BasePath $temporaryModulePath -Version '6.0.0' | Out-Null + $env:PSModulePath = $temporaryModulePath Invoke-NovaTest -WhatIf + } finally { + $env:PSModulePath = $originalModulePath + Remove-Item -LiteralPath $temporaryModulePath -Recurse -Force -ErrorAction SilentlyContinue } - } | Should -Not -Throw + } + + $outputText = Get-NovaPublicCommandIntegrationOutputText -Output $result.Output -NormalizeWhitespace + $result.ExitCode | Should -Not -Be 0 + $outputText | Should -Match 'Pester' + $outputText | Should -Match 'Import-Module' + $outputText | Should -Match 'was not loaded because no valid module file was found|5\.7\.1 through 5\.10\.0' } It 'supports a guarded Run.Container override from the built module' { - { - Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock { - $container = New-PesterContainer -Path 'tests/public/InvokeNovaTest.Tests.ps1' -Data @{ Name = 'runtime-value' } - Invoke-NovaTest -WhatIf -PesterConfigurationOverride @{ - Run = @{ - Container = @($container) - } + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -ScriptBlock { + $container = New-PesterContainer -Path 'tests/public/InvokeNovaTest.Tests.ps1' -Data @{Name = 'runtime-value'} + Invoke-NovaTest -WhatIf -PesterConfigurationOverride @{ + Run = @{ + Container = @($container) } } - } | Should -Not -Throw + } + + $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) } It 'rejects non-file Run.Container overrides from the built module' { - { - Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock { - $container = New-PesterContainer -ScriptBlock {} - Invoke-NovaTest -WhatIf -PesterConfigurationOverride @{ - Run = @{ - Container = @($container) - } + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -ScriptBlock { + $container = [pscustomobject]@{ + Type = 'ScriptBlock' + Item = $null + Data = @{} + } + Invoke-NovaTest -WhatIf -PesterConfigurationOverride @{ + Run = @{ + Container = @($container) } } - } | Should -Throw '*ScriptBlock and other container types are not supported*' + } + + $result.ExitCode | Should -Not -Be 0 + (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output -NormalizeWhitespace) | Should -Match 'ScriptBlock and other container types are not supported' } It 'rejects unsupported override shapes from the built module' { - { - Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock { - Invoke-NovaTest -WhatIf -PesterConfigurationOverride @{ - Run = @{ - Path = @('tests/public/InvokeNovaTest.Tests.ps1') - } + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -ScriptBlock { + Invoke-NovaTest -WhatIf -PesterConfigurationOverride @{ + Run = @{ + Path = @('tests/public/InvokeNovaTest.Tests.ps1') } } - } | Should -Throw '*Unsupported override path: Run.Path*' + } + + $result.ExitCode | Should -Not -Be 0 + (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output -NormalizeWhitespace) | Should -Match 'Unsupported override path: Run.Path' } } diff --git a/tests/public/TestNovaBuild.Integration.Tests.ps1 b/tests/public/TestNovaBuild.Integration.Tests.ps1 index 6926298..5fcbc38 100644 --- a/tests/public/TestNovaBuild.Integration.Tests.ps1 +++ b/tests/public/TestNovaBuild.Integration.Tests.ps1 @@ -6,11 +6,32 @@ BeforeAll { Describe 'Test-NovaBuild integration' { It 'supports WhatIf from the built module' { - { - Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock { + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -ScriptBlock { + Test-NovaBuild -WhatIf + } + + $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) + } + + It 'fails early when the isolated session cannot resolve a supported Pester 5.x module' { + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -ScriptBlock { + $temporaryModulePath = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().Guid) + $originalModulePath = $env:PSModulePath + try { + New-NovaPublicCommandIntegrationPesterModule -BasePath $temporaryModulePath -Version '6.0.0' | Out-Null + $env:PSModulePath = $temporaryModulePath Test-NovaBuild -WhatIf + } finally { + $env:PSModulePath = $originalModulePath + Remove-Item -LiteralPath $temporaryModulePath -Recurse -Force -ErrorAction SilentlyContinue } - } | Should -Not -Throw + } + + $outputText = Get-NovaPublicCommandIntegrationOutputText -Output $result.Output -NormalizeWhitespace + $result.ExitCode | Should -Not -Be 0 + $outputText | Should -Match 'Pester' + $outputText | Should -Match 'Import-Module' + $outputText | Should -Match 'was not loaded because no valid module file was found|5\.7\.1 through 5\.10\.0' } It 'warns with actionable guidance when the current project has no build-validation tests' { @@ -20,19 +41,12 @@ Describe 'Test-NovaBuild integration' { Copy-Item -Path (Join-Path $exampleProjectRoot '*') -Destination $scenarioRoot -Recurse -Force Remove-Item -LiteralPath (Join-Path $scenarioRoot 'tests/public/Get-ExampleGreeting.Integration.Tests.ps1') -Force - $warnings = & { - Invoke-NovaPublicCommandIntegrationInLocation -Path $scenarioRoot -ScriptBlock { - Test-NovaBuild 3>&1 - } + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -Path $scenarioRoot -ScriptBlock { + Test-NovaBuild 3>&1 } - $warningMessages = @( - $warnings | - Where-Object {$_ -is [System.Management.Automation.WarningRecord]} | - ForEach-Object Message - ) - - $warningMessages | Should -Contain "No build-validation integration tests matching '*.Integration.Tests.ps1' were discovered for NovaExampleModule." + $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) + (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output -NormalizeWhitespace) | Should -Match "No build-validation integration tests matching '\*\.Integration\.Tests\.ps1' were discovered for NovaExampleModule\." } It 'passes for a scaffolded example whose project name differs from the packaged template name' { @@ -46,10 +60,10 @@ Describe 'Test-NovaBuild integration' { $projectData.ProjectName = 'BuildValidationExample' $projectData | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $projectJsonPath - { - Invoke-NovaPublicCommandIntegrationInLocation -Path $scenarioRoot -ScriptBlock { - Test-NovaBuild - } - } | Should -Not -Throw + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -Path $scenarioRoot -ScriptBlock { + Test-NovaBuild + } + + $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) } } diff --git a/tests/public/UpdateNovaModuleTools.Integration.Tests.ps1 b/tests/public/UpdateNovaModuleTools.Integration.Tests.ps1 index 6532cb9..92fd221 100644 --- a/tests/public/UpdateNovaModuleTools.Integration.Tests.ps1 +++ b/tests/public/UpdateNovaModuleTools.Integration.Tests.ps1 @@ -10,8 +10,18 @@ Describe 'Update-NovaModuleTool integration' { } It 'supports WhatIf from the built module' { - { + $thrown = $null + + try { Update-NovaModuleTool -WhatIf | Out-Null - } | Should -Not -Throw + } catch { + $thrown = $_ + } + + if ($null -eq $thrown) { + return + } + + $thrown.Exception.Message | Should -Be 'Unable to determine a NovaModuleTools update candidate. Try again when the PowerShell Gallery is reachable.' } } From 13fc341e7c8cfe0dc53b8ed77f1359c382cf3d26 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:26:08 +0000 Subject: [PATCH 5/7] chore: prepare develop for next prerelease --- project.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.json b/project.json index 4139f81..fed86c9 100644 --- a/project.json +++ b/project.json @@ -1,7 +1,7 @@ { "ProjectName": "NovaModuleTools", "Description": "NovaModuleTools is an enterprise-focused build tool for PowerShell module development, with a strong emphasis on structure, maintainability, and build for automated CI/CD pipelines.", - "Version": "3.3.1", + "Version": "3.3.2-preview", "Preamble": [ "Set-StrictMode -Version Latest", "$ErrorActionPreference = 'Stop'" From 65332261df0ae9f2e6c806d3ae3e2475f13a1b18 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Thu, 16 Jul 2026 06:28:40 +0200 Subject: [PATCH 6/7] fix(#262): improve self-update error messaging and Pester version handling - Update-NovaModuleTool and % nova update now split self-update failure details and recovery guidance across separate terminal lines for better readability. - Ensure Nova test workflows only use supported Pester 5.x versions, providing clear errors for unsupported Pester 6.x installations. --- CHANGELOG.md | 1 + RELEASE_NOTE.md | 1 + .../update/InvokeNovaModuleSelfUpdateWorkflow.ps1 | 14 +++++++++++++- .../InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 | 13 ++++++++++++- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdee079..6180322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- `Update-NovaModuleTool` and `% nova update` now split self-update failure details and recovery guidance across separate terminal lines, so long dependency errors stay readable instead of wrapping mid-sentence. - `Invoke-NovaTest`, `Test-NovaBuild`, and `% nova test` now resolve and import a supported installed `Pester` version from `5.7.1` through `5.10.0` instead of using an unsupported `Pester 6.x` installation automatically. - Nova test workflows now fail early with a clear dependency error when only unsupported `Pester 6.x` versions are available. - Nova test workflows now reuse an already loaded supported `Pester 5.x` version in the current PowerShell session before selecting a different installed version. diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index 9311883..8c63e72 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -14,6 +14,7 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang ### Fixed +- `Update-NovaModuleTool` and `% nova update` now keep self-update failure details and recovery guidance on separate terminal lines so the error is easier to scan. - `Invoke-NovaTest`, `Test-NovaBuild`, and `% nova test` now stay on Nova's supported `Pester 5.x` range and stop with a clear error instead of trying to run with an installed `Pester 6.x` version. ### Security diff --git a/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 b/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 index 6ad50db..979e613 100644 --- a/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 +++ b/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 @@ -1,3 +1,15 @@ +function Get-NovaModuleSelfUpdateFailureMessage { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$FailureDetail + ) + + return @( + "NovaModuleTools self-update failed: $FailureDetail" + 'Confirm that the PowerShell Gallery is reachable and that this session can update installed modules, then rerun the self-update command.' + ) -join [Environment]::NewLine +} + function Invoke-NovaModuleSelfUpdateOrStop { [CmdletBinding()] param( @@ -7,7 +19,7 @@ function Invoke-NovaModuleSelfUpdateOrStop { try { $null = Invoke-NovaModuleSelfUpdate -ModuleName $Plan.ModuleName -AllowPrerelease:$Plan.UsedAllowPrerelease } catch { - $message = "NovaModuleTools self-update failed: $( $_.Exception.Message ) Confirm that the PowerShell Gallery is reachable and that this session can update installed modules, then rerun Update-NovaModuleTool." + $message = Get-NovaModuleSelfUpdateFailureMessage -FailureDetail $_.Exception.Message Stop-NovaOperation -Message $message -ErrorId 'Nova.Dependency.ModuleSelfUpdateFailed' -Category InvalidOperation -TargetObject $Plan.ModuleName } } diff --git a/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 b/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 index a74c714..d7b09f2 100644 --- a/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 +++ b/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 @@ -22,6 +22,17 @@ Describe 'Complete-NovaModuleSelfUpdateResult' { } } +Describe 'Get-NovaModuleSelfUpdateFailureMessage' { + It 'splits failure details and recovery guidance across separate lines' { + $message = Get-NovaModuleSelfUpdateFailureMessage -FailureDetail 'gallery offline' + + $message | Should -Be (@( + 'NovaModuleTools self-update failed: gallery offline' + 'Confirm that the PowerShell Gallery is reachable and that this session can update installed modules, then rerun the self-update command.' + ) -join [Environment]::NewLine) + } +} + Describe 'Invoke-NovaModuleSelfUpdateOrStop' { It 'calls Invoke-NovaModuleSelfUpdate with module name and prerelease flag' { Mock Invoke-NovaModuleSelfUpdate {} @@ -39,7 +50,7 @@ Describe 'Invoke-NovaModuleSelfUpdateOrStop' { $thrown | Should -Not -BeNullOrEmpty $thrown.FullyQualifiedErrorId | Should -Be 'Nova.Dependency.ModuleSelfUpdateFailed' - $thrown.Exception.Message | Should -Be 'NovaModuleTools self-update failed: gallery offline Confirm that the PowerShell Gallery is reachable and that this session can update installed modules, then rerun Update-NovaModuleTool.' + $thrown.Exception.Message | Should -Be (Get-NovaModuleSelfUpdateFailureMessage -FailureDetail 'gallery offline') } } From f4c5bcba65f388618d4fa61962d7db1f4b70956c Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Wed, 7 Oct 2026 19:16:26 +0200 Subject: [PATCH 7/7] #263 feat: Enhance project.json support for Manifest.PrivateData (#264) * #263 feat: Enhance project.json support for Manifest.PrivateData - Added support for `Manifest.PrivateData` in `project.json` to allow arbitrary consumer-defined metadata. - Updated `BuildManifest` to serialize structured `PrivateData` into the generated `.psd1`, preserving nested objects and types. - Implemented validation to reject collisions with reserved `PSData` keys in `PrivateData`. - Created utility functions for converting data to PowerShell literals and updating manifest private data. - Added comprehensive tests for `ConvertTo-PowerShellDataLiteral` and `Update-ManifestPrivateData` to ensure correct functionality. - Updated documentation to reflect changes in `project.json` schema and usage of `PrivateData`. * #263 feat: Add tests for Get-GeneratedManifestPsData and related functions --- .github/skills/skills/SKILL.md | 20 ++ CHANGELOG.md | 5 +- RELEASE_NOTE.md | 5 +- docs/project-json-reference.html | 39 +++- docs/schema/v3/project.json | 5 + src/private/build/BuildManifest.ps1 | 125 +++++++---- .../build/ConvertToPowerShellDataLiteral.ps1 | 201 ++++++++++++++++++ .../build/UpdateManifestPrivateData.ps1 | 143 +++++++++++++ src/resources/Schema-Project.json | 5 + .../.github/skills/skills/SKILL.md | 20 ++ tests/private/build/BuildManifest.Tests.ps1 | 68 ++++++ .../ConvertToPowerShellDataLiteral.Tests.ps1 | 111 ++++++++++ .../private/build/TestProjectSchema.Tests.ps1 | 54 +++++ .../build/UpdateManifestPrivateData.Tests.ps1 | 140 ++++++++++++ .../TestNovaBuild.Integration.Tests.ps1 | 66 ++++++ 15 files changed, 963 insertions(+), 44 deletions(-) create mode 100644 .github/skills/skills/SKILL.md create mode 100644 src/private/build/ConvertToPowerShellDataLiteral.ps1 create mode 100644 src/private/build/UpdateManifestPrivateData.ps1 create mode 100644 src/resources/agentic-copilot/.github/skills/skills/SKILL.md create mode 100644 tests/private/build/ConvertToPowerShellDataLiteral.Tests.ps1 create mode 100644 tests/private/build/UpdateManifestPrivateData.Tests.ps1 diff --git a/.github/skills/skills/SKILL.md b/.github/skills/skills/SKILL.md new file mode 100644 index 0000000..be4df8b --- /dev/null +++ b/.github/skills/skills/SKILL.md @@ -0,0 +1,20 @@ +--- +name: skills +description: Index folder for vendored shared skills used by NovaModuleTools scaffolding and local guidance. +--- + +# Skill: skills + +## When to use + +Use this folder only as a container for nested shared skills that are maintained separately from the repository-local skill entry points. + +## Relevant files + +- `.github/skills/skills/*` + +## Expected practices + +- Do not reference this container skill directly from prompts or agents. +- Keep nested shared skills organized beneath this folder when they are intentionally vendored for local reuse. +- Prefer the repository-local top-level skills under `.github/skills/*/SKILL.md` for normal NovaModuleTools task routing. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6180322..9cdc9b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- `project.json` now supports `Manifest.PrivateData` for arbitrary consumer-defined manifest metadata. + - Nova still uses `New-ModuleManifest` for the standard manifest flow, but now serializes structured `PrivateData` itself so nested objects, arrays, booleans, numbers, strings, and `null` values survive in the generated `.psd1`. + - Generated manifests now preserve both PowerShell/Nova-managed `PrivateData.PSData` metadata and custom `Manifest.PrivateData` entries, and reject reserved-key collisions instead of overwriting `PSData` silently. + ### Changed ### Deprecated @@ -541,4 +545,3 @@ This release was yanked because it removed the implicit `Pester` dependency, bef [0.0.6]: https://github.com/stiwicourage/NovaModuleTools/compare/Version_0.0.5...Version_0.0.6 [0.0.5]: https://github.com/stiwicourage/NovaModuleTools/compare/Version_0.0.4...Version_0.0.5 [0.0.4]: https://github.com/stiwicourage/NovaModuleTools/compare/Version_0.0.3...Version_0.0.4 - diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index 8c63e72..075365b 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -6,6 +6,10 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang ### Added +- `project.json` now supports `Manifest.PrivateData` for arbitrary consumer-defined module manifest metadata. + - Nova still uses `New-ModuleManifest` for the standard manifest flow, but now serializes structured `PrivateData` itself so nested values remain valid in the generated `.psd1`. + - Generated manifests preserve both `PrivateData.PSData` and custom `Manifest.PrivateData` entries, and reject reserved-key collisions instead of overwriting manifest metadata silently. + ### Changed ### Deprecated @@ -279,4 +283,3 @@ This release was yanked because it removed the implicit `Pester` dependency befo ## [0.0.4] - 2024-06-25 ### Added - First PowerShell Gallery release of NovaModuleTools with the initial module workflow support. - diff --git a/docs/project-json-reference.html b/docs/project-json-reference.html index 3216f2e..ece2571 100644 --- a/docs/project-json-reference.html +++ b/docs/project-json-reference.html @@ -113,7 +113,7 @@

How to read this file

Schema coverage.

The unified JSON schema covers all documented fields including Manifest.ReleaseNotes, Manifest.LicenseUri, - Manifest.IconUri, and Pester.TestResult.OutputFormat. VS Code picks + Manifest.IconUri, Manifest.PrivateData, and Pester.TestResult.OutputFormat. VS Code picks up the schema automatically when the $schema field is present in project.json.

@@ -142,7 +142,13 @@

Complete example

"Tags": ["Example", "NovaModuleTools", "PowerShell"], "ProjectUri": "https://www.novamoduletools.com/", "ReleaseNotes": "https://www.novamoduletools.com/release-notes.html", - "LicenseUri": "https://www.novamoduletools.com/license.html" + "LicenseUri": "https://www.novamoduletools.com/license.html", + "PrivateData": { + "ExampleProduct": { + "Enabled": true, + "ApiVersion": "1" + } + } }, "Package": { "Id": "NovaExampleModule", @@ -372,12 +378,41 @@

Manifest settings

publicly. + + PrivateData + No + Adds arbitrary consumer-defined metadata to the manifest PrivateData hashtable. + Nova serializes this data itself so arbitrary property names and nested objects, arrays, booleans, numbers, strings, and null values remain valid PowerShell data in the generated manifest. +

If Tags, ProjectUri, ReleaseNotes, or LicenseUri are omitted, packaging still succeeds. The corresponding package metadata fields are simply left out.

+

Use Manifest.PrivateData for arbitrary module metadata. Nova keeps the standard PowerShell-generated + PrivateData.PSData metadata and merges your configured keys into the same top-level + PrivateData hashtable. Property names remain consumer-defined, so keys such as + Example Product, Feature-Flag, or 123abc are serialized safely.

+

Nova validates the rewritten manifest by re-importing the generated + .psd1. PowerShell's own Test-ModuleManifest cmdlet can still reject + consumer-defined top-level PrivateData entries even when the manifest is valid + PowerShell data, so Nova treats the data-file import round-trip as the compatibility check for + custom metadata.

+
{
+  "Manifest": {
+    "PrivateData": {
+      "Example Product": {
+        "Feature-Flag": true,
+        "ApiVersion": "1",
+        "RetryCount": 3,
+        "Nested Data": {
+          "Mode": "Test"
+        }
+      }
+    }
+  }
+}
diff --git a/docs/schema/v3/project.json b/docs/schema/v3/project.json index 8efcc16..66ad0c4 100644 --- a/docs/schema/v3/project.json +++ b/docs/schema/v3/project.json @@ -85,6 +85,11 @@ "type": "string", "description": "Sets the copyright string in the module manifest." }, + "PrivateData": { + "type": "object", + "additionalProperties": true, + "description": "Arbitrary consumer-defined module metadata serialized into the generated manifest PrivateData hashtable." + }, "RequiredModules": { "type": "array", "description": "Lists modules that must be imported before this module. Each entry is either a module name string or a module-specification object.", diff --git a/src/private/build/BuildManifest.ps1 b/src/private/build/BuildManifest.ps1 index 2372562..ebe9ea0 100644 --- a/src/private/build/BuildManifest.ps1 +++ b/src/private/build/BuildManifest.ps1 @@ -6,34 +6,9 @@ function Build-Manifest { Write-Verbose 'Building psd1 data file Manifest' $data = Get-NovaBuildProjectInfo -ProjectInfo $ProjectInfo - - $PubFunctionFiles = @(Get-ChildItem -Path $data.PublicDir -Filter *.ps1) - $functionToExport = @() - $aliasToExport = @() - foreach ($pubFunctionFile in $PubFunctionFiles) { - $functionToExport += Get-FunctionNameFromFile -filePath $pubFunctionFile.FullName - $aliasToExport += Get-AliasInFunctionFromFile -filePath $pubFunctionFile.FullName - } - - ## Import Format.ps1xml (if any) - $FormatsToProcess = @() - Get-ChildItem -Path $data.ResourcesDir -File -Filter '*Format.ps1xml' -ErrorAction SilentlyContinue | ForEach-Object { - if ($data.CopyResourcesToModuleRoot) { - $FormatsToProcess += $_.Name - } else { - $FormatsToProcess += Join-Path -Path 'resources' -ChildPath $_.Name - } - } - - ## Import Types.ps1xml1 (if any) - $TypesToProcess = @() - Get-ChildItem -Path $data.ResourcesDir -File -Filter '*Types.ps1xml' -ErrorAction SilentlyContinue | ForEach-Object { - if ($data.CopyResourcesToModuleRoot) { - $TypesToProcess += $_.Name - } else { - $TypesToProcess += Join-Path -Path 'resources' -ChildPath $_.Name - } - } + $exportDefinition = Get-ManifestExportDefinition -PublicDir $data.PublicDir + $formatFiles = Get-ManifestResourceFilePath -ResourcesDir $data.ResourcesDir -CopyResourcesToModuleRoot:$data.CopyResourcesToModuleRoot -Filter '*Format.ps1xml' + $typeFiles = Get-ManifestResourceFilePath -ResourcesDir $data.ResourcesDir -CopyResourcesToModuleRoot:$data.CopyResourcesToModuleRoot -Filter '*Types.ps1xml' $ManfiestAllowedParams = (Get-Command New-ModuleManifest).Parameters.Keys Assert-ManifestSchema -Manifest $data.Manifest -AllowedParameter $ManfiestAllowedParams @@ -41,12 +16,12 @@ function Build-Manifest { $ParmsManifest = @{ Path = $data.ManifestFilePSD1 Description = $data.Description - FunctionsToExport = $functionToExport - AliasesToExport = $aliasToExport + FunctionsToExport = $exportDefinition.FunctionToExport + AliasesToExport = $exportDefinition.AliasToExport RootModule = "$( $data.ProjectName ).psm1" ModuleVersion = [version]$sv - FormatsToProcess = $FormatsToProcess - TypesToProcess = $TypesToProcess + FormatsToProcess = $formatFiles + TypesToProcess = $typeFiles } ## Release lable @@ -54,18 +29,88 @@ function Build-Manifest { $ParmsManifest['Prerelease'] = $sv.PreReleaseLabel } - # Accept only valid Manifest Parameters - $data.Manifest.Keys | ForEach-Object { - if ($ManfiestAllowedParams -contains $_) { - if ($data.Manifest.$_) { - $ParmsManifest.add($_, $data.Manifest.$_) - } - } - } + Add-AllowedManifestParameterEntry -ManifestParameters $ParmsManifest -Manifest $data.Manifest -AllowedParameter $ManfiestAllowedParams try { New-ModuleManifest @ParmsManifest } catch { Stop-NovaOperation -Message ('Failed to create Manifest: {0}' -f $_.Exception.Message) -ErrorId 'Nova.Dependency.ModuleManifestCreationFailed' -Category OpenError -TargetObject $data.ManifestFilePSD1 } + + if ($data.Manifest.Contains('PrivateData') -and $data.Manifest['PrivateData'] -is [System.Collections.IDictionary]) { + Update-ManifestPrivateData -ManifestPath $data.ManifestFilePSD1 -PrivateData $data.Manifest['PrivateData'] + Assert-GeneratedManifestPrivateDataCanBeImported -ManifestPath $data.ManifestFilePSD1 + } +} + +function Assert-GeneratedManifestPrivateDataCanBeImported { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ManifestPath + ) + + try { + $null = Import-PowerShellDataFile -LiteralPath $ManifestPath -ErrorAction Stop + } catch { + Stop-NovaOperation -Message ('Generated manifest contains invalid PrivateData: {0}' -f $_.Exception.Message) -ErrorId 'Nova.Dependency.ModuleManifestPrivateDataValidationFailed' -Category InvalidData -TargetObject $ManifestPath + } +} + +function Get-ManifestExportDefinition { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$PublicDir + ) + + $functionToExport = @() + $aliasToExport = @() + foreach ($publicFunctionFile in @(Get-ChildItem -Path $PublicDir -Filter *.ps1)) { + $functionToExport += Get-FunctionNameFromFile -filePath $publicFunctionFile.FullName + $aliasToExport += Get-AliasInFunctionFromFile -filePath $publicFunctionFile.FullName + } + + return [pscustomobject]@{ + FunctionToExport = $functionToExport + AliasToExport = $aliasToExport + } +} + +function Get-ManifestResourceFilePath { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ResourcesDir, + [Parameter(Mandatory)][string]$Filter, + [switch]$CopyResourcesToModuleRoot + ) + + $resourceFilePath = @() + Get-ChildItem -Path $ResourcesDir -File -Filter $Filter -ErrorAction SilentlyContinue | ForEach-Object { + if ($CopyResourcesToModuleRoot) { + $resourceFilePath += $_.Name + return + } + + $resourceFilePath += Join-Path -Path 'resources' -ChildPath $_.Name + } + + return $resourceFilePath +} + +function Add-AllowedManifestParameterEntry { + [CmdletBinding()] + param( + [Parameter(Mandatory)][hashtable]$ManifestParameters, + [Parameter(Mandatory)][System.Collections.IDictionary]$Manifest, + [Parameter(Mandatory)][string[]]$AllowedParameter + ) + + foreach ($name in $Manifest.Keys) { + if ($name -eq 'PrivateData' -or $AllowedParameter -notcontains $name) { + continue + } + + if ($Manifest.$name) { + $ManifestParameters.add($name, $Manifest.$name) + } + } } diff --git a/src/private/build/ConvertToPowerShellDataLiteral.ps1 b/src/private/build/ConvertToPowerShellDataLiteral.ps1 new file mode 100644 index 0000000..f272fca --- /dev/null +++ b/src/private/build/ConvertToPowerShellDataLiteral.ps1 @@ -0,0 +1,201 @@ +function ConvertTo-PowerShellDataLiteral { + [CmdletBinding()] + param( + [AllowNull()]$Value, + [int]$IndentLevel = 0 + ) + + if ($null -eq $Value) { + return '$null' + } + + if ($Value -is [string]) { + return ConvertTo-PowerShellQuotedStringLiteral -Value $Value + } + + if ($Value -is [bool]) { + if ($Value) { + return '$true' + } + + return '$false' + } + + if (Test-PowerShellDataNumber -Value $Value) { + return [System.Management.Automation.LanguagePrimitives]::ConvertTo($Value, [string], [System.Globalization.CultureInfo]::InvariantCulture) + } + + if ($Value -is [System.Collections.IDictionary]) { + return ConvertTo-PowerShellCollectionLiteral -Value $Value -IndentLevel $IndentLevel + } + + if (Test-PowerShellDataList -Value $Value) { + return ConvertTo-PowerShellCollectionLiteral -Value $Value -IndentLevel $IndentLevel + } + + return ConvertTo-PowerShellQuotedStringLiteral -Value ([string]$Value) +} + +function ConvertTo-PowerShellQuotedStringLiteral { + [CmdletBinding()] + param( + [AllowEmptyString()] + [Parameter(Mandatory)][string]$Value + ) + + return "'$( $Value -replace '''', '''''' )'" +} + +function Test-PowerShellDataNumber { + [CmdletBinding()] + param( + [AllowNull()]$Value + ) + + if ($Value -isnot [ValueType]) { + return $false + } + + return @( + 'System.Byte' + 'System.SByte' + 'System.Int16' + 'System.UInt16' + 'System.Int32' + 'System.UInt32' + 'System.Int64' + 'System.UInt64' + 'System.Decimal' + 'System.Double' + 'System.Single' + ) -contains $Value.GetType().FullName +} + +function Test-PowerShellDataList { + [CmdletBinding()] + param( + [AllowNull()]$Value + ) + + return $Value -is [System.Collections.IList] -and $Value -isnot [string] +} + +function ConvertTo-PowerShellCollectionLiteral { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Value, + [int]$IndentLevel = 0 + ) + + $blockMetadata = Get-PowerShellCollectionBlockDescriptor -Value $Value + if ($null -ne $blockMetadata.EmptyLiteral) { + return $blockMetadata.EmptyLiteral + } + + return Format-PowerShellDataBlock ` + -OpeningToken $blockMetadata.OpeningToken ` + -ItemList (Get-PowerShellCollectionBlockItemList -Value $Value -IndentLevel $IndentLevel) ` + -IndentLevel $IndentLevel ` + -ClosingToken $blockMetadata.ClosingToken +} + +function Get-PowerShellDataIndent { + [CmdletBinding()] + param( + [int]$IndentLevel = 0 + ) + + return (' ' * 4 * $IndentLevel) +} + +function Format-PowerShellDataBlock { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$OpeningToken, + [Parameter(Mandatory)][string[]]$ItemList, + [int]$IndentLevel = 0, + [string]$ClosingToken = '}' + ) + + $joinedItems = $ItemList -join "`n" + $closingIndent = Get-PowerShellDataIndent -IndentLevel $IndentLevel + return "$OpeningToken`n$joinedItems`n$closingIndent$ClosingToken" +} + +function Get-PowerShellCollectionBlockDescriptor { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Value + ) + + if ($Value -is [System.Collections.IDictionary]) { + return Get-PowerShellCollectionBlockDescriptorForCount -Count $Value.Count -EmptyLiteral '@{}' -OpeningToken '@{' -ClosingToken '}' + } + + if ($Value -is [System.Collections.IList]) { + return Get-PowerShellCollectionBlockDescriptorForCount -Count $Value.Count -EmptyLiteral '@()' -OpeningToken '@(' -ClosingToken ')' + } + + throw "Unsupported PowerShell data collection type: $($Value.GetType().FullName)" +} + +function Get-PowerShellCollectionBlockDescriptorForCount { + [CmdletBinding()] + param( + [Parameter(Mandatory)][int]$Count, + [Parameter(Mandatory)][string]$EmptyLiteral, + [Parameter(Mandatory)][string]$OpeningToken, + [Parameter(Mandatory)][string]$ClosingToken + ) + + if ($Count -eq 0) { + return [pscustomobject]@{ + EmptyLiteral = $EmptyLiteral + OpeningToken = $null + ClosingToken = $null + } + } + + return [pscustomobject]@{ + EmptyLiteral = $null + OpeningToken = $OpeningToken + ClosingToken = $ClosingToken + } +} + +function Get-PowerShellCollectionBlockItemList { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Value, + [int]$IndentLevel = 0 + ) + + $childIndent = Get-PowerShellDataIndent -IndentLevel ($IndentLevel + 1) + if ($Value -is [System.Collections.IDictionary]) { + foreach ($key in (Get-PowerShellDataDictionaryKeyList -Dictionary $Value)) { + $keyLiteral = ConvertTo-PowerShellQuotedStringLiteral -Value $key + "$childIndent$keyLiteral = $( ConvertTo-PowerShellDataLiteral -Value $Value[$key] -IndentLevel ($IndentLevel + 1) )" + } + + return + } + + foreach ($item in $Value) { + "$childIndent$( ConvertTo-PowerShellDataLiteral -Value $item -IndentLevel ($IndentLevel + 1) )" + } +} + +function Get-PowerShellDataDictionaryKeyList { + [CmdletBinding()] + param( + [Parameter(Mandatory)][System.Collections.IDictionary]$Dictionary + ) + + $keyList = @($Dictionary.Keys | ForEach-Object {[string]$_}) + if ($Dictionary -is [System.Collections.Specialized.OrderedDictionary] -or + $Dictionary.GetType().FullName -eq 'System.Management.Automation.OrderedHashtable') { + return $keyList + } + + return @($keyList | Sort-Object) +} diff --git a/src/private/build/UpdateManifestPrivateData.ps1 b/src/private/build/UpdateManifestPrivateData.ps1 new file mode 100644 index 0000000..9383e41 --- /dev/null +++ b/src/private/build/UpdateManifestPrivateData.ps1 @@ -0,0 +1,143 @@ +function Update-ManifestPrivateData { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)][string]$ManifestPath, + [Parameter(Mandatory)][System.Collections.IDictionary]$PrivateData + ) + + if ($PrivateData.Contains('PSData')) { + Stop-NovaOperation -Message "Manifest.PrivateData cannot contain the reserved key 'PSData'. Nova preserves PowerShell-generated PSData separately." -ErrorId 'Nova.Configuration.ManifestPrivateDataReservedKey' -Category InvalidData -TargetObject 'Manifest.PrivateData.PSData' + } + + $manifest = Import-PowerShellDataFile -LiteralPath $ManifestPath -ErrorAction Stop + $mergedPrivateData = Get-MergedManifestPrivateData -Manifest $manifest -PrivateData $PrivateData + $replacementBlock = "PrivateData = $( ConvertTo-PowerShellDataLiteral -Value $mergedPrivateData )" + $updatedContent = Get-ManifestContentWithUpdatedHashtableEntryValue -ManifestPath $ManifestPath -Name 'PrivateData' -ReplacementValue $replacementBlock + + Set-Content -LiteralPath $ManifestPath -Value $updatedContent -NoNewline +} + +function Get-MergedManifestPrivateData { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Manifest, + [Parameter(Mandatory)][System.Collections.IDictionary]$PrivateData + ) + + $mergedPrivateData = [ordered]@{} + $generatedPsData = Get-GeneratedManifestPsData -Manifest $Manifest + if ($null -ne $generatedPsData) { + $mergedPrivateData['PSData'] = $generatedPsData + } + + foreach ($key in (Get-PowerShellDataDictionaryKeyList -Dictionary $PrivateData)) { + $mergedPrivateData[$key] = $PrivateData[$key] + } + + return $mergedPrivateData +} + +function Get-GeneratedManifestPsData { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Manifest + ) + + $privateData = $Manifest.PrivateData + if ($privateData -is [System.Collections.IDictionary]) { + return Get-ManifestPsDataDictionary -PSData $privateData['PSData'] + } + + if ($null -eq $privateData) { + return $null + } + + return Get-ManifestPsDataDictionary -PSData $privateData.PSData +} + +function Get-ManifestPsDataDictionary { + [CmdletBinding()] + param( + [AllowNull()]$PSData + ) + + if ($null -eq $PSData) { + return $null + } + + if ($PSData -is [System.Collections.IDictionary]) { + return [ordered]@{} + $PSData + } + + $dictionary = [ordered]@{} + foreach ($property in $PSData.PSObject.Properties) { + $dictionary[$property.Name] = $property.Value + } + + return $dictionary +} + +function Get-ManifestContentWithUpdatedHashtableEntryValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ManifestPath, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$ReplacementValue + ) + + $content = Get-Content -LiteralPath $ManifestPath -Raw + $pair = Get-ManifestHashtableEntryAst -ManifestPath $ManifestPath -Name $Name + $keyStart = $pair.Item1.Extent.StartOffset + $valueEnd = $pair.Item2.Extent.EndOffset + + return "$( $content.Substring(0, $keyStart) )$ReplacementValue$( $content.Substring($valueEnd) )" +} + +function Get-ManifestHashtableEntryAst { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ManifestPath, + [Parameter(Mandatory)][string]$Name + ) + + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($ManifestPath, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -gt 0) { + Stop-NovaOperation -Message "Failed to parse generated manifest before updating PrivateData: $ManifestPath" -ErrorId 'Nova.Dependency.ModuleManifestParsingFailed' -Category ParserError -TargetObject $ManifestPath + } + + $topLevelHashtable = Get-TopLevelManifestHashtableAst -Ast $ast + $pair = @( + $topLevelHashtable.KeyValuePairs | + Where-Object {$_.Item1.SafeGetValue() -eq $Name} | + Select-Object -First 1 + )[0] + if ($null -eq $pair) { + Stop-NovaOperation -Message "Generated manifest is missing the top-level $Name entry: $ManifestPath" -ErrorId 'Nova.Dependency.ModuleManifestPrivateDataMissing' -Category InvalidData -TargetObject $ManifestPath + } + + return $pair +} + +function Get-TopLevelManifestHashtableAst { + [CmdletBinding()] + param( + [Parameter(Mandatory)][System.Management.Automation.Language.ScriptBlockAst]$Ast + ) + + $hashtableList = @( + $Ast.FindAll( + { param($node) $node -is [System.Management.Automation.Language.HashtableAst] }, + $true + ) | + Sort-Object { + $_.Extent.EndOffset - $_.Extent.StartOffset + } -Descending + ) + if ($hashtableList.Count -eq 0) { + Stop-NovaOperation -Message 'Generated manifest does not contain a top-level hashtable.' -ErrorId 'Nova.Dependency.ModuleManifestParsingFailed' -Category InvalidData -TargetObject $Ast.Extent.File + } + + return $hashtableList[0] +} diff --git a/src/resources/Schema-Project.json b/src/resources/Schema-Project.json index 8efcc16..66ad0c4 100644 --- a/src/resources/Schema-Project.json +++ b/src/resources/Schema-Project.json @@ -85,6 +85,11 @@ "type": "string", "description": "Sets the copyright string in the module manifest." }, + "PrivateData": { + "type": "object", + "additionalProperties": true, + "description": "Arbitrary consumer-defined module metadata serialized into the generated manifest PrivateData hashtable." + }, "RequiredModules": { "type": "array", "description": "Lists modules that must be imported before this module. Each entry is either a module name string or a module-specification object.", diff --git a/src/resources/agentic-copilot/.github/skills/skills/SKILL.md b/src/resources/agentic-copilot/.github/skills/skills/SKILL.md new file mode 100644 index 0000000..7e0f41f --- /dev/null +++ b/src/resources/agentic-copilot/.github/skills/skills/SKILL.md @@ -0,0 +1,20 @@ +--- +name: skills +description: Index folder for vendored shared skills used by {{ProjectName}} scaffolding and local guidance. +--- + +# Skill: skills + +## When to use + +Use this folder only as a container for nested shared skills that are maintained separately from the repository-local skill entry points. + +## Relevant files + +- `.github/skills/skills/*` + +## Expected practices + +- Do not reference this container skill directly from prompts or agents. +- Keep nested shared skills organized beneath this folder when they are intentionally vendored for local reuse. +- Prefer the repository-local top-level skills under `.github/skills/*/SKILL.md` for normal {{ProjectName}} task routing. diff --git a/tests/private/build/BuildManifest.Tests.ps1 b/tests/private/build/BuildManifest.Tests.ps1 index b8b2cfe..a6651d2 100644 --- a/tests/private/build/BuildManifest.Tests.ps1 +++ b/tests/private/build/BuildManifest.Tests.ps1 @@ -1,6 +1,8 @@ BeforeAll { $projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) . (Join-Path $projectRoot 'src/private/build/BuildManifest.ps1') + . (Join-Path $projectRoot 'src/private/build/UpdateManifestPrivateData.ps1') + . (Join-Path $projectRoot 'src/private/build/ConvertToPowerShellDataLiteral.ps1') . (Join-Path $projectRoot 'src/private/build/manifest/GetFunctionNameFromFile.ps1') . (Join-Path $projectRoot 'src/private/build/manifest/GetAliasNameFromFunction.ps1') . (Join-Path $projectRoot 'src/private/build/manifest/AssertManifestSchema.ps1') @@ -74,6 +76,72 @@ Describe 'Build-Manifest' { $manifest.PrivateData.PSData.Prerelease | Should -Be 'beta1' } + It 'merges generated PSData and configured Manifest.PrivateData into the generated manifest' { + $script:ctx.Manifest = @{ + Author = 'Me' + Tags = @('Example') + PrivateData = [ordered]@{ + 'Example Product' = [ordered]@{ + 'Feature-Flag' = $true + ApiVersion = '1' + RetryCount = 3 + 'Nested Data' = [ordered]@{ + Mode = 'Test' + } + Values = @('one', 'two') + } + } + } + Mock Get-NovaBuildProjectInfo { $script:ctx } + Mock Assert-ManifestSchema {} + + Build-Manifest -ProjectInfo ([pscustomobject]@{}) + + $manifest = Import-PowerShellDataFile -Path $script:ctx.ManifestFilePSD1 + $manifest.PrivateData.PSData.Tags | Should -Be @('Example') + $manifest.PrivateData['Example Product']['Feature-Flag'] | Should -BeTrue + $manifest.PrivateData['Example Product'].ApiVersion | Should -Be '1' + $manifest.PrivateData['Example Product'].RetryCount | Should -Be 3 + $manifest.PrivateData['Example Product']['Nested Data'].Mode | Should -Be 'Test' + $manifest.PrivateData['Example Product'].Values | Should -Be @('one', 'two') + } + + It 'stops with friendly error when rewritten PrivateData leaves an invalid manifest' { + $script:ctx.Manifest = @{ + Author = 'Me' + PrivateData = [ordered]@{ + ExampleProduct = [ordered]@{ + Enabled = $true + } + } + } + Mock Get-NovaBuildProjectInfo { $script:ctx } + Mock Assert-ManifestSchema {} + Mock Update-ManifestPrivateData {} + Mock Import-PowerShellDataFile -ParameterFilter {$LiteralPath -eq $script:ctx.ManifestFilePSD1} { + throw [System.Exception]::new('invalid data file') + } + + { Build-Manifest -ProjectInfo ([pscustomobject]@{}) } | + Should -Throw -ErrorId 'Nova.Dependency.ModuleManifestPrivateDataValidationFailed' + } + + It 'throws when configured Manifest.PrivateData collides with PSData' { + $script:ctx.Manifest = @{ + Author = 'Me' + PrivateData = @{ + PSData = @{ + ExampleProduct = 'bad' + } + } + } + Mock Get-NovaBuildProjectInfo { $script:ctx } + Mock Assert-ManifestSchema {} + + { Build-Manifest -ProjectInfo ([pscustomobject]@{}) } | + Should -Throw -ErrorId 'Nova.Configuration.ManifestPrivateDataReservedKey' + } + It 'stops with friendly error when New-ModuleManifest fails' { $script:ctx.ManifestFilePSD1 = '/nonexistent/x/y/Out.psd1' Mock Get-NovaBuildProjectInfo { $script:ctx } diff --git a/tests/private/build/ConvertToPowerShellDataLiteral.Tests.ps1 b/tests/private/build/ConvertToPowerShellDataLiteral.Tests.ps1 new file mode 100644 index 0000000..bcfceae --- /dev/null +++ b/tests/private/build/ConvertToPowerShellDataLiteral.Tests.ps1 @@ -0,0 +1,111 @@ +BeforeAll { + $projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + . (Join-Path $projectRoot 'src/private/build/ConvertToPowerShellDataLiteral.ps1') +} + +Describe 'ConvertTo-PowerShellDataLiteral' { + It 'serializes an empty string' { + ConvertTo-PowerShellDataLiteral -Value '' | Should -Be "''" + } + + It 'serializes a string with apostrophes safely' { + ConvertTo-PowerShellDataLiteral -Value "don't run" | Should -Be "'don''t run'" + } + + It 'quotes arbitrary dictionary keys safely' { + $value = [ordered]@{ + 'Example Product' = $true + 'Example-Product' = 3 + "Owner's Choice" = 'ready' + '123abc' = $null + } + + $result = ConvertTo-PowerShellDataLiteral -Value $value + + $result.Contains('''Example Product'' = $true') | Should -BeTrue + $result.Contains('''Example-Product'' = 3') | Should -BeTrue + $result.Contains('''Owner''''s Choice'' = ''ready''') | Should -BeTrue + $result.Contains('''123abc'' = $null') | Should -BeTrue + } + + It 'serializes boolean true' { + ConvertTo-PowerShellDataLiteral -Value $true | Should -Be '$true' + } + + It 'serializes boolean false' { + ConvertTo-PowerShellDataLiteral -Value $false | Should -Be '$false' + } + + It 'serializes null' { + ConvertTo-PowerShellDataLiteral -Value $null | Should -Be '$null' + } + + It 'serializes integers' { + ConvertTo-PowerShellDataLiteral -Value 3 | Should -Be '3' + } + + It 'serializes numbers' { + ConvertTo-PowerShellDataLiteral -Value 3.5 | Should -Be '3.5' + } + + It 'serializes unsupported scalar values as quoted strings' { + $version = [version]'1.2.3' + + ConvertTo-PowerShellDataLiteral -Value $version | Should -Be "'1.2.3'" + } + + It 'serializes empty objects' { + ConvertTo-PowerShellDataLiteral -Value ([ordered]@{}) | Should -Be '@{}' + } + + It 'serializes nested objects and arrays recursively' { + $value = [ordered]@{ + ExampleProduct = [ordered]@{ + Enabled = $true + ApiVersion = '1' + RetryCount = 3 + Nested = [ordered]@{ + Mode = 'Test' + } + Values = @('one', 'two') + } + } + + $result = ConvertTo-PowerShellDataLiteral -Value $value + + $result.Contains("`n 'ExampleProduct' = @{") | Should -BeTrue + $result.Contains('''Enabled'' = $true') | Should -BeTrue + $result.Contains('''ApiVersion'' = ''1''') | Should -BeTrue + $result.Contains('''RetryCount'' = 3') | Should -BeTrue + $result.Contains('''Nested'' = @{') | Should -BeTrue + $result.Contains('''Mode'' = ''Test''') | Should -BeTrue + $result.Contains('''Values'' = @(') | Should -BeTrue + $result.Contains('''one''') | Should -BeTrue + $result.Contains('''two''') | Should -BeTrue + } + + It 'sorts plain hashtable keys deterministically' { + $value = @{b = 2; a = 1} + + $result = ConvertTo-PowerShellDataLiteral -Value $value + + $result.IndexOf("'a' = 1") | Should -BeLessThan $result.IndexOf("'b' = 2") + } + + It 'preserves ordered dictionary keys in their configured order' { + $value = [ordered]@{ + b = 2 + a = 1 + } + + $result = ConvertTo-PowerShellDataLiteral -Value $value + + $result.IndexOf("'b' = 2") | Should -BeLessThan $result.IndexOf("'a' = 1") + } + + It 'throws for unsupported collection values' { + $queue = [System.Collections.Queue]::new() + + { Get-PowerShellCollectionBlockDescriptor -Value $queue } | Should -Throw 'Unsupported PowerShell data collection type*' + } +} diff --git a/tests/private/build/TestProjectSchema.Tests.ps1 b/tests/private/build/TestProjectSchema.Tests.ps1 index 62af82e..d2d8519 100644 --- a/tests/private/build/TestProjectSchema.Tests.ps1 +++ b/tests/private/build/TestProjectSchema.Tests.ps1 @@ -27,4 +27,58 @@ Describe 'Test-ProjectSchema' { {Test-ProjectSchema} | Should -Throw } + + It 'accepts project.json when Manifest.PrivateData is omitted' { + $schemaPath = Join-Path $projectRoot 'src/resources/Schema-Project.json' + $projectJsonPath = Join-Path $TestDrive 'project-no-private-data.json' + Set-Content -LiteralPath $projectJsonPath -Value @' +{ + "ProjectName": "Demo", + "Description": "Demo module", + "Version": "1.0.0", + "Manifest": { + "Author": "Nova", + "PowerShellHostVersion": "7.4", + "GUID": "11111111-1111-1111-1111-111111111111" + } +} +'@ + + Test-Json -Path $projectJsonPath -Schema (Get-Content -LiteralPath $schemaPath -Raw) | Should -BeTrue + } + + It 'accepts project.json when Manifest.PrivateData contains arbitrary nested data' { + $schemaPath = Join-Path $projectRoot 'src/resources/Schema-Project.json' + $projectJsonPath = Join-Path $TestDrive 'project-private-data.json' + Set-Content -LiteralPath $projectJsonPath -Value @' +{ + "ProjectName": "Demo", + "Description": "Demo module", + "Version": "1.0.0", + "Manifest": { + "Author": "Nova", + "PowerShellHostVersion": "7.4", + "GUID": "11111111-1111-1111-1111-111111111111", + "PrivateData": { + "ExampleProduct": { + "Enabled": true, + "ApiVersion": "1", + "RetryCount": 3, + "Nested": { + "Mode": "Test" + }, + "Values": [ + "one", + "two" + ], + "Nothing": null, + "EmptyObject": {} + } + } + } +} +'@ + + Test-Json -Path $projectJsonPath -Schema (Get-Content -LiteralPath $schemaPath -Raw) | Should -BeTrue + } } diff --git a/tests/private/build/UpdateManifestPrivateData.Tests.ps1 b/tests/private/build/UpdateManifestPrivateData.Tests.ps1 new file mode 100644 index 0000000..0837af0 --- /dev/null +++ b/tests/private/build/UpdateManifestPrivateData.Tests.ps1 @@ -0,0 +1,140 @@ +BeforeAll { + $projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + . (Join-Path $projectRoot 'src/private/build/UpdateManifestPrivateData.ps1') + . (Join-Path $projectRoot 'src/private/build/ConvertToPowerShellDataLiteral.ps1') + + function Stop-NovaOperation { + param([string]$Message, [string]$ErrorId, $Category, $TargetObject) + + $exception = [System.Exception]::new($Message) + $record = [System.Management.Automation.ErrorRecord]::new($exception, $ErrorId, $Category, $TargetObject) + throw $record + } +} + +Describe 'Update-ManifestPrivateData' { + BeforeEach { + $script:manifestPath = Join-Path $TestDrive 'Demo.psd1' + New-ModuleManifest -Path $script:manifestPath -RootModule 'Demo.psm1' -ModuleVersion '1.0.0' -Author 'Me' -Tags @('Example') | Out-Null + } + + It 'merges generated PSData with consumer-defined PrivateData' { + $privateData = [ordered]@{ + ExampleProduct = [ordered]@{ + Enabled = $true + ApiVersion = '1' + RetryCount = 3 + Nested = [ordered]@{ + Mode = 'Test' + } + Values = @('one', 'two') + } + } + + Update-ManifestPrivateData -ManifestPath $script:manifestPath -PrivateData $privateData + + $manifest = Import-PowerShellDataFile -LiteralPath $script:manifestPath + $manifest.PrivateData.PSData.Tags | Should -Be @('Example') + $manifest.PrivateData.ExampleProduct.Enabled | Should -BeTrue + $manifest.PrivateData.ExampleProduct.ApiVersion | Should -Be '1' + $manifest.PrivateData.ExampleProduct.RetryCount | Should -Be 3 + $manifest.PrivateData.ExampleProduct.Nested.Mode | Should -Be 'Test' + $manifest.PrivateData.ExampleProduct.Values | Should -Be @('one', 'two') + + (Get-Content -LiteralPath $script:manifestPath -Raw) | Should -Match "'PSData' = @\{" + } + + It 'preserves arbitrary dictionary keys that require quoting' { + $privateData = [ordered]@{ + 'Example Product' = [ordered]@{ + 'Feature-Flag' = $true + "Owner's Choice" = 'ready' + '123abc' = 3 + } + } + + Update-ManifestPrivateData -ManifestPath $script:manifestPath -PrivateData $privateData + + $manifest = Import-PowerShellDataFile -LiteralPath $script:manifestPath + $manifest.PrivateData['Example Product']['Feature-Flag'] | Should -BeTrue + $manifest.PrivateData['Example Product']["Owner's Choice"] | Should -Be 'ready' + $manifest.PrivateData['Example Product']['123abc'] | Should -Be 3 + + (Get-Content -LiteralPath $script:manifestPath -Raw) | Should -Match "'Example Product' = @\{" + } + + It 'throws when consumer PrivateData collides with reserved PSData' { + $privateData = [ordered]@{ + PSData = [ordered]@{ + ExampleProduct = 'bad' + } + } + + { Update-ManifestPrivateData -ManifestPath $script:manifestPath -PrivateData $privateData } | + Should -Throw -ErrorId 'Nova.Configuration.ManifestPrivateDataReservedKey' + } +} + +Describe 'Get-GeneratedManifestPsData' { + It 'returns null when the manifest has no PrivateData' { + $manifest = [pscustomobject]@{ + PrivateData = $null + } + + Get-GeneratedManifestPsData -Manifest $manifest | Should -BeNullOrEmpty + } + + It 'converts object-based PSData into an ordered dictionary' { + $manifest = [pscustomobject]@{ + PrivateData = [pscustomobject]@{ + PSData = [pscustomobject]@{ + Tags = @('Example') + ReleaseNotes = 'https://example.test/release' + } + } + } + + $result = Get-GeneratedManifestPsData -Manifest $manifest + + $result | Should -BeOfType ([System.Collections.Specialized.OrderedDictionary]) + $result.Tags | Should -Be @('Example') + $result.ReleaseNotes | Should -Be 'https://example.test/release' + } +} + +Describe 'Get-ManifestPsDataDictionary' { + It 'returns null when PSData is null' { + Get-ManifestPsDataDictionary -PSData $null | Should -BeNullOrEmpty + } +} + +Describe 'Get-ManifestHashtableEntryAst' { + It 'throws when the generated manifest cannot be parsed' { + $invalidManifestPath = Join-Path $TestDrive 'Invalid.psd1' + Set-Content -LiteralPath $invalidManifestPath -Value '@{ PrivateData = ' -NoNewline + + { Get-ManifestHashtableEntryAst -ManifestPath $invalidManifestPath -Name 'PrivateData' } | + Should -Throw -ErrorId 'Nova.Dependency.ModuleManifestParsingFailed' + } + + It 'throws when the generated manifest is missing the requested top-level entry' { + $manifestPath = Join-Path $TestDrive 'MissingEntry.psd1' + Set-Content -LiteralPath $manifestPath -Value "@{`n RootModule = 'Demo.psm1'`n}" -NoNewline + + { Get-ManifestHashtableEntryAst -ManifestPath $manifestPath -Name 'PrivateData' } | + Should -Throw -ErrorId 'Nova.Dependency.ModuleManifestPrivateDataMissing' + } +} + +Describe 'Get-TopLevelManifestHashtableAst' { + It 'throws when the parsed file does not contain a top-level hashtable' { + $scriptPath = Join-Path $TestDrive 'NoHashtable.ps1' + Set-Content -LiteralPath $scriptPath -Value "'plain text'" -NoNewline + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$parseErrors) + + { Get-TopLevelManifestHashtableAst -Ast $ast } | + Should -Throw -ErrorId 'Nova.Dependency.ModuleManifestParsingFailed' + } +} diff --git a/tests/public/TestNovaBuild.Integration.Tests.ps1 b/tests/public/TestNovaBuild.Integration.Tests.ps1 index 5fcbc38..42a51cf 100644 --- a/tests/public/TestNovaBuild.Integration.Tests.ps1 +++ b/tests/public/TestNovaBuild.Integration.Tests.ps1 @@ -66,4 +66,70 @@ Describe 'Test-NovaBuild integration' { $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) } + + It 'builds a manifest that preserves structured Manifest.PrivateData and generated PSData' { + $exampleProjectRoot = Join-Path $script:projectRoot 'src/resources/example' + $scenarioRoot = Join-Path $TestDrive 'private-data-build-validation' + $projectJsonPath = Join-Path $scenarioRoot 'project.json' + $integrationTestPath = Join-Path $scenarioRoot 'tests/public/ManifestPrivateData.Integration.Tests.ps1' + $null = New-Item -ItemType Directory -Path $scenarioRoot -Force + Copy-Item -Path (Join-Path $exampleProjectRoot '*') -Destination $scenarioRoot -Recurse -Force + + $projectData = Get-Content -LiteralPath $projectJsonPath -Raw | ConvertFrom-Json -AsHashtable + $projectData.Manifest.Tags = @('Example') + $projectData.Manifest.PrivateData = [ordered]@{ + 'Example Product' = [ordered]@{ + 'Feature-Flag' = $true + ApiVersion = '1' + RetryCount = 3 + 'Nested Data' = [ordered]@{ + Mode = 'Test' + } + Values = @('one', 'two') + "Owner's Choice" = 'ready' + '123abc' = $null + } + } + $projectData | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $projectJsonPath + + Set-Content -LiteralPath $integrationTestPath -Value @' +BeforeAll { + $projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $projectFile = Join-Path $projectRoot 'project.json' + $projectData = Get-Content -LiteralPath $projectFile -Raw | ConvertFrom-Json -AsHashtable + $script:moduleName = [string]$projectData.ProjectName + $script:manifestPath = Join-Path $projectRoot "dist/$($script:moduleName)/$($script:moduleName).psd1" + $script:manifest = Import-PowerShellDataFile -LiteralPath $script:manifestPath +} + +Describe 'Manifest.PrivateData integration' { + It 'preserves native structured values in the generated manifest' { + $script:manifest.PrivateData['Example Product']['Feature-Flag'] | Should -BeTrue + $script:manifest.PrivateData['Example Product'].ApiVersion | Should -Be '1' + $script:manifest.PrivateData['Example Product'].RetryCount | Should -Be 3 + $script:manifest.PrivateData['Example Product']['Nested Data'].Mode | Should -Be 'Test' + $script:manifest.PrivateData['Example Product'].Values | Should -Be @('one', 'two') + $script:manifest.PrivateData['Example Product']["Owner's Choice"] | Should -Be 'ready' + $script:manifest.PrivateData['Example Product']['123abc'] | Should -BeNullOrEmpty + } + + It 'preserves generated PSData alongside custom PrivateData' { + $script:manifest.PrivateData.PSData.Tags | Should -Be @('Example') + } + + It 'writes a manifest that remains importable after the PrivateData rewrite' { + $content = Get-Content -LiteralPath $script:manifestPath -Raw + + $script:manifest.PrivateData.PSData.Tags | Should -Be @('Example') + $content | Should -Match "'Example Product' = @\{" + } +} +'@ + + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -Path $scenarioRoot -ScriptBlock { + Test-NovaBuild + } + + $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) + } }