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 ecfbf39..a821b94 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. @@ -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/project.json b/project.json index e754abc..9e9959a 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", + "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", "$ErrorActionPreference = 'Stop'" @@ -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 } } 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.' } }