From af3afba58eb907fe9694a982627f0b6ddcf969cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:27:28 +0000 Subject: [PATCH 1/3] 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 d6c5271..cd5eba4 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.4.0", + "Version": "3.4.1-preview", "Preamble": [ "Set-StrictMode -Version Latest", "$ErrorActionPreference = 'Stop'" From 6de474c9265ba13a9f371a4a1a2de590b4181304 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Thu, 8 Oct 2026 16:00:53 +0200 Subject: [PATCH 2/3] fix: improve handling of unsupported Pester versions in test workflows --- CHANGELOG.md | 5 ----- RELEASE_NOTE.md | 2 -- 2 files changed, 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e40ca4..a9f3e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,10 +28,6 @@ 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. - - 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. ## [3.3.1] - 2026-07-15 @@ -552,4 +548,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 d67b31c..c772d25 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -27,7 +27,6 @@ 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. ## [3.3.1] - 2026-07-15 @@ -289,4 +288,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. - From 6a6338df79c0dd64bfae168705774d673c8b07f1 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Thu, 8 Oct 2026 16:57:55 +0200 Subject: [PATCH 3/3] fix: enhance coverage path resolution and artifact management in Invoke-NovaTest --- CHANGELOG.md | 3 + RELEASE_NOTE.md | 4 +- docs/NovaModuleTools/en-US/Invoke-NovaTest.md | 2 + .../quality/GetNovaTestWorkflowContext.ps1 | 44 +++++- .../quality/InvokeNovaTestWorkflow.ps1 | 58 ++++--- ...GetNovaTestWorkflowContext.TestSupport.ps1 | 9 +- .../GetNovaTestWorkflowContext.Tests.ps1 | 39 ++++- .../quality/InvokeNovaTestWorkflow.Tests.ps1 | 5 +- .../InvokeNovaTest.Integration.Tests.ps1 | 148 ++++++++++++++++++ 9 files changed, 277 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9f3e7f..fff26af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- `Invoke-NovaTest` now resolves Pester coverage paths and coverage-report output against the selected Nova project root instead of the PowerShell process's original working directory. + - Unit-test runs now keep coverage measurement, coverage thresholds, and generated test artifacts aligned with the project you selected through `Set-Location`, including when you switch between sibling projects in one PowerShell session. + ### Security ## [3.4.0] - 2026-10-07 diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index c772d25..f511387 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` now keeps coverage measurement, coverage thresholds, and generated test artifacts aligned with the selected project root even when you switch between sibling projects with `Set-Location` in the same PowerShell session. + ### Security ## [3.4.0] - 2026-10-07 @@ -107,7 +109,7 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang ### Fixed -- PowerShell command help `RELATED LINKS` now use valid help-topic. +- PowerShell command help `RELATED LINKS` now use valid help-topic links instead of broken or non-help targets. ## [3.0.1] - 2026-05-18 diff --git a/docs/NovaModuleTools/en-US/Invoke-NovaTest.md b/docs/NovaModuleTools/en-US/Invoke-NovaTest.md index 1a96d2e..b8b8e35 100644 --- a/docs/NovaModuleTools/en-US/Invoke-NovaTest.md +++ b/docs/NovaModuleTools/en-US/Invoke-NovaTest.md @@ -38,6 +38,8 @@ 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`. +Nova resolves test discovery, coverage inputs, and generated test artifacts from the selected project root for the current invocation, so `Set-Location` between sibling projects does not require a new PowerShell process. + Use `Test-NovaBuild` when you need the separate build-validation integration flow that runs against the built module output. Use `-PesterConfigurationOverride` only when you need runtime-only unit-test data injection through file-backed `New-PesterContainer -Path` objects. In v1, Nova accepts only `Run.Container` and still keeps test discovery, output files, coverage, and required execution flags under Nova control. diff --git a/src/private/quality/GetNovaTestWorkflowContext.ps1 b/src/private/quality/GetNovaTestWorkflowContext.ps1 index 65f1d8f..ded2fbf 100644 --- a/src/private/quality/GetNovaTestWorkflowContext.ps1 +++ b/src/private/quality/GetNovaTestWorkflowContext.ps1 @@ -214,6 +214,7 @@ function Get-NovaTestWorkflowContext { $coverageConfiguration = Get-NovaPesterCoverageConfigurationState -ProjectInfo $projectInfo -CoverageEnabled:$workflowProfile.CoverageEnabled $pesterConfig.CodeCoverage.Enabled = $coverageConfiguration.Enabled $pesterConfig.CodeCoverage.Path = $coverageConfiguration.Path + $pesterConfig.CodeCoverage.OutputPath = Resolve-NovaPesterProjectPath -ProjectRoot $projectInfo.ProjectRoot -Path (Get-NovaPesterPathOptionValue -InputObject (Get-NovaPesterSettingValue -InputObject $pesterConfig.CodeCoverage -Name 'OutputPath')) if ($null -ne $coverageConfiguration.CoveragePercentTarget) { $pesterConfig.CodeCoverage.CoveragePercentTarget = $coverageConfiguration.CoveragePercentTarget } @@ -469,7 +470,7 @@ function Get-NovaPesterCoverageFile { [Parameter(Mandatory)][string]$ProjectRoot ) - $resolvedProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path + $resolvedProjectRoot = [System.IO.Path]::GetFullPath($ProjectRoot) foreach ($file in (Get-ChildItem -LiteralPath $resolvedProjectRoot -Recurse -File | Sort-Object FullName)) { [pscustomobject]@{ FullPath = ConvertTo-NovaCoveragePathString -Path $file.FullName @@ -489,12 +490,49 @@ function Add-NovaResolvedCoveragePath { $patternVariant = @(Get-NovaCoveragePathPatternVariant -Pattern $Pattern) foreach ($file in $CoverageFile) { - if ((Test-NovaCoveragePathMatch -CoverageFile $file -Pattern $patternVariant) -and -not $ResolvedPath.Contains($file.RelativePath)) { - $ResolvedPath.Add($file.RelativePath) + if ((Test-NovaCoveragePathMatch -CoverageFile $file -Pattern $patternVariant) -and -not $ResolvedPath.Contains($file.FullPath)) { + $ResolvedPath.Add($file.FullPath) } } } +function Resolve-NovaPesterProjectPath { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ProjectRoot, + [AllowNull()][string]$Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return $null + } + + $resolvedProjectRoot = [System.IO.Path]::GetFullPath($ProjectRoot) + if ([System.IO.Path]::IsPathRooted($Path)) { + return [System.IO.Path]::GetFullPath($Path) + } + + return [System.IO.Path]::GetFullPath($Path, $resolvedProjectRoot) +} + +function Get-NovaPesterPathOptionValue { + [CmdletBinding()] + param( + [AllowNull()][object]$InputObject + ) + + if ($null -eq $InputObject) { + return $null + } + + $value = Get-NovaPesterSettingValue -InputObject $InputObject -Name 'Value' + if ($null -ne $value) { + return [string]$value + } + + return [string]$InputObject +} + function Test-NovaCoveragePathMatch { [CmdletBinding()] param( diff --git a/src/private/quality/InvokeNovaTestWorkflow.ps1 b/src/private/quality/InvokeNovaTestWorkflow.ps1 index dd171fb..c8d1f4a 100644 --- a/src/private/quality/InvokeNovaTestWorkflow.ps1 +++ b/src/private/quality/InvokeNovaTestWorkflow.ps1 @@ -98,7 +98,7 @@ function Invoke-NovaTestWorkflowExecution { StartPercentComplete = 70 EndPercentComplete = 94 } - $testResult = Invoke-NovaPesterWithSuppressedProgress -Configuration $WorkflowContext.PesterConfig -ProgressContext $testProgressContext + $testResult = Invoke-NovaPesterWithSuppressedProgress -Configuration $WorkflowContext.PesterConfig -ProgressContext $testProgressContext -ProjectRoot $WorkflowContext.ProjectInfo.ProjectRoot Invoke-NovaTestWorkflowStep -Activity $Activity -Status 'Writing the test result report' -PercentComplete 96 -Action { & $WorkflowContext.TestResultArtifactWriter.ScriptBlock -TestResult $testResult -OutputPath $WorkflowContext.TestResultPath -ReportWriter $WorkflowContext.TestResultReportWriter.ScriptBlock @@ -145,7 +145,8 @@ function Invoke-NovaPesterWithSuppressedProgress { [CmdletBinding()] param( [Parameter(Mandatory)][object]$Configuration, - [Parameter(Mandatory)][pscustomobject]$ProgressContext + [Parameter(Mandatory)][pscustomobject]$ProgressContext, + [string]$ProjectRoot ) $heartbeatMilliseconds = Get-NovaPropertyValue -InputObject $ProgressContext -Name 'HeartbeatMilliseconds' @@ -154,7 +155,7 @@ function Invoke-NovaPesterWithSuppressedProgress { } $moduleSpecification = Get-NovaPropertyValue -InputObject $Configuration -Name 'PesterModuleSpecification' - $execution = Get-NovaPesterExecution -Configuration $Configuration -ModuleSpecification $moduleSpecification + $execution = Get-NovaPesterExecution -Configuration $Configuration -ModuleSpecification $moduleSpecification -ProjectRoot $ProjectRoot try { Write-NovaTestWorkflowPesterProgress -Execution $execution -ProgressContext $ProgressContext while (-not (Wait-NovaPesterExecution -Execution $execution -TimeoutMilliseconds $HeartbeatMilliseconds)) { @@ -325,32 +326,18 @@ function Get-NovaPesterExecution { [CmdletBinding()] param( [Parameter(Mandatory)][object]$Configuration, - [AllowNull()][object]$ModuleSpecification + [AllowNull()][object]$ModuleSpecification, + [string]$ProjectRoot ) $powershell = [powershell]::Create() - $command = @' -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 { - Invoke-Pester -Configuration $Configuration -} finally { - $global:ProgressPreference = $previousProgressPreference -} -'@ + $command = Get-NovaPesterExecutionScript $moduleImportSpecification = $null if ($null -ne $ModuleSpecification) { $moduleImportSpecification = Get-NovaPropertyValue -InputObject $ModuleSpecification -Name 'FullyQualifiedName' } - $null = $powershell.AddScript($command).AddArgument($Configuration).AddArgument($moduleImportSpecification) + $null = $powershell.AddScript($command).AddArgument($Configuration).AddArgument($moduleImportSpecification).AddArgument($ProjectRoot) return [pscustomobject]@{ PowerShell = $powershell @@ -363,6 +350,35 @@ try { } } +function Get-NovaPesterExecutionScript { + [CmdletBinding()] + param() + + return @' +param($Configuration, $ModuleSpecification, $ProjectRoot) +if ($null -ne $ModuleSpecification) { + Import-Module -FullyQualifiedName $ModuleSpecification -Force -ErrorAction Stop +} +else { + Import-Module Pester -ErrorAction Stop +} +$enteredProjectRoot = -not [string]::IsNullOrWhiteSpace($ProjectRoot) +if ($enteredProjectRoot) { + Push-Location -LiteralPath $ProjectRoot +} +$previousProgressPreference = $global:ProgressPreference +$global:ProgressPreference = 'SilentlyContinue' +try { + Invoke-Pester -Configuration $Configuration +} finally { + $global:ProgressPreference = $previousProgressPreference + if ($enteredProjectRoot) { + Pop-Location + } +} +'@ +} + function Wait-NovaPesterExecution { [CmdletBinding()] param( diff --git a/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 b/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 index b2ac60b..883f1d9 100644 --- a/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 +++ b/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 @@ -24,7 +24,7 @@ function Get-NovaPesterRunPath { ExcludePattern = $ExcludePattern } - return @('tests/Example.Tests.ps1') + return @(Join-Path $ProjectInfo.ProjectRoot 'tests/Example.Tests.ps1') } function Get-NovaPesterTestResultPath { param($ProjectRoot, $FileName) @@ -63,7 +63,12 @@ function New-TestPesterConfig { Filter = [pscustomobject]@{Tag = @(); ExcludeTag = @()} Output = [pscustomobject]@{Verbosity = 'Detailed'; RenderMode = 'Auto'} TestResult = [pscustomobject]@{Enabled = $true; OutputPath = $null} - CodeCoverage = [pscustomobject]@{Enabled = $true; CoveragePercentTarget = 80; Path = $null} + CodeCoverage = [pscustomobject]@{ + Enabled = $true + CoveragePercentTarget = 80 + OutputPath = [pscustomobject]@{Value = 'artifacts/coverage.xml'} + Path = $null + } } } diff --git a/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 b/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 index 61ba5a4..bab67d4 100644 --- a/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 +++ b/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 @@ -47,6 +47,7 @@ Describe 'Get-NovaTestWorkflowContext' { $result.BuildRequested | Should -BeFalse $result.CommandName | Should -Be 'Invoke-NovaTest' $result.PesterConfig.CodeCoverage.CoveragePercentTarget | Should -Be 99 + $result.PesterConfig.CodeCoverage.OutputPath | Should -Be ([System.IO.Path]::Join($projectInfo.ProjectRoot, 'artifacts/coverage.xml')) $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 @@ -146,7 +147,7 @@ Describe 'Get-NovaTestWorkflowContext' { $result.TestDiscoveryMessageLines[3] | Should -Be 'Use Invoke-NovaTest for unit tests and Test-NovaBuild for build-validation integration tests.' } - It 'expands configured coverage paths into concrete project-relative source files' { + It 'expands configured coverage paths into concrete source files rooted at the selected project' { $projectRoot = Join-Path $TestDrive 'coverage-project' foreach ($relativePath in @( 'src/public/GetAlpha.ps1' @@ -178,12 +179,13 @@ Describe 'Get-NovaTestWorkflowContext' { $result = Get-NovaTestWorkflowContext -TestOption @{TestMode = 'Unit'} -BoundParameters @{} $result.PesterConfig.CodeCoverage.Path | Should -Be @( - 'src/public/GetAlpha.ps1' - 'src/private/GetBeta.ps1' - 'src/private/quality/duplicates/GetDelta.ps1' - 'src/private/quality/GetGamma.ps1' - 'src/classes/NovaThing.ps1' + (Join-Path $projectRoot 'src/public/GetAlpha.ps1') + (Join-Path $projectRoot 'src/private/GetBeta.ps1') + (Join-Path $projectRoot 'src/private/quality/duplicates/GetDelta.ps1') + (Join-Path $projectRoot 'src/private/quality/GetGamma.ps1') + (Join-Path $projectRoot 'src/classes/NovaThing.ps1') ) + $result.PesterConfig.CodeCoverage.OutputPath | Should -Be ([System.IO.Path]::Join($projectRoot, 'artifacts/coverage.xml')) } It 'forwards the guarded Pester configuration override to the execution configuration initializer' { @@ -275,6 +277,29 @@ Describe 'Get-NovaDiscoveredTestPathState' { } } +Describe 'Resolve-NovaPesterProjectPath' { + It 'returns null when the requested path is blank' { + Resolve-NovaPesterProjectPath -ProjectRoot $TestDrive -Path $null | Should -BeNullOrEmpty + Resolve-NovaPesterProjectPath -ProjectRoot $TestDrive -Path ' ' | Should -BeNullOrEmpty + } + + It 'returns an absolute path unchanged when the requested path is already rooted' { + $absolutePath = [System.IO.Path]::GetFullPath((Join-Path $TestDrive 'artifacts/coverage.xml')) + + Resolve-NovaPesterProjectPath -ProjectRoot (Join-Path $TestDrive 'project-root') -Path $absolutePath | Should -Be $absolutePath + } +} + +Describe 'Get-NovaPesterPathOptionValue' { + It 'returns null when no option object was provided' { + Get-NovaPesterPathOptionValue -InputObject $null | Should -BeNullOrEmpty + } + + It 'falls back to the input string when the object has no Value property' { + Get-NovaPesterPathOptionValue -InputObject 'artifacts/coverage.xml' | Should -Be 'artifacts/coverage.xml' + } +} + Describe 'Assert-NovaPesterAvailable' { BeforeEach { $script:lastImportModuleRequest = $null @@ -558,7 +583,7 @@ Describe 'Get-NovaPesterCoverageConfigurationState' { $result.Enabled | Should -BeTrue $result.CoveragePercentTarget | Should -Be 99.0 - $result.Path | Should -Be @('src/public/GetAlpha.ps1') + $result.Path | Should -Be @((Join-Path $projectRoot 'src/public/GetAlpha.ps1')) } } diff --git a/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 b/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 index cda0718..9a26b9f 100644 --- a/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 +++ b/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 @@ -16,7 +16,7 @@ Describe 'Invoke-NovaTestWorkflow' { It 'uses the pre-resolved coverage assertion after the Pester run' { $global:coverageAssertionRan = $false $workflowContext = [pscustomobject]@{ - ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'; Pester = [ordered]@{}} + ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'; ProjectRoot = '/tmp/nova-project'; Pester = [ordered]@{}} TestResultDirectory = '/tmp/nova-project/artifacts' CommandName = 'Invoke-NovaTest' TestResultPath = '/tmp/nova-project/artifacts/UnitTestResults.xml' @@ -45,6 +45,9 @@ Describe 'Invoke-NovaTestWorkflow' { $Status -eq 'Checking the configured code coverage target' -and $PercentComplete -eq 99 } Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} + Assert-MockCalled Invoke-NovaPesterWithSuppressedProgress -Times 1 -ParameterFilter { + $ProjectRoot -eq '/tmp/nova-project' + } Assert-MockCalled Write-Message -Times 1 -ParameterFilter { $Text -eq 'Pester tests passed for NovaModuleTools' -and $color -eq 'Green' } diff --git a/tests/public/InvokeNovaTest.Integration.Tests.ps1 b/tests/public/InvokeNovaTest.Integration.Tests.ps1 index 07899bc..40eba7b 100644 --- a/tests/public/InvokeNovaTest.Integration.Tests.ps1 +++ b/tests/public/InvokeNovaTest.Integration.Tests.ps1 @@ -1,9 +1,101 @@ BeforeAll { $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $script:exampleProjectRoot = Join-Path $script:projectRoot 'src/resources/example' . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1') Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null } +function script:New-NovaInvokeNovaTestIntegrationWorkspace { + [CmdletBinding()] + param( + [switch]$FailProjectB + ) + + $workspaceRoot = Join-Path $TestDrive ([guid]::NewGuid().Guid) + $projectARoot = Join-Path $workspaceRoot 'ProjectA' + $projectBRoot = Join-Path $workspaceRoot 'ProjectB' + New-Item -ItemType Directory -Path $projectARoot -Force | Out-Null + New-Item -ItemType Directory -Path $projectBRoot -Force | Out-Null + Copy-Item -Path (Join-Path $script:exampleProjectRoot '*') -Destination $projectARoot -Recurse -Force + Copy-Item -Path (Join-Path $script:exampleProjectRoot '*') -Destination $projectBRoot -Recurse -Force + + Initialize-NovaInvokeNovaTestIntegrationProject -ProjectRoot $projectARoot -ProjectName 'ProjectA' + Initialize-NovaInvokeNovaTestIntegrationProject -ProjectRoot $projectBRoot -ProjectName 'ProjectB' -FailCoverage:$FailProjectB + + return [pscustomobject]@{ + WorkspaceRoot = $workspaceRoot + ProjectARoot = $projectARoot + ProjectBRoot = $projectBRoot + ProjectAArtifacts = Get-NovaInvokeNovaTestArtifactPathMap -ProjectRoot $projectARoot + ProjectBArtifacts = Get-NovaInvokeNovaTestArtifactPathMap -ProjectRoot $projectBRoot + } +} + +function script:Initialize-NovaInvokeNovaTestIntegrationProject { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ProjectRoot, + [Parameter(Mandatory)][string]$ProjectName, + [switch]$FailCoverage + ) + + $projectJsonPath = Join-Path $ProjectRoot 'project.json' + $projectData = Get-Content -LiteralPath $projectJsonPath -Raw | ConvertFrom-Json -AsHashtable + $projectData.ProjectName = $ProjectName + if ($FailCoverage) { + $projectData.Pester.CodeCoverage.CoveragePercentTarget = 100 + } + + $projectData | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $projectJsonPath + + $artifactsDir = Join-Path $ProjectRoot 'artifacts' + New-Item -ItemType Directory -Path $artifactsDir -Force | Out-Null + Set-Content -LiteralPath (Join-Path $artifactsDir 'coverage.xml') -Value "sentinel-$ProjectName-coverage" + Set-Content -LiteralPath (Join-Path $artifactsDir 'UnitTestResults.xml') -Value "sentinel-$ProjectName-results" + + if (-not $FailCoverage) { + return + } + + $uncoveredPath = Join-Path $ProjectRoot 'src/private/GetUncoveredValue.ps1' + Set-Content -LiteralPath $uncoveredPath -Value @' +function Get-UncoveredValue { + return 'uncovered' +} +'@ +} + +function script:Get-NovaInvokeNovaTestArtifactPathMap { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ProjectRoot + ) + + return [pscustomobject]@{ + CoveragePath = Join-Path $ProjectRoot 'artifacts/coverage.xml' + TestResultPath = Join-Path $ProjectRoot 'artifacts/UnitTestResults.xml' + } +} + +function script:Assert-NovaInvokeNovaTestSiblingArtifactsRemainUntouched { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Workspace + ) + + (Get-Content -LiteralPath $Workspace.ProjectAArtifacts.CoveragePath -Raw) | Should -Be "sentinel-ProjectA-coverage$([Environment]::NewLine)" + (Get-Content -LiteralPath $Workspace.ProjectAArtifacts.TestResultPath -Raw) | Should -Be "sentinel-ProjectA-results$([Environment]::NewLine)" +} + +function script:Assert-NovaInvokeNovaTestSelectedProjectProducedResults { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Workspace + ) + + (Get-Content -LiteralPath $Workspace.ProjectBArtifacts.TestResultPath -Raw) | Should -Not -Match 'sentinel-ProjectB-results' +} + Describe 'Invoke-NovaTest integration' { It 'supports WhatIf from the built module' { $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -ScriptBlock { @@ -77,4 +169,60 @@ Describe 'Invoke-NovaTest integration' { $result.ExitCode | Should -Not -Be 0 (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output -NormalizeWhitespace) | Should -Match 'Unsupported override path: Run.Path' } + + It 'writes coverage and test-result artifacts to the selected project when started in that project root' { + $workspace = New-NovaInvokeNovaTestIntegrationWorkspace + + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -Path $workspace.ProjectBRoot -ScriptBlock { + Invoke-NovaTest + } + + $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) + (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output -NormalizeWhitespace) | Should -Match 'Covered 100% / 90%' + (Get-Content -LiteralPath $workspace.ProjectBArtifacts.TestResultPath -Raw) | Should -Not -Match 'sentinel-ProjectB-results' + } + + It 'keeps artifacts isolated when the session starts in a sibling project and navigates with Set-Location' { + $workspace = New-NovaInvokeNovaTestIntegrationWorkspace + + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -Path $workspace.ProjectARoot -ScriptBlock { + Set-Location ../ProjectB + Invoke-NovaTest + } + + $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) + (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output -NormalizeWhitespace) | Should -Match 'Covered 100% / 90%' + Assert-NovaInvokeNovaTestSiblingArtifactsRemainUntouched -Workspace $workspace + Assert-NovaInvokeNovaTestSelectedProjectProducedResults -Workspace $workspace + } + + It 'resolves the selected project from a parent directory and keeps sequential runs isolated in one session' { + $workspace = New-NovaInvokeNovaTestIntegrationWorkspace + + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -Path $workspace.WorkspaceRoot -ScriptBlock { + Set-Location ./ProjectA + Invoke-NovaTest + Set-Location ../ProjectB + Invoke-NovaTest + } + + $result.ExitCode | Should -Be 0 -Because (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output) + (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output -NormalizeWhitespace) | Should -Match 'Covered 100% / 90%.*Covered 100% / 90%' + (Get-Content -LiteralPath $workspace.ProjectAArtifacts.TestResultPath -Raw) | Should -Not -Match 'sentinel-ProjectA-results' + (Get-Content -LiteralPath $workspace.ProjectBArtifacts.TestResultPath -Raw) | Should -Not -Match 'sentinel-ProjectB-results' + } + + It 'keeps failures and artifacts scoped to the selected project when coverage does not meet the configured target' { + $workspace = New-NovaInvokeNovaTestIntegrationWorkspace -FailProjectB + + $result = Invoke-NovaPublicCommandIntegrationInIsolatedSession -ProjectRoot $script:projectRoot -Path $workspace.ProjectARoot -ScriptBlock { + Set-Location ../ProjectB + Invoke-NovaTest + } + + $result.ExitCode | Should -Not -Be 0 + (Get-NovaPublicCommandIntegrationOutputText -Output $result.Output -NormalizeWhitespace) | Should -Match 'Code coverage .* did not meet the configured target 100%' + Assert-NovaInvokeNovaTestSiblingArtifactsRemainUntouched -Workspace $workspace + Assert-NovaInvokeNovaTestSelectedProjectProducedResults -Workspace $workspace + } }