From e38bc48f88eb667c0b8fb1da59089a9a7679b797 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 16:31:21 +0000 Subject: [PATCH 01/12] 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 f4dc486..5d584a7 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.1.0", + "Version": "3.1.1-preview", "Preamble": [ "Set-StrictMode -Version Latest", "$ErrorActionPreference = 'Stop'" From 144c20424110836620330aba5488b1e191d9f4e7 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Sun, 24 May 2026 18:45:21 +0200 Subject: [PATCH 02/12] fix: format community principles for clarity and consistency --- CODE_OF_CONDUCT.md | 40 +++++----------------------------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 21007e4..11bcf41 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,41 +60,11 @@ The following behavior is not acceptable in the Terra Nova community: ## Community Principles Terra Nova is guided by these principles: - -- - -* - -*Clarity over cleverness -** - -- - -* - -*Automation over manual processes -** - -- - -* - -*Consistency over fragmentation -** - -- - -* - -*Quality and pragmatism in balance -** - -- - -* - -*Open by default -** +- **Clarity over cleverness** +- **Automation over manual processes** +- **Consistency over fragmentation** +- **Quality and pragmatism in balance** +- **Open by default** We encourage contributions that align with these principles and improve the ecosystem as a whole. From 94bf7c99bd0b19acaeac0179f6911a7b41d70027 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Mon, 25 May 2026 08:31:00 +0200 Subject: [PATCH 03/12] #238 fix: update Get-NovaProjectInfo and Update-NovaModuleTool commands to enhance version retrieval and user guidance (#239) --- CHANGELOG.md | 6 +- README.md | 6 +- RELEASE_NOTE.md | 6 +- .../en-US/Get-NovaProjectInfo.md | 144 ++++++++++++------ .../en-US/Update-NovaModuleTools.md | 2 +- docs/commands.html | 13 +- docs/versioning-and-updates.html | 13 +- .../InvokeNovaModuleSelfUpdateWorkflow.ps1 | 2 +- src/public/GetNovaProjectInfo.ps1 | 10 +- tests/ArchitectureGuardrails.Tests.ps1 | 2 +- ...vokeNovaModuleSelfUpdateWorkflow.Tests.ps1 | 2 +- .../public/GetNovaProjectInfo.TestSupport.ps1 | 1 + tests/public/GetNovaProjectInfo.Tests.ps1 | 8 +- 13 files changed, 142 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ca0b6..f162166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed +- `Get-NovaProjectInfo` now aligns its installed-version views with the existing CLI version contract. + - `-Installed` now returns the installed version of the current project/module from the local module path. + - `-InstalledNovaVersion` now returns the installed `NovaModuleTools` module name and version from PowerShell. +- `Update-NovaModuleTool` now suggests `Get-NovaProjectInfo -InstalledNovaVersion` after a successful self-update so the PowerShell verification step checks the installed NovaModuleTools version directly. + ### Deprecated ### Removed @@ -468,4 +473,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/README.md b/README.md index 5b90aa0..92b14ab 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,9 @@ To inspect the current project version, the installed version of the current pro `NovaModuleTools` tool version, use: ```powershell +PS> Get-NovaProjectInfo -Version PS> Get-NovaProjectInfo -Installed +PS> Get-NovaProjectInfo -InstalledNovaVersion % nova version % nova version --installed % nova version -i @@ -140,7 +142,9 @@ PS> Get-NovaProjectInfo -Installed - `% nova version` shows the version from the current project's `project.json` - `% nova version --installed` / `% nova version -i` shows the locally installed version of the current project/module from the local module path -- `Get-NovaProjectInfo -Installed` shows the installed `NovaModuleTools` module name and version from PowerShell +- `Get-NovaProjectInfo -Version` shows the version from the current project's `project.json` +- `Get-NovaProjectInfo -Installed` shows the locally installed version of the current project/module from PowerShell +- `Get-NovaProjectInfo -InstalledNovaVersion` shows the installed `NovaModuleTools` module name and version from PowerShell - `% nova --version` / `% nova -v` shows the installed `NovaModuleTools` version ### CLI help diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index 7f24d51..f1e2fc8 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -8,6 +8,11 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang ### Changed +- `Get-NovaProjectInfo` now matches the CLI version split on the PowerShell surface. + - Use `-Installed` for the installed current project/module version. + - Use `-InstalledNovaVersion` for the installed `NovaModuleTools` version. +- `Update-NovaModuleTool` now suggests `Get-NovaProjectInfo -InstalledNovaVersion` after a successful self-update. + ### Deprecated ### Removed @@ -226,4 +231,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/NovaModuleTools/en-US/Get-NovaProjectInfo.md b/docs/NovaModuleTools/en-US/Get-NovaProjectInfo.md index 3674e6c..f4f87c2 100644 --- a/docs/NovaModuleTools/en-US/Get-NovaProjectInfo.md +++ b/docs/NovaModuleTools/en-US/Get-NovaProjectInfo.md @@ -1,10 +1,10 @@ ---- +--- document type: cmdlet external help file: NovaModuleTools-Help.xml -HelpUri: 'https://www.novamoduletools.com/project-json-reference.html' +HelpUri: https://www.novamoduletools.com/project-json-reference.html Locale: en-US Module Name: NovaModuleTools -ms.date: 05/06/2026 +ms.date: 05.25.2026 PlatyPS schema version: 2024-05-01 title: Get-NovaProjectInfo --- @@ -17,24 +17,32 @@ Reads `project.json` and returns resolved NovaModuleTools project metadata or a ## SYNTAX -### ProjectInfo +### ProjectInfo (Default) -```text -PS> Get-NovaProjectInfo [[-Path] ] [] +``` +Get-NovaProjectInfo [[-Path] ] [] ``` ### ProjectVersion -```text -PS> Get-NovaProjectInfo [[-Path] ] [-Version] [] +``` +Get-NovaProjectInfo [[-Path] ] [-Version] [] ``` -### InstalledVersion +### InstalledProjectVersion -```text -PS> Get-NovaProjectInfo [-Installed] [] +``` +Get-NovaProjectInfo [-Installed] [] ``` +### InstalledNovaVersion + +``` +Get-NovaProjectInfo [-InstalledNovaVersion] [] +``` + +## ALIASES + ## DESCRIPTION `Get-NovaProjectInfo` reads the `project.json` file in a NovaModuleTools project and returns a project information object with: @@ -48,7 +56,11 @@ Use this command from scripts, tests, or troubleshooting when you want one objec When you use `-Version`, the command returns only the project version string instead of the full project object. -When you use `-Installed`, the command returns the installed `NovaModuleTools` module name and version string instead of project metadata. +When you use `-Installed`, the command returns the installed version of the current project/module from the local +module path instead of project metadata. + +When you use `-InstalledNovaVersion`, the command returns the installed `NovaModuleTools` module name and version +string. When `-Path` does not resolve to an existing project root folder, or the folder does not contain `project.json`, the command fails with an actionable error that tells you how to recover. @@ -85,40 +97,42 @@ Returns only the version string from `project.json`. PS> Get-NovaProjectInfo -Installed ``` +Returns the installed version of the current project/module from the local module path. + +### EXAMPLE 5 + +```text +PS> Get-NovaProjectInfo -InstalledNovaVersion +``` + Returns the installed `NovaModuleTools` module name and version string. ## PARAMETERS -### -Path +### -Installed -Project root path that contains `project.json`. +Return the installed version of the current project/module from the local module path instead of project metadata. ```yaml -Type: System.String -DefaultValue: (Get-Location).Path +Type: System.Management.Automation.SwitchParameter +DefaultValue: False SupportsWildcards: false Aliases: [] ParameterSets: - - Name: ProjectInfo - Position: 0 - IsRequired: false - ValueFromPipeline: false - ValueFromPipelineByPropertyName: false - ValueFromRemainingArguments: false - - Name: ProjectVersion - Position: 0 - IsRequired: false - ValueFromPipeline: false - ValueFromPipelineByPropertyName: false - ValueFromRemainingArguments: false +- Name: InstalledProjectVersion + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false DontShow: false AcceptedValues: [] HelpMessage: '' ``` -### -Version +### -InstalledNovaVersion -Return only the project version string instead of the full project information object. +Return the installed `NovaModuleTools` module name and version string instead of project metadata. ```yaml Type: System.Management.Automation.SwitchParameter @@ -126,20 +140,47 @@ DefaultValue: False SupportsWildcards: false Aliases: [] ParameterSets: - - Name: ProjectVersion - Position: Named - IsRequired: false - ValueFromPipeline: false - ValueFromPipelineByPropertyName: false - ValueFromRemainingArguments: false +- Name: InstalledNovaVersion + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false DontShow: false AcceptedValues: [] HelpMessage: '' ``` -### -Installed +### -Path -Return the installed `NovaModuleTools` module name and version string instead of project metadata. +Project root path that contains `project.json`. + +```yaml +Type: System.String +DefaultValue: (Get-Location).Path +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: ProjectVersion + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +- Name: ProjectInfo + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Version + +Return only the project version string instead of the full project information object. ```yaml Type: System.Management.Automation.SwitchParameter @@ -147,12 +188,12 @@ DefaultValue: False SupportsWildcards: false Aliases: [] ParameterSets: - - Name: InstalledVersion - Position: Named - IsRequired: false - ValueFromPipeline: false - ValueFromPipelineByPropertyName: false - ValueFromRemainingArguments: false +- Name: ProjectVersion + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false DontShow: false AcceptedValues: [] HelpMessage: '' @@ -160,9 +201,10 @@ HelpMessage: '' ### CommonParameters -This cmdlet supports the common parameters: `-Debug`, `-ErrorAction`, `-ErrorVariable`, `-InformationAction`, -`-InformationVariable`, `-OutBuffer`, `-OutVariable`, `-PipelineVariable`, `-ProgressAction`, `-Verbose`, -`-WarningAction`, and `-WarningVariable`. +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). ## INPUTS @@ -184,6 +226,10 @@ Returned when you use `-Installed`. Returned by default. The object includes project metadata, defaulted build settings, and resolved paths. +### System.String + +Returned when you use `-InstalledNovaVersion`. + ## NOTES This command throws a clear error when `project.json` is missing or empty. @@ -191,7 +237,9 @@ This command throws a clear error when `project.json` is missing or empty. If `-Path` points to a file or a folder that does not exist, `Get-NovaProjectInfo` tells you to rerun it from a Nova project root or pass `-Path` to the folder that contains `project.json`. -`-Installed` does not require a project path or a `project.json` file. +`-Installed` resolves the current Nova project and then reads the installed module version from the local module path. + +`-InstalledNovaVersion` does not require a project path or a `project.json` file. ## RELATED LINKS diff --git a/docs/NovaModuleTools/en-US/Update-NovaModuleTools.md b/docs/NovaModuleTools/en-US/Update-NovaModuleTools.md index 2b4bda4..add5244 100644 --- a/docs/NovaModuleTools/en-US/Update-NovaModuleTools.md +++ b/docs/NovaModuleTools/en-US/Update-NovaModuleTools.md @@ -40,7 +40,7 @@ Stable updates do not require prerelease confirmation. When a newer version is available, `Update-NovaModuleTool` shows progress while it installs the update and reads the release-notes link from the updated module. Every command path ends with a visible summary: up-to-date, preview, cancelled, or updated. -After a successful update, `Update-NovaModuleTool` prints the release notes link from the installed module manifest and suggests `Get-NovaProjectInfo -Installed` as the next verification step. +After a successful update, `Update-NovaModuleTool` prints the release notes link from the installed module manifest and suggests `Get-NovaProjectInfo -InstalledNovaVersion` as the next verification step. ## EXAMPLES diff --git a/docs/commands.html b/docs/commands.html index c9e4f19..1b38a24 100644 --- a/docs/commands.html +++ b/docs/commands.html @@ -268,7 +268,7 @@

Inspect the current project

  • Use when: you need to confirm what Nova resolved from project.json
  • Extra views: - -Version and -Installed
  • + -Version, -Installed, and -InstalledNovaVersion
    @@ -282,8 +282,9 @@

    Inspect the current project

    PS> Get-NovaProjectInfo
     PS> Get-NovaProjectInfo -Version
    -PS> Get-NovaProjectInfo -Installed
    -
    +PS> Get-NovaProjectInfo -Installed +PS> Get-NovaProjectInfo -InstalledNovaVersion +

    See project.json behavior

    @@ -589,7 +590,8 @@

    Check the version you actually mean

  • Use: Get-NovaProjectInfo -Version for project.json and - Get-NovaProjectInfo -Installed for the installed NovaModuleTools tool + Get-NovaProjectInfo -Installed for the installed current project/module and + Get-NovaProjectInfo -InstalledNovaVersion for the installed NovaModuleTools tool version
  • @@ -608,7 +610,8 @@

    Check the version you actually mean

    PS> Get-NovaProjectInfo -Version
    -PS> Get-NovaProjectInfo -Installed
    +PS> Get-NovaProjectInfo -Installed +PS> Get-NovaProjectInfo -InstalledNovaVersion

    See version view diff --git a/docs/versioning-and-updates.html b/docs/versioning-and-updates.html index eff0090..6108b06 100644 --- a/docs/versioning-and-updates.html +++ b/docs/versioning-and-updates.html @@ -113,14 +113,14 @@

    Choose the right version command

    Use this when you want to know what your project will build and publish as. - % nova version --installed or % nova version -i + Get-NovaProjectInfo -Installed or % nova version --installed The version installed locally for the current project/module from the module path. Use this when you want to compare the installed copy against the current working project. - Get-NovaProjectInfo -Installed or % nova --version + Get-NovaProjectInfo -InstalledNovaVersion or % nova --version The installed NovaModuleTools version. Use this when you are troubleshooting Nova itself or checking whether the tool needs an update. @@ -137,7 +137,8 @@

    Choose the right version command

    PS> Get-NovaProjectInfo -Version
    -PS> Get-NovaProjectInfo -Installed
    +PS> Get-NovaProjectInfo -Installed +PS> Get-NovaProjectInfo -InstalledNovaVersion
    -
    -

    PowerShell note: project version lookup and installed-tool version lookup both - have direct - cmdlet forms. The installed current-project module view remains launcher-oriented on this page, - as shown in the comparison table above.

    -
    diff --git a/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 b/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 index 40b5266..6ad50db 100644 --- a/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 +++ b/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 @@ -215,6 +215,6 @@ function Get-NovaModuleSelfUpdateWorkflowNextStepLine { return @( 'Next step:' - 'Get-NovaProjectInfo -Installed' + 'Get-NovaProjectInfo -InstalledNovaVersion' ) } diff --git a/src/public/GetNovaProjectInfo.ps1 b/src/public/GetNovaProjectInfo.ps1 index 0bf68cd..8c5f250 100644 --- a/src/public/GetNovaProjectInfo.ps1 +++ b/src/public/GetNovaProjectInfo.ps1 @@ -6,11 +6,17 @@ function Get-NovaProjectInfo { [string]$Path = (Get-Location).Path, [Parameter(ParameterSetName = 'ProjectVersion')] [switch]$Version, - [Parameter(ParameterSetName = 'InstalledVersion')] - [switch]$Installed + [Parameter(ParameterSetName = 'InstalledProjectVersion')] + [switch]$Installed, + [Parameter(ParameterSetName = 'InstalledNovaVersion')] + [switch]$InstalledNovaVersion ) if ($Installed) { + return Get-NovaInstalledProjectVersion + } + + if ($InstalledNovaVersion) { $module = $ExecutionContext.SessionState.Module $installedVersion = Get-NovaCliInstalledVersion -Module $module return Format-NovaCliVersionString -Name $module.Name -Version $installedVersion diff --git a/tests/ArchitectureGuardrails.Tests.ps1 b/tests/ArchitectureGuardrails.Tests.ps1 index e9cf619..b1fe434 100644 --- a/tests/ArchitectureGuardrails.Tests.ps1 +++ b/tests/ArchitectureGuardrails.Tests.ps1 @@ -83,7 +83,7 @@ Describe 'Architecture guardrails' { It 'public command files use only their approved Nova helper surface' { $testCases = @( [pscustomobject]@{Path = 'src/public/DeployNovaPackage.ps1'; ExpectedHelpers = @('Get-NovaPackageUploadWorkflowContext', 'Get-NovaProjectInfo', 'Invoke-NovaPackageUploadWorkflow', 'New-NovaPackageUploadDynamicParameterDictionary', 'New-NovaPackageUploadOption', 'Write-NovaPackageUploadResultOutput', 'Write-NovaPackageUploadWorkflowContext')} - [pscustomobject]@{Path = 'src/public/GetNovaProjectInfo.ps1'; ExpectedHelpers = @('Format-NovaCliVersionString', 'Get-NovaCliInstalledVersion', 'Get-NovaProjectInfoContext', 'Get-NovaProjectInfoResult')} + [pscustomobject]@{Path = 'src/public/GetNovaProjectInfo.ps1'; ExpectedHelpers = @('Format-NovaCliVersionString', 'Get-NovaCliInstalledVersion', 'Get-NovaInstalledProjectVersion', 'Get-NovaProjectInfoContext', 'Get-NovaProjectInfoResult')} [pscustomobject]@{Path = 'src/public/GetNovaUpdateNotificationPreference.ps1'; ExpectedHelpers = @('Get-NovaUpdateNotificationPreferenceStatus')} [pscustomobject]@{Path = 'src/public/InitializeNovaModule.ps1'; ExpectedHelpers = @('Get-NovaModuleInitializationWorkflowContext', 'Invoke-NovaModuleInitializationWorkflow')} [pscustomobject]@{Path = 'src/public/InstallNovaCli.ps1'; ExpectedHelpers = @('Get-NovaCliInstallWorkflowContext', 'Invoke-NovaCliInstallWorkflow', 'Write-NovaModuleReleaseNotesLink')} diff --git a/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 b/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 index 8cf42bc..29349d1 100644 --- a/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 +++ b/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 @@ -149,7 +149,7 @@ Describe 'Invoke-NovaModuleSelfUpdateWorkflow' { $result.Updated | Should -BeTrue $result.ReleaseNotesUri | Should -Be 'https://example.com/n' Assert-MockCalled Write-Message -Times 1 -ParameterFilter {$Text -eq 'Updated NovaModuleTools to version 1.1.0.' -and $color -eq 'Green'} - Assert-MockCalled Write-Message -Times 1 -ParameterFilter {$Text -eq 'Get-NovaProjectInfo -Installed'} + Assert-MockCalled Write-Message -Times 1 -ParameterFilter {$Text -eq 'Get-NovaProjectInfo -InstalledNovaVersion'} Assert-MockCalled Write-Progress -Times 3 } } diff --git a/tests/public/GetNovaProjectInfo.TestSupport.ps1 b/tests/public/GetNovaProjectInfo.TestSupport.ps1 index bd7ad3a..1323e6a 100644 --- a/tests/public/GetNovaProjectInfo.TestSupport.ps1 +++ b/tests/public/GetNovaProjectInfo.TestSupport.ps1 @@ -1,4 +1,5 @@ function Get-NovaCliInstalledVersion {param($Module) return '1.2.3'} +function Get-NovaInstalledProjectVersion { return 'ProjectX 9.8.7' } function Format-NovaCliVersionString {param($Name, $Version) return "$Name $Version"} function Get-NovaProjectInfoContext {param($Path) return [pscustomobject]@{Path=$Path}} function Get-NovaProjectInfoResult {param($WorkflowContext, [switch]$Version) diff --git a/tests/public/GetNovaProjectInfo.Tests.ps1 b/tests/public/GetNovaProjectInfo.Tests.ps1 index 603ccc0..49c16d6 100644 --- a/tests/public/GetNovaProjectInfo.Tests.ps1 +++ b/tests/public/GetNovaProjectInfo.Tests.ps1 @@ -6,8 +6,12 @@ BeforeAll { } Describe 'Get-NovaProjectInfo' { - It 'returns a formatted installed version when -Installed is set' { - Get-NovaProjectInfo -Installed | Should -Match 'NovaModuleTools 1\.2\.3|.+1\.2\.3' + It 'returns the installed project version when -Installed is set' { + Get-NovaProjectInfo -Installed | Should -Be 'ProjectX 9.8.7' + } + + It 'returns the installed NovaModuleTools version when -InstalledNovaVersion is set' { + Get-NovaProjectInfo -InstalledNovaVersion | Should -Match 'NovaModuleTools 1\.2\.3|.+1\.2\.3' } It 'returns the project info result when -Version is not set' { From 010ecff5ee9deffab09b2ba1afc9f6d0f636f6d7 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Mon, 25 May 2026 12:34:43 +0200 Subject: [PATCH 04/12] fix: streamline Invoke-NovaBuildWorkflow by removing unnecessary CI result handling and updating test cases --- src/private/build/InvokeNovaBuildWorkflow.ps1 | 14 +++---- .../build/InvokeNovaBuildWorkflow.Tests.ps1 | 38 +++++++++++++++++-- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/private/build/InvokeNovaBuildWorkflow.ps1 b/src/private/build/InvokeNovaBuildWorkflow.ps1 index b59ba8f..1b71232 100644 --- a/src/private/build/InvokeNovaBuildWorkflow.ps1 +++ b/src/private/build/InvokeNovaBuildWorkflow.ps1 @@ -43,15 +43,19 @@ function Invoke-NovaBuildWorkflow { } if ($continuousIntegrationRequested) { + Write-NovaBuildWorkflowResult -ProjectInfo $projectInfo + Invoke-NovaBuildWorkflowStep -Activity $progressActivity -Status 'Refreshing the current session with the built module' -PercentComplete 98 -Action { $null = Import-NovaBuiltModuleForCi -ProjectInfo $projectInfo } + + return } } finally { Write-Progress -Activity $progressActivity -Completed } - Write-NovaBuildWorkflowResult -ProjectInfo $projectInfo -ContinuousIntegrationRequested:$continuousIntegrationRequested + Write-NovaBuildWorkflowResult -ProjectInfo $projectInfo } function Invoke-NovaBuildWorkflowStep { @@ -70,17 +74,11 @@ function Invoke-NovaBuildWorkflowStep { function Write-NovaBuildWorkflowResult { [CmdletBinding()] param( - [Parameter(Mandatory)][pscustomobject]$ProjectInfo, - [switch]$ContinuousIntegrationRequested + [Parameter(Mandatory)][pscustomobject]$ProjectInfo ) Write-Message "Built Nova module: $( $ProjectInfo.ProjectName )" -color Green Write-Message "Output module: $( $ProjectInfo.OutputModuleDir )" - - if ($ContinuousIntegrationRequested) { - Write-Message 'The freshly built dist module is loaded for later commands in this session.' - } - Write-Message 'Next step: Test-NovaBuild' } diff --git a/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1 b/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1 index 6949da5..6a130b5 100644 --- a/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1 +++ b/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1 @@ -77,10 +77,42 @@ Describe 'Invoke-NovaBuildWorkflow' { $global:steps -join ',' | Should -Be 'public-layout,reset,module,duplicates,manifest,help,resources,notification,ci' Assert-MockCalled Import-NovaBuiltModuleForCi -Times 1 Assert-MockCalled Write-Progress -Times 10 - Assert-MockCalled Write-Message -Times 4 - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'The freshly built dist module is loaded for later commands in this session.' + Assert-MockCalled Write-Message -Times 3 + } finally { + Remove-Variable -Name steps -Scope Global -ErrorAction SilentlyContinue + } + } + + It 'does not call the local result helper after the CI import refreshes the module session' { + $global:steps = @() + try { + $workflowContext = [pscustomobject]@{ + ProjectInfo = [pscustomobject]@{ + ProjectName = 'NovaModuleTools' + OutputModuleDir = '/tmp/dist/NovaModuleTools' + FailOnDuplicateFunctionNames = $true + } + ContinuousIntegrationRequested = $true } + + Mock Assert-NovaPublicFunctionFileLayout {$global:steps += 'public-layout'} + Mock Reset-ProjectDist {$global:steps += 'reset'} + Mock Build-Module {$global:steps += 'module'} + Mock Assert-BuiltModuleHasNoDuplicateFunctionName {$global:steps += 'duplicates'} + Mock Build-Manifest {$global:steps += 'manifest'} + Mock Build-Help {$global:steps += 'help'} + Mock Copy-ProjectResource {$global:steps += 'resources'} + Mock Invoke-NovaModuleUpdateNotificationSafely {$global:steps += 'notification'} + Mock Import-NovaBuiltModuleForCi { + $global:steps += 'ci' + Remove-Item Function:\Write-NovaBuildWorkflowResult -ErrorAction SilentlyContinue + } + Mock Write-Message {} + Mock Write-Progress {} + + { Invoke-NovaBuildWorkflow -WorkflowContext $workflowContext } | Should -Not -Throw + + $global:steps -join ',' | Should -Be 'public-layout,reset,module,duplicates,manifest,help,resources,notification,ci' } finally { Remove-Variable -Name steps -Scope Global -ErrorAction SilentlyContinue } From b3c01e7eb5b32ca2074dc3ee4399ac5d508104d4 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Mon, 25 May 2026 12:37:31 +0200 Subject: [PATCH 05/12] fix: update supported versions in SECURITY.md to reflect current release lines --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 048b6d4..8155806 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,9 +6,9 @@ Security updates are provided for the latest two stable release lines only. | Version | Supported | |---------|--------------------| +| 3.x.x | :white_check_mark: | | 2.x.x | :white_check_mark: | -| 1.9.x | :white_check_mark: | -| < 1.9 | :x: | +| < 2.x | :x: | ## Reporting a Vulnerability From 5a483b56fc326eba568dd2302607f76b97847c31 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Tue, 26 May 2026 21:28:45 +0200 Subject: [PATCH 06/12] Enhance progress tracking and testing for Nova workflows (#241) * #240 fix: Enhance progress tracking in Nova workflows and tests - Added detailed progress assertions in Invoke-NovaBuildWorkflow, Invoke-NovaPackageUploadWorkflow, Invoke-NovaPackageWorkflow, Invoke-NovaTestWorkflow, Invoke-NovaPublishWorkflow, Invoke-NovaReleaseWorkflow, Invoke-NovaModuleInitializationWorkflow, and Invoke-NovaModuleSelfUpdateWorkflow tests to ensure accurate reporting of workflow states. - Implemented a new test suite to verify that all workflows writing progress have corresponding test files, ensuring consistency and coverage. - Updated mock functions to capture and validate messages related to workflow actions, improving test reliability and output verification. * #240 fix: add tests for Get-NovaPesterExecution and Get-NovaPublishWorkflowPropertyValue functions --- CHANGELOG.md | 7 + RELEASE_NOTE.md | 4 + .../quality/InvokeNovaTestWorkflow.ps1 | 321 ++++++++++++++- .../release/InvokeNovaPublishWorkflow.ps1 | 74 +++- .../release/InvokeNovaReleaseWorkflow.ps1 | 41 +- tests/ProgressWorkflowGuardrails.Tests.ps1 | 29 ++ .../build/InvokeNovaBuildWorkflow.Tests.ps1 | 11 + .../InvokeNovaPackageUploadWorkflow.Tests.ps1 | 10 + .../InvokeNovaPackageWorkflow.Tests.ps1 | 12 + .../quality/InvokeNovaTestWorkflow.Tests.ps1 | 370 +++++++++++++++--- .../InvokeNovaPublishWorkflow.Tests.ps1 | 145 +++++-- .../InvokeNovaReleaseWorkflow.Tests.ps1 | 86 +++- ...vaAgenticCopilotScaffoldWorkflow.Tests.ps1 | 7 + ...NovaModuleInitializationWorkflow.Tests.ps1 | 12 + ...vokeNovaModuleSelfUpdateWorkflow.Tests.ps1 | 7 + 15 files changed, 1004 insertions(+), 132 deletions(-) create mode 100644 tests/ProgressWorkflowGuardrails.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f162166..1abe592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- `Publish-NovaModule` now keeps its completion summary available after local publish import or CI session refresh steps reload the module, so local publish flows no longer fail with missing private release-helper functions at the end of a successful publish. +- `Test-NovaBuild` and `% nova test` now keep the Nova progress display visibly active during long Pester runs instead of appearing stuck on one step while tests continue in the background. + - During the long Pester phase, Nova now uses the discovered test count plus completed test results to drive the progress bar, so the visible percentage tracks real test completion instead of elapsed time. + - The progress text now stays simple while the bar itself reflects the real test-driven completion state. + - The configured Pester output still flows live so `project.json` settings such as `Pester.Output.Verbosity = "Detailed"` remain visible. +- Progress-enabled private workflows now have stronger mirrored progress-contract tests, and the repository adds a guardrail test so future `Write-Progress` workflows cannot land without matching test ownership. + ### Security ## [3.1.0] - 2026-05-24 diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index f1e2fc8..1f7f4d3 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -19,6 +19,10 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang ### Fixed +- `Publish-NovaModule` no longer loses its completion summary after local publish import or CI session refresh steps reload the module. +- `Test-NovaBuild` and `% nova test` now keep the Nova progress display visibly active during long Pester runs instead of appearing stuck on one step while tests continue. + - During the long Pester phase, Nova now drives the progress bar from discovered and completed Pester tests instead of elapsed time, keeps the progress text simple, and still shows configured Pester output such as `Pester.Output.Verbosity = "Detailed"`. + ### Security ## [3.1.0] - 2026-05-24 diff --git a/src/private/quality/InvokeNovaTestWorkflow.ps1 b/src/private/quality/InvokeNovaTestWorkflow.ps1 index 9e31450..14b763c 100644 --- a/src/private/quality/InvokeNovaTestWorkflow.ps1 +++ b/src/private/quality/InvokeNovaTestWorkflow.ps1 @@ -71,11 +71,14 @@ function Invoke-NovaTestWorkflowExecution { $WorkflowContext.PesterConfig.TestResult.OutputPath = $WorkflowContext.TestResultPath $coverageTargetAssertion = Get-NovaCoverageTargetAssertionScriptBlock -WorkflowContext $WorkflowContext - $testResult = Invoke-NovaTestWorkflowStep -Activity $Activity -Status 'Running Pester tests' -PercentComplete 70 -Action { - Invoke-NovaPesterWithSuppressedProgress -Configuration $WorkflowContext.PesterConfig + $testProgressContext = [pscustomobject]@{ + Activity = $Activity + StartPercentComplete = 70 + EndPercentComplete = 94 } + $testResult = Invoke-NovaPesterWithSuppressedProgress -Configuration $WorkflowContext.PesterConfig -ProgressContext $testProgressContext - Invoke-NovaTestWorkflowStep -Activity $Activity -Status 'Writing the test result report' -PercentComplete 85 -Action { + 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 } @@ -83,7 +86,7 @@ function Invoke-NovaTestWorkflowExecution { Stop-NovaOperation -Message (Get-NovaTestWorkflowFailureMessage -WorkflowContext $WorkflowContext) -ErrorId 'Nova.Workflow.TestRunFailed' -Category InvalidOperation -TargetObject $WorkflowContext.TestResultPath } - Invoke-NovaTestWorkflowStep -Activity $Activity -Status 'Checking the configured code coverage target' -PercentComplete 95 -Action { + Invoke-NovaTestWorkflowStep -Activity $Activity -Status 'Checking the configured code coverage target' -PercentComplete 99 -Action { & $coverageTargetAssertion -WorkflowContext $WorkflowContext -TestResult $testResult } @@ -119,16 +122,316 @@ function Get-NovaTestWorkflowBuildStatus { function Invoke-NovaPesterWithSuppressedProgress { [CmdletBinding()] param( - [Parameter(Mandatory)][object]$Configuration + [Parameter(Mandatory)][object]$Configuration, + [Parameter(Mandatory)][pscustomobject]$ProgressContext ) - $previousProgressPreference = $global:ProgressPreference - $global:ProgressPreference = 'SilentlyContinue' + $heartbeatMilliseconds = Get-NovaPropertyValue -InputObject $ProgressContext -Name 'HeartbeatMilliseconds' + if ($null -eq $heartbeatMilliseconds) { + $heartbeatMilliseconds = 2000 + } + + $execution = Get-NovaPesterExecution -Configuration $Configuration try { - return Invoke-NovaPester -Configuration $Configuration + Write-NovaTestWorkflowPesterProgress -Execution $execution -ProgressContext $ProgressContext + while (-not (Wait-NovaPesterExecution -Execution $execution -TimeoutMilliseconds $HeartbeatMilliseconds)) { + Write-NovaPesterExecutionOutput -Execution $execution + Write-NovaTestWorkflowPesterProgress -Execution $execution -ProgressContext $ProgressContext + } + + Write-NovaPesterExecutionOutput -Execution $execution + Write-NovaTestWorkflowPesterProgress -Execution $execution -ProgressContext $ProgressContext + return Receive-NovaPesterExecutionResult -Execution $execution } finally { - $global:ProgressPreference = $previousProgressPreference + Complete-NovaPesterExecution -Execution $execution + } +} + +function Write-NovaPesterExecutionOutput { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Execution + ) + + $informationRecords = @(Get-NovaPesterExecutionInformationRecordBuffer -Execution $Execution) + $nextInformationRecordIndex = Get-NovaPropertyValue -InputObject $Execution -Name 'NextInformationRecordIndex' + if ($null -eq $nextInformationRecordIndex) { + $nextInformationRecordIndex = 0 } + + while ($nextInformationRecordIndex -lt $informationRecords.Count) { + $record = $informationRecords[$nextInformationRecordIndex] + Invoke-NovaPesterExecutionProgressStateUpdate -Execution $Execution -Record $record + Write-NovaPesterInformationRecord -Record $record + $nextInformationRecordIndex += 1 + } + + $Execution.NextInformationRecordIndex = $nextInformationRecordIndex +} + +function Get-NovaPesterExecutionInformationRecordBuffer { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Execution + ) + + $powershell = Get-NovaPropertyValue -InputObject $Execution -Name 'PowerShell' + if ($null -eq $powershell) { + return @() + } + + return @(Get-NovaPropertyValue -InputObject $powershell.Streams -Name 'Information') +} + +function Write-NovaPesterInformationRecord { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$Record + ) + + $messageData = Get-NovaPropertyValue -InputObject $Record -Name 'MessageData' + $tags = @(Get-NovaPropertyValue -InputObject $Record -Name 'Tags') + if (($tags -contains 'PSHOST') -and (Test-NovaPesterHostInformationMessage -MessageData $messageData)) { + Write-NovaPesterHostInformationMessage -MessageData $messageData + return + } + + Write-Information -MessageData $messageData -Tags $tags -InformationAction Continue +} + +function Invoke-NovaPesterExecutionProgressStateUpdate { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Execution, + [Parameter(Mandatory)][object]$Record + ) + + $messageText = Get-NovaPesterInformationMessageText -Record $Record + $discoveredTestCount = Get-NovaPesterDiscoveredTestCount -MessageText $messageText + if ($null -ne $discoveredTestCount) { + $Execution.TotalTestCount = $discoveredTestCount + return + } + + if (Test-NovaPesterTestCompletionMessage -Record $Record -MessageText $messageText) { + $Execution.CompletedTestCount = (Get-NovaPropertyValue -InputObject $Execution -Name 'CompletedTestCount') + 1 + } +} + +function Get-NovaPesterInformationMessageText { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$Record + ) + + $messageData = Get-NovaPropertyValue -InputObject $Record -Name 'MessageData' + if (Test-NovaPesterHostInformationMessage -MessageData $messageData) { + return [string](Get-NovaPropertyValue -InputObject $messageData -Name 'Message') + } + + return [string]$messageData +} + +function Get-NovaPesterDiscoveredTestCount { + [CmdletBinding()] + param( + [AllowNull()][string]$MessageText + ) + + $match = [System.Text.RegularExpressions.Regex]::Match([string]$MessageText, 'Discovery found (?\d+) tests? in') + if (-not $match.Success) { + return $null + } + + return [int]$match.Groups['Count'].Value +} + +function Test-NovaPesterTestCompletionMessage { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$Record, + [AllowNull()][string]$MessageText + ) + + $messageData = Get-NovaPropertyValue -InputObject $Record -Name 'MessageData' + if (-not (Test-NovaPesterHostInformationMessage -MessageData $messageData)) { + return $false + } + + if ($true -ne [bool](Get-NovaPropertyValue -InputObject $messageData -Name 'NoNewLine')) { + return $false + } + + return [string]$MessageText -match '^\s+\[(\+|-|!|\?)\]\s+' +} + +function Test-NovaPesterHostInformationMessage { + [CmdletBinding()] + param( + [AllowNull()][object]$MessageData + ) + + return $null -ne $MessageData -and $MessageData.PSObject.Properties.Name -contains 'Message' +} + +function Write-NovaPesterHostInformationMessage { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$MessageData + ) + + $writeHostParameters = @{ + Object = $MessageData.Message + } + + if ($true -eq [bool](Get-NovaPropertyValue -InputObject $MessageData -Name 'NoNewLine')) { + $writeHostParameters.NoNewline = $true + } + + foreach ($colorName in 'ForegroundColor', 'BackgroundColor') { + $colorValue = Get-NovaPropertyValue -InputObject $MessageData -Name $colorName + if ($null -ne $colorValue) { + $writeHostParameters[$colorName] = $colorValue + } + } + + Write-Host @writeHostParameters +} + +function Get-NovaPesterExecution { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$Configuration + ) + + $powershell = [powershell]::Create() + $command = @' +param($Configuration) +Import-Module Pester -ErrorAction Stop +$previousProgressPreference = $global:ProgressPreference +$global:ProgressPreference = 'SilentlyContinue' +try { + Invoke-Pester -Configuration $Configuration +} finally { + $global:ProgressPreference = $previousProgressPreference +} +'@ + $null = $powershell.AddScript($command).AddArgument($Configuration) + + return [pscustomobject]@{ + PowerShell = $powershell + AsyncResult = $powershell.BeginInvoke() + CompletedTestCount = 0 + NextInformationRecordIndex = 0 + TotalTestCount = $null + LastProgressStatus = $null + LastProgressPercentComplete = $null + } +} + +function Wait-NovaPesterExecution { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Execution, + [Parameter(Mandatory)][int]$TimeoutMilliseconds + ) + + return $Execution.AsyncResult.AsyncWaitHandle.WaitOne($TimeoutMilliseconds) +} + +function Receive-NovaPesterExecutionResult { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Execution + ) + + $output = $Execution.PowerShell.EndInvoke($Execution.AsyncResult) + return @($output | Select-Object -Last 1)[0] +} + +function Complete-NovaPesterExecution { + [CmdletBinding()] + param( + [AllowNull()][pscustomobject]$Execution + ) + + if ($null -eq $Execution) { + return + } + + $powershell = Get-NovaPropertyValue -InputObject $Execution -Name 'PowerShell' + if ($null -eq $powershell) { + return + } + + $powershell.Dispose() +} + +function Write-NovaTestWorkflowPesterProgress { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Execution, + [Parameter(Mandatory)][pscustomobject]$ProgressContext + ) + + $activity = Get-NovaPropertyValue -InputObject $ProgressContext -Name 'Activity' + $startPercentComplete = Get-NovaPropertyValue -InputObject $ProgressContext -Name 'StartPercentComplete' + $endPercentComplete = Get-NovaPropertyValue -InputObject $ProgressContext -Name 'EndPercentComplete' + $totalTestCount = Get-NovaPropertyValue -InputObject $Execution -Name 'TotalTestCount' + $completedTestCount = Get-NovaPropertyValue -InputObject $Execution -Name 'CompletedTestCount' + $status = Get-NovaTestWorkflowPesterStatus -TotalTestCount $totalTestCount + $percentComplete = Get-NovaTestWorkflowPesterPercentComplete -StartPercentComplete $startPercentComplete -EndPercentComplete $endPercentComplete -CompletedTestCount $completedTestCount -TotalTestCount $totalTestCount + + if (($Execution.LastProgressStatus -eq $status) -and ($Execution.LastProgressPercentComplete -eq $percentComplete)) { + return + } + + Write-Progress -Activity $activity -Status $status -PercentComplete $percentComplete + $Execution.LastProgressStatus = $status + $Execution.LastProgressPercentComplete = $percentComplete +} + +function Get-NovaTestWorkflowPesterStatus { + [CmdletBinding()] + param( + [AllowNull()][object]$TotalTestCount + ) + + if ($null -eq $TotalTestCount) { + return 'Discovering Pester tests' + } + + return 'Running Pester tests' +} + +function Get-NovaTestWorkflowPesterPercentComplete { + [CmdletBinding()] + param( + [Parameter(Mandatory)][int]$StartPercentComplete, + [Parameter(Mandatory)][int]$EndPercentComplete, + [Parameter(Mandatory)][int]$CompletedTestCount, + [AllowNull()][object]$TotalTestCount + ) + + if ($null -eq $TotalTestCount) { + return $StartPercentComplete + } + + if ($TotalTestCount -le 0) { + return $EndPercentComplete + } + + $progressRange = $EndPercentComplete - $StartPercentComplete + $percentComplete = $StartPercentComplete + [int][math]::Floor(($CompletedTestCount / $TotalTestCount) * $progressRange) + if ($percentComplete -gt $EndPercentComplete) { + return $EndPercentComplete + } + + if ($percentComplete -lt $StartPercentComplete) { + return $StartPercentComplete + } + + return $percentComplete } function Get-NovaTestWorkflowFailureMessage { diff --git a/src/private/release/InvokeNovaPublishWorkflow.ps1 b/src/private/release/InvokeNovaPublishWorkflow.ps1 index 97f34dc..30903c5 100644 --- a/src/private/release/InvokeNovaPublishWorkflow.ps1 +++ b/src/private/release/InvokeNovaPublishWorkflow.ps1 @@ -58,6 +58,9 @@ function Invoke-NovaPublishWorkflow { $progressActivity = 'Running Nova publish workflow' $importedLocalModule = $false $restoredBuiltModule = $false + $resolvedResult = Get-NovaPublishWorkflowResolvedResult -WorkflowContext $WorkflowContext -WhatIfEnabled:$whatIfEnabled + $messageWriter = (Get-Command -Name Write-Message -CommandType Function -ErrorAction Stop).ScriptBlock + $resultWriter = (Get-Command -Name Write-NovaPublishWorkflowResolvedResult -CommandType Function -ErrorAction Stop).ScriptBlock try { Invoke-NovaPublishWorkflowStep -Activity $progressActivity -Status (Get-NovaPublishWorkflowValidationStatus -WorkflowContext $WorkflowContext) -PercentComplete 35 -Action { @@ -86,7 +89,7 @@ function Invoke-NovaPublishWorkflow { Write-Progress -Activity $progressActivity -Completed } - Write-NovaPublishWorkflowResult -WorkflowContext $WorkflowContext -WhatIfEnabled:$whatIfEnabled -ImportedLocalModule:$importedLocalModule -RestoredBuiltModule:$restoredBuiltModule + & $resultWriter -Result $resolvedResult -MessageWriter $messageWriter -ImportedLocalModule:$importedLocalModule -RestoredBuiltModule:$restoredBuiltModule } function Get-NovaPublishWorkflowValidationStatus { @@ -122,32 +125,53 @@ function Get-NovaPublishWorkflowPublishStatus { return "Publishing to $targetDescription" } -function Write-NovaPublishWorkflowResult { +function Get-NovaPublishWorkflowResolvedResult { [CmdletBinding()] param( [Parameter(Mandatory)][pscustomobject]$WorkflowContext, - [switch]$WhatIfEnabled, + [switch]$WhatIfEnabled + ) + + $localManifestPath = $null + if ($WorkflowContext.PSObject.Properties.Name -contains 'LocalPublishActivation') { + $localManifestPath = Get-NovaPublishWorkflowPropertyValue -InputObject $WorkflowContext.LocalPublishActivation -Name 'ManifestPath' + } + + return [pscustomobject]@{ + StatusMessage = Get-NovaPublishWorkflowStatusMessage -WorkflowContext $WorkflowContext -WhatIfEnabled:$WhatIfEnabled + PublishTarget = $WorkflowContext.PublishInvocation.Target + SkipTestsRequested = $WorkflowContext.SkipTestsRequested + LocalManifestPath = $localManifestPath + NextStepLines = @(Get-NovaPublishWorkflowNextStepLine -WorkflowContext $WorkflowContext -WhatIfEnabled:$WhatIfEnabled) + } +} + +function Write-NovaPublishWorkflowResolvedResult { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Result, + [Parameter(Mandatory)][scriptblock]$MessageWriter, [switch]$ImportedLocalModule, [switch]$RestoredBuiltModule ) - Write-Message (Get-NovaPublishWorkflowStatusMessage -WorkflowContext $WorkflowContext -WhatIfEnabled:$WhatIfEnabled) -color Green - Write-Message "Publish target: $( $WorkflowContext.PublishInvocation.Target )" + & $MessageWriter $Result.StatusMessage -color Green + & $MessageWriter "Publish target: $( $Result.PublishTarget )" - if ($WorkflowContext.SkipTestsRequested) { - Write-Message 'Pre-publish tests were skipped for this run.' + if ($Result.SkipTestsRequested) { + & $MessageWriter 'Pre-publish tests were skipped for this run.' } - if ($ImportedLocalModule) { - Write-Message "The published local module is loaded from $( $WorkflowContext.LocalPublishActivation.ManifestPath )." + if ($ImportedLocalModule -and -not [string]::IsNullOrWhiteSpace($Result.LocalManifestPath)) { + & $MessageWriter "The published local module is loaded from $( $Result.LocalManifestPath )." } if ($RestoredBuiltModule) { - Write-Message 'The freshly built dist module is loaded again for later commands in this session.' + & $MessageWriter 'The freshly built dist module is loaded again for later commands in this session.' } - foreach ($line in (Get-NovaPublishWorkflowNextStepLine -WorkflowContext $WorkflowContext -WhatIfEnabled:$WhatIfEnabled)) { - Write-Message $line + foreach ($line in $Result.NextStepLines) { + & $MessageWriter $line } } @@ -191,3 +215,29 @@ function Get-NovaPublishWorkflowNextStepLine { "Find-Module $( $WorkflowContext.ProjectInfo.ProjectName ) -Repository $( $WorkflowContext.PublishInvocation.Target )" ) } + +function Get-NovaPublishWorkflowPropertyValue { + [CmdletBinding()] + param( + [AllowNull()][object]$InputObject, + [Parameter(Mandatory)][string]$Name + ) + + if ($null -eq $InputObject) { + return $null + } + + if ($InputObject -is [System.Collections.IDictionary]) { + if ( $InputObject.Contains($Name)) { + return $InputObject[$Name] + } + + return $null + } + + if ($InputObject.PSObject.Properties.Name -contains $Name) { + return $InputObject.$Name + } + + return $null +} diff --git a/src/private/release/InvokeNovaReleaseWorkflow.ps1 b/src/private/release/InvokeNovaReleaseWorkflow.ps1 index 9661b7b..75def13 100644 --- a/src/private/release/InvokeNovaReleaseWorkflow.ps1 +++ b/src/private/release/InvokeNovaReleaseWorkflow.ps1 @@ -55,6 +55,9 @@ function Invoke-NovaReleaseWorkflow { $whatIfEnabled = Test-NovaReleaseWorkflowWhatIfEnabled -WorkflowParams $workflowParams $progressActivity = 'Running Nova release workflow' $versionResult = $null + $resolvedResult = Get-NovaReleaseWorkflowResolvedResult -WorkflowContext $WorkflowContext -WhatIfEnabled:$whatIfEnabled + $messageWriter = (Get-Command -Name Write-Message -CommandType Function -ErrorAction Stop).ScriptBlock + $resultWriter = (Get-Command -Name Write-NovaReleaseWorkflowResolvedResult -CommandType Function -ErrorAction Stop).ScriptBlock try { Invoke-NovaReleaseWorkflowStep -Activity $progressActivity -Status 'Building the current project state' -PercentComplete 15 -Action { @@ -88,7 +91,7 @@ function Invoke-NovaReleaseWorkflow { Write-Progress -Activity $progressActivity -Completed } - Write-NovaReleaseWorkflowResult -WorkflowContext $WorkflowContext -VersionResult $versionResult -WhatIfEnabled:$whatIfEnabled -ShouldRestoreBuiltModule:$shouldRestoreBuiltModule + & $resultWriter -Result $resolvedResult -MessageWriter $messageWriter -VersionResult $versionResult -ShouldRestoreBuiltModule:$shouldRestoreBuiltModule return $versionResult } @@ -147,30 +150,46 @@ function Get-NovaReleasePublishStepStatus { return "Publishing release to $targetDescription" } -function Write-NovaReleaseWorkflowResult { +function Get-NovaReleaseWorkflowResolvedResult { [CmdletBinding()] param( [Parameter(Mandatory)][pscustomobject]$WorkflowContext, + [switch]$WhatIfEnabled + ) + + return [pscustomobject]@{ + ProjectInfo = $WorkflowContext.ProjectInfo + PublishTarget = $WorkflowContext.PublishInvocation.Target + SkipTestsRequested = ($WorkflowContext.PSObject.Properties.Name -contains 'SkipTestsRequested') -and $WorkflowContext.SkipTestsRequested + NextStepLines = @(Get-NovaReleaseWorkflowNextStepLine -WorkflowContext $WorkflowContext -WhatIfEnabled:$WhatIfEnabled) + WhatIfEnabled = [bool]$WhatIfEnabled + } +} + +function Write-NovaReleaseWorkflowResolvedResult { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Result, + [Parameter(Mandatory)][scriptblock]$MessageWriter, [AllowNull()][object]$VersionResult, - [switch]$WhatIfEnabled, [switch]$ShouldRestoreBuiltModule ) $resolvedVersion = Get-NovaReleaseWorkflowResultVersion -VersionResult $VersionResult - $statusMessage = Get-NovaReleaseWorkflowStatusMessage -ProjectInfo $WorkflowContext.ProjectInfo -Version $resolvedVersion -WhatIfEnabled:$WhatIfEnabled - Write-Message $statusMessage -color Green - Write-Message "Publish target: $( $WorkflowContext.PublishInvocation.Target )" + $statusMessage = Get-NovaReleaseWorkflowStatusMessage -ProjectInfo $Result.ProjectInfo -Version $resolvedVersion -WhatIfEnabled:$Result.WhatIfEnabled + & $MessageWriter $statusMessage -color Green + & $MessageWriter "Publish target: $( $Result.PublishTarget )" - if ($WorkflowContext.SkipTestsRequested) { - Write-Message 'Pre-release tests were skipped for this run.' + if ($Result.SkipTestsRequested) { + & $MessageWriter 'Pre-release tests were skipped for this run.' } if ($ShouldRestoreBuiltModule) { - Write-Message 'The freshly built dist module is loaded again for later commands in this session.' + & $MessageWriter 'The freshly built dist module is loaded again for later commands in this session.' } - foreach ($line in (Get-NovaReleaseWorkflowNextStepLine -WorkflowContext $WorkflowContext -WhatIfEnabled:$WhatIfEnabled)) { - Write-Message $line + foreach ($line in $Result.NextStepLines) { + & $MessageWriter $line } } diff --git a/tests/ProgressWorkflowGuardrails.Tests.ps1 b/tests/ProgressWorkflowGuardrails.Tests.ps1 new file mode 100644 index 0000000..5734d48 --- /dev/null +++ b/tests/ProgressWorkflowGuardrails.Tests.ps1 @@ -0,0 +1,29 @@ +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + $script:srcRoot = Join-Path $script:repoRoot 'src/private' + $script:testRoot = Join-Path $script:repoRoot 'tests/private' +} + +Describe 'Progress workflow guardrails' { + It 'each private workflow that writes progress has a mirrored test file' { + $progressWorkflowPaths = @( + Get-ChildItem -LiteralPath $script:srcRoot -Filter '*.ps1' -Recurse -File | + Where-Object { + Select-String -Path $_.FullName -Pattern '\bWrite-Progress\b' -Quiet + } | + ForEach-Object { + ([System.IO.Path]::GetRelativePath($script:repoRoot, $_.FullName)).Replace('\', '/') + } | + Sort-Object + ) + + $missingTests = foreach ($sourcePath in $progressWorkflowPaths) { + $expectedTestPath = $sourcePath.Replace('src/private/', 'tests/private/').Replace('.ps1', '.Tests.ps1') + if (-not (Test-Path -LiteralPath (Join-Path $script:repoRoot $expectedTestPath))) { + $expectedTestPath + } + } + + $missingTests | Should -BeNullOrEmpty -Because "Missing mirrored progress tests: $( $missingTests -join ', ' )" + } +} diff --git a/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1 b/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1 index 6a130b5..ccc6acc 100644 --- a/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1 +++ b/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1 @@ -36,6 +36,13 @@ Describe 'Invoke-NovaBuildWorkflow' { Assert-MockCalled Invoke-NovaModuleUpdateNotificationSafely -Times 1 Assert-MockCalled Import-NovaBuiltModuleForCi -Times 0 Assert-MockCalled Write-Progress -Times 9 + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Validating public command layout' -and $PercentComplete -eq 10 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Checking update notifications' -and $PercentComplete -eq 94 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} Assert-MockCalled Write-Message -Times 3 Assert-MockCalled Write-Message -Times 1 -ParameterFilter { $Text -eq 'Built Nova module: NovaModuleTools' -and $color -eq 'Green' @@ -77,6 +84,10 @@ Describe 'Invoke-NovaBuildWorkflow' { $global:steps -join ',' | Should -Be 'public-layout,reset,module,duplicates,manifest,help,resources,notification,ci' Assert-MockCalled Import-NovaBuiltModuleForCi -Times 1 Assert-MockCalled Write-Progress -Times 10 + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Refreshing the current session with the built module' -and $PercentComplete -eq 98 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} Assert-MockCalled Write-Message -Times 3 } finally { Remove-Variable -Name steps -Scope Global -ErrorAction SilentlyContinue diff --git a/tests/private/package/InvokeNovaPackageUploadWorkflow.Tests.ps1 b/tests/private/package/InvokeNovaPackageUploadWorkflow.Tests.ps1 index e15e66a..d823242 100644 --- a/tests/private/package/InvokeNovaPackageUploadWorkflow.Tests.ps1 +++ b/tests/private/package/InvokeNovaPackageUploadWorkflow.Tests.ps1 @@ -24,6 +24,16 @@ Describe 'Invoke-NovaPackageUploadWorkflow' { Should -Invoke Invoke-NovaPackageArtifactUpload -Times 2 Should -Invoke Write-Progress -Times 2 -ParameterFilter {-not $Completed} Should -Invoke Write-Progress -Times 1 -ParameterFilter {$Completed} + Should -Invoke Write-Progress -Times 1 -ParameterFilter { + (-not $Completed) -and + $Status -eq 'Uploading a.nupkg (1 of 2)' -and + $PercentComplete -eq 0 + } + Should -Invoke Write-Progress -Times 1 -ParameterFilter { + (-not $Completed) -and + $Status -eq 'Uploading b.nupkg (2 of 2)' -and + $PercentComplete -eq 50 + } } It 'uses a generic progress status when the artifact file name is blank' { diff --git a/tests/private/package/InvokeNovaPackageWorkflow.Tests.ps1 b/tests/private/package/InvokeNovaPackageWorkflow.Tests.ps1 index 5dcad36..916883f 100644 --- a/tests/private/package/InvokeNovaPackageWorkflow.Tests.ps1 +++ b/tests/private/package/InvokeNovaPackageWorkflow.Tests.ps1 @@ -22,6 +22,10 @@ Describe 'Invoke-NovaPackageWorkflow' { $script:validated | Should -BeTrue Should -Invoke Invoke-NovaPackageArtifactCreation -Times 0 Assert-MockCalled Write-Progress -Times 2 + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Building and testing package input' -and $PercentComplete -eq 30 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} $result | Should -BeNullOrEmpty } @@ -36,6 +40,10 @@ Describe 'Invoke-NovaPackageWorkflow' { }) -ShouldRun Should -Invoke Invoke-NovaPackageArtifactCreation -Times 1 Assert-MockCalled Write-Progress -Times 3 + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Creating package artifacts' -and $PercentComplete -eq 85 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} Assert-MockCalled Write-Message -Times 3 Assert-MockCalled Write-Message -Times 1 -ParameterFilter { $Text -eq 'Created 1 package artifact for Demo' -and $color -eq 'Green' @@ -56,6 +64,10 @@ Describe 'Invoke-NovaPackageWorkflow' { $script:validated | Should -BeTrue Assert-MockCalled Write-Message -Times 3 + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Building package input with tests skipped' -and $PercentComplete -eq 30 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} Assert-MockCalled Write-Message -Times 1 -ParameterFilter { $Text -eq 'Package plan ready for Demo' -and $color -eq 'Green' } diff --git a/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 b/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 index c10bc18..e8ca414 100644 --- a/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 +++ b/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 @@ -9,6 +9,7 @@ Describe 'Invoke-NovaTestWorkflow' { BeforeEach { Mock Write-Message {} Mock Write-Progress {} + Mock Invoke-NovaPesterWithSuppressedProgress {[pscustomobject]@{Result = 'Passed'}} } It 'uses the pre-resolved coverage assertion after the Pester run' { @@ -27,12 +28,21 @@ Describe 'Invoke-NovaTestWorkflow' { try { Mock Test-Path {$true} - Mock Invoke-NovaPester {[pscustomobject]@{Result = 'Passed'}} {Invoke-NovaTestWorkflow -WorkflowContext $workflowContext} | Should -Not -Throw $global:coverageAssertionRan | Should -BeTrue Assert-MockCalled Write-Message -Times 4 - Assert-MockCalled Write-Progress -Times 5 + Assert-MockCalled Write-Progress -Times 4 + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Preparing the test result directory' -and $PercentComplete -eq 40 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Writing the test result report' -and $PercentComplete -eq 96 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Checking the configured code coverage target' -and $PercentComplete -eq 99 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} Assert-MockCalled Write-Message -Times 1 -ParameterFilter { $Text -eq 'Pester tests passed for NovaModuleTools' -and $color -eq 'Green' } @@ -73,7 +83,7 @@ Describe 'Invoke-NovaTestWorkflow' { } Mock Test-Path {$true} - Mock Invoke-NovaPester {$PesterResult}.GetNewClosure() + Mock Invoke-NovaPesterWithSuppressedProgress {$PesterResult}.GetNewClosure() $thrown = $null try {Invoke-NovaTestWorkflow -WorkflowContext $workflowContext} catch {$thrown = $_} @@ -96,7 +106,7 @@ Describe 'Invoke-NovaTestWorkflow' { } Mock Test-Path {$true} - Mock Invoke-NovaPester { + Mock Invoke-NovaPesterWithSuppressedProgress { [pscustomobject]@{Result = 'Passed'; CodeCoverage = [pscustomobject]@{CoveragePercent = 10}} } @@ -139,60 +149,6 @@ Describe 'Invoke-NovaTestWorkflow' { Get-NovaConfiguredCoveragePercentTarget -WorkflowContext $workflowContext | Should -BeNullOrEmpty } - It 'suppresses global progress output around the Pester run and restores the previous preference' { - $workflowContext = [pscustomobject]@{ - ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'; Pester = [ordered]@{}} - TestResultDirectory = '/tmp/nova-project/artifacts' - TestResultPath = '/tmp/nova-project/artifacts/TestResults.xml' - PesterConfig = [pscustomobject]@{TestResult = [pscustomobject]@{OutputPath = $null}} - TestResultArtifactWriter = [pscustomobject]@{ScriptBlock = {}} - TestResultReportWriter = [pscustomobject]@{ScriptBlock = {}} - } - - $previous = $global:ProgressPreference - $global:ProgressPreference = 'Continue' - $global:observedProgressPreferenceDuringPester = $null - try { - Mock Test-Path {$true} - Mock Invoke-NovaPester { - $global:observedProgressPreferenceDuringPester = $global:ProgressPreference - [pscustomobject]@{Result = 'Passed'} - } - - Invoke-NovaTestWorkflow -WorkflowContext $workflowContext - - $global:observedProgressPreferenceDuringPester | Should -Be 'SilentlyContinue' - $global:ProgressPreference | Should -Be 'Continue' - } finally { - $global:ProgressPreference = $previous - Remove-Variable -Name observedProgressPreferenceDuringPester -Scope Global -ErrorAction SilentlyContinue - } - } - - It 'restores the previous progress preference even when Pester throws' { - $workflowContext = [pscustomobject]@{ - ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'; Pester = [ordered]@{}} - TestResultDirectory = '/tmp/nova-project/artifacts' - TestResultPath = '/tmp/nova-project/artifacts/TestResults.xml' - PesterConfig = [pscustomobject]@{TestResult = [pscustomobject]@{OutputPath = $null}} - TestResultArtifactWriter = [pscustomobject]@{ScriptBlock = {}} - TestResultReportWriter = [pscustomobject]@{ScriptBlock = {}} - } - - $previous = $global:ProgressPreference - $global:ProgressPreference = 'Continue' - try { - Mock Test-Path {$true} - Mock Invoke-NovaPester {throw 'boom'} - - {Invoke-NovaTestWorkflow -WorkflowContext $workflowContext} | Should -Throw - - $global:ProgressPreference | Should -Be 'Continue' - } finally { - $global:ProgressPreference = $previous - } - } - It 'handles workflow execution control for ' -ForEach @( @{ Name = 'a requested build' @@ -242,7 +198,7 @@ Describe 'Invoke-NovaTestWorkflow' { $workflowContext = New-NovaInvokeNovaTestWorkflowContext -BuildRequested $BuildRequested -WorkflowParams $WorkflowParams Mock Invoke-NovaBuild {} Mock Test-Path {$true} - Mock Invoke-NovaPester {$PesterResult} + Mock Invoke-NovaPesterWithSuppressedProgress {$PesterResult} $thrown = $null try { @@ -268,7 +224,7 @@ Describe 'Invoke-NovaTestWorkflow' { } Should -Invoke Invoke-NovaBuild -Times $ExpectedBuildCalls - Should -Invoke Invoke-NovaPester -Times $ExpectedPesterCalls + Should -Invoke Invoke-NovaPesterWithSuppressedProgress -Times $ExpectedPesterCalls Assert-MockCalled Write-Message -Times $ExpectedMessageCount } @@ -282,7 +238,7 @@ Describe 'Invoke-NovaTestWorkflow' { TestResultArtifactWriter = [pscustomobject]@{ScriptBlock = {}} TestResultReportWriter = [pscustomobject]@{ScriptBlock = {}} } - Mock Invoke-NovaPester {[pscustomobject]@{Result = 'Passed'}} + Mock Invoke-NovaPesterWithSuppressedProgress {[pscustomobject]@{Result = 'Passed'}} try { Invoke-NovaTestWorkflow -WorkflowContext $workflowContext Test-Path -LiteralPath $tempDir | Should -BeTrue @@ -299,17 +255,18 @@ Describe 'Invoke-NovaTestWorkflow' { } } Mock Invoke-NovaBuild {} - Mock Invoke-NovaPester {} + Mock Invoke-NovaPesterWithSuppressedProgress {} Invoke-NovaTestWorkflow -WorkflowContext $workflowContext Should -Invoke Invoke-NovaBuild -Times 1 - Should -Invoke Invoke-NovaPester -Times 0 + Should -Invoke Invoke-NovaPesterWithSuppressedProgress -Times 0 Assert-MockCalled Write-Message -Times 5 Assert-MockCalled Write-Progress -Times 2 Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { $Status -eq 'Previewing the build-before-test workflow' -and $PercentComplete -eq 20 } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} Assert-MockCalled Write-Message -Times 1 -ParameterFilter { $Text -eq 'Test plan ready for NovaModuleTools' -and $color -eq 'Green' } @@ -323,6 +280,194 @@ Describe 'Invoke-NovaTestWorkflow' { } +Describe 'Invoke-NovaPesterWithSuppressedProgress' { + BeforeEach { + $script:outputCallCount = 0 + Mock Complete-NovaPesterExecution {} + } + + It 'writes progress from discovered and completed tests until the Pester execution completes' { + $execution = [pscustomobject]@{ + PowerShell = [pscustomobject]@{} + AsyncResult = [pscustomobject]@{} + CompletedTestCount = 0 + TotalTestCount = $null + LastProgressStatus = $null + LastProgressPercentComplete = $null + } + $waitResults = [System.Collections.Queue]::new() + $waitResults.Enqueue($false) + $waitResults.Enqueue($false) + $waitResults.Enqueue($true) + + Mock Write-Progress {} + Mock Get-NovaPesterExecution {$execution} + Mock Wait-NovaPesterExecution {$waitResults.Dequeue()} + Mock Receive-NovaPesterExecutionResult {[pscustomobject]@{Result = 'Passed'}} + Mock Write-NovaPesterExecutionOutput { + switch ($script:outputCallCount) { + 0 {$Execution.TotalTestCount = 2} + 1 {$Execution.CompletedTestCount = 1} + 2 {$Execution.CompletedTestCount = 2} + } + + $script:outputCallCount += 1 + } + + $result = Invoke-NovaPesterWithSuppressedProgress -Configuration ([pscustomobject]@{}) -ProgressContext ([pscustomobject]@{ + Activity = 'Running Nova test workflow' + StartPercentComplete = 70 + EndPercentComplete = 94 + HeartbeatMilliseconds = 2000 + }) + + $result.Result | Should -Be 'Passed' + Should -Invoke Get-NovaPesterExecution -Times 1 + Should -Invoke Wait-NovaPesterExecution -Times 3 + Should -Invoke Write-NovaPesterExecutionOutput -Times 3 + Should -Invoke Receive-NovaPesterExecutionResult -Times 1 + Should -Invoke Complete-NovaPesterExecution -Times 1 + Should -Invoke Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Discovering Pester tests' -and $PercentComplete -eq 70 + } + Should -Invoke Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Running Pester tests' -and $PercentComplete -eq 70 + } + Should -Invoke Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Running Pester tests' -and $PercentComplete -eq 82 + } + Should -Invoke Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Running Pester tests' -and $PercentComplete -eq 94 + } + } + + It 'stops the async execution when receiving the Pester result throws' { + $execution = [pscustomobject]@{ + PowerShell = [pscustomobject]@{} + AsyncResult = [pscustomobject]@{} + CompletedTestCount = 0 + TotalTestCount = $null + LastProgressStatus = $null + LastProgressPercentComplete = $null + } + + Mock Write-Progress {} + Mock Get-NovaPesterExecution {$execution} + Mock Wait-NovaPesterExecution {$true} + Mock Receive-NovaPesterExecutionResult {throw 'boom'} + Mock Write-NovaPesterExecutionOutput {} + + { Invoke-NovaPesterWithSuppressedProgress -Configuration ([pscustomobject]@{}) -ProgressContext ([pscustomobject]@{ + Activity = 'Running Nova test workflow' + StartPercentComplete = 70 + EndPercentComplete = 94 + }) } | Should -Throw + Should -Invoke Write-NovaPesterExecutionOutput -Times 1 + Should -Invoke Complete-NovaPesterExecution -Times 1 + } +} + +Describe 'Write-NovaPesterExecutionOutput' { + BeforeEach { + Mock Write-Host {} + Mock Write-Information {} + } + + It 'writes pending host information records, tracks discovery, and advances the cursor' { + $execution = [pscustomobject]@{ + PowerShell = [pscustomobject]@{ + Streams = [pscustomobject]@{ + Information = @( + [pscustomobject]@{ + Tags = @('PSHOST') + MessageData = [pscustomobject]@{ + Message = 'Discovery found 2 tests in 50ms.' + NoNewLine = $false + ForegroundColor = $null + BackgroundColor = $null + } + } + [pscustomobject]@{ + Tags = @('PSHOST') + MessageData = [pscustomobject]@{ + Message = ' [+] first' + NoNewLine = $true + ForegroundColor = 'DarkGreen' + BackgroundColor = $null + } + } + [pscustomobject]@{ + Tags = @('PSHOST') + MessageData = [pscustomobject]@{ + Message = ' second' + NoNewLine = $false + ForegroundColor = 'DarkGray' + BackgroundColor = $null + } + } + ) + } + } + CompletedTestCount = 0 + NextInformationRecordIndex = 0 + TotalTestCount = $null + } + + Write-NovaPesterExecutionOutput -Execution $execution + Write-NovaPesterExecutionOutput -Execution $execution + + $execution.NextInformationRecordIndex | Should -Be 3 + $execution.CompletedTestCount | Should -Be 1 + $execution.TotalTestCount | Should -Be 2 + Should -Invoke Write-Host -Times 1 -ParameterFilter { + $Object -eq ' [+] first' -and $NoNewline -and $ForegroundColor -eq 'DarkGreen' + } + Should -Invoke Write-Host -Times 1 -ParameterFilter { + $Object -eq ' second' -and -not $NoNewline -and $ForegroundColor -eq 'DarkGray' + } + Should -Invoke Write-Information -Times 0 + } + + It 'forwards non-host information records through Write-Information' { + $execution = [pscustomobject]@{ + PowerShell = [pscustomobject]@{ + Streams = [pscustomobject]@{ + Information = @( + [pscustomobject]@{ + Tags = @('Pester') + MessageData = 'discovery' + } + ) + } + } + NextInformationRecordIndex = 0 + } + + Write-NovaPesterExecutionOutput -Execution $execution + + $execution.NextInformationRecordIndex | Should -Be 1 + Should -Invoke Write-Host -Times 0 + Should -Invoke Write-Information -Times 1 -ParameterFilter { + $MessageData -eq 'discovery' -and $Tags.Count -eq 1 -and $Tags[0] -eq 'Pester' -and $InformationAction -eq 'Continue' + } + } + + It 'initializes the record index to zero when the property value is null' { + $execution = [pscustomobject]@{ + PowerShell = [pscustomobject]@{ + Streams = [pscustomobject]@{Information = @()} + } + CompletedTestCount = 0 + TotalTestCount = $null + NextInformationRecordIndex = $null + } + + Write-NovaPesterExecutionOutput -Execution $execution + + $execution.NextInformationRecordIndex | Should -Be 0 + } +} + Describe 'Get-NovaTestWorkflowCoverageMessage' { It 'includes the measured and configured coverage values when both are available' { $workflowContext = [pscustomobject]@{ @@ -345,3 +490,102 @@ Describe 'Get-NovaTestWorkflowCoverageMessage' { Get-NovaTestWorkflowCoverageMessage -WorkflowContext $workflowContext -TestResult $testResult | Should -Be 'Measured code coverage: 66.67% (target: 90%)' } } + +Describe 'Get-NovaTestWorkflowPesterPercentComplete' { + It 'uses discovered and completed test counts to calculate progress' { + Get-NovaTestWorkflowPesterPercentComplete -StartPercentComplete 70 -EndPercentComplete 94 -CompletedTestCount 1 -TotalTestCount 2 | Should -Be 82 + } + + It 'stays at the start percent until discovery finds the total test count' { + Get-NovaTestWorkflowPesterPercentComplete -StartPercentComplete 70 -EndPercentComplete 94 -CompletedTestCount 0 -TotalTestCount $null | Should -Be 70 + } + + It 'caps completed test progress at the configured end percent' { + Get-NovaTestWorkflowPesterPercentComplete -StartPercentComplete 70 -EndPercentComplete 94 -CompletedTestCount 5 -TotalTestCount 4 | Should -Be 94 + } + + It 'returns the end percent immediately when the total test count is zero' { + Get-NovaTestWorkflowPesterPercentComplete -StartPercentComplete 70 -EndPercentComplete 94 -CompletedTestCount 0 -TotalTestCount 0 | Should -Be 94 + } + + It 'returns the start percent when the computed value falls below the range minimum' { + Get-NovaTestWorkflowPesterPercentComplete -StartPercentComplete 94 -EndPercentComplete 70 -CompletedTestCount 5 -TotalTestCount 4 | Should -Be 94 + } +} + +Describe 'Get-NovaPesterExecutionInformationRecordBuffer' { + It 'returns an empty array when the execution has no PowerShell' { + $execution = [pscustomobject]@{PowerShell = $null} + $result = @(Get-NovaPesterExecutionInformationRecordBuffer -Execution $execution) + $result.Count | Should -Be 0 + } +} + +Describe 'Get-NovaPesterExecution' { + It 'returns an execution object with the expected initial properties' { + $execution = $null + try { + $execution = Get-NovaPesterExecution -Configuration ([pscustomobject]@{}) + $execution.PowerShell | Should -Not -BeNullOrEmpty + $execution.AsyncResult | Should -Not -BeNullOrEmpty + $execution.CompletedTestCount | Should -Be 0 + $execution.NextInformationRecordIndex | Should -Be 0 + $execution.TotalTestCount | Should -BeNullOrEmpty + $execution.LastProgressStatus | Should -BeNullOrEmpty + $execution.LastProgressPercentComplete | Should -BeNullOrEmpty + } finally { + if ($null -ne $execution -and $null -ne $execution.PowerShell) { + $execution.PowerShell.Dispose() + } + } + } +} + +Describe 'Wait-NovaPesterExecution' { + It 'returns true when the async operation completes within the timeout' { + $ps = [powershell]::Create() + $null = $ps.AddScript('return 0') + $asyncResult = $ps.BeginInvoke() + $execution = [pscustomobject]@{PowerShell = $ps; AsyncResult = $asyncResult} + try { + $result = Wait-NovaPesterExecution -Execution $execution -TimeoutMilliseconds 30000 + $result | Should -BeTrue + } finally { + $ps.Dispose() + } + } +} + +Describe 'Receive-NovaPesterExecutionResult' { + It 'returns the last output object from the completed execution' { + $ps = [powershell]::Create() + $null = $ps.AddScript('[pscustomobject]@{Result = "Passed"}') + $asyncResult = $ps.BeginInvoke() + $execution = [pscustomobject]@{PowerShell = $ps; AsyncResult = $asyncResult} + try { + $null = $asyncResult.AsyncWaitHandle.WaitOne(30000) + $result = Receive-NovaPesterExecutionResult -Execution $execution + $result.Result | Should -Be 'Passed' + } finally { + $ps.Dispose() + } + } +} + +Describe 'Complete-NovaPesterExecution' { + It 'returns immediately when the execution is null' { + { Complete-NovaPesterExecution -Execution $null } | Should -Not -Throw + } + + It 'returns immediately when the PowerShell property is null' { + $execution = [pscustomobject]@{PowerShell = $null} + { Complete-NovaPesterExecution -Execution $execution } | Should -Not -Throw + } + + It 'disposes the PowerShell instance' { + $ps = [powershell]::Create() + $null = $ps.AddScript('return 0') + $execution = [pscustomobject]@{PowerShell = $ps} + { Complete-NovaPesterExecution -Execution $execution } | Should -Not -Throw + } +} diff --git a/tests/private/release/InvokeNovaPublishWorkflow.Tests.ps1 b/tests/private/release/InvokeNovaPublishWorkflow.Tests.ps1 index 59281df..1b95bca 100644 --- a/tests/private/release/InvokeNovaPublishWorkflow.Tests.ps1 +++ b/tests/private/release/InvokeNovaPublishWorkflow.Tests.ps1 @@ -48,11 +48,19 @@ Describe 'Invoke-NovaPublishWorkflowCiRestore' { Describe 'Invoke-NovaPublishWorkflow' { BeforeEach { + . (Join-Path $projectRoot 'src/private/release/InvokeNovaPublishWorkflow.ps1') $script:validationCalls = 0 $script:publishCalls = 0 $script:localImportCalls = 0 $script:ciImportCalls = 0 - Mock Write-Message {} + $script:messages = @() + Set-Item -Path Function:\Write-Message -Value { + param($Text, $color) + $script:messages += [pscustomobject]@{ + Text = $Text + Color = $color + } + } Mock Write-Progress {} } @@ -72,13 +80,16 @@ Describe 'Invoke-NovaPublishWorkflow' { $script:publishCalls | Should -Be 1 $script:localImportCalls | Should -Be 0 Assert-MockCalled Write-Progress -Times 3 - Assert-MockCalled Write-Message -Times 4 - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Published Nova module: NovaModuleTools' -and $color -eq 'Green' + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Building and testing publish output' -and $PercentComplete -eq 35 } - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Find-Module NovaModuleTools -Repository PSGallery' + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Publishing to repository PSGallery' -and $PercentComplete -eq 75 } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} + $script:messages.Count | Should -Be 4 + ($script:messages | Where-Object {$_.Text -eq 'Published Nova module: NovaModuleTools' -and $_.Color -eq 'Green'}).Count | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'Find-Module NovaModuleTools -Repository PSGallery'}).Count | Should -Be 1 } It 'imports the local published module, restores the built module in CI, and reports the result' { @@ -104,19 +115,18 @@ Describe 'Invoke-NovaPublishWorkflow' { $script:localImportCalls | Should -Be 1 $script:ciImportCalls | Should -Be 1 Assert-MockCalled Write-Progress -Times 5 - Assert-MockCalled Write-Message -Times 6 - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Pre-publish tests were skipped for this run.' - } - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'The published local module is loaded from /m/Mod.psd1.' - } - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'The freshly built dist module is loaded again for later commands in this session.' + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Importing the published local module' -and $PercentComplete -eq 90 } - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Get-NovaProjectInfo -Installed' + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Refreshing the current session with the built module' -and $PercentComplete -eq 98 } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} + $script:messages.Count | Should -Be 7 + ($script:messages | Where-Object {$_.Text -eq 'Pre-publish tests were skipped for this run.'}).Count | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'The published local module is loaded from /m/Mod.psd1.'}).Count | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'The freshly built dist module is loaded again for later commands in this session.'}).Count | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'Get-NovaProjectInfo -Installed'}).Count | Should -Be 1 } It 'writes a publish plan summary in WhatIf mode without importing or restoring modules' { @@ -139,12 +149,103 @@ Describe 'Invoke-NovaPublishWorkflow' { $script:publishCalls | Should -Be 1 $script:localImportCalls | Should -Be 0 $script:ciImportCalls | Should -Be 0 - Assert-MockCalled Write-Message -Times 4 - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Publish plan ready for NovaModuleTools' -and $color -eq 'Green' + $script:messages.Count | Should -Be 4 + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Previewing publish to repository PSGallery' -and $PercentComplete -eq 75 } - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Run Publish-NovaModule without -WhatIf when you are ready to publish the module.' + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} + ($script:messages | Where-Object {$_.Text -eq 'Publish plan ready for NovaModuleTools' -and $_.Color -eq 'Green'}).Count | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'Run Publish-NovaModule without -WhatIf when you are ready to publish the module.'}).Count | Should -Be 1 + } + + It 'still writes the result after the local publish import refreshes the module session' { + $originalStatusMessage = (Get-Command -Name Get-NovaPublishWorkflowStatusMessage -CommandType Function).ScriptBlock + $originalNextStepLine = (Get-Command -Name Get-NovaPublishWorkflowNextStepLine -CommandType Function).ScriptBlock + Mock Invoke-NovaBuildValidation {$script:validationCalls += 1} + $ctx = [pscustomobject]@{ + PublishParams = @{} + PublishInvocation = [pscustomobject]@{ + Action = {param() $script:publishCalls += 1} + IsLocal = $true + Target = '/modules' + Parameters = [pscustomobject]@{ProjectInfo = [pscustomobject]@{ProjectName='NovaModuleTools'}} + } + LocalPublishActivation = [pscustomobject]@{ + ManifestPath='/m/NovaModuleTools.psd1' + ImportAction = { + param($ProjectName,$ManifestPath) + $script:localImportCalls += 1 + Remove-Item Function:\Get-NovaPublishWorkflowStatusMessage -ErrorAction SilentlyContinue + Remove-Item Function:\Get-NovaPublishWorkflowNextStepLine -ErrorAction SilentlyContinue + } + } + ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'} + WorkflowParams = @{} + SkipTestsRequested = $true + ContinuousIntegrationRequested = $false + } + + try { + { Invoke-NovaPublishWorkflow -WorkflowContext $ctx -ShouldRun } | Should -Not -Throw + + $script:localImportCalls | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'Published Nova module: NovaModuleTools' -and $_.Color -eq 'Green'}).Count | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'The published local module is loaded from /m/NovaModuleTools.psd1.'}).Count | Should -Be 1 + } finally { + Set-Item -Path Function:\Get-NovaPublishWorkflowStatusMessage -Value $originalStatusMessage + Set-Item -Path Function:\Get-NovaPublishWorkflowNextStepLine -Value $originalNextStepLine } } + + It 'still writes the result after the CI restore refreshes the module session' { + $originalStatusMessage = (Get-Command -Name Get-NovaPublishWorkflowStatusMessage -CommandType Function).ScriptBlock + $originalNextStepLine = (Get-Command -Name Get-NovaPublishWorkflowNextStepLine -CommandType Function).ScriptBlock + Mock Invoke-NovaBuildValidation {$script:validationCalls += 1} + $ctx = [pscustomobject]@{ + PublishParams = @{} + PublishInvocation = [pscustomobject]@{ + Action = {param() $script:publishCalls += 1} + IsLocal = $false + Target = 'PSGallery' + } + LocalPublishActivation = $null + ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'} + WorkflowParams = @{} + SkipTestsRequested = $false + ContinuousIntegrationRequested = $true + } + + Mock Import-NovaBuiltModuleForCi { + $script:ciImportCalls += 1 + Remove-Item Function:\Get-NovaPublishWorkflowStatusMessage -ErrorAction SilentlyContinue + Remove-Item Function:\Get-NovaPublishWorkflowNextStepLine -ErrorAction SilentlyContinue + } + + try { + { Invoke-NovaPublishWorkflow -WorkflowContext $ctx -ShouldRun } | Should -Not -Throw + + $script:ciImportCalls | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'Published Nova module: NovaModuleTools' -and $_.Color -eq 'Green'}).Count | Should -Be 1 + } finally { + Set-Item -Path Function:\Get-NovaPublishWorkflowStatusMessage -Value $originalStatusMessage + Set-Item -Path Function:\Get-NovaPublishWorkflowNextStepLine -Value $originalNextStepLine + } + } +} + +Describe 'Get-NovaPublishWorkflowPropertyValue' { + It 'returns the value from a dictionary when the key exists' { + $dict = @{Name = 'test-value'} + Get-NovaPublishWorkflowPropertyValue -InputObject $dict -Name 'Name' | Should -Be 'test-value' + } + + It 'returns null from a dictionary when the key does not exist' { + $dict = @{Other = 'test-value'} + Get-NovaPublishWorkflowPropertyValue -InputObject $dict -Name 'Missing' | Should -BeNullOrEmpty + } + + It 'returns null when a PSObject does not have the named property' { + $obj = [pscustomobject]@{Exists = 'yes'} + Get-NovaPublishWorkflowPropertyValue -InputObject $obj -Name 'Missing' | Should -BeNullOrEmpty + } } diff --git a/tests/private/release/InvokeNovaReleaseWorkflow.Tests.ps1 b/tests/private/release/InvokeNovaReleaseWorkflow.Tests.ps1 index 2bd26fc..deadf18 100644 --- a/tests/private/release/InvokeNovaReleaseWorkflow.Tests.ps1 +++ b/tests/private/release/InvokeNovaReleaseWorkflow.Tests.ps1 @@ -65,12 +65,20 @@ Describe 'Test-NovaReleaseWorkflowShouldRestoreBuiltModule' { Describe 'Invoke-NovaReleaseWorkflow' { BeforeEach { + . (Join-Path $projectRoot 'src/private/release/InvokeNovaReleaseWorkflow.ps1') $script:buildCalls = 0 $script:testCalls = 0 $script:versionCalls = 0 $script:restoreCalls = 0 $script:publishCalls = 0 - Mock Write-Message {} + $script:messages = @() + Set-Item -Path Function:\Write-Message -Value { + param($Text, $color) + $script:messages += [pscustomobject]@{ + Text = $Text + Color = $color + } + } Mock Write-Progress {} } @@ -92,13 +100,28 @@ Describe 'Invoke-NovaReleaseWorkflow' { $script:publishCalls | Should -Be 1 $script:restoreCalls | Should -Be 1 Assert-MockCalled Write-Progress -Times 6 - Assert-MockCalled Write-Message -Times 5 - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Released Nova module: NovaModuleTools 1.0.0' -and $color -eq 'Green' + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Building the current project state' -and $PercentComplete -eq 15 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Running pre-release tests' -and $PercentComplete -eq 35 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Updating the project version' -and $PercentComplete -eq 55 } - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Get-NovaProjectInfo -Version' + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Rebuilding release output' -and $PercentComplete -eq 75 } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Publishing release to repository PSGallery' -and $PercentComplete -eq 90 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Refreshing the current session with the built module' -and $PercentComplete -eq 98 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} + $script:messages.Count | Should -Be 5 + ($script:messages | Where-Object {$_.Text -eq 'Released Nova module: NovaModuleTools 1.0.0' -and $_.Color -eq 'Green'}).Count | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'Get-NovaProjectInfo -Version'}).Count | Should -Be 1 } It 'skips tests when SkipTestsRequested and skips restore when not CI' { @@ -112,12 +135,12 @@ Describe 'Invoke-NovaReleaseWorkflow' { $null = Invoke-NovaReleaseWorkflow -WorkflowContext $ctx $script:testCalls | Should -Be 0 $script:restoreCalls | Should -Be 0 - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Pre-release tests were skipped for this run.' - } - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Get-NovaProjectInfo -Installed' + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Publishing release to the local module path' -and $PercentComplete -eq 90 } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} + ($script:messages | Where-Object {$_.Text -eq 'Pre-release tests were skipped for this run.'}).Count | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'Get-NovaProjectInfo -Installed'}).Count | Should -Be 1 } It 'writes a release plan summary in WhatIf mode without restoring the built module' { @@ -133,11 +156,44 @@ Describe 'Invoke-NovaReleaseWorkflow' { $null = Invoke-NovaReleaseWorkflow -WorkflowContext $ctx $script:restoreCalls | Should -Be 0 - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Release plan ready for NovaModuleTools -> 1.0.0' -and $color -eq 'Green' + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Planning the next release version' -and $PercentComplete -eq 55 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Previewing publish to repository PSGallery' -and $PercentComplete -eq 90 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} + ($script:messages | Where-Object {$_.Text -eq 'Release plan ready for NovaModuleTools -> 1.0.0' -and $_.Color -eq 'Green'}).Count | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'Run Invoke-NovaRelease without -WhatIf when you are ready to apply the release.'}).Count | Should -Be 1 + } + + It 'still writes the result after the CI restore refreshes the module session' { + $originalStatusMessage = (Get-Command -Name Get-NovaReleaseWorkflowStatusMessage -CommandType Function).ScriptBlock + $originalNextStepLine = (Get-Command -Name Get-NovaReleaseWorkflowNextStepLine -CommandType Function).ScriptBlock + $ctx = [pscustomobject]@{ + WorkflowParams = @{} + PublishParams = @{} + PublishInvocation = [pscustomobject]@{Action = {param() $script:publishCalls += 1}; Target = 'PSGallery'; IsLocal = $false} + ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'} + ContinuousIntegrationRequested = $true + SkipTestsRequested = $false + OverrideWarningRequested = $false + } + + Mock Import-NovaBuiltModuleForCi { + $script:restoreCalls += 1 + Remove-Item Function:\Get-NovaReleaseWorkflowStatusMessage -ErrorAction SilentlyContinue + Remove-Item Function:\Get-NovaReleaseWorkflowNextStepLine -ErrorAction SilentlyContinue } - Assert-MockCalled Write-Message -Times 1 -ParameterFilter { - $Text -eq 'Run Invoke-NovaRelease without -WhatIf when you are ready to apply the release.' + + try { + { Invoke-NovaReleaseWorkflow -WorkflowContext $ctx } | Should -Not -Throw + + $script:restoreCalls | Should -Be 1 + ($script:messages | Where-Object {$_.Text -eq 'Released Nova module: NovaModuleTools 1.0.0' -and $_.Color -eq 'Green'}).Count | Should -Be 1 + } finally { + Set-Item -Path Function:\Get-NovaReleaseWorkflowStatusMessage -Value $originalStatusMessage + Set-Item -Path Function:\Get-NovaReleaseWorkflowNextStepLine -Value $originalNextStepLine } } } diff --git a/tests/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.Tests.ps1 b/tests/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.Tests.ps1 index 8b83b75..90bede5 100644 --- a/tests/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.Tests.ps1 +++ b/tests/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.Tests.ps1 @@ -110,6 +110,13 @@ Describe 'Invoke-NovaAgenticCopilotScaffoldWorkflow' { Assert-MockCalled Confirm-NovaAgenticCopilotScaffoldWarning -Times 1 Assert-MockCalled Initialize-NovaModuleAgenticCopilotScaffold -Times 1 Assert-MockCalled Write-Progress -Times 3 + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Confirming overwrite warning' -and $PercentComplete -eq 20 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Refreshing managed scaffold files' -and $PercentComplete -eq 75 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} Assert-MockCalled Write-Message -Times 5 Assert-MockCalled Write-Message -Times 1 -ParameterFilter { $Message -eq 'Agentic Copilot scaffold applied to Demo' -and $color -eq 'Green' diff --git a/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1 b/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1 index 8b76241..7eeda36 100644 --- a/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1 +++ b/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1 @@ -27,6 +27,13 @@ Describe 'Invoke-NovaModuleInitializationWorkflow' { Assert-MockCalled Write-NovaModuleProjectJson -Times 1 Assert-MockCalled Initialize-NovaModuleAgenticCopilotScaffold -Times 0 Assert-MockCalled Write-Progress -Times 3 + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Creating scaffold files' -and $PercentComplete -eq 25 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Writing project.json' -and $PercentComplete -eq 60 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} Assert-MockCalled Write-Message -Times 4 Assert-MockCalled Write-Message -Times 1 -ParameterFilter { $InputObject -eq 'Created Nova module scaffold: DemoModule' -and $color -eq 'Green' @@ -52,6 +59,10 @@ Describe 'Invoke-NovaModuleInitializationWorkflow' { Assert-MockCalled Initialize-NovaModuleAgenticCopilotScaffold -Times 1 Assert-MockCalled Write-Progress -Times 4 + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Applying Agentic Copilot starter' -and $PercentComplete -eq 85 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} } It 'suggests Test-NovaBuild as the next step for the example scaffold' { @@ -69,6 +80,7 @@ Describe 'Invoke-NovaModuleInitializationWorkflow' { Invoke-NovaModuleInitializationWorkflow -WorkflowContext $exampleContext + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} Assert-MockCalled Write-Message -Times 1 -ParameterFilter { $InputObject -eq 'Test-NovaBuild' } diff --git a/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 b/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 index 29349d1..a74c714 100644 --- a/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 +++ b/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 @@ -148,6 +148,13 @@ Describe 'Invoke-NovaModuleSelfUpdateWorkflow' { $result.Updated | Should -BeTrue $result.ReleaseNotesUri | Should -Be 'https://example.com/n' + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Installing version 1.1.0' -and $PercentComplete -eq 80 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Reading release notes from the updated module' -and $PercentComplete -eq 95 + } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} Assert-MockCalled Write-Message -Times 1 -ParameterFilter {$Text -eq 'Updated NovaModuleTools to version 1.1.0.' -and $color -eq 'Green'} Assert-MockCalled Write-Message -Times 1 -ParameterFilter {$Text -eq 'Get-NovaProjectInfo -InstalledNovaVersion'} Assert-MockCalled Write-Progress -Times 3 From f77021496c91a4b834939756869cd4127ca6c2eb Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Wed, 27 May 2026 06:34:46 +0200 Subject: [PATCH 07/12] fix: update terminal UX instructions to remove `% nova` references for clarity --- scripts/build/Sync-AgenticCopilotScaffold.psd1 | 8 ++++++++ .../instructions/terminal-ux-design.instructions.md | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/build/Sync-AgenticCopilotScaffold.psd1 b/scripts/build/Sync-AgenticCopilotScaffold.psd1 index de05cf0..afbfc66 100644 --- a/scripts/build/Sync-AgenticCopilotScaffold.psd1 +++ b/scripts/build/Sync-AgenticCopilotScaffold.psd1 @@ -454,5 +454,13 @@ Old = 'Stop-NovaOperation' New = 'Stop-{{ShortName}}Operation' } + @{ + Old = '`% nova` CLI routes' + New = 'CLI routes' + } + @{ + Old = '`% nova` CLI UX distinct' + New = 'CLI UX distinct' + } ) } diff --git a/src/resources/agentic-copilot/.github/instructions/terminal-ux-design.instructions.md b/src/resources/agentic-copilot/.github/instructions/terminal-ux-design.instructions.md index a6312f2..f4c4fb0 100644 --- a/src/resources/agentic-copilot/.github/instructions/terminal-ux-design.instructions.md +++ b/src/resources/agentic-copilot/.github/instructions/terminal-ux-design.instructions.md @@ -9,11 +9,11 @@ Use the `terminal-ux-design` skill when a change touches any user-facing command This applies to: - PowerShell cmdlets -- `% nova` CLI routes +- CLI routes - private helpers that shape user-visible terminal behavior - contributor docs, command help, and scaffold content that teach those workflows -Keep PowerShell cmdlet UX and `% nova` CLI UX distinct, but hold both to the same terminal UX bar. +Keep PowerShell cmdlet UX and CLI UX distinct, but hold both to the same terminal UX bar. The skill is authoritative for: From 2b4d167a5237f5130ac86c6e21ab329e2ec80134 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Sat, 30 May 2026 11:34:58 +0200 Subject: [PATCH 08/12] feat: Enhance project.json schema support and VS Code integration (#243) * #242 feat: Enhance project.json schema support and VS Code integration - Added `$schema` property injection in `nova init` for new projects, enabling VS Code IntelliSense and autocomplete. - Introduced authoritative `Schema-Project.json` for project.json validation. - Updated `Test-ProjectSchema` to validate against the new schema. - Removed obsolete `Schema-Build.json` and `Schema-Pester.json`. - Implemented `Export-NovaProjectJsonSchema` to copy the schema to the appropriate documentation directory. - Updated documentation to reflect schema changes and IntelliSense support. - Enhanced tests for schema validation and project.json writing functionality. * #242 fix: Update project.json and schema to enhance RequiredModules definition and versioning --- CHANGELOG.md | 6 + RELEASE_NOTE.md | 2 + docs/project-json-reference.html | 30 +- docs/schema/v3/project.json | 361 ++++++++++++++++++ project.json | 2 +- src/private/build/BuildModule.ps1 | 3 +- .../build/ExportNovaProjectJsonSchema.ps1 | 16 + src/private/build/TestProjectSchema.ps1 | 26 +- .../quality/GetNovaTestWorkflowContext.ps1 | 2 +- .../scaffold/WriteNovaModuleProjectJson.ps1 | 6 + src/resources/Schema-Build.json | 221 ----------- src/resources/Schema-Pester.json | 37 -- src/resources/Schema-Project.json | 361 ++++++++++++++++++ src/resources/example/project.json | 1 + .../private/build/BuildModule.TestSupport.ps1 | 3 +- tests/private/build/BuildModule.Tests.ps1 | 3 + .../ExportNovaProjectJsonSchema.Tests.ps1 | 37 ++ .../private/build/TestProjectSchema.Tests.ps1 | 12 +- .../WriteNovaModuleProjectJson.Tests.ps1 | 45 +++ 19 files changed, 877 insertions(+), 297 deletions(-) create mode 100644 docs/schema/v3/project.json create mode 100644 src/private/build/ExportNovaProjectJsonSchema.ps1 delete mode 100644 src/resources/Schema-Build.json delete mode 100644 src/resources/Schema-Pester.json create mode 100644 src/resources/Schema-Project.json create mode 100644 tests/private/build/ExportNovaProjectJsonSchema.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abe592..2a6e4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- `project.json` now has a single authoritative JSON schema hosted at `https://www.novamoduletools.com/schema/v{major}/project.json`. + - `Invoke-NovaBuild` exports the schema to `docs/schema/v{major}/project.json` during build, creating the directory when missing. + - `nova init` injects `"$schema": "https://www.novamoduletools.com/schema/v{major}/project.json"` into every scaffolded project. + - VS Code picks up the schema automatically to provide field validation, autocomplete, and hover descriptions while editing `project.json`. + - The two partial internal schemas (`Schema-Build.json`, `Schema-Pester.json`) are removed; `Schema-Project.json` is the new single source. + ### Changed - `Get-NovaProjectInfo` now aligns its installed-version views with the existing CLI version contract. diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index 1f7f4d3..a06730b 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -6,6 +6,8 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang ### Added +- `nova init` now injects a `"$schema"` property into new projects pointing to the hosted versioned JSON schema, enabling VS Code IntelliSense, autocomplete, and hover descriptions while editing `project.json`. + ### Changed - `Get-NovaProjectInfo` now matches the CLI version split on the PowerShell surface. diff --git a/docs/project-json-reference.html b/docs/project-json-reference.html index 17754fb..4a31900 100644 --- a/docs/project-json-reference.html +++ b/docs/project-json-reference.html @@ -109,14 +109,13 @@

    How to read this file

    defaults to a single .nupkg written to artifacts/packages/.

    -
    - Important compatibility note. -

    The runtime and shipped examples currently use some manifest and Pester fields more broadly than - the JSON schema files explicitly enumerate. In practice, the current product and examples use +

    + Schema coverage. +

    The unified JSON schema covers all documented fields including Manifest.ReleaseNotes, Manifest.LicenseUri, - Manifest.IconUri, and Pester.TestResult.OutputFormat. The reference - below documents the live behavior that the product uses today and calls out those schema gaps - where they matter.

    + Manifest.IconUri, and Pester.TestResult.OutputFormat. VS Code picks + up the schema automatically when the $schema field is present in + project.json.

    @@ -204,6 +203,15 @@

    Complete example

    Top-level project settings

    +
    + VS Code IntelliSense. +

    When nova init scaffolds a new project, it injects a $schema field + that points to the versioned public schema at + https://www.novamoduletools.com/schema/v{major}/project.json. VS Code picks this + up automatically to provide field validation, autocomplete, and hover descriptions while you + edit project.json. You can add the field manually to any existing project by + inserting it as the first key in the file.

    +
    @@ -215,6 +223,14 @@

    Top-level project settings

    + + + + + + diff --git a/docs/schema/v3/project.json b/docs/schema/v3/project.json new file mode 100644 index 0000000..b3fe112 --- /dev/null +++ b/docs/schema/v3/project.json @@ -0,0 +1,361 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "description": "Authoritative project.json schema for NovaModuleTools projects.", + "properties": { + "$schema": { + "type": "string", + "description": "Optional URI pointing to the versioned JSON schema for this file. Injected automatically by nova init." + }, + "ProjectName": { + "type": "string", + "description": "Name of the module and the output folder under dist/." + }, + "Description": { + "type": "string", + "description": "Human-readable project description. Feeds manifest and package metadata." + }, + "Version": { + "type": "string", + "description": "Current semantic version of the project." + }, + "CopyResourcesToModuleRoot": { + "type": "boolean", + "description": "When true, resources are copied into the module root instead of a resources/ subfolder." + }, + "BuildRecursiveFolders": { + "type": "boolean", + "description": "Default enabled recursive discovery for src/classes, src/private and tests. src/public stays top-level only unless explicitly set to false for top-level-only discovery." + }, + "SetSourcePath": { + "type": "boolean", + "description": "Default enabled source markers: emit '# Source: ' before each concatenated source file in the generated dist//.psm1." + }, + "FailOnDuplicateFunctionNames": { + "type": "boolean", + "description": "Default enabled validation: fail build when duplicate top-level function names exist in the generated dist//.psm1." + }, + "Preamble": { + "type": "array", + "description": "Optional module-level lines written at the very top of the generated dist//.psm1 before any source markers or other generated content.", + "items": { + "type": "string" + } + }, + "Manifest": { + "type": "object", + "description": "Controls metadata for the generated PowerShell module manifest.", + "properties": { + "Author": { + "type": "string", + "description": "Sets the manifest author and the default package author when Package.Authors is omitted." + }, + "PowerShellHostVersion": { + "type": "string", + "description": "Defines the minimum PowerShell version expected by the generated module." + }, + "GUID": { + "type": "string", + "description": "Sets the manifest GUID. Generate a new GUID for each distinct module." + }, + "Tags": { + "type": "array", + "description": "Adds manifest tags and is reused by package metadata when present.", + "items": { + "type": "string" + } + }, + "ProjectUri": { + "type": "string", + "description": "Publishes a project URL in the manifest and package metadata." + }, + "ReleaseNotes": { + "type": "string", + "description": "Publishes a release notes link for packaging and user-facing metadata." + }, + "LicenseUri": { + "type": "string", + "description": "Publishes a license URL for packaging and manifest metadata." + }, + "IconUri": { + "type": "string", + "description": "Publishes an icon URL for the module manifest." + }, + "Copyright": { + "type": "string", + "description": "Sets the copyright string in the module manifest." + }, + "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.", + "items": { + "anyOf": [ + { + "type": "string", + "description": "Module name." + }, + { + "type": "object", + "description": "Module specification.", + "required": ["ModuleName"], + "properties": { + "ModuleName": { "type": "string" }, + "ModuleVersion": { "type": "string" }, + "RequiredVersion": { "type": "string" }, + "MaximumVersion": { "type": "string" }, + "GUID": { "type": "string" } + }, + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "Author", + "PowerShellHostVersion", + "GUID" + ] + }, + "Package": { + "type": "object", + "description": "Controls package creation and optional raw HTTP upload defaults.", + "properties": { + "Id": { + "type": "string", + "description": "Package identifier used in generated package file names." + }, + "Types": { + "type": "array", + "description": "List of package types to produce.", + "items": { + "type": "string", + "pattern": "^(?:[Nn][Uu][Gg][Ee][Tt]|[Zz][Ii][Pp]|\\.[Nn][Uu][Pp][Kk][Gg]|\\.[Zz][Ii][Pp])$" + } + }, + "Latest": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "never", + "stable", + "always" + ] + } + ], + "description": "Controls whether a latest alias is published alongside the versioned package." + }, + "OutputDirectory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "Path": { + "type": "string" + }, + "Clean": { + "type": "boolean" + } + } + } + ], + "description": "Directory where generated packages are written." + }, + "PackageFileName": { + "type": "string", + "description": "Base file name for the generated package (without extension)." + }, + "AddVersionToFileName": { + "type": "boolean", + "description": "When true, the version string is appended to the package file name." + }, + "FileNamePattern": { + "type": "string", + "description": "Glob pattern used to identify generated package files for upload." + }, + "Authors": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "Package authors list. Defaults to Manifest.Author when omitted." + }, + "Description": { + "type": "string", + "description": "Package description. Defaults to top-level Description when omitted." + }, + "RepositoryUrl": { + "type": "string", + "description": "Base URL for raw HTTP upload." + }, + "RawRepositoryUrl": { + "type": "string", + "description": "Alternate raw repository URL." + }, + "UploadPath": { + "type": "string", + "description": "Sub-path appended to RepositoryUrl for the upload target." + }, + "Headers": { + "type": "object", + "description": "Custom HTTP headers sent with every upload request.", + "additionalProperties": { + "type": "string" + } + }, + "Auth": { + "type": "object", + "description": "Authentication configuration for upload requests.", + "properties": { + "HeaderName": { + "type": "string" + }, + "Scheme": { + "type": "string" + }, + "Token": { + "type": "string" + }, + "TokenEnvironmentVariable": { + "type": "string" + } + } + }, + "Repositories": { + "type": "array", + "description": "List of named upload repository targets.", + "items": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Url": { + "type": "string" + }, + "UploadPath": { + "type": "string" + }, + "Headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Auth": { + "type": "object", + "properties": { + "HeaderName": { + "type": "string" + }, + "Scheme": { + "type": "string" + }, + "Token": { + "type": "string" + }, + "TokenEnvironmentVariable": { + "type": "string" + } + } + } + }, + "required": [ + "Name", + "Url" + ] + } + } + } + }, + "Pester": { + "type": "object", + "description": "Controls the Pester test run configuration used by Test-NovaBuild.", + "properties": { + "CodeCoverage": { + "type": "object", + "description": "Configures code coverage collection during test runs.", + "properties": { + "Enabled": { + "type": "boolean", + "description": "When true, code coverage is collected." + }, + "Path": { + "type": "array", + "description": "Glob patterns selecting source files to include in coverage analysis.", + "items": { + "type": "string" + } + }, + "CoveragePercentTarget": { + "type": "number", + "description": "Minimum code coverage percentage required for a passing build." + }, + "OutputPath": { + "type": "string", + "description": "Path where the coverage report file is written." + }, + "OutputFormat": { + "type": "string", + "description": "Coverage report format (e.g. JaCoCo)." + } + } + }, + "TestResult": { + "type": "object", + "description": "Configures test result file output.", + "properties": { + "Enabled": { + "type": "boolean", + "description": "When true, a test result file is written." + }, + "OutputFormat": { + "type": "string", + "description": "Test result file format (e.g. NUnitXml)." + } + }, + "required": [ + "Enabled" + ] + }, + "Output": { + "type": "object", + "description": "Configures Pester console output during test runs.", + "properties": { + "Verbosity": { + "type": "string", + "description": "Pester output verbosity level (e.g. Detailed, Normal, Minimal)." + } + }, + "required": [ + "Verbosity" + ] + } + }, + "required": [ + "TestResult", + "Output" + ] + } + }, + "required": [ + "ProjectName", + "Description", + "Version", + "Manifest" + ] +} diff --git a/project.json b/project.json index 5d584a7..824bc75 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.1.1-preview", + "Version": "3.1.1-preview02", "Preamble": [ "Set-StrictMode -Version Latest", "$ErrorActionPreference = 'Stop'" diff --git a/src/private/build/BuildModule.ps1 b/src/private/build/BuildModule.ps1 index 9504442..baa421d 100644 --- a/src/private/build/BuildModule.ps1 +++ b/src/private/build/BuildModule.ps1 @@ -8,7 +8,8 @@ function Build-Module { $novaBuildVersion = (Get-Command Invoke-NovaBuild).Version Write-Verbose "Running NovaModuleTools Version: $novaBuildVersion" Write-Verbose 'Buidling module psm1 file' - Test-ProjectSchema -Schema Build | Out-Null + Test-ProjectSchema | Out-Null + Export-NovaProjectJsonSchema $sb = [System.Text.StringBuilder]::new() Add-ProjectPreambleToModuleBuilder -Builder $sb -ProjectInfo $data diff --git a/src/private/build/ExportNovaProjectJsonSchema.ps1 b/src/private/build/ExportNovaProjectJsonSchema.ps1 new file mode 100644 index 0000000..23bd524 --- /dev/null +++ b/src/private/build/ExportNovaProjectJsonSchema.ps1 @@ -0,0 +1,16 @@ +function Export-NovaProjectJsonSchema { + [CmdletBinding()] + param() + + $projectInfo = Get-NovaProjectInfo + $version = $projectInfo.Version + $major = ($version -split '\.')[0] + $schemaSource = Get-ResourceFilePath -FileName 'Schema-Project.json' + $outputDir = Join-Path (Get-Location).Path "docs/schema/v$major" + if (-not (Test-Path -LiteralPath $outputDir)) { + New-Item -ItemType Directory -Path $outputDir | Out-Null + } + $outputPath = Join-Path $outputDir 'project.json' + Copy-Item -LiteralPath $schemaSource -Destination $outputPath -Force + Write-Verbose "Schema exported to $outputPath" +} diff --git a/src/private/build/TestProjectSchema.ps1 b/src/private/build/TestProjectSchema.ps1 index 31331db..bb278c5 100644 --- a/src/private/build/TestProjectSchema.ps1 +++ b/src/private/build/TestProjectSchema.ps1 @@ -1,27 +1,13 @@ function Test-ProjectSchema { [CmdletBinding()] - param ( - [Parameter(Mandatory)] - [ValidateSet('Build', 'Pester')] - [string] - $Schema - ) - Write-Verbose "Running Schema test against using $Schema schema" - $SchemaPath = @{ - Build = Get-ResourceFilePath -FileName 'Schema-Build.json' - Pester = Get-ResourceFilePath -FileName 'Schema-Pester.json' - } + param() + + Write-Verbose 'Running schema validation against Schema-Project.json' + $schemaPath = Get-ResourceFilePath -FileName 'Schema-Project.json' try { - $result = switch ($Schema) { - 'Build' { - Test-Json -Path 'project.json' -Schema (Get-Content $SchemaPath.Build -Raw) - } - 'Pester' { - Test-Json -Path 'project.json' -Schema (Get-Content $SchemaPath.Pester -Raw) - } - } + $result = Test-Json -Path 'project.json' -Schema (Get-Content $schemaPath -Raw) } catch { - Stop-NovaOperation -Message "Invalid project.json for the $Schema schema: $( $_.Exception.Message )" -ErrorId 'Nova.Configuration.ProjectSchemaValidationFailed' -Category InvalidData -TargetObject 'project.json' + Stop-NovaOperation -Message "Invalid project.json: $( $_.Exception.Message )" -ErrorId 'Nova.Configuration.ProjectSchemaValidationFailed' -Category InvalidData -TargetObject 'project.json' } return $result diff --git a/src/private/quality/GetNovaTestWorkflowContext.ps1 b/src/private/quality/GetNovaTestWorkflowContext.ps1 index fdf003d..0694654 100644 --- a/src/private/quality/GetNovaTestWorkflowContext.ps1 +++ b/src/private/quality/GetNovaTestWorkflowContext.ps1 @@ -27,7 +27,7 @@ function Get-NovaTestWorkflowContext { [Parameter(Mandatory)][hashtable]$BoundParameters ) - Test-ProjectSchema Pester | Out-Null + Test-ProjectSchema | Out-Null Assert-NovaPesterAvailable $projectInfo = Get-NovaProjectInfo $pesterConfig = New-PesterConfiguration -Hashtable $projectInfo.Pester diff --git a/src/private/scaffold/WriteNovaModuleProjectJson.ps1 b/src/private/scaffold/WriteNovaModuleProjectJson.ps1 index 053f38a..4fe5399 100644 --- a/src/private/scaffold/WriteNovaModuleProjectJson.ps1 +++ b/src/private/scaffold/WriteNovaModuleProjectJson.ps1 @@ -18,6 +18,12 @@ function Write-NovaModuleProjectJson { $jsonData.Manifest.GUID = (New-Guid).GUID } + $moduleVersion = $ExecutionContext.SessionState.Module.Version + if ($null -ne $moduleVersion) { + $majorVersion = $moduleVersion.Major + $jsonData['$schema'] = "https://www.novamoduletools.com/schema/v$majorVersion/project.json" + } + if (-not $Example -and $Answer.EnablePester -eq 'No') { $jsonData.Remove('Pester') } diff --git a/src/resources/Schema-Build.json b/src/resources/Schema-Build.json deleted file mode 100644 index f7359b0..0000000 --- a/src/resources/Schema-Build.json +++ /dev/null @@ -1,221 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "description": "Project Schema for Build only", - "properties": { - "ProjectName": { - "type": "string" - }, - "Description": { - "type": "string" - }, - "Version": { - "type": "string" - }, - "CopyResourcesToModuleRoot": { - "type": "boolean" - }, - "BuildRecursiveFolders": { - "type": "boolean", - "description": "Default enabled recursive discovery for src/classes, src/private and tests. src/public stays top-level only unless explicitly set to false for top-level-only discovery." - }, - "SetSourcePath": { - "type": "boolean", - "description": "Default enabled source markers: emit '# Source: ' before each concatenated source file in the generated dist//.psm1." - }, - "FailOnDuplicateFunctionNames": { - "type": "boolean", - "description": "Default enabled validation: fail build when duplicate top-level function names exist in the generated dist//.psm1." - }, - "Preamble": { - "type": "array", - "description": "Optional module-level lines written at the very top of the generated dist//.psm1 before any source markers or other generated content.", - "items": { - "type": "string" - } - }, - "Manifest": { - "type": "object", - "properties": { - "Author": { - "type": "string" - }, - "PowerShellHostVersion": { - "type": "string" - }, - "GUID": { - "type": "string" - }, - "Tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "ProjectUri": { - "type": "string" - } - }, - "required": [ - "Author", - "PowerShellHostVersion", - "GUID" - ] - }, - "Package": { - "type": "object", - "properties": { - "Id": { - "type": "string" - }, - "Types": { - "type": "array", - "items": { - "type": "string", - "pattern": "^(?:[Nn][Uu][Gg][Ee][Tt]|[Zz][Ii][Pp]|\\.[Nn][Uu][Pp][Kk][Gg]|\\.[Zz][Ii][Pp])$" - } - }, - "Latest": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "never", - "stable", - "always" - ] - } - ] - }, - "OutputDirectory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "Path": { - "type": "string" - }, - "Clean": { - "type": "boolean" - } - } - } - ] - }, - "PackageFileName": { - "type": "string" - }, - "AddVersionToFileName": { - "type": "boolean" - }, - "FileNamePattern": { - "type": "string" - }, - "Authors": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "Description": { - "type": "string" - }, - "RepositoryUrl": { - "type": "string" - }, - "RawRepositoryUrl": { - "type": "string" - }, - "UploadPath": { - "type": "string" - }, - "Headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "Auth": { - "type": "object", - "properties": { - "HeaderName": { - "type": "string" - }, - "Scheme": { - "type": "string" - }, - "Token": { - "type": "string" - }, - "TokenEnvironmentVariable": { - "type": "string" - } - } - }, - "Repositories": { - "type": "array", - "items": { - "type": "object", - "properties": { - "Name": { - "type": "string" - }, - "Url": { - "type": "string" - }, - "UploadPath": { - "type": "string" - }, - "Headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "Auth": { - "type": "object", - "properties": { - "HeaderName": { - "type": "string" - }, - "Scheme": { - "type": "string" - }, - "Token": { - "type": "string" - }, - "TokenEnvironmentVariable": { - "type": "string" - } - } - } - }, - "required": [ - "Name", - "Url" - ] - } - } - } - } - }, - "required": [ - "ProjectName", - "Description", - "Version", - "Manifest" - ] -} diff --git a/src/resources/Schema-Pester.json b/src/resources/Schema-Pester.json deleted file mode 100644 index 78dddc0..0000000 --- a/src/resources/Schema-Pester.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "Pester": { - "type": "object", - "properties": { - "TestResult": { - "type": "object", - "properties": { - "Enabled": { - "type": "boolean" - } - }, - "required": [ - "Enabled" - ] - }, - "Output": { - "type": "object", - "properties": { - "Verbosity": { - "type": "string" - } - }, - "required": [ - "Verbosity" - ] - } - }, - "required": [ - "TestResult", - "Output" - ] - } - } -} diff --git a/src/resources/Schema-Project.json b/src/resources/Schema-Project.json new file mode 100644 index 0000000..b3fe112 --- /dev/null +++ b/src/resources/Schema-Project.json @@ -0,0 +1,361 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "description": "Authoritative project.json schema for NovaModuleTools projects.", + "properties": { + "$schema": { + "type": "string", + "description": "Optional URI pointing to the versioned JSON schema for this file. Injected automatically by nova init." + }, + "ProjectName": { + "type": "string", + "description": "Name of the module and the output folder under dist/." + }, + "Description": { + "type": "string", + "description": "Human-readable project description. Feeds manifest and package metadata." + }, + "Version": { + "type": "string", + "description": "Current semantic version of the project." + }, + "CopyResourcesToModuleRoot": { + "type": "boolean", + "description": "When true, resources are copied into the module root instead of a resources/ subfolder." + }, + "BuildRecursiveFolders": { + "type": "boolean", + "description": "Default enabled recursive discovery for src/classes, src/private and tests. src/public stays top-level only unless explicitly set to false for top-level-only discovery." + }, + "SetSourcePath": { + "type": "boolean", + "description": "Default enabled source markers: emit '# Source: ' before each concatenated source file in the generated dist//.psm1." + }, + "FailOnDuplicateFunctionNames": { + "type": "boolean", + "description": "Default enabled validation: fail build when duplicate top-level function names exist in the generated dist//.psm1." + }, + "Preamble": { + "type": "array", + "description": "Optional module-level lines written at the very top of the generated dist//.psm1 before any source markers or other generated content.", + "items": { + "type": "string" + } + }, + "Manifest": { + "type": "object", + "description": "Controls metadata for the generated PowerShell module manifest.", + "properties": { + "Author": { + "type": "string", + "description": "Sets the manifest author and the default package author when Package.Authors is omitted." + }, + "PowerShellHostVersion": { + "type": "string", + "description": "Defines the minimum PowerShell version expected by the generated module." + }, + "GUID": { + "type": "string", + "description": "Sets the manifest GUID. Generate a new GUID for each distinct module." + }, + "Tags": { + "type": "array", + "description": "Adds manifest tags and is reused by package metadata when present.", + "items": { + "type": "string" + } + }, + "ProjectUri": { + "type": "string", + "description": "Publishes a project URL in the manifest and package metadata." + }, + "ReleaseNotes": { + "type": "string", + "description": "Publishes a release notes link for packaging and user-facing metadata." + }, + "LicenseUri": { + "type": "string", + "description": "Publishes a license URL for packaging and manifest metadata." + }, + "IconUri": { + "type": "string", + "description": "Publishes an icon URL for the module manifest." + }, + "Copyright": { + "type": "string", + "description": "Sets the copyright string in the module manifest." + }, + "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.", + "items": { + "anyOf": [ + { + "type": "string", + "description": "Module name." + }, + { + "type": "object", + "description": "Module specification.", + "required": ["ModuleName"], + "properties": { + "ModuleName": { "type": "string" }, + "ModuleVersion": { "type": "string" }, + "RequiredVersion": { "type": "string" }, + "MaximumVersion": { "type": "string" }, + "GUID": { "type": "string" } + }, + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "Author", + "PowerShellHostVersion", + "GUID" + ] + }, + "Package": { + "type": "object", + "description": "Controls package creation and optional raw HTTP upload defaults.", + "properties": { + "Id": { + "type": "string", + "description": "Package identifier used in generated package file names." + }, + "Types": { + "type": "array", + "description": "List of package types to produce.", + "items": { + "type": "string", + "pattern": "^(?:[Nn][Uu][Gg][Ee][Tt]|[Zz][Ii][Pp]|\\.[Nn][Uu][Pp][Kk][Gg]|\\.[Zz][Ii][Pp])$" + } + }, + "Latest": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "never", + "stable", + "always" + ] + } + ], + "description": "Controls whether a latest alias is published alongside the versioned package." + }, + "OutputDirectory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "Path": { + "type": "string" + }, + "Clean": { + "type": "boolean" + } + } + } + ], + "description": "Directory where generated packages are written." + }, + "PackageFileName": { + "type": "string", + "description": "Base file name for the generated package (without extension)." + }, + "AddVersionToFileName": { + "type": "boolean", + "description": "When true, the version string is appended to the package file name." + }, + "FileNamePattern": { + "type": "string", + "description": "Glob pattern used to identify generated package files for upload." + }, + "Authors": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "Package authors list. Defaults to Manifest.Author when omitted." + }, + "Description": { + "type": "string", + "description": "Package description. Defaults to top-level Description when omitted." + }, + "RepositoryUrl": { + "type": "string", + "description": "Base URL for raw HTTP upload." + }, + "RawRepositoryUrl": { + "type": "string", + "description": "Alternate raw repository URL." + }, + "UploadPath": { + "type": "string", + "description": "Sub-path appended to RepositoryUrl for the upload target." + }, + "Headers": { + "type": "object", + "description": "Custom HTTP headers sent with every upload request.", + "additionalProperties": { + "type": "string" + } + }, + "Auth": { + "type": "object", + "description": "Authentication configuration for upload requests.", + "properties": { + "HeaderName": { + "type": "string" + }, + "Scheme": { + "type": "string" + }, + "Token": { + "type": "string" + }, + "TokenEnvironmentVariable": { + "type": "string" + } + } + }, + "Repositories": { + "type": "array", + "description": "List of named upload repository targets.", + "items": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Url": { + "type": "string" + }, + "UploadPath": { + "type": "string" + }, + "Headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Auth": { + "type": "object", + "properties": { + "HeaderName": { + "type": "string" + }, + "Scheme": { + "type": "string" + }, + "Token": { + "type": "string" + }, + "TokenEnvironmentVariable": { + "type": "string" + } + } + } + }, + "required": [ + "Name", + "Url" + ] + } + } + } + }, + "Pester": { + "type": "object", + "description": "Controls the Pester test run configuration used by Test-NovaBuild.", + "properties": { + "CodeCoverage": { + "type": "object", + "description": "Configures code coverage collection during test runs.", + "properties": { + "Enabled": { + "type": "boolean", + "description": "When true, code coverage is collected." + }, + "Path": { + "type": "array", + "description": "Glob patterns selecting source files to include in coverage analysis.", + "items": { + "type": "string" + } + }, + "CoveragePercentTarget": { + "type": "number", + "description": "Minimum code coverage percentage required for a passing build." + }, + "OutputPath": { + "type": "string", + "description": "Path where the coverage report file is written." + }, + "OutputFormat": { + "type": "string", + "description": "Coverage report format (e.g. JaCoCo)." + } + } + }, + "TestResult": { + "type": "object", + "description": "Configures test result file output.", + "properties": { + "Enabled": { + "type": "boolean", + "description": "When true, a test result file is written." + }, + "OutputFormat": { + "type": "string", + "description": "Test result file format (e.g. NUnitXml)." + } + }, + "required": [ + "Enabled" + ] + }, + "Output": { + "type": "object", + "description": "Configures Pester console output during test runs.", + "properties": { + "Verbosity": { + "type": "string", + "description": "Pester output verbosity level (e.g. Detailed, Normal, Minimal)." + } + }, + "required": [ + "Verbosity" + ] + } + }, + "required": [ + "TestResult", + "Output" + ] + } + }, + "required": [ + "ProjectName", + "Description", + "Version", + "Manifest" + ] +} diff --git a/src/resources/example/project.json b/src/resources/example/project.json index 0476b59..87cf32f 100644 --- a/src/resources/example/project.json +++ b/src/resources/example/project.json @@ -1,4 +1,5 @@ { + "$schema": "https://www.novamoduletools.com/schema/v3/project.json", "ProjectName": "NovaExampleModule", "Description": "A working example project that demonstrates how to build, test, package, and upload a small module with NovaModuleTools.", "Version": "0.1.0", diff --git a/tests/private/build/BuildModule.TestSupport.ps1 b/tests/private/build/BuildModule.TestSupport.ps1 index 682bc5f..27a0b43 100644 --- a/tests/private/build/BuildModule.TestSupport.ps1 +++ b/tests/private/build/BuildModule.TestSupport.ps1 @@ -5,7 +5,8 @@ function Stop-NovaOperation { throw $record } function Get-NovaBuildProjectInfo {param($ProjectInfo); return $ProjectInfo} -function Test-ProjectSchema {param([string]$Schema); return $true} +function Test-ProjectSchema {} +function Export-NovaProjectJsonSchema {} function Add-ProjectPreambleToModuleBuilder {param($Builder, $ProjectInfo)} function Get-ProjectScriptFile {param($ProjectInfo); return @()} function Add-ScriptFileContentToModuleBuilder {param($Builder, $ProjectInfo, $File)} diff --git a/tests/private/build/BuildModule.Tests.ps1 b/tests/private/build/BuildModule.Tests.ps1 index c89dca8..81c5050 100644 --- a/tests/private/build/BuildModule.Tests.ps1 +++ b/tests/private/build/BuildModule.Tests.ps1 @@ -24,6 +24,7 @@ Describe 'Build-Module' { It 'throws when no source files exist' { Mock Get-NovaBuildProjectInfo { $script:ctx } Mock Test-ProjectSchema {} + Mock Export-NovaProjectJsonSchema {} Mock Add-ProjectPreambleToModuleBuilder {} Mock Get-ProjectScriptFile { @() } { Build-Module -ProjectInfo ([pscustomobject]@{}) } | Should -Throw -ErrorId 'Nova.Environment.BuildSourceFilesNotFound' @@ -32,6 +33,7 @@ Describe 'Build-Module' { It 'writes the psm1 when source files exist' { Mock Get-NovaBuildProjectInfo { $script:ctx } Mock Test-ProjectSchema {} + Mock Export-NovaProjectJsonSchema {} Mock Add-ProjectPreambleToModuleBuilder {} Set-Content -Path (Join-Path $script:ctx.PublicDir 'A.ps1') -Value 'function A {}' Mock Get-ProjectScriptFile { @(Get-Item (Join-Path $script:ctx.PublicDir 'A.ps1')) } @@ -44,6 +46,7 @@ Describe 'Build-Module' { It 'stops with friendly error when psm1 write fails' { Mock Get-NovaBuildProjectInfo { $script:ctx } Mock Test-ProjectSchema {} + Mock Export-NovaProjectJsonSchema {} Mock Add-ProjectPreambleToModuleBuilder {} Set-Content -Path (Join-Path $script:ctx.PublicDir 'A.ps1') -Value 'function A {}' Mock Get-ProjectScriptFile { @(Get-Item (Join-Path $script:ctx.PublicDir 'A.ps1')) } diff --git a/tests/private/build/ExportNovaProjectJsonSchema.Tests.ps1 b/tests/private/build/ExportNovaProjectJsonSchema.Tests.ps1 new file mode 100644 index 0000000..2ecd5ac --- /dev/null +++ b/tests/private/build/ExportNovaProjectJsonSchema.Tests.ps1 @@ -0,0 +1,37 @@ +BeforeAll { + . (Join-Path (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))) 'src/private/build/ExportNovaProjectJsonSchema.ps1') +} + +Describe 'Export-NovaProjectJsonSchema' { + BeforeAll { + function Get-NovaProjectInfo { [pscustomobject]@{ Version = '3.1.0' } } + function Get-ResourceFilePath { param([string]$FileName) Join-Path $TestDrive 'Schema-Project.json' } + } + + BeforeEach { + Set-Content -Path (Join-Path $TestDrive 'Schema-Project.json') -Value '{"type":"object"}' + } + + It 'copies Schema-Project.json to docs/schema/v{major}/ derived from project version' { + $outputDir = Join-Path $TestDrive 'docs/schema/v3' + Push-Location $TestDrive + try { + Export-NovaProjectJsonSchema + Test-Path (Join-Path $outputDir 'project.json') | Should -BeTrue + } finally { + Pop-Location + } + } + + It 'creates the output directory when it does not exist' { + Push-Location $TestDrive + try { + $outputDir = Join-Path $TestDrive 'docs/schema/v3' + if (Test-Path $outputDir) { Remove-Item $outputDir -Recurse -Force } + Export-NovaProjectJsonSchema + Test-Path $outputDir | Should -BeTrue + } finally { + Pop-Location + } + } +} diff --git a/tests/private/build/TestProjectSchema.Tests.ps1 b/tests/private/build/TestProjectSchema.Tests.ps1 index 2459170..62af82e 100644 --- a/tests/private/build/TestProjectSchema.Tests.ps1 +++ b/tests/private/build/TestProjectSchema.Tests.ps1 @@ -17,18 +17,14 @@ Describe 'Test-ProjectSchema' { Remove-Item -LiteralPath $script:tempSchema -ErrorAction SilentlyContinue } - It 'returns the result for the Build schema' { - Test-ProjectSchema -Schema 'Build' | Should -BeTrue - Assert-MockCalled Get-ResourceFilePath -Times 1 -ParameterFilter {$FileName -eq 'Schema-Build.json'} - } - - It 'returns the result for the Pester schema' { - Test-ProjectSchema -Schema 'Pester' | Should -BeTrue + It 'validates project.json against Schema-Project.json and returns true' { + Test-ProjectSchema | Should -BeTrue + Assert-MockCalled Get-ResourceFilePath -Times 1 -ParameterFilter {$FileName -eq 'Schema-Project.json'} } It 'translates Test-Json failures into Stop-NovaOperation' { Mock Test-Json {throw 'bad schema'} - {Test-ProjectSchema -Schema 'Build'} | Should -Throw + {Test-ProjectSchema} | Should -Throw } } diff --git a/tests/private/scaffold/WriteNovaModuleProjectJson.Tests.ps1 b/tests/private/scaffold/WriteNovaModuleProjectJson.Tests.ps1 index af3e09e..bf3064f 100644 --- a/tests/private/scaffold/WriteNovaModuleProjectJson.Tests.ps1 +++ b/tests/private/scaffold/WriteNovaModuleProjectJson.Tests.ps1 @@ -48,4 +48,49 @@ Describe 'Write-NovaModuleProjectJson' { $script:writtenData.ContainsKey('Pester') | Should -BeTrue $script:writtenData.Pester.CodeCoverage.Enabled | Should -BeTrue } + + It 'omits $schema when module version is not available (dot-sourced test context)' { + $answer = @{ProjectName='Mod'; Description='desc'; Version='1.0.0'; Author='Me'; PowerShellHostVersion='7.4'; EnablePester='Yes'} + Write-NovaModuleProjectJson -Answer $answer -ProjectJsonFile '/out/project.json' + $script:writtenData.ContainsKey('$schema') | Should -BeFalse + } + + It 'injects $schema with the correct URL when module version is available' { + $root = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + $srcFile = Join-Path $root 'src/private/scaffold/WriteNovaModuleProjectJson.ps1' + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("NovaSchemaTest-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + try { + $psm1 = Join-Path $tempDir 'NovaSchemaTestMod.psm1' + $psd1 = Join-Path $tempDir 'NovaSchemaTestMod.psd1' + + Set-Content -LiteralPath $psm1 -Value @" +`$script:capturedData = `$null +function Get-NovaModuleProjectTemplatePath { param([switch]`$Example) '/template/project.json' } +function Read-ProjectJsonData { param(`$ProjectJsonPath) + @{ ProjectName='X'; Description='X'; Version='0.0.1' + Manifest=@{Author='?'; PowerShellHostVersion='5.1'; GUID='00000000-0000-0000-0000-000000000000'} + Pester=@{Enabled=`$true; CodeCoverage=@{Enabled=`$true; Path=@('src/public/*.ps1'); CoveragePercentTarget=90}} } +} +function Write-ProjectJsonData { param(`$ProjectJsonPath, `$Data) `$script:capturedData = `$Data } +. "$srcFile" +"@ + New-ModuleManifest -Path $psd1 -RootModule 'NovaSchemaTestMod.psm1' ` + -ModuleVersion '3.1.0' -FunctionsToExport @('Write-NovaModuleProjectJson') -Author 'Test' + Import-Module $psd1 -Force -Global + + $answer = @{ProjectName='Mod'; Description='desc'; Version='1.0.0'; Author='Me'; + PowerShellHostVersion='7.4'; EnablePester='Yes'} + InModuleScope 'NovaSchemaTestMod' -Parameters @{Answer = $answer} { + param($Answer) + Write-NovaModuleProjectJson -Answer $Answer -ProjectJsonFile '/out/project.json' + } + + $capturedData = & (Get-Module 'NovaSchemaTestMod') { $script:capturedData } + $capturedData['$schema'] | Should -Be 'https://www.novamoduletools.com/schema/v3/project.json' + } finally { + Get-Module 'NovaSchemaTestMod' | Remove-Module -Force -ErrorAction SilentlyContinue + Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } } From 81df4298fb0b1c1cccbf5dcebdf0f329d7929616 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Sat, 30 May 2026 16:23:45 +0200 Subject: [PATCH 09/12] feat: Enhance project.json schema support and VS Code integration (#246) * #242 feat: Enhance project.json schema support and VS Code integration - Added `$schema` property injection in `nova init` for new projects, enabling VS Code IntelliSense and autocomplete. - Introduced authoritative `Schema-Project.json` for project.json validation. - Updated `Test-ProjectSchema` to validate against the new schema. - Removed obsolete `Schema-Build.json` and `Schema-Pester.json`. - Implemented `Export-NovaProjectJsonSchema` to copy the schema to the appropriate documentation directory. - Updated documentation to reflect schema changes and IntelliSense support. - Enhanced tests for schema validation and project.json writing functionality. * #242 fix: Update project.json and schema to enhance RequiredModules definition and versioning * #242 feat: Add VS Code settings integration for project.json schema support * #242 feat: Update prompts to require skill invocation before actions for improved workflow clarity * #242 feat: Update prompts to require skill invocation before actions for improved workflow clarity --- .github/prompts/implement-issue.prompt.md | 3 +- .github/prompts/prepare-release.prompt.md | 3 +- .github/prompts/review-change.prompt.md | 3 +- .github/skills/markdown-authoring/SKILL.md | 2 +- CHANGELOG.md | 2 + RELEASE_NOTE.md | 2 + .../en-US/Initialize-NovaModule.md | 2 +- docs/project-json-reference.html | 12 +- docs/schema/v3/project.json | 3 +- ...InvokeNovaModuleInitializationWorkflow.ps1 | 4 + .../scaffold/WriteNovaVsCodeSettings.ps1 | 67 ++++++++ src/resources/Schema-Project.json | 3 +- .../.github/prompts/implement-issue.prompt.md | 3 +- .../.github/prompts/prepare-release.prompt.md | 3 +- .../.github/prompts/review-change.prompt.md | 3 +- .../skills/markdown-authoring/SKILL.md | 2 +- ...duleInitializationWorkflow.TestSupport.ps1 | 1 + ...NovaModuleInitializationWorkflow.Tests.ps1 | 11 +- .../WriteNovaVsCodeSettings.Tests.ps1 | 159 ++++++++++++++++++ 19 files changed, 270 insertions(+), 18 deletions(-) create mode 100644 src/private/scaffold/WriteNovaVsCodeSettings.ps1 create mode 100644 tests/private/scaffold/WriteNovaVsCodeSettings.Tests.ps1 diff --git a/.github/prompts/implement-issue.prompt.md b/.github/prompts/implement-issue.prompt.md index 2c50eeb..3785e27 100644 --- a/.github/prompts/implement-issue.prompt.md +++ b/.github/prompts/implement-issue.prompt.md @@ -12,7 +12,8 @@ Implement the issue in the NovaModuleTools repository using the repository-local ## Required process -1. If scope, acceptance criteria, or ownership are still unclear, start with `.github/prompts/design-change.prompt.md` and `architect.agent.md` before implementing. +1. **Before any other action:** invoke the `skill` tool for `markdown-authoring` when the final handoff summary will be returned as copy-ready Markdown. This is a blocking requirement — load the skill before reading files or producing output. +2. If scope, acceptance criteria, or ownership are still unclear, start with `.github/prompts/design-change.prompt.md` and `architect.agent.md` before implementing. 2. Read `README.md`, `CONTRIBUTING.md`, `.github/copilot-instructions.md`, and `.github/pull_request_template.md`. 3. Inspect the relevant public command, matching private helper domain, tests, and docs. 4. If the issue is release-, workflow-, or coverage-related, also inspect the matching `.github/workflows/*.yml` and `scripts/build/ci/*.ps1` files. diff --git a/.github/prompts/prepare-release.prompt.md b/.github/prompts/prepare-release.prompt.md index 8091f6c..7879a16 100644 --- a/.github/prompts/prepare-release.prompt.md +++ b/.github/prompts/prepare-release.prompt.md @@ -6,7 +6,8 @@ Prepare release-related changes in NovaModuleTools without publishing or tagging ## Required process -1. Read `CHANGELOG.md`, `RELEASE_NOTE.md`, `project.json`, `README.md`, `CONTRIBUTING.md`, `.github/pull_request_template.md`, and `.github/workflows/Publish.yml`. +1. **Before any other action:** invoke the `skill` tool for both `markdown-authoring` and `release-and-changelog`. This is a blocking requirement — do not read files or produce output until both skills are loaded. +2. Read `CHANGELOG.md`, `RELEASE_NOTE.md`, `project.json`, `README.md`, `CONTRIBUTING.md`, `.github/pull_request_template.md`, and `.github/workflows/Publish.yml`. 2. Inspect the touched versioning, package, publish, or release tests. 3. Confirm whether the change affects stable releases, prereleases, or both. 4. Update changelog, release notes, and contributor docs as needed. diff --git a/.github/prompts/review-change.prompt.md b/.github/prompts/review-change.prompt.md index 7fa8814..8356a9f 100644 --- a/.github/prompts/review-change.prompt.md +++ b/.github/prompts/review-change.prompt.md @@ -6,7 +6,8 @@ Review a NovaModuleTools change set with emphasis on correctness, maintainabilit ## Required process -1. Start with the highest-risk public command, workflow, or release path in the diff. +1. **Before any other action:** invoke the `skill` tool for `markdown-authoring` when the review output will be returned as copy-ready Markdown. This is a blocking requirement — load the skill before reading files or producing output. +2. Start with the highest-risk public command, workflow, or release path in the diff. 2. Compare the changed files against the relevant repository instructions and skills. 3. Check changed `src/**/*.ps1` against `.github/instructions/code-quality-matrix.instructions.md` and `tests/**/*.ps1` against `.github/instructions/testing-policy.instructions.md`. 4. Check changed PowerShell validation flow against `.github/instructions/psscriptanalyzer.instructions.md`; flag direct analyzer usage that bypasses the repository wrapper or repo-approved settings without a clear reason. diff --git a/.github/skills/markdown-authoring/SKILL.md b/.github/skills/markdown-authoring/SKILL.md index 75766e9..1a6e8f0 100644 --- a/.github/skills/markdown-authoring/SKILL.md +++ b/.github/skills/markdown-authoring/SKILL.md @@ -33,7 +33,7 @@ Use this skill when producing Markdown files in the repository or Markdown outpu ## Copy-safe fence rules - When the entire response must be wrapped, start with a line containing exactly `~~~` and end with a line containing exactly `~~~`. -- Do not place prose before or after that outer wrapper. +- **Any text outside the `~~~` block is a rule violation.** This includes greetings, preambles, "here is the summary" lead-ins, observations appended after the block, and any other prose. The response must be the `~~~` block and nothing else. - Inside the wrapped block, use normal Markdown. - For inner code examples, use triple backticks and include a language when helpful. - Never use triple backticks as the outer wrapper when the content itself may already contain fenced code blocks. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a6e4d2..d17010c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `project.json` now has a single authoritative JSON schema hosted at `https://www.novamoduletools.com/schema/v{major}/project.json`. - `Invoke-NovaBuild` exports the schema to `docs/schema/v{major}/project.json` during build, creating the directory when missing. - `nova init` injects `"$schema": "https://www.novamoduletools.com/schema/v{major}/project.json"` into every scaffolded project. + - `nova init` also writes `.vscode/settings.json` mapping `project.json` to the hosted schema URL so VS Code trusts the remote schema automatically without prompting. - VS Code picks up the schema automatically to provide field validation, autocomplete, and hover descriptions while editing `project.json`. - The two partial internal schemas (`Schema-Build.json`, `Schema-Pester.json`) are removed; `Schema-Project.json` is the new single source. @@ -32,6 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - The progress text now stays simple while the bar itself reflects the real test-driven completion state. - The configured Pester output still flows live so `project.json` settings such as `Pester.Output.Verbosity = "Detailed"` remain visible. - Progress-enabled private workflows now have stronger mirrored progress-contract tests, and the repository adds a guardrail test so future `Write-Progress` workflows cannot land without matching test ownership. +- `Package.Types` values in `project.json` now report a human-readable validation error in VS Code instead of showing the raw regex pattern. VS Code now shows the accepted values (`NuGet`, `Zip`, `.nupkg`, `.zip`) directly in the error. ### Security diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index a06730b..abbc649 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -7,6 +7,7 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang ### Added - `nova init` now injects a `"$schema"` property into new projects pointing to the hosted versioned JSON schema, enabling VS Code IntelliSense, autocomplete, and hover descriptions while editing `project.json`. + - `nova init` also writes `.vscode/settings.json` mapping `project.json` to the schema URL so VS Code trusts it automatically — no manual "Allow" step required. ### Changed @@ -24,6 +25,7 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang - `Publish-NovaModule` no longer loses its completion summary after local publish import or CI session refresh steps reload the module. - `Test-NovaBuild` and `% nova test` now keep the Nova progress display visibly active during long Pester runs instead of appearing stuck on one step while tests continue. - During the long Pester phase, Nova now drives the progress bar from discovered and completed Pester tests instead of elapsed time, keeps the progress text simple, and still shows configured Pester output such as `Pester.Output.Verbosity = "Detailed"`. +- Invalid `Package.Types` values in `project.json` now show the accepted values (`NuGet`, `Zip`, `.nupkg`, `.zip`) directly in VS Code instead of the raw regex pattern. ### Security diff --git a/docs/NovaModuleTools/en-US/Initialize-NovaModule.md b/docs/NovaModuleTools/en-US/Initialize-NovaModule.md index 7c39675..5f2d9a6 100644 --- a/docs/NovaModuleTools/en-US/Initialize-NovaModule.md +++ b/docs/NovaModuleTools/en-US/Initialize-NovaModule.md @@ -25,7 +25,7 @@ PS> Initialize-NovaModule [-Path ] [-Example] [-WhatIf] [-Confirm] [Top-level project settings
    VS Code IntelliSense.

    When nova init scaffolds a new project, it injects a $schema field - that points to the versioned public schema at - https://www.novamoduletools.com/schema/v{major}/project.json. VS Code picks this - up automatically to provide field validation, autocomplete, and hover descriptions while you - edit project.json. You can add the field manually to any existing project by - inserting it as the first key in the file.

    + pointing to the versioned public schema at + https://www.novamoduletools.com/schema/v{major}/project.json, and also writes a + .vscode/settings.json entry that maps project.json to the same URL. + Together these two outputs let VS Code load the schema and provide IntelliSense, field + validation, and hover descriptions without a manual trust prompt. To enable this on an existing + project, add the $schema field as the first key in project.json and + add the matching json.schemas entry to .vscode/settings.json.

    $schemaOptional URI pointing to the versioned JSON schema for this file.Injected automatically by nova initEnables VS Code IntelliSense, field validation, and hover descriptions while editing + project.json. +
    ProjectName Name of the module and the output folder under dist/.
    diff --git a/docs/schema/v3/project.json b/docs/schema/v3/project.json index b3fe112..717f0bd 100644 --- a/docs/schema/v3/project.json +++ b/docs/schema/v3/project.json @@ -130,7 +130,8 @@ "description": "List of package types to produce.", "items": { "type": "string", - "pattern": "^(?:[Nn][Uu][Gg][Ee][Tt]|[Zz][Ii][Pp]|\\.[Nn][Uu][Pp][Kk][Gg]|\\.[Zz][Ii][Pp])$" + "enum": ["NuGet", "Zip", ".nupkg", ".zip"], + "description": "Valid values: NuGet, Zip, .nupkg, .zip (case-insensitive at runtime)." } }, "Latest": { diff --git a/src/private/scaffold/InvokeNovaModuleInitializationWorkflow.ps1 b/src/private/scaffold/InvokeNovaModuleInitializationWorkflow.ps1 index 0b483c4..611ae79 100644 --- a/src/private/scaffold/InvokeNovaModuleInitializationWorkflow.ps1 +++ b/src/private/scaffold/InvokeNovaModuleInitializationWorkflow.ps1 @@ -15,6 +15,10 @@ function Invoke-NovaModuleInitializationWorkflow { Write-NovaModuleProjectJson -Answer $WorkflowContext.AnswerSet -ProjectJsonFile $WorkflowContext.Layout.ProjectJsonFile -Example:$WorkflowContext.Example } + Invoke-NovaModuleInitializationStep -Activity $progressActivity -Status 'Writing VS Code settings' -PercentComplete 75 -Action { + Write-NovaVsCodeSettings -ProjectRoot $WorkflowContext.Layout.Project + } + if ($WorkflowContext.AnswerSet.EnableAgenticCopilot -eq 'Yes') { Invoke-NovaModuleInitializationStep -Activity $progressActivity -Status 'Applying Agentic Copilot starter' -PercentComplete 85 -Action { Initialize-NovaModuleAgenticCopilotScaffold -Answer $WorkflowContext.AnswerSet -ProjectRoot $WorkflowContext.Layout.Project -Example:$WorkflowContext.Example diff --git a/src/private/scaffold/WriteNovaVsCodeSettings.ps1 b/src/private/scaffold/WriteNovaVsCodeSettings.ps1 new file mode 100644 index 0000000..9592336 --- /dev/null +++ b/src/private/scaffold/WriteNovaVsCodeSettings.ps1 @@ -0,0 +1,67 @@ +function Write-NovaVsCodeSettings { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'VS Code settings is the domain term for the .vscode/settings.json file.')] + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ProjectRoot + ) + + $moduleVersion = $ExecutionContext.SessionState.Module.Version + if ($null -eq $moduleVersion) { return } + + $schemaUrl = "https://www.novamoduletools.com/schema/v$($moduleVersion.Major)/project.json" + $vsCodeDir = Join-Path $ProjectRoot '.vscode' + $settingsFile = Join-Path $vsCodeDir 'settings.json' + + if (Test-Path -LiteralPath $settingsFile) { + Add-NovaVsCodeJsonSchemaEntry -SettingsFile $settingsFile -SchemaUrl $schemaUrl + } else { + New-NovaVsCodeSettingsFile -VsCodeDir $vsCodeDir -SettingsFile $settingsFile -SchemaUrl $schemaUrl + } +} + +function Add-NovaVsCodeJsonSchemaEntry { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$SettingsFile, + [Parameter(Mandatory)][string]$SchemaUrl + ) + + $settings = Get-Content -LiteralPath $SettingsFile -Raw | ConvertFrom-Json -AsHashtable + if (-not $settings) { $settings = @{} } + if (-not $settings.ContainsKey('json.schemas')) { $settings['json.schemas'] = @() } + + $schemas = @($settings['json.schemas']) + $alreadyMapped = $schemas | Where-Object { + $fm = Resolve-NovaFileMatchArray $_ + ($fm -contains '/project.json') -or ($fm -contains 'project.json') + } + if ($alreadyMapped) { return } + + $settings['json.schemas'] = $schemas + @{ fileMatch = @('/project.json'); url = $SchemaUrl } + $settings | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $SettingsFile -Encoding utf8NoBOM +} + +function Resolve-NovaFileMatchArray { + [CmdletBinding()] + param($Entry) + + $raw = if ($Entry -is [hashtable]) { $Entry['fileMatch'] } else { $Entry.fileMatch } + return @($raw) +} + +function New-NovaVsCodeSettingsFile { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Private file-writing helper. ShouldProcess is managed at the public Initialize-NovaModule command level.')] + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$VsCodeDir, + [Parameter(Mandatory)][string]$SettingsFile, + [Parameter(Mandatory)][string]$SchemaUrl + ) + + if (-not (Test-Path -LiteralPath $VsCodeDir)) { + New-Item -ItemType Directory -Path $VsCodeDir | Out-Null + } + @{ 'json.schemas' = @(@{ fileMatch = @('/project.json'); url = $SchemaUrl }) } | + ConvertTo-Json -Depth 10 | + Set-Content -LiteralPath $SettingsFile -Encoding utf8NoBOM +} diff --git a/src/resources/Schema-Project.json b/src/resources/Schema-Project.json index b3fe112..717f0bd 100644 --- a/src/resources/Schema-Project.json +++ b/src/resources/Schema-Project.json @@ -130,7 +130,8 @@ "description": "List of package types to produce.", "items": { "type": "string", - "pattern": "^(?:[Nn][Uu][Gg][Ee][Tt]|[Zz][Ii][Pp]|\\.[Nn][Uu][Pp][Kk][Gg]|\\.[Zz][Ii][Pp])$" + "enum": ["NuGet", "Zip", ".nupkg", ".zip"], + "description": "Valid values: NuGet, Zip, .nupkg, .zip (case-insensitive at runtime)." } }, "Latest": { diff --git a/src/resources/agentic-copilot/.github/prompts/implement-issue.prompt.md b/src/resources/agentic-copilot/.github/prompts/implement-issue.prompt.md index 7894127..d0bea69 100644 --- a/src/resources/agentic-copilot/.github/prompts/implement-issue.prompt.md +++ b/src/resources/agentic-copilot/.github/prompts/implement-issue.prompt.md @@ -12,7 +12,8 @@ Implement the issue in the {{ProjectName}} repository using the repository-local ## Required process -1. If scope, acceptance criteria, or ownership are still unclear, start with `.github/prompts/design-change.prompt.md` and `architect.agent.md` before implementing. +1. **Before any other action:** invoke the `skill` tool for `markdown-authoring` when the final handoff summary will be returned as copy-ready Markdown. This is a blocking requirement — load the skill before reading files or producing output. +2. If scope, acceptance criteria, or ownership are still unclear, start with `.github/prompts/design-change.prompt.md` and `architect.agent.md` before implementing. 2. Read `README.md`, `CONTRIBUTING.md`, `.github/copilot-instructions.md`, and `.github/pull_request_template.md`. 3. Inspect the relevant public command, matching private helper domain, tests, and docs. 4. If the issue is release-, workflow-, or coverage-related, also inspect the matching workflow files, when present and `scripts/build/ci/*.ps1` files. diff --git a/src/resources/agentic-copilot/.github/prompts/prepare-release.prompt.md b/src/resources/agentic-copilot/.github/prompts/prepare-release.prompt.md index 18ece92..d9fd8f1 100644 --- a/src/resources/agentic-copilot/.github/prompts/prepare-release.prompt.md +++ b/src/resources/agentic-copilot/.github/prompts/prepare-release.prompt.md @@ -6,7 +6,8 @@ Prepare release-related changes in {{ProjectName}} without publishing or tagging ## Required process -1. Read `CHANGELOG.md`, `RELEASE_NOTE.md`, `project.json`, `README.md`, `CONTRIBUTING.md`, `.github/pull_request_template.md`, and release workflow files, when present. +1. **Before any other action:** invoke the `skill` tool for both `markdown-authoring` and `release-and-changelog`. This is a blocking requirement — do not read files or produce output until both skills are loaded. +2. Read `CHANGELOG.md`, `RELEASE_NOTE.md`, `project.json`, `README.md`, `CONTRIBUTING.md`, `.github/pull_request_template.md`, and release workflow files, when present. 2. Inspect the touched versioning, package, publish, or release tests. 3. Confirm whether the change affects stable releases, prereleases, or both. 4. Update changelog, release notes, and contributor docs as needed. diff --git a/src/resources/agentic-copilot/.github/prompts/review-change.prompt.md b/src/resources/agentic-copilot/.github/prompts/review-change.prompt.md index 89f2a20..a4ffd0a 100644 --- a/src/resources/agentic-copilot/.github/prompts/review-change.prompt.md +++ b/src/resources/agentic-copilot/.github/prompts/review-change.prompt.md @@ -6,7 +6,8 @@ Review a {{ProjectName}} change set with emphasis on correctness, maintainabilit ## Required process -1. Start with the highest-risk public command, workflow, or release path in the diff. +1. **Before any other action:** invoke the `skill` tool for `markdown-authoring` when the review output will be returned as copy-ready Markdown. This is a blocking requirement — load the skill before reading files or producing output. +2. Start with the highest-risk public command, workflow, or release path in the diff. 2. Compare the changed files against the relevant repository instructions and skills. 3. Check changed `src/**/*.ps1` against `.github/instructions/code-quality-matrix.instructions.md` and `tests/**/*.ps1` against `.github/instructions/testing-policy.instructions.md`. 4. Check changed PowerShell validation flow against `.github/instructions/psscriptanalyzer.instructions.md`; flag direct analyzer usage that bypasses the repository wrapper or repo-approved settings without a clear reason. diff --git a/src/resources/agentic-copilot/.github/skills/markdown-authoring/SKILL.md b/src/resources/agentic-copilot/.github/skills/markdown-authoring/SKILL.md index 2669308..d820f13 100644 --- a/src/resources/agentic-copilot/.github/skills/markdown-authoring/SKILL.md +++ b/src/resources/agentic-copilot/.github/skills/markdown-authoring/SKILL.md @@ -33,7 +33,7 @@ Use this skill when producing Markdown files in the repository or Markdown outpu ## Copy-safe fence rules - When the entire response must be wrapped, start with a line containing exactly `~~~` and end with a line containing exactly `~~~`. -- Do not place prose before or after that outer wrapper. +- **Any text outside the `~~~` block is a rule violation.** This includes greetings, preambles, "here is the summary" lead-ins, observations appended after the block, and any other prose. The response must be the `~~~` block and nothing else. - Inside the wrapped block, use normal Markdown. - For inner code examples, use triple backticks and include a language when helpful. - Never use triple backticks as the outer wrapper when the content itself may already contain fenced code blocks. diff --git a/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.TestSupport.ps1 b/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.TestSupport.ps1 index 5a730fa..31108a4 100644 --- a/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.TestSupport.ps1 +++ b/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.TestSupport.ps1 @@ -1,4 +1,5 @@ function Initialize-NovaModuleScaffold {param($Answer, $Paths, [switch]$Example)} function Write-NovaModuleProjectJson {param($Answer, [string]$ProjectJsonFile, [switch]$Example)} +function Write-NovaVsCodeSettings {param([string]$ProjectRoot)} function Initialize-NovaModuleAgenticCopilotScaffold {param($Answer, [string]$ProjectRoot, [switch]$Example)} function Write-Message {param([Parameter(ValueFromPipeline = $true)]$InputObject, [string]$color)} diff --git a/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1 b/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1 index 7eeda36..70f2bd3 100644 --- a/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1 +++ b/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1 @@ -17,6 +17,7 @@ Describe 'Invoke-NovaModuleInitializationWorkflow' { It 'initializes the scaffold and writes the project.json before announcing completion' { Mock Initialize-NovaModuleScaffold {} Mock Write-NovaModuleProjectJson {} + Mock Write-NovaVsCodeSettings {} Mock Initialize-NovaModuleAgenticCopilotScaffold {} Mock Write-Message {} Mock Write-Progress {} @@ -25,14 +26,18 @@ Describe 'Invoke-NovaModuleInitializationWorkflow' { Assert-MockCalled Initialize-NovaModuleScaffold -Times 1 Assert-MockCalled Write-NovaModuleProjectJson -Times 1 + Assert-MockCalled Write-NovaVsCodeSettings -Times 1 -ParameterFilter { $ProjectRoot -eq '/tmp/DemoModule' } Assert-MockCalled Initialize-NovaModuleAgenticCopilotScaffold -Times 0 - Assert-MockCalled Write-Progress -Times 3 + Assert-MockCalled Write-Progress -Times 4 Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { $Status -eq 'Creating scaffold files' -and $PercentComplete -eq 25 } Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { $Status -eq 'Writing project.json' -and $PercentComplete -eq 60 } + Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { + $Status -eq 'Writing VS Code settings' -and $PercentComplete -eq 75 + } Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed} Assert-MockCalled Write-Message -Times 4 Assert-MockCalled Write-Message -Times 1 -ParameterFilter { @@ -46,6 +51,7 @@ Describe 'Invoke-NovaModuleInitializationWorkflow' { It 'invokes the Agentic Copilot scaffold step when the answer set requests it' { Mock Initialize-NovaModuleScaffold {} Mock Write-NovaModuleProjectJson {} + Mock Write-NovaVsCodeSettings {} Mock Initialize-NovaModuleAgenticCopilotScaffold {} Mock Write-Message {} Mock Write-Progress {} @@ -58,7 +64,7 @@ Describe 'Invoke-NovaModuleInitializationWorkflow' { Invoke-NovaModuleInitializationWorkflow -WorkflowContext $contextWithAgentic Assert-MockCalled Initialize-NovaModuleAgenticCopilotScaffold -Times 1 - Assert-MockCalled Write-Progress -Times 4 + Assert-MockCalled Write-Progress -Times 5 Assert-MockCalled Write-Progress -Times 1 -ParameterFilter { $Status -eq 'Applying Agentic Copilot starter' -and $PercentComplete -eq 85 } @@ -68,6 +74,7 @@ Describe 'Invoke-NovaModuleInitializationWorkflow' { It 'suggests Test-NovaBuild as the next step for the example scaffold' { Mock Initialize-NovaModuleScaffold {} Mock Write-NovaModuleProjectJson {} + Mock Write-NovaVsCodeSettings {} Mock Initialize-NovaModuleAgenticCopilotScaffold {} Mock Write-Message {} Mock Write-Progress {} diff --git a/tests/private/scaffold/WriteNovaVsCodeSettings.Tests.ps1 b/tests/private/scaffold/WriteNovaVsCodeSettings.Tests.ps1 new file mode 100644 index 0000000..0df9e6e --- /dev/null +++ b/tests/private/scaffold/WriteNovaVsCodeSettings.Tests.ps1 @@ -0,0 +1,159 @@ +BeforeAll { + $projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + . (Join-Path $projectRoot 'src/private/scaffold/WriteNovaVsCodeSettings.ps1') +} + +Describe 'Write-NovaVsCodeSettings' { + It 'does nothing when module version is not available (dot-sourced context)' { + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("NovaVsCode-$([guid]::NewGuid())") + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + try { + Write-NovaVsCodeSettings -ProjectRoot $tempDir + Test-Path -LiteralPath (Join-Path $tempDir '.vscode/settings.json') | Should -BeFalse + } finally { + Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Describe 'with module version available' { + BeforeAll { + $root = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) + $srcFile = Join-Path $root 'src/private/scaffold/WriteNovaVsCodeSettings.ps1' + $script:modDir = Join-Path ([System.IO.Path]::GetTempPath()) ("NovaVsCodeMod-$([guid]::NewGuid())") + New-Item -ItemType Directory -Path $script:modDir -Force | Out-Null + $psm1 = Join-Path $script:modDir 'NovaVsCodeTestMod.psm1' + $psd1 = Join-Path $script:modDir 'NovaVsCodeTestMod.psd1' + Set-Content -LiteralPath $psm1 -Value ". `"$srcFile`"" + New-ModuleManifest -Path $psd1 -RootModule 'NovaVsCodeTestMod.psm1' ` + -ModuleVersion '3.1.0' -FunctionsToExport @('Write-NovaVsCodeSettings') -Author 'Test' + # Deviation from testing-policy: Import-Module -Global and InModuleScope are required + # because $ExecutionContext.SessionState.Module.Version is $null when dot-sourced, + # causing Write-NovaVsCodeSettings to return early as a no-op. A real module context + # is the only way to exercise the positive paths. See the same pattern in + # WriteNovaModuleProjectJson.Tests.ps1. + Import-Module $psd1 -Force -Global + } + + AfterAll { + Get-Module 'NovaVsCodeTestMod' | Remove-Module -Force -ErrorAction SilentlyContinue + Remove-Item $script:modDir -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'creates .vscode/settings.json with the versioned schema URL' { + $projDir = Join-Path ([System.IO.Path]::GetTempPath()) ("NovaVsCodeProj-$([guid]::NewGuid())") + New-Item -ItemType Directory -Path $projDir -Force | Out-Null + try { + InModuleScope 'NovaVsCodeTestMod' -Parameters @{ProjectDir = $projDir} { + param($ProjectDir) + Write-NovaVsCodeSettings -ProjectRoot $ProjectDir + } + $settingsFile = Join-Path $projDir '.vscode/settings.json' + Test-Path -LiteralPath $settingsFile | Should -BeTrue + $content = Get-Content -LiteralPath $settingsFile -Raw | ConvertFrom-Json -AsHashtable + $entry = $content['json.schemas'][0] + $entry['fileMatch'] | Should -Contain '/project.json' + $entry['url'] | Should -Be 'https://www.novamoduletools.com/schema/v3/project.json' + } finally { + Remove-Item $projDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'adds schema entry to existing settings.json via the Add dispatch path' { + $projDir = Join-Path ([System.IO.Path]::GetTempPath()) ("NovaVsCodeProj-$([guid]::NewGuid())") + $vsCodeDir = Join-Path $projDir '.vscode' + $settingsFile = Join-Path $vsCodeDir 'settings.json' + New-Item -ItemType Directory -Path $vsCodeDir -Force | Out-Null + '{"editor.tabSize": 4}' | Set-Content -LiteralPath $settingsFile -Encoding utf8NoBOM + try { + InModuleScope 'NovaVsCodeTestMod' -Parameters @{ProjectDir = $projDir} { + param($ProjectDir) + Write-NovaVsCodeSettings -ProjectRoot $ProjectDir + } + Test-Path -LiteralPath $settingsFile | Should -BeTrue + $content = Get-Content -LiteralPath $settingsFile -Raw | ConvertFrom-Json -AsHashtable + ($content['json.schemas'] | Where-Object { $_['fileMatch'] -contains '/project.json' }) | + Should -Not -BeNullOrEmpty + } finally { + Remove-Item $projDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + } +} + +Describe 'New-NovaVsCodeSettingsFile' { + BeforeAll { + $script:tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("NovaVsCode-$([guid]::NewGuid())") + New-Item -ItemType Directory -Path $script:tempDir -Force | Out-Null + } + + AfterAll { + Remove-Item $script:tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'creates .vscode directory and settings.json with the schema entry' { + $vsCodeDir = Join-Path $script:tempDir '.vscode' + $settingsFile = Join-Path $vsCodeDir 'settings.json' + New-NovaVsCodeSettingsFile -VsCodeDir $vsCodeDir -SettingsFile $settingsFile -SchemaUrl 'https://example.com/schema/v3/project.json' + Test-Path -LiteralPath $settingsFile | Should -BeTrue + $content = Get-Content -LiteralPath $settingsFile -Raw | ConvertFrom-Json -AsHashtable + $entry = $content['json.schemas'][0] + $entry['fileMatch'] | Should -Contain '/project.json' + $entry['url'] | Should -Be 'https://example.com/schema/v3/project.json' + } + + It 'reuses existing .vscode directory when it already exists' { + $vsCodeDir = Join-Path $script:tempDir '.vscode-existing' + New-Item -ItemType Directory -Path $vsCodeDir -Force | Out-Null + $settingsFile = Join-Path $vsCodeDir 'settings.json' + New-NovaVsCodeSettingsFile -VsCodeDir $vsCodeDir -SettingsFile $settingsFile -SchemaUrl 'https://example.com/schema/v3/project.json' + Test-Path -LiteralPath $settingsFile | Should -BeTrue + } +} + +Describe 'Add-NovaVsCodeJsonSchemaEntry' { + BeforeAll { + $script:tempDir2 = Join-Path ([System.IO.Path]::GetTempPath()) ("NovaVsCode-$([guid]::NewGuid())") + New-Item -ItemType Directory -Path $script:tempDir2 -Force | Out-Null + } + + AfterAll { + Remove-Item $script:tempDir2 -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'adds entry when json.schemas key is missing' { + $file = Join-Path $script:tempDir2 'settings-no-key.json' + '{"editor.tabSize": 4}' | Set-Content -LiteralPath $file -Encoding utf8NoBOM + Add-NovaVsCodeJsonSchemaEntry -SettingsFile $file -SchemaUrl 'https://example.com/v3/project.json' + $content = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json -AsHashtable + $content['json.schemas'].Count | Should -Be 1 + $content['json.schemas'][0]['fileMatch'] | Should -Contain '/project.json' + } + + It 'adds entry when json.schemas exists with no project.json mapping' { + $file = Join-Path $script:tempDir2 'settings-other-schemas.json' + @{ 'json.schemas' = @(@{ fileMatch = @('*.schema.json'); url = 'https://other.com/schema.json' }) } | + ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $file -Encoding utf8NoBOM + Add-NovaVsCodeJsonSchemaEntry -SettingsFile $file -SchemaUrl 'https://example.com/v3/project.json' + $content = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json -AsHashtable + $content['json.schemas'].Count | Should -Be 2 + ($content['json.schemas'] | Where-Object { $_['fileMatch'] -contains '/project.json' }) | Should -Not -BeNullOrEmpty + } + + It 'skips silently when /project.json mapping already exists' { + $file = Join-Path $script:tempDir2 'settings-already-mapped.json' + @{ 'json.schemas' = @(@{ fileMatch = @('/project.json'); url = 'https://custom.com/schema.json' }) } | + ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $file -Encoding utf8NoBOM + $before = Get-Content -LiteralPath $file -Raw + Add-NovaVsCodeJsonSchemaEntry -SettingsFile $file -SchemaUrl 'https://example.com/v3/project.json' + Get-Content -LiteralPath $file -Raw | Should -Be $before + } + + It 'skips silently when unanchored project.json mapping already exists' { + $file = Join-Path $script:tempDir2 'settings-unanchored.json' + @{ 'json.schemas' = @(@{ fileMatch = @('project.json'); url = 'https://custom.com/schema.json' }) } | + ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $file -Encoding utf8NoBOM + $before = Get-Content -LiteralPath $file -Raw + Add-NovaVsCodeJsonSchemaEntry -SettingsFile $file -SchemaUrl 'https://example.com/v3/project.json' + Get-Content -LiteralPath $file -Raw | Should -Be $before + } +} From 4daf5460c16d1b5d5e72b21f2f56c069d16b9bc9 Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Sun, 31 May 2026 00:17:58 +0200 Subject: [PATCH 10/12] fix: Removed boolean `Package.Latest` compat shim deprecated in 2.4.0. (#248) * #242 feat: Enhance project.json schema support and VS Code integration - Added `$schema` property injection in `nova init` for new projects, enabling VS Code IntelliSense and autocomplete. - Introduced authoritative `Schema-Project.json` for project.json validation. - Updated `Test-ProjectSchema` to validate against the new schema. - Removed obsolete `Schema-Build.json` and `Schema-Pester.json`. - Implemented `Export-NovaProjectJsonSchema` to copy the schema to the appropriate documentation directory. - Updated documentation to reflect schema changes and IntelliSense support. - Enhanced tests for schema validation and project.json writing functionality. * #242 fix: Update project.json and schema to enhance RequiredModules definition and versioning * #242 feat: Add VS Code settings integration for project.json schema support * #242 feat: Update prompts to require skill invocation before actions for improved workflow clarity * #242 feat: Update prompts to require skill invocation before actions for improved workflow clarity * #242 feat: Remove support for boolean values in Package.Latest and update related JSON schema and error handling --- CHANGELOG.md | 3 +++ RELEASE_NOTE.md | 2 ++ docs/schema/v3/project.json | 17 +++++------------ .../GetNovaResolvedProjectPackageSettings.ps1 | 7 +------ src/resources/Schema-Project.json | 17 +++++------------ ...NovaResolvedProjectPackageSettings.Tests.ps1 | 8 ++++---- 6 files changed, 20 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17010c..30653f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Removed +- Removed boolean `Package.Latest` compat shim deprecated in 2.4.0. Setting `Package.Latest` to `true` or `false` now throws `Nova.Validation.InvalidPackageLatestPolicy` with a migration message. Use `"always"` or `"never"` instead. +- Removed `boolean` from the JSON schema `anyOf` for `Package.Latest` in `src/resources/Schema-Project.json` and `docs/schema/v3/project.json`. VS Code and schema-aware editors now reject boolean values for this field at edit time. + ### Fixed - `Publish-NovaModule` now keeps its completion summary available after local publish import or CI session refresh steps reload the module, so local publish flows no longer fail with missing private release-helper functions at the end of a successful publish. diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index abbc649..e1b0882 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -20,6 +20,8 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang ### Removed +- Boolean `Package.Latest` values (`true` / `false`) are no longer accepted. Passing them now throws with a migration message pointing to `"always"` or `"never"`. The JSON schema also rejects boolean values for this field. + ### Fixed - `Publish-NovaModule` no longer loses its completion summary after local publish import or CI session refresh steps reload the module. diff --git a/docs/schema/v3/project.json b/docs/schema/v3/project.json index 717f0bd..8efcc16 100644 --- a/docs/schema/v3/project.json +++ b/docs/schema/v3/project.json @@ -135,18 +135,11 @@ } }, "Latest": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "never", - "stable", - "always" - ] - } + "type": "string", + "enum": [ + "never", + "stable", + "always" ], "description": "Controls whether a latest alias is published alongside the versioned package." }, diff --git a/src/private/shared/GetNovaResolvedProjectPackageSettings.ps1 b/src/private/shared/GetNovaResolvedProjectPackageSettings.ps1 index c04136e..de50164 100644 --- a/src/private/shared/GetNovaResolvedProjectPackageSettings.ps1 +++ b/src/private/shared/GetNovaResolvedProjectPackageSettings.ps1 @@ -41,13 +41,8 @@ function ConvertTo-NovaPackageLatestPolicy { return 'never' } - # TODO: Remove legacy boolean Package.Latest handling in the next major version. if ($Value -is [bool]) { - if ($Value) { - return 'always' - } - - return 'never' + Stop-NovaOperation -Message "true/false is no longer a valid Package.Latest value. Use 'always' or 'never' instead." -ErrorId 'Nova.Validation.InvalidPackageLatestPolicy' -Category InvalidData -TargetObject $Value } $policy = "$Value".Trim() diff --git a/src/resources/Schema-Project.json b/src/resources/Schema-Project.json index 717f0bd..8efcc16 100644 --- a/src/resources/Schema-Project.json +++ b/src/resources/Schema-Project.json @@ -135,18 +135,11 @@ } }, "Latest": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "never", - "stable", - "always" - ] - } + "type": "string", + "enum": [ + "never", + "stable", + "always" ], "description": "Controls whether a latest alias is published alongside the versioned package." }, diff --git a/tests/private/shared/GetNovaResolvedProjectPackageSettings.Tests.ps1 b/tests/private/shared/GetNovaResolvedProjectPackageSettings.Tests.ps1 index 9c96b1f..380fa78 100644 --- a/tests/private/shared/GetNovaResolvedProjectPackageSettings.Tests.ps1 +++ b/tests/private/shared/GetNovaResolvedProjectPackageSettings.Tests.ps1 @@ -9,11 +9,11 @@ Describe 'ConvertTo-NovaPackageLatestPolicy' { It 'returns never for $null' { ConvertTo-NovaPackageLatestPolicy -Value $null | Should -Be 'never' } - It 'maps boolean $true to always' { - ConvertTo-NovaPackageLatestPolicy -Value $true | Should -Be 'always' + It 'throws for boolean $true with migration-friendly error' { + { ConvertTo-NovaPackageLatestPolicy -Value $true } | Should -Throw -ErrorId 'Nova.Validation.InvalidPackageLatestPolicy' } - It 'maps boolean $false to never' { - ConvertTo-NovaPackageLatestPolicy -Value $false | Should -Be 'never' + It 'throws for boolean $false with migration-friendly error' { + { ConvertTo-NovaPackageLatestPolicy -Value $false } | Should -Throw -ErrorId 'Nova.Validation.InvalidPackageLatestPolicy' } It 'returns never for whitespace' { ConvertTo-NovaPackageLatestPolicy -Value ' ' | Should -Be 'never' From 9c447542935777c50458dec995792c1e80af96db Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Sun, 31 May 2026 11:09:30 +0200 Subject: [PATCH 11/12] feat: Enhance Nova CLI testing, workflow functionality, and documentation (#252) * #247 feat: Enhance Nova CLI Testing and Workflow Functionality - Added tests for Invoke-NovaCliTestRouteCommand to ensure proper routing of test commands. - Improved Get-NovaPesterRunPath to return matching recursive test files based on BuildRecursiveFolders setting. - Updated Get-NovaPesterTestResultPath to allow custom result file names. - Refactored Get-NovaTestWorkflowContext to support new test workflow configurations and parameters. - Introduced new tests for Invoke-NovaTest to validate command options and workflow invocation. - Enhanced Invoke-NovaReleaseWorkflow to track unit and integration test calls separately. - Removed deprecated NewNovaTestDynamicParameterDictionary tests. - Updated scaffolding tests to reflect changes in suggested next steps for workflows. - Refined dynamic parameter handling in Nova testing functions to improve clarity and maintainability. * #247 feat: Add -OverrideWarning parameter to Invoke-NovaTest and related updates for coverage configuration * #247 feat: Refactor testing and validation instructions for Nova-managed projects - Update reviewer and test engineer agents to specify `Invoke-NovaTest` for unit validation and `Test-NovaBuild` for build-validation integration. - Revise documentation to clarify public command unit tests and integration tests structure under `tests/public/.Tests.ps1` and `tests/public/.Integration.Tests.ps1`. - Enhance instructions for handling destructive or environment-coupled commands with a preference for safe `-WhatIf` integration coverage. - Adjust various prompts and guidelines to reflect the new testing structure and validation processes. - Ensure consistency across all documentation regarding the use of `Invoke-NovaTest` and `Test-NovaBuild`. * #247 feat: Add integration tests for Nova commands with WhatIf support --- .github/agents/powershell-developer.agent.md | 7 +- .github/agents/reviewer.agent.md | 4 +- .github/agents/test-engineer.agent.md | 8 +- .github/copilot-instructions.md | 2 +- .../code-quality-matrix.instructions.md | 2 +- ...owershell-coding-standards.instructions.md | 2 +- .../repository-conventions.instructions.md | 2 +- .../testing-policy.instructions.md | 20 +- .github/prompts/implement-issue.prompt.md | 15 +- .../prompts/improve-test-coverage.prompt.md | 8 +- .github/prompts/review-change.prompt.md | 2 +- .github/pull_request_template.md | 1 + .../building-maintainable-code/SKILL.md | 11 +- .github/skills/pester-testing/SKILL.md | 8 +- .../powershell-module-development/SKILL.md | 7 +- AGENTS.md | 2 +- CHANGELOG.md | 7 + CONTRIBUTING.md | 6 +- README.md | 49 ++-- RELEASE_NOTE.md | 7 + docs/NovaModuleTools/en-US/Invoke-NovaTest.md | 273 ++++++++++++++++++ docs/NovaModuleTools/en-US/Test-NovaBuild.md | 196 +++++++------ docs/commands.html | 24 +- docs/core-workflows.html | 22 +- docs/getting-started.html | 7 +- docs/packaging-and-delivery.html | 4 +- docs/project-json-reference.html | 12 +- docs/troubleshooting.html | 9 +- docs/working-with-modules.html | 1 + .../build/Sync-AgenticCopilotScaffold.psd1 | 10 +- scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 | 18 +- .../AssertNovaPublicFunctionFileLayout.ps1 | 2 +- src/private/build/InvokeNovaBuildWorkflow.ps1 | 4 +- src/private/cli/InvokeNovaCliCommandRoute.ps1 | 20 +- src/private/quality/GetNovaPesterRunPath.ps1 | 39 ++- .../quality/GetNovaPesterTestResultPath.ps1 | 5 +- .../quality/GetNovaTestWorkflowContext.ps1 | 126 ++++++-- .../quality/InvokeNovaTestWorkflow.ps1 | 38 ++- .../NewNovaTestDynamicParameterDictionary.ps1 | 12 - .../release/InvokeNovaReleaseWorkflow.ps1 | 1 + ...ializeNovaModuleAgenticCopilotScaffold.ps1 | 2 +- ...vokeNovaAgenticCopilotScaffoldWorkflow.ps1 | 1 + ...InvokeNovaModuleInitializationWorkflow.ps1 | 1 + .../shared/InvokeNovaBuildValidation.ps1 | 3 +- ...ovaDynamicSkipTestsParameterDictionary.ps1 | 46 +-- src/public/InvokeNovaTest.ps1 | 32 ++ src/public/TestNovaBuild.ps1 | 4 +- .../agents/powershell-developer.agent.md | 7 +- .../.github/agents/reviewer.agent.md | 4 +- .../.github/agents/test-engineer.agent.md | 8 +- .../.github/copilot-instructions.md | 2 +- .../code-quality-matrix.instructions.md | 2 +- ...owershell-coding-standards.instructions.md | 2 +- .../repository-conventions.instructions.md | 4 +- .../testing-policy.instructions.md | 20 +- .../.github/prompts/implement-issue.prompt.md | 15 +- .../prompts/improve-test-coverage.prompt.md | 8 +- .../.github/prompts/review-change.prompt.md | 2 +- .../building-maintainable-code/SKILL.md | 11 +- .../.github/skills/pester-testing/SKILL.md | 8 +- .../powershell-module-development/SKILL.md | 7 +- src/resources/agentic-copilot/AGENTS.md | 2 +- src/resources/agentic-copilot/CONTRIBUTING.md | 7 +- src/resources/agentic-copilot/README.md | 7 +- tests/AgenticCopilotScaffoldSync.Tests.ps1 | 50 +++- tests/ArchitectureGuardrails.Tests.ps1 | 3 +- ...BuildValidationSmoke.Integration.Tests.ps1 | 39 +++ tests/Invoke-NovaModuleToolsCI.Tests.ps1 | 42 ++- tests/PublicCommandTestOwnership.Tests.ps1 | 33 +++ .../TestHelpers/PublicCommandIntegration.ps1 | 48 +++ .../build/InvokeNovaBuildWorkflow.Tests.ps1 | 10 +- .../InvokeNovaCliCommandRoute.TestSupport.ps1 | 2 + .../cli/InvokeNovaCliCommandRoute.Tests.ps1 | 39 ++- .../quality/GetNovaPesterRunPath.Tests.ps1 | 44 ++- .../GetNovaPesterTestResultPath.Tests.ps1 | 4 + ...GetNovaTestWorkflowContext.TestSupport.ps1 | 30 +- .../GetNovaTestWorkflowContext.Tests.ps1 | 193 +++++++++---- .../InvokeNovaTestWorkflow.TestSupport.ps1 | 24 +- .../quality/InvokeNovaTestWorkflow.Tests.ps1 | 41 ++- ...vaTestDynamicParameterDictionary.Tests.ps1 | 19 -- .../InvokeNovaReleaseWorkflow.TestSupport.ps1 | 8 +- .../InvokeNovaReleaseWorkflow.Tests.ps1 | 9 +- ...vaAgenticCopilotScaffoldWorkflow.Tests.ps1 | 5 +- ...NovaModuleInitializationWorkflow.Tests.ps1 | 7 +- .../InvokeNovaBuildValidation.Tests.ps1 | 8 +- ...amicSkipTestsParameterDictionary.Tests.ps1 | 16 +- .../DeployNovaPackage.Integration.Tests.ps1 | 18 ++ .../GetNovaProjectInfo.Integration.Tests.ps1 | 15 + ...tificationPreference.Integration.Tests.ps1 | 38 +++ ...InitializeNovaModule.Integration.Tests.ps1 | 15 + .../InstallNovaCli.Integration.Tests.ps1 | 17 ++ ...enticCopilotScaffold.Integration.Tests.ps1 | 15 + .../InvokeNovaBuild.Integration.Tests.ps1 | 15 + .../InvokeNovaCli.Integration.Tests.ps1 | 13 + .../InvokeNovaRelease.Integration.Tests.ps1 | 15 + .../InvokeNovaTest.Integration.Tests.ps1 | 15 + tests/public/InvokeNovaTest.Tests.ps1 | 53 ++++ ...NewNovaModulePackage.Integration.Tests.ps1 | 15 + .../PublishNovaModule.Integration.Tests.ps1 | 15 + ...tificationPreference.Integration.Tests.ps1 | 13 + .../TestNovaBuild.Integration.Tests.ps1 | 15 + tests/public/TestNovaBuild.Tests.ps1 | 34 ++- ...pdateNovaModuleTools.Integration.Tests.ps1 | 17 ++ ...ateNovaModuleVersion.Integration.Tests.ps1 | 15 + 104 files changed, 1751 insertions(+), 474 deletions(-) create mode 100644 docs/NovaModuleTools/en-US/Invoke-NovaTest.md delete mode 100644 src/private/quality/NewNovaTestDynamicParameterDictionary.ps1 create mode 100644 src/public/InvokeNovaTest.ps1 create mode 100644 tests/BuildValidationSmoke.Integration.Tests.ps1 create mode 100644 tests/PublicCommandTestOwnership.Tests.ps1 create mode 100644 tests/TestHelpers/PublicCommandIntegration.ps1 delete mode 100644 tests/private/quality/NewNovaTestDynamicParameterDictionary.Tests.ps1 create mode 100644 tests/public/DeployNovaPackage.Integration.Tests.ps1 create mode 100644 tests/public/GetNovaProjectInfo.Integration.Tests.ps1 create mode 100644 tests/public/GetNovaUpdateNotificationPreference.Integration.Tests.ps1 create mode 100644 tests/public/InitializeNovaModule.Integration.Tests.ps1 create mode 100644 tests/public/InstallNovaCli.Integration.Tests.ps1 create mode 100644 tests/public/InvokeNovaAgenticCopilotScaffold.Integration.Tests.ps1 create mode 100644 tests/public/InvokeNovaBuild.Integration.Tests.ps1 create mode 100644 tests/public/InvokeNovaCli.Integration.Tests.ps1 create mode 100644 tests/public/InvokeNovaRelease.Integration.Tests.ps1 create mode 100644 tests/public/InvokeNovaTest.Integration.Tests.ps1 create mode 100644 tests/public/InvokeNovaTest.Tests.ps1 create mode 100644 tests/public/NewNovaModulePackage.Integration.Tests.ps1 create mode 100644 tests/public/PublishNovaModule.Integration.Tests.ps1 create mode 100644 tests/public/SetNovaUpdateNotificationPreference.Integration.Tests.ps1 create mode 100644 tests/public/TestNovaBuild.Integration.Tests.ps1 create mode 100644 tests/public/UpdateNovaModuleTools.Integration.Tests.ps1 create mode 100644 tests/public/UpdateNovaModuleVersion.Integration.Tests.ps1 diff --git a/.github/agents/powershell-developer.agent.md b/.github/agents/powershell-developer.agent.md index 2a0090d..3a384a1 100644 --- a/.github/agents/powershell-developer.agent.md +++ b/.github/agents/powershell-developer.agent.md @@ -47,7 +47,9 @@ Implement PowerShell command and helper changes in the NovaModuleTools style. - Preserve existing command names, warning semantics, and output shape. - Keep new or heavily changed source functions aligned with `.github/instructions/code-quality-matrix.instructions.md`: short, single-purpose, low-duplication, and split by clear responsibility unless the scope explicitly justifies otherwise. - Prefer `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` and `./run.ps1` for normal analyzer loops; use direct `Invoke-ScriptAnalyzer` only for focused local checks that reuse the repository-approved settings. -- Validate Nova-managed project tests through `Test-NovaBuild`; do not call `Invoke-Pester` directly. +- Validate Nova-managed project tests through `Invoke-NovaTest` for unit validation and `Test-NovaBuild` for build-validation integration validation; do not call `Invoke-Pester` directly. +- For public commands, keep unit coverage in `tests/public/.Tests.ps1` and keep per-command integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation. +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output behavior. - When help files change, keep `docs/NovaModuleTools/en-US/*.md` valid for `Import-MarkdownCommandHelp`: build and import the dist module first (`Import-Module ./dist/NovaModuleTools/NovaModuleTools.psd1 -Force`), then use `New-MarkdownCommandHelp` for new files, `Update-MarkdownCommandHelp` after command-surface changes, and `Test-MarkdownCommandHelp` before handoff. Generating help without the module imported causes `external help file` to default to the command name instead of the module name, producing per-command XML files that the manifest cannot find. A new public `src/public/*.ps1` file is not done until its matching help file exists. ## Definition of done @@ -56,7 +58,8 @@ Implement PowerShell command and helper changes in the NovaModuleTools style. - Build output still comes from Nova-generated `dist/` files, not hand-authored module files in `src/`. - Public/private file ownership still follows the one externally called function per file rule, with private helpers kept as sibling top-level functions instead of nested function declarations. - Every new public entry point has its matching help file. -- Project test validation ran through `Test-NovaBuild`. +- Project test validation ran through `Invoke-NovaTest` for unit coverage and `Test-NovaBuild` for build-validation integration coverage. +- Public-command integration coverage stays owned by `tests/public/.Integration.Tests.ps1` when built-module behavior needs validation. - Any ScriptAnalyzer findings reported by `run.ps1` or `Invoke-ScriptAnalyzerCI.ps1` are resolved. - Every changed or generated text file has been checked and ends with exactly one trailing newline and no extra blank lines at the bottom. - Docs/changelog review is complete. diff --git a/.github/agents/reviewer.agent.md b/.github/agents/reviewer.agent.md index 69fcb87..a5a3452 100644 --- a/.github/agents/reviewer.agent.md +++ b/.github/agents/reviewer.agent.md @@ -27,7 +27,9 @@ Review changes for correctness, maintainability, test coverage, workflow safety, - Review changed `src/**/*.ps1` against `.github/instructions/code-quality-matrix.instructions.md` and `tests/**/*.ps1` against `.github/instructions/testing-policy.instructions.md`; flag new or heavily changed code that ignores those maintainability rules without a clear, explicit reason. - Flag public files that do not keep exactly one top-level function, and flag private files that group multiple externally called functions instead of limiting extra functions to related same-file top-level support helpers. Also flag file/function name mismatches for public commands or externally called private helpers, and flag nested function declarations inside PowerShell functions. - Flag broad catch-all test files when focused source-mirrored tests would make ownership clearer. -- Flag Nova-managed validation that bypasses `Test-NovaBuild` with direct `Invoke-Pester`. +- Flag public-command changes that skip `tests/public/.Tests.ps1` or the owning `tests/public/.Integration.Tests.ps1` without a clear cross-cutting justification. +- Flag destructive or environment-coupled public-command integrations that should have used safe `-WhatIf` coverage but did not. +- Flag Nova-managed validation that bypasses `Invoke-NovaTest` or `Test-NovaBuild` with direct `Invoke-Pester`. - Flag any PSScriptAnalyzer rule excludes or suppressions; the code should be fixed instead. - Flag unresolved ScriptAnalyzer findings from `run.ps1` or `Invoke-ScriptAnalyzerCI.ps1`; they should be fixed instead of deferred. - Flag every changed or generated text file if they do not exactly have one trailing newline with no extra blank lines at the bottom. diff --git a/.github/agents/test-engineer.agent.md b/.github/agents/test-engineer.agent.md index 4071d21..c1f5f8e 100644 --- a/.github/agents/test-engineer.agent.md +++ b/.github/agents/test-engineer.agent.md @@ -40,22 +40,24 @@ Improve or maintain the repository's Pester coverage, coverage-gate behavior, an ## Constraints -- Prefer the smallest `Test-NovaBuild` scope the project already supports, then the full repo quality loop. +- Prefer the smallest `Invoke-NovaTest` scope for unit behavior, then `Test-NovaBuild` for build-validation integration coverage, then the full repo quality loop. - Keep test files maintainable; passing tests are not enough if Code Health degrades. - Reuse existing fixture and support patterns before adding new ones. - Do not group unrelated source files into one broad test file when mirrored `tests/public`, `tests/private`, or `tests/classes` ownership is possible. +- For public commands, keep unit coverage in `tests/public/.Tests.ps1` and per-command integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior needs coverage. +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves the command wiring and `ShouldProcess` behavior. - Do not introduce PowerShell 7.x-only test syntax or APIs into a project that targets `5.1` unless compatibility coverage is explicitly part of the scope. - If CodeScene flags a regression, refactor the tests or helpers instead of suppressing the finding. - Keep new or heavily changed tests focused, isolated, and easy to scan; split setup or assertion helpers when a test stops being readable. - Use `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` as the normal analyzer entrypoint for changed test/helpers, and only fall back to direct `Invoke-ScriptAnalyzer` for focused local investigation with the repository-approved settings. -- Use `Test-NovaBuild` as the test entrypoint for Nova-managed projects; do not validate with direct `Invoke-Pester`. +- Use `Invoke-NovaTest` as the unit-test entrypoint and `Test-NovaBuild` as the build-validation integration-test entrypoint for Nova-managed projects; do not validate with direct `Invoke-Pester`. ## Definition of done - The changed behavior is covered. - Each new or changed `src/**/*.ps1` file has a matching source-mirrored test, or the cross-cutting owner test is named explicitly. - The touched tests are readable and low-duplication. -- Validation uses `Test-NovaBuild` for project test execution. +- Validation uses `Invoke-NovaTest` for unit execution and `Test-NovaBuild` for build-validation integration execution. - Validation and CodeScene implications are addressed. - The pre-commit CodeScene safeguard is clean before the work is treated as commit-ready when local CodeScene tooling is available. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 55b93ee..e1842e1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -21,7 +21,7 @@ For new or not-yet-scoped work, use the `architect` agent with `.github/prompts/ - `src/public/` — public PowerShell commands; one top-level function per file, file name matches function name - `src/private/` — domain-grouped helpers (`build/`, `cli/`, `package/`, `quality/`, `release/`, `scaffold/`, `shared/`, `update/`); one externally called helper per file -- `tests/` — Pester tests and shared test-support scripts +- `tests/` — Pester tests and shared test-support scripts; public command unit tests live under `tests/public/.Tests.ps1` and per-command build-validation integration tests live under `tests/public/.Integration.Tests.ps1` - `scripts/build/` — local analyzer and build helpers - `scripts/build/ci/` — CI coverage, CodeScene, and artifact helpers - `.github/workflows/` — GitHub Actions CI, analyzer, dependency review, publish automation diff --git a/.github/instructions/code-quality-matrix.instructions.md b/.github/instructions/code-quality-matrix.instructions.md index ba1261b..725f1e9 100644 --- a/.github/instructions/code-quality-matrix.instructions.md +++ b/.github/instructions/code-quality-matrix.instructions.md @@ -111,7 +111,7 @@ This file tells Agentic Copilot how to shape source code from the start. Test-sp - How to apply in PowerShell: - Add or update a source-mirrored `tests//.Tests.ps1` file for every changed `src/**/*.ps1` file. - Cover both the happy path and the meaningful unhappy, invalid, and boundary cases that the change introduces. - - Use `Test-NovaBuild` as the authoritative test entrypoint in Nova-managed projects. Do not call `Invoke-Pester` directly. + - Use `Invoke-NovaTest` as the unit-test entrypoint and `Test-NovaBuild` as the build-validation integration-test entrypoint in Nova-managed projects. Do not call `Invoke-Pester` directly. - Isolate collaborators with mocks/stubs when verifying side effects or branching. Keep tests order-independent. - See `.github/instructions/testing-policy.instructions.md` and the `pester-testing` skill for the full testing rules. - Common objection: "It is too small to test." If it is too small to test, it is too small to be a change worth landing. diff --git a/.github/instructions/powershell-coding-standards.instructions.md b/.github/instructions/powershell-coding-standards.instructions.md index 3d1a5b7..c7da222 100644 --- a/.github/instructions/powershell-coding-standards.instructions.md +++ b/.github/instructions/powershell-coding-standards.instructions.md @@ -38,7 +38,7 @@ Use this file when changing `src/public/`, `src/private/`, or PowerShell build/r - Follow `.github/instructions/psscriptanalyzer.instructions.md` as the ScriptAnalyzer workflow source of truth. Use the repository analyzer wrapper for normal runs and reuse repository-approved settings when invoking `Invoke-ScriptAnalyzer` directly on a focused path. - When public command help changes, follow `.github/instructions/platyps-help.instructions.md` and use `New-MarkdownCommandHelp`, `Update-MarkdownCommandHelp`, and `Test-MarkdownCommandHelp` instead of hand-authoring the help structure. - Do not add PSScriptAnalyzer `ExcludeRule`, `ExcludeRules`, suppression attributes, or generated settings that hide analyzer findings. Fix the rule violation instead. -- Keep local quality wrappers ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Test-NovaBuild`. +- Keep local quality wrappers ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Invoke-NovaTest`, then `Test-NovaBuild`. - If `run.ps1` or `Invoke-ScriptAnalyzerCI.ps1` reports ScriptAnalyzer findings, fix them before treating the change as complete. ## Formatting rules diff --git a/.github/instructions/repository-conventions.instructions.md b/.github/instructions/repository-conventions.instructions.md index c31d503..0ec4c41 100644 --- a/.github/instructions/repository-conventions.instructions.md +++ b/.github/instructions/repository-conventions.instructions.md @@ -29,7 +29,7 @@ Canonical rule source for cross-cutting NovaModuleTools conventions that apply t Use the smallest validation set that proves the change, then run the repository quality loop before finishing code work: - local quality loop: `pwsh -NoLogo -NoProfile -File ./run.ps1` -- test validation: `Test-NovaBuild` +- test validation: `Invoke-NovaTest` for unit-test validation, then `Test-NovaBuild` for build-validation integration coverage - analyzer only: `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` - CI-parity coverage flow: `./scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 -OutputDirectory ./artifacts` diff --git a/.github/instructions/testing-policy.instructions.md b/.github/instructions/testing-policy.instructions.md index 5ef94f5..fa9adb0 100644 --- a/.github/instructions/testing-policy.instructions.md +++ b/.github/instructions/testing-policy.instructions.md @@ -30,6 +30,14 @@ BeforeAll { Legacy tests that still use `Import-Module $project.OutputModuleDir` + `InModuleScope` can still exist while maintainers finish migrations, but the mirrored pattern itself now supports enabled repository coverage gates. Generated project templates still ship `CodeCoverage.Enabled = false` until maintainers opt into the coverage gate for their own project. +## Integration tests may import `dist/` + +- New mirrored unit tests should keep dot-sourcing `src/**/*.ps1` files directly. +- Public command integration ownership lives in `tests/public/.Integration.Tests.ps1` when the purpose is validating built-module behavior, exported command shape, command wiring, or build-validation smoke coverage. +- Cross-cutting `*.Integration.Tests.ps1` files may `Import-Module ./dist//.psd1` when the behavior genuinely spans multiple source files. +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output behavior. +- Keep that `dist/` import limited to integration scenarios; do not use it as the default pattern for mirrored unit tests. + ## Cross-cutting tests are still allowed Use a cross-cutting test file (not a mirrored one) when the behavior under test truly spans multiple source files: @@ -46,6 +54,7 @@ Cross-cutting files should be **named for the behavior** they validate (e.g., `A | Source file | Mirrored test file | |-----------------------------------|-------------------------------------------| | `src/public/.ps1` | `tests/public/.Tests.ps1` | +| `src/public/.ps1` | `tests/public/.Integration.Tests.ps1` for built-module/public-command integration ownership | | `src/private//.ps1` | `tests/private//.Tests.ps1` | | `src/classes/.ps1` | `tests/classes/.Tests.ps1` | @@ -54,8 +63,10 @@ A non-blocking mirror status helper is available at `scripts/build/Get-TestMirro ## Test expectations - Behavior changes require Pester coverage. -- Use `Test-NovaBuild` as the authoritative test entrypoint in Nova-managed projects. Do not validate with direct `Invoke-Pester`, because it can bypass Nova's build/import/StrictMode flow and disagree with what users see later. -- Prefer the smallest `Test-NovaBuild` scope the project already supports before running the full quality loop. +- `Invoke-NovaTest` is the unit-test entrypoint and `Test-NovaBuild` is the build-validation integration-test entrypoint in Nova-managed projects. Do not validate with direct `Invoke-Pester`, because it can bypass Nova's build/import/StrictMode flow and disagree with what users see later. +- Prefer the smallest supported test scope first: `Invoke-NovaTest` for unit behavior, then `Test-NovaBuild` when the change needs built-module or integration validation, before running the full quality loop. +- For public commands, keep unit coverage in `tests/public/.Tests.ps1` and keep per-command integration ownership in `tests/public/.Integration.Tests.ps1` when the built command behavior itself needs validation. +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output semantics. - Keep test names explicit about the behavior being proven. - Reuse `*.TestSupport.ps1` helpers where possible. - For every new or changed `src/**/*.ps1` file, add or update the matching source-mirrored `.Tests.ps1` file. @@ -71,6 +82,7 @@ A non-blocking mirror status helper is available at `scripts/build/Get-TestMirro ## Repository test structure - `tests/public/.Tests.ps1`, `tests/private//.Tests.ps1`, `tests/classes/.Tests.ps1` - mirrored unit tests (preferred for new and migrated tests) +- `tests/public/.Integration.Tests.ps1` - per-command build-validation integration tests for public commands - `tests/ArchitectureGuardrails.Tests.ps1` - layering and adapter boundaries - `tests/NovaCommandModel*.Tests.ps1` - public command, CLI, and workflow behavior (legacy bucket, being migrated) - `tests/*TestSupport.ps1`, `tests/TestHelpers/` - shared helpers, reusable fixtures, dot-source helpers @@ -79,7 +91,8 @@ A non-blocking mirror status helper is available at `scripts/build/Get-TestMirro ## Coverage and CodeScene - CI coverage is generated by `./scripts/build/ci/Invoke-NovaModuleToolsCI.ps1`. -- `Test-NovaBuild` runs once and produces a JaCoCo coverage report at `artifacts/coverage.xml`. +- `Invoke-NovaTest` produces the repository JaCoCo coverage report at `artifacts/coverage.xml`. +- `Test-NovaBuild` focuses on build-validation integration coverage and produces NUnit results without source coverage enforcement. - The JaCoCo artifact is reused by the CodeScene PR coverage gate and by the develop/manual CodeScene analysis flow. - The CodeScene analysis upload sends coverage twice: once for `line-coverage` and once for `branch-coverage`. - Coverage paths in `project.json` must point at `src/**/*.ps1`, not at the built `dist` psm1. Nova does not override `CodeCoverage.Path`. @@ -99,6 +112,7 @@ A non-blocking mirror status helper is available at `scripts/build/Get-TestMirro ## Verification +- `Invoke-NovaTest` - `Test-NovaBuild` - `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` when PowerShell code changed - `pwsh -NoLogo -NoProfile -File ./run.ps1` before completion diff --git a/.github/prompts/implement-issue.prompt.md b/.github/prompts/implement-issue.prompt.md index 3785e27..13327fd 100644 --- a/.github/prompts/implement-issue.prompt.md +++ b/.github/prompts/implement-issue.prompt.md @@ -27,13 +27,14 @@ Implement the issue in the NovaModuleTools repository using the repository-local 12. Keep file/function ownership explicit: one externally called function per file, with private-file extras limited to related same-file top-level support helpers, keep the file name aligned to the entry function, and do not declare functions inside functions. 13. Before handoff, review every changed or generated text file and normalize it to exactly one trailing newline with no extra blank lines at the bottom. 14. Add or update the matching source-mirrored Pester file for every changed `src/**/*.ps1` file; if the behavior is genuinely cross-cutting, document which integration/guardrail test owns it and why a mirrored unit test is not practical. -15. Validate Nova-managed project tests through `Test-NovaBuild`; do not call `Invoke-Pester` directly because it can bypass Nova's build/import/StrictMode flow. -16. Add or update valid PlatyPS-compatible help under `docs/NovaModuleTools/en-US/` when public commands or public classes change. Use `New-MarkdownCommandHelp` for new help, `Update-MarkdownCommandHelp` after command-surface changes, and `Test-MarkdownCommandHelp` before handoff; do not replace command help with plain Markdown prose. -17. For every new public entry point, create its matching help file immediately in the same change. -18. Review `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `RELEASE_NOTE.md`, help docs, and `docs/*.html` as applicable. -19. If a commit message is requested, derive it from `$GIT_BRANCH_NAME` and the implemented change using the repository's Conventional Commit rules. -20. Run the relevant validation, then summarize what changed, why, and how it was verified. -21. If that summary is returned as Markdown or copy-ready UI output, format it according to the `markdown-authoring` skill (`.github/skills/markdown-authoring/SKILL.md`). +15. Validate Nova-managed project tests through `Invoke-NovaTest` for unit validation and `Test-NovaBuild` for build-validation integration validation; do not call `Invoke-Pester` directly because it can bypass Nova's build/import/StrictMode flow. +16. For public commands, keep unit coverage in `tests/public/.Tests.ps1` and keep per-command integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation. For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output behavior. +17. Add or update valid PlatyPS-compatible help under `docs/NovaModuleTools/en-US/` when public commands or public classes change. Use `New-MarkdownCommandHelp` for new help, `Update-MarkdownCommandHelp` after command-surface changes, and `Test-MarkdownCommandHelp` before handoff; do not replace command help with plain Markdown prose. +18. For every new public entry point, create its matching help file immediately in the same change. +19. Review `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `RELEASE_NOTE.md`, help docs, and `docs/*.html` as applicable. +20. If a commit message is requested, derive it from `$GIT_BRANCH_NAME` and the implemented change using the repository's Conventional Commit rules. +21. Run the relevant validation, then summarize what changed, why, and how it was verified. +22. If that summary is returned as Markdown or copy-ready UI output, format it according to the `markdown-authoring` skill (`.github/skills/markdown-authoring/SKILL.md`). ## Repository-specific reminders diff --git a/.github/prompts/improve-test-coverage.prompt.md b/.github/prompts/improve-test-coverage.prompt.md index 3f8181c..7208c85 100644 --- a/.github/prompts/improve-test-coverage.prompt.md +++ b/.github/prompts/improve-test-coverage.prompt.md @@ -14,12 +14,14 @@ Improve changed-code coverage in NovaModuleTools without lowering maintainabilit 6. Follow `.github/instructions/psscriptanalyzer.instructions.md` when test code or test helpers change. Prefer `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` for repo-standard analyzer runs, and use direct `Invoke-ScriptAnalyzer` only for focused local checks that reuse the repo-approved settings. 7. Add the smallest test that proves the missing behavior. 8. If setup is duplicated, refactor the tests before adding more assertions. -9. Re-run `Test-NovaBuild`, then the repository quality loop if code changed. Do not validate a Nova-managed project with direct `Invoke-Pester`. +9. Re-run `Invoke-NovaTest` for unit coverage, then `Test-NovaBuild` when build-validation integration ownership changed, then the repository quality loop if code changed. Do not validate a Nova-managed project with direct `Invoke-Pester`. 10. Recheck CodeScene coverage or Code Health if that was the original failure. ## Repository-specific reminders - Many tests expect a built `dist/NovaModuleTools` module. -- The CI coverage flow writes `artifacts/pester-coverage.cobertura.xml`. -- Use `Test-NovaBuild` as the project test entrypoint; direct `Invoke-Pester` can miss Nova-specific strict-mode behavior. +- The CI coverage flow writes `artifacts/coverage.xml`. +- Use `Invoke-NovaTest` for unit coverage validation and `Test-NovaBuild` for build-validation integration coverage; direct `Invoke-Pester` can miss Nova-specific strict-mode behavior. +- Keep public command unit coverage in `tests/public/.Tests.ps1` and per-command integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation. +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output behavior. - Do not "fix" coverage by weakening assertions or suppressing CodeScene warnings. diff --git a/.github/prompts/review-change.prompt.md b/.github/prompts/review-change.prompt.md index 8356a9f..619a692 100644 --- a/.github/prompts/review-change.prompt.md +++ b/.github/prompts/review-change.prompt.md @@ -12,7 +12,7 @@ Review a NovaModuleTools change set with emphasis on correctness, maintainabilit 3. Check changed `src/**/*.ps1` against `.github/instructions/code-quality-matrix.instructions.md` and `tests/**/*.ps1` against `.github/instructions/testing-policy.instructions.md`. 4. Check changed PowerShell validation flow against `.github/instructions/psscriptanalyzer.instructions.md`; flag direct analyzer usage that bypasses the repository wrapper or repo-approved settings without a clear reason. 5. Check changed `docs/NovaModuleTools/en-US/*.md` against `.github/instructions/platyps-help.instructions.md` when command help was added or updated; flag files that do not follow the `New-MarkdownCommandHelp` / `Update-MarkdownCommandHelp` / `Test-MarkdownCommandHelp` workflow, miss a new public entry point's matching help file, or break the required PlatyPS section structure. -6. Check whether tests, docs, and changelog updates match the change, and flag Nova-managed validation that bypasses `Test-NovaBuild` with direct `Invoke-Pester`. +6. Check whether tests, docs, and changelog updates match the change, and flag Nova-managed validation that bypasses `Invoke-NovaTest` or `Test-NovaBuild` with direct `Invoke-Pester`. 7. Call out the smallest set of meaningful issues first. 8. Note any missing validation or follow-up work. 9. If the review is returned as Markdown or copy-ready UI text, format it according to the `markdown-authoring` skill (`.github/skills/markdown-authoring/SKILL.md`). diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c327641..71c0dd2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -32,6 +32,7 @@ ## Validation - [ ] `Invoke-NovaBuild` +- [ ] `Invoke-NovaTest` - [ ] `Test-NovaBuild` - [ ] `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` - [ ] `./scripts/build/ci/Invoke-NovaModuleToolsCI.ps1` diff --git a/.github/skills/building-maintainable-code/SKILL.md b/.github/skills/building-maintainable-code/SKILL.md index 5ac612c..76089ab 100644 --- a/.github/skills/building-maintainable-code/SKILL.md +++ b/.github/skills/building-maintainable-code/SKILL.md @@ -28,6 +28,7 @@ The companion instruction file is `.github/instructions/code-quality-matrix.inst - `tests/ArchitectureGuardrails.Tests.ps1` - `pwsh -NoLogo -NoProfile -File ./run.ps1` - `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` +- `Invoke-NovaTest` - `Test-NovaBuild` ## Guideline order and precedence @@ -67,7 +68,7 @@ Follow these steps for any non-trivial PowerShell change. 1. Read the changed function and surrounding file. Note where it sits in `src/public/`, `src/private//`, or `scripts/`. 2. For each new or heavily changed function, walk the checklist below in order. 3. If a step requires a refactor, make it the smallest structural step that fixes the specific finding. Do not bundle unrelated cleanup. -4. After meaningful steps, run `./run.ps1` (analyzer → build → `Test-NovaBuild`). For risk-bearing files, also run `code_health_review` from the `safeguarding-ai-generated-code` skill. +4. After meaningful steps, run `./run.ps1` (analyzer → build → `Invoke-NovaTest` → `Test-NovaBuild`). For risk-bearing files, also run `code_health_review` from the `safeguarding-ai-generated-code` skill. 5. Before handoff, normalize every changed text file to exactly one trailing newline. ### Step 1 — Short units (≤ 15 lines) @@ -112,7 +113,8 @@ Refactor options in PowerShell: ```powershell $script:NovaCommandMap = @{ build = { param($Args) Invoke-NovaBuild @Args } - test = { param($Args) Test-NovaBuild @Args } + test = { param($Args) Invoke-NovaTest @Args } + testBuild = { param($Args) Test-NovaBuild @Args } package = { param($Args) New-NovaPackage @Args } } @@ -201,7 +203,7 @@ Use parameter sets only for genuinely different calling shapes, not to hide 8 pa - Add or update one source-mirrored `tests//.Tests.ps1` for every changed `src/**/*.ps1` file. - Cover happy path plus the meaningful unhappy/invalid/boundary cases that the change introduces. -- Validate with `Test-NovaBuild`. Do not call `Invoke-Pester` directly. +- Validate with `Invoke-NovaTest` for unit behavior and `Test-NovaBuild` for build-validation integration behavior. Do not call `Invoke-Pester` directly. - For full rules, follow the `pester-testing` skill and `.github/instructions/testing-policy.instructions.md`. ### Step 10 — Clean code @@ -230,6 +232,7 @@ Run this short pass before handoff on every changed source file: - `pwsh -NoLogo -NoProfile -File ./run.ps1` - `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` when iterating quickly on PowerShell changes -- `Test-NovaBuild` for behavior validation +- `Invoke-NovaTest` for unit behavior validation +- `Test-NovaBuild` for build-validation integration behavior - `code_health_review` and `pre_commit_code_health_safeguard` from `safeguarding-ai-generated-code` before suggesting a commit - `code_health_review` paired with `guiding-refactoring-with-code-health` when iterating on a single unhealthy file diff --git a/.github/skills/pester-testing/SKILL.md b/.github/skills/pester-testing/SKILL.md index 71df597..2f15aa2 100644 --- a/.github/skills/pester-testing/SKILL.md +++ b/.github/skills/pester-testing/SKILL.md @@ -13,6 +13,7 @@ Use this skill when adding tests, closing coverage gaps, fixing regressions, or - `tests/*.Tests.ps1` - `tests/*TestSupport.ps1` +- `Invoke-NovaTest` - `Test-NovaBuild` - `pwsh -NoLogo -NoProfile -File ./run.ps1` - `./scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 -OutputDirectory ./artifacts` @@ -22,12 +23,14 @@ Use this skill when adding tests, closing coverage gaps, fixing regressions, or - Match existing `Describe` / `It` naming style. - Prefer support helpers for repeated setup. - For new and migrated tests, dot-source `src/**/*.ps1` files directly in `BeforeAll`. Do not `Import-Module $project.OutputModuleDir` in mirrored unit tests, and do not use `InModuleScope NovaModuleTools { ... }` - the function under test is already in scope after dot-sourcing. -- Use `Test-NovaBuild` as the authoritative test entrypoint in Nova-managed projects. Do not validate with direct `Invoke-Pester`, because it can miss the Nova build/import/StrictMode flow. +- Use `Invoke-NovaTest` as the unit-test entrypoint and `Test-NovaBuild` as the build-validation integration-test entrypoint in Nova-managed projects. Do not validate with direct `Invoke-Pester`, because it can miss the Nova build/import/StrictMode flow. - Add coverage for both happy paths and explicit warnings/errors when behavior changed. - For every new or changed `src/**/*.ps1` file, add or update one focused test file that mirrors the source path under `tests/`. - Keep test files and helpers compatible with `project.json` `Manifest.PowerShellHostVersion`; if a project targets `5.1`, do not introduce PowerShell 7.x-only syntax, cmdlets, parameters, or APIs in the tests. - Use `.github/instructions/testing-policy.instructions.md` as the test-design source of truth. Cover normal, boundary, and unhappy paths; isolate collaborators with mocks/stubs where needed; and extract setup or assertion helpers when a test stops being easy to scan. - Keep shared setup in `tests/TestHelpers/` or `*TestSupport.ps1`; do not hide unrelated source-file coverage in broad catch-all test files. +- Public command unit tests belong in `tests/public/.Tests.ps1`; public command integration ownership belongs in `tests/public/.Integration.Tests.ps1` when the built-module behavior itself needs coverage. +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves command wiring, `ShouldProcess`, and output semantics. - If a mirrored test is not practical because the behavior is genuinely cross-cutting, document the reason in the handoff and point to the owning integration or guardrail test. ## Mirrored layout example @@ -51,7 +54,7 @@ Describe 'Initialize-NovaPesterCoverageConfiguration' { - Using `InModuleScope NovaModuleTools { ... }` in new mirrored tests - the function is already in scope after dot-sourcing. - Duplicating setup instead of extending `*.TestSupport.ps1` or `tests/TestHelpers/`. - Exporting helper functions at the wrong time in test lifecycle. -- Validating a Nova-managed project with direct `Invoke-Pester` instead of `Test-NovaBuild`. +- Validating a Nova-managed project with direct `Invoke-Pester` instead of `Invoke-NovaTest` and `Test-NovaBuild`. - Passing tests while still degrading Code Health through duplication. - Grouping unrelated source files into one large test file when a source-mirrored layout would make ownership clearer. - Adding source files without a matching mirrored test or an explicit cross-cutting-test justification. @@ -59,6 +62,7 @@ Describe 'Initialize-NovaPesterCoverageConfiguration' { ## Verification +- Run `Invoke-NovaTest` - Run `Test-NovaBuild` - Run `./run.ps1` before finishing code changes - If coverage is the goal, inspect `artifacts/coverage.xml` produced by the CI helper flow. Coverage is JaCoCo and references source files under `src/**/*.ps1` directly. diff --git a/.github/skills/powershell-module-development/SKILL.md b/.github/skills/powershell-module-development/SKILL.md index a0e84f5..1778cf9 100644 --- a/.github/skills/powershell-module-development/SKILL.md +++ b/.github/skills/powershell-module-development/SKILL.md @@ -35,12 +35,14 @@ Use this skill when changing public commands, private helpers, CLI routing suppo - Follow the repository's PowerShell style rules: 4-space indentation, same-line opening braces, restrained blank lines, full cmdlet names, and readable operator spacing. - Follow `.github/instructions/psscriptanalyzer.instructions.md` as the ScriptAnalyzer workflow source of truth. Prefer `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` and `./run.ps1`, and use direct `Invoke-ScriptAnalyzer` only for focused local checks or deliberate analyzer-tooling work. - Keep ScriptAnalyzer strict: do not add excluded rules, suppression attributes, or settings that hide analyzer findings. -- Keep `run.ps1`-style local checks ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Test-NovaBuild`. +- Keep `run.ps1`-style local checks ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Invoke-NovaTest`, then `Test-NovaBuild`. - If `run.ps1` or `Invoke-ScriptAnalyzerCI.ps1` reports ScriptAnalyzer findings, fix them before handoff instead of just reporting the failure. - Before handoff, review every changed or generated text file and normalize it to exactly one trailing newline with no extra blank lines at the end. - Add or update valid PlatyPS-compatible help under `docs/NovaModuleTools/en-US/` when public commands or public classes change. Always build and import the dist module first (`Import-Module ./dist/NovaModuleTools/NovaModuleTools.psd1 -Force`) before running `New-MarkdownCommandHelp` or `Update-MarkdownCommandHelp`, so PlatyPS writes the module name — not the command name — into `external help file` and `Module Name`. Use `Test-MarkdownCommandHelp` to validate structure before handoff instead of writing plain Markdown from scratch. - For every new public `src/public/*.ps1` file, create the matching help file immediately in the same change. - Add or update the source-mirrored Pester test file for every changed `src/**/*.ps1` file. +- For public commands, keep unit coverage in `tests/public/.Tests.ps1` and keep per-command integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation. +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output behavior. ## Common pitfalls @@ -62,6 +64,7 @@ Use this skill when changing public commands, private helpers, CLI routing suppo ## Verification -- `Test-NovaBuild` for the changed behavior +- `Invoke-NovaTest` for unit-level behavior changes +- `Test-NovaBuild` when the change needs built-module or integration validation - `tests/ArchitectureGuardrails.Tests.ps1` implications checked - `pwsh -NoLogo -NoProfile -File ./run.ps1` diff --git a/AGENTS.md b/AGENTS.md index 015cd5f..b280973 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ For new or not-yet-scoped work, use the `architect` agent with `.github/prompts/ - `src/public/` — public PowerShell commands; one top-level function per file, file name matches function name - `src/private/` — domain-grouped helpers (`build/`, `cli/`, `package/`, `quality/`, `release/`, `scaffold/`, `shared/`, `update/`); one externally called helper per file -- `tests/` — Pester tests and shared test-support scripts +- `tests/` — Pester tests and shared test-support scripts; public command unit tests live under `tests/public/.Tests.ps1` and per-command build-validation integration tests live under `tests/public/.Integration.Tests.ps1` - `scripts/build/` — local analyzer and build helpers - `scripts/build/ci/` — CI coverage, CodeScene, and artifact helpers - `.github/workflows/` — GitHub Actions CI, analyzer, dependency review, publish automation diff --git a/CHANGELOG.md b/CHANGELOG.md index 30653f4..ce7f52c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `nova init` also writes `.vscode/settings.json` mapping `project.json` to the hosted schema URL so VS Code trusts the remote schema automatically without prompting. - VS Code picks up the schema automatically to provide field validation, autocomplete, and hover descriptions while editing `project.json`. - The two partial internal schemas (`Schema-Build.json`, `Schema-Pester.json`) are removed; `Schema-Project.json` is the new single source. +- Added `Invoke-NovaTest` as the dedicated NovaModuleTools unit-test command. + - `% nova test` now routes to `Invoke-NovaTest`. + - `Invoke-NovaTest` writes unit-test NUnit output to `artifacts/UnitTestResults.xml` and remains the code-coverage entry point. ### Changed @@ -20,6 +23,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `-Installed` now returns the installed version of the current project/module from the local module path. - `-InstalledNovaVersion` now returns the installed `NovaModuleTools` module name and version from PowerShell. - `Update-NovaModuleTool` now suggests `Get-NovaProjectInfo -InstalledNovaVersion` after a successful self-update so the PowerShell verification step checks the installed NovaModuleTools version directly. +- `Test-NovaBuild` now focuses on build-validation integration tests instead of acting as the combined unit-and-build test entry point. + - `% nova test --build` and `% nova test -b` now route to `Test-NovaBuild`. + - `Test-NovaBuild` now writes build-validation NUnit output to `artifacts/TestResults.xml` without enforcing source coverage. +- CI, release, packaging, and repository quality workflows now run `Invoke-NovaTest` before `Test-NovaBuild` when tests are not skipped. ### Deprecated diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9822f7e..4ed10b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,7 +55,8 @@ pwsh -NoLogo -NoProfile -File ./run.ps1 `run.ps1` is the authoritative repository quality wrapper. It runs ScriptAnalyzer first, validates `CHANGELOG.md` and `RELEASE_NOTE.md`, refreshes the Agentic Copilot scaffold mirror, and then runs -`Test-NovaBuild`. +`Invoke-NovaTest` followed by `Test-NovaBuild`. `Invoke-NovaTest` is the unit-test entrypoint, while +`Test-NovaBuild` is the build-validation integration-test entrypoint. If you are working on the CodeScene integration, the CI coverage helper writes the JaCoCo artifact that the CodeScene upload step consumes: @@ -68,6 +69,9 @@ If you are working on the CodeScene integration, the CI coverage helper writes t Please also make sure your contribution includes the right kind of follow-up work: - add or update tests when behavior changes + - keep public command unit ownership in `tests/public/.Tests.ps1` + - keep per-command public integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation + - for destructive or environment-coupled commands, prefer safe `-WhatIf` integration coverage when appropriate - update help files in `docs/` when a command changes - update `README.md` when repository workflow, architecture, or contributor expectations change - update `CHANGELOG.md` when the change is relevant to users, maintainers, or future contributors diff --git a/README.md b/README.md index 92b14ab..3126be1 100644 --- a/README.md +++ b/README.md @@ -89,14 +89,25 @@ This creates the built module under `dist/NovaModuleTools/`. Files under `src/public/` are expected to contain exactly one top-level function each. Nova now stops build-driven workflows when a public file contains zero or multiple top-level functions, because that layout can accidentally export helpers as part of the public API surface. Use `-OverrideWarning` / `--override-warning` / `-o` only when you intentionally want to bypass that guard for a specific build, test-build, package, publish, or release run. -When you want the test workflow to rebuild first, use: +When you want the PowerShell unit-test workflow, use: ```powershell -PS> Test-NovaBuild -Build +PS> Invoke-NovaTest +% nova test +``` + +When you want the build-validation integration workflow, use: + +```powershell +PS> Test-NovaBuild % nova test --build % nova test -b ``` +- `Invoke-NovaTest` is the unit-test entrypoint. Keep public command unit ownership in `tests/public/.Tests.ps1`. +- `Test-NovaBuild` is the build-validation integration-test entrypoint. Keep per-command public integration ownership in `tests/public/.Integration.Tests.ps1` when the built command behavior itself needs coverage. +- For destructive or environment-coupled commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess` wiring and command behavior. + NovaModuleTools can self-update the installed module from PowerShell or the `nova` CLI launcher. - Stable self-updates are always available. @@ -271,20 +282,22 @@ $module = Import-Module $distManifestPath -Force -PassThru Run the repository test workflow from the repository root: ```powershell +PS> Invoke-NovaTest PS> Test-NovaBuild ``` Notes: -- `Test-NovaBuild` runs Nova's managed test workflow; this repo's mirrored unit tests dot-source `src/**/*.ps1` - directly, while cross-cutting tests still cover built-module behavior where that is the point of the test -- it writes NUnit XML to `artifacts/TestResults.xml` -- when `Pester.CodeCoverage.Enabled` is `true`, it also writes JaCoCo XML to `artifacts/coverage.xml` -- it respects `BuildRecursiveFolders` when discovering tests -- if `project.json` sets `Pester.CodeCoverage.CoveragePercentTarget`, `Test-NovaBuild` fails when the measured coverage percentage is lower than that configured target +- `Invoke-NovaTest` runs Nova's managed unit-test workflow; this repo's mirrored unit tests dot-source `src/**/*.ps1` + directly, while cross-cutting integration tests cover built-module behavior where that is the point of the test +- `Invoke-NovaTest` writes NUnit XML to `artifacts/UnitTestResults.xml` +- when `Pester.CodeCoverage.Enabled` is `true`, `Invoke-NovaTest` also writes JaCoCo XML to `artifacts/coverage.xml` +- `Test-NovaBuild` runs the build-validation integration flow and writes NUnit XML to `artifacts/TestResults.xml` +- both commands respect `BuildRecursiveFolders` when discovering matching test files +- 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 `Test-NovaBuild` +- 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 ### Create a package artifact @@ -307,7 +320,7 @@ PS> New-NovaModulePackage -SkipTests % nova package -s ``` -`-SkipTests` / `--skip-tests` skips `Test-NovaBuild` only. `Invoke-NovaBuild` still runs. +`-SkipTests` / `--skip-tests` skips both `Invoke-NovaTest` and `Test-NovaBuild`. `Invoke-NovaBuild` still runs. Use this `project.json` shape when you want to control the package types and output directory: @@ -405,8 +418,8 @@ PS> Invoke-NovaRelease -Repository PSGallery -ApiKey $env:PSGALLERY_API -SkipTes % nova release --repository PSGallery --api-key $env:PSGALLERY_API -s ``` -These forms skip `Test-NovaBuild` only. `Publish-NovaModule` still builds before publishing, and `Invoke-NovaRelease` -still runs both build steps around the version bump. +These forms skip both `Invoke-NovaTest` and `Test-NovaBuild`. `Publish-NovaModule` still builds before publishing, and +`Invoke-NovaRelease` still runs both build steps around the version bump. `Invoke-NovaRelease` now uses the same direct delivery parameters as `Publish-NovaModule` and `% nova release`, so PowerShell automation can pass `-Local`, `-Repository`, `-ModuleDirectoryPath`, and `-ApiKey` without wrapping them in a `-PublishOption` hashtable. @@ -447,7 +460,8 @@ PS> pwsh -NoLogo -NoProfile -File ./run.ps1 ``` `run.ps1` is the repository quality wrapper. It runs ScriptAnalyzer first, validates `CHANGELOG.md` and -`RELEASE_NOTE.md`, refreshes the Agentic Copilot scaffold mirror, and then runs `Test-NovaBuild`. +`RELEASE_NOTE.md`, refreshes the Agentic Copilot scaffold mirror, then runs `Invoke-NovaTest`, and finally runs +`Test-NovaBuild`. ### Working on help and docs @@ -522,7 +536,7 @@ Packaged resources that ship with the module, including: - the packaged example project under `src/resources/example/` - the Agentic Copilot starter package under `src/resources/agentic-copilot/` -The example project is both a shipped resource and a maintained working reference. The Agentic Copilot starter package is generated from Nova's repository-local agentic guidance, including Nova build/test/package expectations, `project.json` `Manifest.PowerShellHostVersion` compatibility guidance, generated `dist` module files, command-help ownership, source-mirrored test guidance, Test-NovaBuild-only project test guidance that forbids direct `Invoke-Pester` so agent validation matches Nova's build/import/StrictMode flow, guidance that ScriptAnalyzer findings reported by `run.ps1` must be fixed before handoff, explicit PSScriptAnalyzer workflow guidance for `./scripts/build/Invoke-ScriptAnalyzerCI.ps1`, `./run.ps1`, and focused `Invoke-ScriptAnalyzer` usage, explicit public/private PowerShell file ownership rules, best-effort source/helper-script maintainability guidance plus separate test-design guidance that live in Agentic Copilot files, a generated +The example project is both a shipped resource and a maintained working reference. The Agentic Copilot starter package is generated from Nova's repository-local agentic guidance, including Nova build/test/package expectations, `project.json` `Manifest.PowerShellHostVersion` compatibility guidance, generated `dist` module files, command-help ownership, source-mirrored test guidance, separate `Invoke-NovaTest` unit-test and `Test-NovaBuild` build-validation guidance that forbids direct `Invoke-Pester` so agent validation matches Nova's build/import/StrictMode flow, guidance that ScriptAnalyzer findings reported by `run.ps1` must be fixed before handoff, explicit PSScriptAnalyzer workflow guidance for `./scripts/build/Invoke-ScriptAnalyzerCI.ps1`, `./run.ps1`, and focused `Invoke-ScriptAnalyzer` usage, explicit public/private PowerShell file ownership rules, best-effort source/helper-script maintainability guidance plus separate test-design guidance that live in Agentic Copilot files, a generated `scripts/build/Test-TextFileFormatting.ps1` helper and matching `tests/TextFileFormatting.Tests.ps1` guardrail when Pester is enabled so extra blank lines at EOF fail the project test flow, and explicit valid-PlatyPS help guidance for `docs//en-US/*.md` that uses the documented `New-MarkdownCommandHelp` / `Update-MarkdownCommandHelp` / `Test-MarkdownCommandHelp` workflow and requires a matching help file for every new public entry point in the same change, and a strict file-ending rule for changed or generated text files. When the init flow adds that starter package, it also asks for a short project name so scaffolded guidance can replace placeholders such as `Invoke-*`. Run @@ -593,9 +607,10 @@ explicitly. The repository `Tests.yml` workflow also downloads that same JaCoCo The normal repository workflow is: 1. `pwsh -NoLogo -NoProfile -File ./run.ps1` for the full repository quality loop -2. `Test-NovaBuild` when you only need focused test validation -3. `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` when you only need the analyzer pass -4. Optional CI helper flow via `scripts/build/ci/Invoke-NovaModuleToolsCI.ps1` +2. `Invoke-NovaTest` when you only need focused unit-test validation +3. `Test-NovaBuild` when you need focused build-validation integration coverage +4. `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` when you only need the analyzer pass +5. Optional CI helper flow via `scripts/build/ci/Invoke-NovaModuleToolsCI.ps1` When you test local publish behavior during development, remember that `Publish-NovaModule -Local` reloads the published module from the local install directory into the current PowerShell session. Re-import `dist/` if your next step depends on the built-but-unpublished output instead. diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index e1b0882..150d489 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -8,6 +8,9 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang - `nova init` now injects a `"$schema"` property into new projects pointing to the hosted versioned JSON schema, enabling VS Code IntelliSense, autocomplete, and hover descriptions while editing `project.json`. - `nova init` also writes `.vscode/settings.json` mapping `project.json` to the schema URL so VS Code trusts it automatically — no manual "Allow" step required. +- Added `Invoke-NovaTest` as the dedicated PowerShell unit-test command. + - `% nova test` now maps to `Invoke-NovaTest`. + - Unit-test NUnit results are now written to `artifacts/UnitTestResults.xml`. ### Changed @@ -15,6 +18,10 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang - Use `-Installed` for the installed current project/module version. - Use `-InstalledNovaVersion` for the installed `NovaModuleTools` version. - `Update-NovaModuleTool` now suggests `Get-NovaProjectInfo -InstalledNovaVersion` after a successful self-update. +- `Test-NovaBuild` is now the build-validation integration-test command instead of the combined default test entry point. + - `% nova test --build` and `% nova test -b` now map to `Test-NovaBuild`. + - Build-validation NUnit results continue to use `artifacts/TestResults.xml`. +- Repository and CI quality loops now run `Invoke-NovaTest` before `Test-NovaBuild` when tests are enabled. ### Deprecated diff --git a/docs/NovaModuleTools/en-US/Invoke-NovaTest.md b/docs/NovaModuleTools/en-US/Invoke-NovaTest.md new file mode 100644 index 0000000..e3ac37f --- /dev/null +++ b/docs/NovaModuleTools/en-US/Invoke-NovaTest.md @@ -0,0 +1,273 @@ +--- +document type: cmdlet +external help file: NovaModuleTools-Help.xml +HelpUri: 'https://www.novamoduletools.com/core-workflows.html#test' +Locale: en-US +Module Name: NovaModuleTools +ms.date: 05/31/2026 +PlatyPS schema version: 2024-05-01 +title: Invoke-NovaTest +--- + +# Invoke-NovaTest + +## SYNOPSIS + +Runs the NovaModuleTools unit-test workflow for the current project. + +## SYNTAX + +### __AllParameterSets + +```text +PS> Invoke-NovaTest [[-TagFilter] ] [[-ExcludeTagFilter] ] + [[-OutputVerbosity] ] [[-OutputRenderMode] ] [-OverrideWarning] + [-WhatIf] [-Confirm] [] +``` + +## ALIASES + +## DESCRIPTION + +`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. + +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`. + +Use `Test-NovaBuild` when you need the separate build-validation integration flow that runs against the built module output. + +Use `-OverrideWarning` only when you intentionally want to bypass Nova's public-file export guard for the current unit-test run. + +This command supports `-WhatIf` and `-Confirm` through PowerShell `SupportsShouldProcess`. Use `-WhatIf` to preview the planned unit-test run and output paths without invoking Pester. + +## EXAMPLES + +### EXAMPLE 1 + +```text +PS> Invoke-NovaTest +``` + +Runs the managed unit-test workflow for the current project and writes `artifacts/UnitTestResults.xml`. + +### EXAMPLE 2 + +```text +PS> Invoke-NovaTest -TagFilter fast +``` + +Runs only unit tests tagged `fast`. + +### EXAMPLE 3 + +```text +PS> Invoke-NovaTest -ExcludeTagFilter slow +``` + +Runs the unit-test workflow while excluding tests tagged `slow`. + +### EXAMPLE 4 + +```text +PS> Invoke-NovaTest -OutputVerbosity Detailed -OutputRenderMode Ansi +``` + +Overrides the Pester console output settings for the current unit-test run. + +### EXAMPLE 5 + +```text +PS> Invoke-NovaTest -OverrideWarning +``` + +Runs the unit-test workflow while explicitly bypassing Nova's public-file export guard for this invocation. + +### EXAMPLE 6 + +```text +PS> Invoke-NovaTest -WhatIf +``` + +Previews the planned unit-test run and does not execute tests or write artifacts. + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -ExcludeTagFilter + +Array of Pester tags to exclude from the unit-test run. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -OutputRenderMode + +Overrides how Pester renders console output for the current unit-test run. + +```yaml +Type: System.String +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 3 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: +- Auto +- Ansi +HelpMessage: '' +``` + +### -OutputVerbosity + +Overrides the Pester console verbosity for the current unit-test run. + +```yaml +Type: System.String +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 2 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: +- None +- Normal +- Detailed +- Diagnostic +HelpMessage: '' +``` + +### -OverrideWarning + +Bypasses Nova's public-file export guard for this unit-test invocation. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -TagFilter + +Array of Pester tags to include in the unit-test run. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +### System.Object + +Returns the Pester result object from the managed unit-test run. + +## NOTES + +`Invoke-NovaTest` is the PowerShell unit-test entry point for NovaModuleTools. The `nova test` CLI route maps to this command unless `--build` or `-b` is supplied. + +## RELATED LINKS + +[Test-NovaBuild](Test-NovaBuild.md) +[Invoke-NovaBuild](Invoke-NovaBuild.md) diff --git a/docs/NovaModuleTools/en-US/Test-NovaBuild.md b/docs/NovaModuleTools/en-US/Test-NovaBuild.md index e2a5146..aff590a 100644 --- a/docs/NovaModuleTools/en-US/Test-NovaBuild.md +++ b/docs/NovaModuleTools/en-US/Test-NovaBuild.md @@ -4,7 +4,7 @@ external help file: NovaModuleTools-Help.xml HelpUri: 'https://www.novamoduletools.com/core-workflows.html#test' Locale: en-US Module Name: NovaModuleTools -ms.date: 04/26/2026 +ms.date: 05/31/2026 PlatyPS schema version: 2024-05-01 title: Test-NovaBuild --- @@ -13,34 +13,33 @@ title: Test-NovaBuild ## SYNOPSIS -Runs Pester tests for the current NovaModuleTools project. +Runs the NovaModuleTools build-validation integration-test workflow for the current project. ## SYNTAX ### __AllParameterSets ```text -PS> Test-NovaBuild [-Build] [-OverrideWarning] [[-TagFilter] ] [[-ExcludeTagFilter] ] - [[-OutputVerbosity] ] [[-OutputRenderMode] ] [-WhatIf] [-Confirm] [] +PS> Test-NovaBuild [[-TagFilter] ] [[-ExcludeTagFilter] ] + [[-OutputVerbosity] ] [[-OutputRenderMode] ] [-OverrideWarning] [-WhatIf] + [-Confirm] [] ``` -## DESCRIPTION +## ALIASES -`Test-NovaBuild` reads the Pester configuration from `project.json`, resolves the correct test path, and runs the test suite against the current project. +## DESCRIPTION -When `project.json` sets `Pester.CodeCoverage.CoveragePercentTarget`, Nova also treats the configured threshold as a failure condition and stops the run if the measured coverage percentage is lower than that target. +`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. -Use `-Build` when you want Nova to rebuild the project output before the Pester run starts. +This build-validation flow writes NUnit XML to `artifacts/TestResults.xml`. -When `-Build` is used, `-OverrideWarning` lets that nested build continue even if the `src/public` layout guard reports zero or multiple top-level functions in a public file. +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. -With the default -`BuildRecursiveFolders=true`, test files in nested folders under `tests` are discovered and run. Set -`BuildRecursiveFolders=false` to limit discovery to top-level `tests/*.Tests.ps1` files, following Pester's normal test-file convention. The generated Pester XML report is written to `artifacts/TestResults.xml`. +`-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. -This command supports `-WhatIf` and `-Confirm` through PowerShell `SupportsShouldProcess`. Use `-WhatIf` to preview the planned test run and XML output path without creating `artifacts/` or invoking Pester. +With the default `BuildRecursiveFolders=true`, integration test files in nested folders under `tests` are discovered and run. Set `BuildRecursiveFolders=false` to limit discovery to top-level matching test files. -During a test run, Nova shows progress for the optional pre-test build, test-result preparation, the Pester run, result writing, and code-coverage validation. When tests pass, Nova prints the result file path, a coverage summary when one is available, and a suggested next step. In `-WhatIf` mode, Nova ends with a test-plan summary instead of invoking Pester. +This command supports `-WhatIf` and `-Confirm` through PowerShell `SupportsShouldProcess`. Use `-WhatIf` to preview the planned build-validation run and XML output path without invoking Pester. ## EXAMPLES @@ -50,39 +49,39 @@ During a test run, Nova shows progress for the optional pre-test build, test-res PS> Test-NovaBuild ``` -Runs the Pester tests for the current project and prints the result file path plus the next suggested command when the run succeeds. +Runs the build-validation integration tests for the current project and writes `artifacts/TestResults.xml`. ### EXAMPLE 2 ```text -PS> Test-NovaBuild -Build +PS> Test-NovaBuild -TagFilter smoke ``` -Builds the project first, then runs the configured Pester test workflow with the same completion summary as a normal test run. +Runs only build-validation tests tagged `smoke`. ### EXAMPLE 3 ```text -PS> Test-NovaBuild -TagFilter unit,fast +PS> Test-NovaBuild -ExcludeTagFilter slow ``` -Runs only tests tagged `unit` or `fast`. +Runs the build-validation suite while excluding tests tagged `slow`. ### EXAMPLE 4 ```text -PS> Test-NovaBuild -ExcludeTagFilter slow +PS> Test-NovaBuild -OverrideWarning ``` -Runs the test suite while excluding tests tagged `slow`. +Continues the build-validation flow even if the public-command file-layout guard reports warnings. ### EXAMPLE 5 ```text -PS> Test-NovaBuild -OutputVerbosity Normal -OutputRenderMode Ansi +PS> Test-NovaBuild -OutputVerbosity Detailed -OutputRenderMode Ansi ``` -Overrides the console output settings for the current test run while keeping color-capable rendering. +Overrides the console output settings for the current build-validation run. ### EXAMPLE 6 @@ -90,63 +89,35 @@ Overrides the console output settings for the current test run while keeping col PS> Test-NovaBuild -WhatIf ``` -Previews the planned Pester run, prints the planned result file path, and does not execute tests or write `artifacts/TestResults.xml`. - -### EXAMPLE 7 - -```text -PS> Test-NovaBuild -Build -WhatIf -``` - -Previews the build-before-test workflow, including the planned result file path and configured coverage target, without rebuilding the project or running Pester. +Previews the planned build-validation run and does not execute tests or write `artifacts/TestResults.xml`. ## PARAMETERS -### -Build +### -Confirm -Builds the project before the test workflow starts. +Prompts you for confirmation before running the cmdlet. ```yaml Type: System.Management.Automation.SwitchParameter -DefaultValue: False -SupportsWildcards: false -Aliases: [ ] -ParameterSets: - - Name: (All) - Position: Named - IsRequired: false - ValueFromPipeline: false - ValueFromPipelineByPropertyName: false - ValueFromRemainingArguments: false -DontShow: false -AcceptedValues: [ ] -HelpMessage: '' -``` - -### -OverrideWarning - -When `-Build` is also used, continue the nested build even if the `src/public` layout guard reports that a public file does not contain exactly one top-level function. - -```yaml -Type: System.Management.Automation.SwitchParameter -DefaultValue: False +DefaultValue: '' SupportsWildcards: false -Aliases: [ ] +Aliases: +- cf ParameterSets: - - Name: (All) - Position: Named - IsRequired: false - ValueFromPipeline: false - ValueFromPipelineByPropertyName: false - ValueFromRemainingArguments: false +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false DontShow: false -AcceptedValues: [ ] +AcceptedValues: [] HelpMessage: '' ``` ### -ExcludeTagFilter -Array of Pester tags to exclude from the run. +Array of Pester tags to exclude from the build-validation run. ```yaml Type: System.String[] @@ -165,30 +136,32 @@ AcceptedValues: [] HelpMessage: '' ``` -### -TagFilter +### -OutputRenderMode -Array of Pester tags to include in the run. +Overrides how Pester renders console output for the current build-validation run. ```yaml -Type: System.String[] +Type: System.String DefaultValue: '' SupportsWildcards: false Aliases: [] ParameterSets: - Name: (All) - Position: 0 + Position: 3 IsRequired: false ValueFromPipeline: false ValueFromPipelineByPropertyName: false ValueFromRemainingArguments: false DontShow: false -AcceptedValues: [] +AcceptedValues: +- Auto +- Ansi HelpMessage: '' ``` ### -OutputVerbosity -Overrides the Pester console verbosity for the current run. +Overrides the Pester console verbosity for the current build-validation run. ```yaml Type: System.String @@ -211,59 +184,90 @@ AcceptedValues: HelpMessage: '' ``` -### -OutputRenderMode +### -OverrideWarning -Overrides how Pester renders console output for the current run. +Continue the nested build-validation setup even if the `src/public` layout guard reports that a public file does not contain exactly one top-level function. ```yaml -Type: System.String +Type: System.Management.Automation.SwitchParameter +DefaultValue: False +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -TagFilter + +Array of Pester tags to include in the build-validation run. + +```yaml +Type: System.String[] DefaultValue: '' SupportsWildcards: false Aliases: [] ParameterSets: - Name: (All) - Position: 3 + Position: 0 IsRequired: false ValueFromPipeline: false ValueFromPipelineByPropertyName: false ValueFromRemainingArguments: false DontShow: false -AcceptedValues: -- Auto -- Ansi +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] HelpMessage: '' ``` ### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, -ProgressAction, -Verbose, -WarningAction, -WarningVariable, -WhatIf, and -Confirm. For more information, see +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). ## INPUTS -### None - -You can't pipe objects to this cmdlet. - ## OUTPUTS -### None +### System.Object -This cmdlet does not emit an output object. It throws when the test run fails or when a configured code coverage target is not met. +Returns the Pester result object from the managed build-validation run. ## NOTES -The generated test result XML is written to `artifacts/TestResults.xml`. - -If `project.json` configures `Pester.CodeCoverage.CoveragePercentTarget`, `Test-NovaBuild` also fails when the measured coverage percentage is lower than that configured threshold. - -`Test-NovaBuild` uses `SupportsShouldProcess`, so `Get-Help Test-NovaBuild -Full` surfaces native `-WhatIf` and -`-Confirm` support. - -Use `Ctrl+C` if you need to stop a running test workflow before Pester completes. +`Test-NovaBuild` is the PowerShell build-validation entry point for NovaModuleTools. The `nova test --build` and `nova test -b` CLI forms map to this command. ## RELATED LINKS -- [Get-NovaProjectInfo](./Get-NovaProjectInfo.md) -- [Invoke-NovaBuild](./Invoke-NovaBuild.md) -- [Publish-NovaModule](./Publish-NovaModule.md) +[Invoke-NovaTest](Invoke-NovaTest.md) +[Invoke-NovaBuild](Invoke-NovaBuild.md) diff --git a/docs/commands.html b/docs/commands.html index 1b38a24..7cc65d1 100644 --- a/docs/commands.html +++ b/docs/commands.html @@ -339,21 +339,25 @@

    Run the test workflow

    Launcher route: nova test

    -

    Cmdlet: Test-NovaBuild

    +

    Cmdlets: Invoke-NovaTest for unit tests, Test-NovaBuild for build validation

    -

    Run Pester for the current project and write results to +

    Run Nova's managed test workflows for the current project. Invoke-NovaTest + writes unit-test results to artifacts/UnitTestResults.xml, and + Test-NovaBuild writes build-validation results to artifacts/TestResults.xml.

    -

    If coverage is enabled in project.json, the same workflow also writes - artifacts/coverage.xml and fails when measured coverage stays below the +

    If coverage is enabled in project.json, Invoke-NovaTest also + writes artifacts/coverage.xml and fails when measured coverage stays below the configured CoveragePercentTarget.

      -
    • Use when: you want the standard Nova test flow
    • +
    • Use when: you want unit tests from PowerShell or the default Nova test flow from the CLI
    • Cmdlet extras: - -Build, -TagFilter, -ExcludeTagFilter, - -OutputVerbosity, and -OutputRenderMode
    • + Invoke-NovaTest supports -TagFilter, + -ExcludeTagFilter, -OutputVerbosity, and + -OutputRenderMode. Test-NovaBuild supports the same options + plus -OverrideWarning.
    @@ -367,9 +371,9 @@

    Run the test workflow

    % nova test --what-if
    -
    PS> Test-NovaBuild
    -PS> Test-NovaBuild -Build
    -PS> Test-NovaBuild -TagFilter unit,fast
    +
    PS> Invoke-NovaTest
    +PS> Test-NovaBuild
    +PS> Invoke-NovaTest -TagFilter unit,fast

    See the test workflow

    diff --git a/docs/core-workflows.html b/docs/core-workflows.html index 5735788..bf946c1 100644 --- a/docs/core-workflows.html +++ b/docs/core-workflows.html @@ -141,8 +141,9 @@

    Create the project

    guidance, a best-effort source/helper-script maintainability guidance plus separate test-design guidance delivered through Agentic Copilot guidance files, explicit PSScriptAnalyzer workflow guidance for ./scripts/build/Invoke-ScriptAnalyzerCI.ps1, - ./run.ps1, and focused Invoke-ScriptAnalyzer usage, Test-NovaBuild-only - project test guidance that forbids direct Invoke-Pester, explicit valid-PlatyPS help + ./run.ps1, and focused Invoke-ScriptAnalyzer usage, separate + Invoke-NovaTest unit-test and Test-NovaBuild build-validation guidance + that forbids direct Invoke-Pester, explicit valid-PlatyPS help guidance for docs/<ProjectName>/en-US/*.md using New-MarkdownCommandHelp, Update-MarkdownCommandHelp, and Test-MarkdownCommandHelp, with a matching help file required for every new public @@ -241,11 +242,11 @@

    Run the tests

    -
    PS> Test-NovaBuild
    -PS> Test-NovaBuild -Build
    -PS> Test-NovaBuild -TagFilter unit,fast
    +                        
    PS> Invoke-NovaTest
    +PS> Test-NovaBuild
    +PS> Invoke-NovaTest -TagFilter unit,fast
     PS> Test-NovaBuild -ExcludeTagFilter slow
    -PS> Test-NovaBuild -OutputVerbosity Detailed -OutputRenderMode Ansi
    +PS> Invoke-NovaTest -OutputVerbosity Detailed -OutputRenderMode Ansi
    -

    Test-NovaBuild reads the Pester configuration from project.json, resolves - the test path, and writes results to artifacts/TestResults.xml.

    +

    Invoke-NovaTest reads the Pester configuration from project.json, runs the + unit-test flow, and writes results to artifacts/UnitTestResults.xml. Test-NovaBuild + runs the build-validation integration flow and writes results to artifacts/TestResults.xml.

    Starter projects already include the matching Pester.CodeCoverage block in project.json, but keep it disabled until you opt in. When you set - Pester.CodeCoverage.Enabled to true, Nova also writes JaCoCo coverage to - artifacts/coverage.xml and stops if the measured coverage percentage is lower than the + Pester.CodeCoverage.Enabled to true, Invoke-NovaTest also + writes JaCoCo coverage to artifacts/coverage.xml and stops if the measured coverage percentage is lower than the configured CoveragePercentTarget.

    Use -Build, --build, or -b when you want Nova to rebuild the project before the test workflow starts.

    diff --git a/docs/getting-started.html b/docs/getting-started.html index bb0f601..23b4df0 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -210,8 +210,9 @@

    4. Scaffold the packaged example

    best-effort source/helper-script maintainability guidance plus separate test-design guidance delivered through Agentic Copilot guidance files, explicit PSScriptAnalyzer workflow guidance for ./scripts/build/Invoke-ScriptAnalyzerCI.ps1, ./run.ps1, and - focused Invoke-ScriptAnalyzer usage, Test-NovaBuild-only project test guidance that - forbids direct Invoke-Pester, explicit valid-PlatyPS help guidance for + focused Invoke-ScriptAnalyzer usage, separate Invoke-NovaTest unit-test + and Test-NovaBuild build-validation guidance that forbids direct + Invoke-Pester, explicit valid-PlatyPS help guidance for docs/<ProjectName>/en-US/*.md using New-MarkdownCommandHelp, Update-MarkdownCommandHelp, and Test-MarkdownCommandHelp, with a matching help file required for every new public @@ -257,6 +258,7 @@

    5. Build and test the example

    PS> Set-Location ~/Work/<YourProjectName>
     PS> Invoke-NovaBuild
    +PS> Invoke-NovaTest
     PS> Test-NovaBuild

    Common examples

    @@ -435,7 +435,7 @@

    What happens

    CI/CD shortcut:

    Use -SkipTests, --skip-tests, or -s when tests already ran earlier in your pipeline. Release still runs both build steps; it only skips the pre-release - Test-NovaBuild step.

    + Invoke-NovaTest and Test-NovaBuild steps.

    Examples

    diff --git a/docs/project-json-reference.html b/docs/project-json-reference.html index 267aff1..dd71989 100644 --- a/docs/project-json-reference.html +++ b/docs/project-json-reference.html @@ -99,7 +99,7 @@

    How to read this file

  • Package controls package creation and optional raw HTTP upload defaults.
  • -
  • Pester controls the test run configuration used by Test-NovaBuild. +
  • Pester controls the test run configuration used by Invoke-NovaTest and Test-NovaBuild.
  • @@ -621,8 +621,8 @@

    Authentication examples

    Pester settings

    -

    NovaModuleTools passes your Pester settings into the test run configuration used by - Test-NovaBuild.

    +

    NovaModuleTools passes your Pester settings into the managed test run configuration used by + Invoke-NovaTest and Test-NovaBuild.

    @@ -644,7 +644,7 @@

    Pester settings

    - + @@ -686,7 +686,7 @@

    Pester settings

    - @@ -696,7 +696,7 @@

    Pester settings

    the explicit default source globs shown above. The minimal scaffold keeps Enabled=false, while the packaged example scaffold sets Enabled=true so coverage works against src/**/*.ps1 immediately. At runtime, - Test-NovaBuild also lets you override verbosity and render mode temporarily with + Invoke-NovaTest and Test-NovaBuild both let you override verbosity and render mode temporarily with -OutputVerbosity and -OutputRenderMode.

    When you enable coverage, Nova writes JaCoCo output to artifacts/coverage.xml and fails the test workflow if the measured percentage is lower than diff --git a/docs/troubleshooting.html b/docs/troubleshooting.html index bdee8c7..12dbe02 100644 --- a/docs/troubleshooting.html +++ b/docs/troubleshooting.html @@ -193,9 +193,12 @@

    Tests ran but I cannot find the results

    Likely cause: you expected test output in a different location, or you ran Pester directly instead of through Nova.

    Fix: use the Nova workflow and check:

    -
    artifacts/TestResults.xml
    -

    Success looks like: the file exists after a successful Test-NovaBuild - or % nova test run.

    +
    artifacts/UnitTestResults.xml
    +artifacts/TestResults.xml
    +

    Success looks like: artifacts/UnitTestResults.xml exists after a + successful Invoke-NovaTest or % nova test run, and + artifacts/TestResults.xml exists after a successful Test-NovaBuild or + % nova test --build run.

    See the test workflow

    diff --git a/docs/working-with-modules.html b/docs/working-with-modules.html index 15a9c79..7901359 100644 --- a/docs/working-with-modules.html +++ b/docs/working-with-modules.html @@ -240,6 +240,7 @@

    How to learn from it

    PS> Initialize-NovaModule -Example -Path ~/Work
     PS> Set-Location ~/Work/<YourProjectName>
     PS> Invoke-NovaBuild
    +PS> Invoke-NovaTest
     PS> Test-NovaBuild
     PS> $project = Get-NovaProjectInfo
     PS> Import-Module $project.OutputModuleDir -Force
    diff --git a/scripts/build/Sync-AgenticCopilotScaffold.psd1 b/scripts/build/Sync-AgenticCopilotScaffold.psd1
    index afbfc66..aa46739 100644
    --- a/scripts/build/Sync-AgenticCopilotScaffold.psd1
    +++ b/scripts/build/Sync-AgenticCopilotScaffold.psd1
    @@ -284,7 +284,7 @@
             }
             @{
                 Old = '- local quality loop: `pwsh -NoLogo -NoProfile -File ./run.ps1`'
    -            New = '- local quality loop: use the repository quality wrapper when one exists; otherwise run ScriptAnalyzer, build, and `Test-NovaBuild` in the documented project order'
    +            New = '- local quality loop: use the repository quality wrapper when one exists; otherwise run ScriptAnalyzer, build, `Invoke-NovaTest`, and `Test-NovaBuild` in the documented project order'
             }
             @{
                 Old = 'If `run.ps1` or `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` reports findings, fix them before review, handoff, or commit. Do not treat a failing local quality loop as an acceptable stopping point.'
    @@ -319,16 +319,16 @@
                 New = 'from the repository quality loop or `Invoke-ScriptAnalyzerCI.ps1`'
             }
             @{
    -            Old = '`run.ps1`-style local checks ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Test-NovaBuild`.'
    -            New = 'local quality checks ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Test-NovaBuild` when the project defines a combined wrapper.'
    +            Old = '`run.ps1`-style local checks ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Invoke-NovaTest`, then `Test-NovaBuild`.'
    +            New = 'local quality checks ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Invoke-NovaTest`, then `Test-NovaBuild` when the project defines a combined wrapper.'
             }
             @{
                 Old = 'Resolve any ScriptAnalyzer findings that `./run.ps1` reports before handoff.'
                 New = 'Resolve any ScriptAnalyzer findings reported by the repository quality loop before handoff.'
             }
             @{
    -            Old = '4. After meaningful steps, run `./run.ps1` (analyzer → build → `Test-NovaBuild`).'
    -            New = '4. After meaningful steps, run the repository quality loop when present (typically analyzer → build → `Test-NovaBuild`).'
    +            Old = '4. After meaningful steps, run `./run.ps1` (analyzer → build → `Invoke-NovaTest` → `Test-NovaBuild`).'
    +            New = '4. After meaningful steps, run the repository quality loop when present (typically analyzer → build → `Invoke-NovaTest` → `Test-NovaBuild`).'
             }
             @{
                 Old = 'or GitHub release automation.'
    diff --git a/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 b/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1
    index aea0ce0..81b38cd 100644
    --- a/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1
    +++ b/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1
    @@ -5,15 +5,14 @@ param(
     
     Set-StrictMode -Version Latest
     
    -function Copy-NovaModuleToolsTestResultIfPresent {
    +function Copy-NovaModuleToolsArtifactIfPresent {
         param(
    -        [Parameter(Mandatory)][string]$ProjectRoot,
    -        [Parameter(Mandatory)][string]$ArtifactsDirectory
    +        [Parameter(Mandatory)][string]$SourcePath,
    +        [Parameter(Mandatory)][string]$DestinationPath
         )
     
    -    $sourcePath = Join-Path $ProjectRoot 'artifacts/TestResults.xml'
    -    if (Test-Path -LiteralPath $sourcePath) {
    -        Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $ArtifactsDirectory 'novamoduletools-nunit.xml') -Force
    +    if (Test-Path -LiteralPath $SourcePath) {
    +        Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Force
         }
     }
     
    @@ -34,15 +33,18 @@ $projectInfo = Get-NovaProjectInfo
     $novaModuleToolsTestFailed = $false
     try {
         if (@($ExcludeTag).Count -gt 0) {
    +        Invoke-NovaTest -ExcludeTagFilter $ExcludeTag
             Test-NovaBuild -ExcludeTagFilter $ExcludeTag
         } else {
    +        Invoke-NovaTest
             Test-NovaBuild
         }
     } catch {
         $novaModuleToolsTestFailed = $true
    -    Write-Warning "Test-NovaBuild failed: $( $_.Exception.Message )"
    +    Write-Warning "Nova test workflow failed: $( $_.Exception.Message )"
     } finally {
    -    Copy-NovaModuleToolsTestResultIfPresent -ProjectRoot $projectInfo.ProjectRoot -ArtifactsDirectory $OutputDirectory
    +    Copy-NovaModuleToolsArtifactIfPresent -SourcePath (Join-Path $projectInfo.ProjectRoot 'artifacts/UnitTestResults.xml') -DestinationPath (Join-Path $OutputDirectory 'novamoduletools-unit-nunit.xml')
    +    Copy-NovaModuleToolsArtifactIfPresent -SourcePath (Join-Path $projectInfo.ProjectRoot 'artifacts/TestResults.xml') -DestinationPath (Join-Path $OutputDirectory 'novamoduletools-integration-nunit.xml')
     }
     
     if ($novaModuleToolsTestFailed) {
    diff --git a/src/private/build/AssertNovaPublicFunctionFileLayout.ps1 b/src/private/build/AssertNovaPublicFunctionFileLayout.ps1
    index 49e4f2d..64ce357 100644
    --- a/src/private/build/AssertNovaPublicFunctionFileLayout.ps1
    +++ b/src/private/build/AssertNovaPublicFunctionFileLayout.ps1
    @@ -56,7 +56,7 @@ function Format-NovaPublicFunctionFileValidationMessage {
     
         $messageLines += @(
             ''
    -        'Affected commands: Invoke-NovaBuild, nova build, Test-NovaBuild -Build, nova test --build/-b, New-NovaModulePackage, nova package (--skip-tests/-s), Publish-NovaModule, nova publish (--skip-tests), Invoke-NovaRelease, nova release (--skip-tests/-s).'
    +        'Affected commands: Invoke-NovaBuild, nova build, Invoke-NovaTest, nova test, Test-NovaBuild, nova test --build/-b, New-NovaModulePackage, nova package (--skip-tests/-s), Publish-NovaModule, nova publish (--skip-tests), Invoke-NovaRelease, nova release (--skip-tests/-s).'
             'Fix the file layout or continue intentionally with -OverrideWarning / --override-warning / -o.'
         )
     
    diff --git a/src/private/build/InvokeNovaBuildWorkflow.ps1 b/src/private/build/InvokeNovaBuildWorkflow.ps1
    index 1b71232..db901d9 100644
    --- a/src/private/build/InvokeNovaBuildWorkflow.ps1
    +++ b/src/private/build/InvokeNovaBuildWorkflow.ps1
    @@ -79,7 +79,9 @@ function Write-NovaBuildWorkflowResult {
     
         Write-Message "Built Nova module: $( $ProjectInfo.ProjectName )" -color Green
         Write-Message "Output module: $( $ProjectInfo.OutputModuleDir )"
    -    Write-Message 'Next step: Test-NovaBuild'
    +    Write-Message 'Next steps:'
    +    Write-Message 'Invoke-NovaTest'
    +    Write-Message 'Test-NovaBuild'
     }
     
     function Invoke-NovaBuildDuplicateValidation {
    diff --git a/src/private/cli/InvokeNovaCliCommandRoute.ps1 b/src/private/cli/InvokeNovaCliCommandRoute.ps1
    index d63f499..068a5b5 100644
    --- a/src/private/cli/InvokeNovaCliCommandRoute.ps1
    +++ b/src/private/cli/InvokeNovaCliCommandRoute.ps1
    @@ -34,6 +34,24 @@ function Invoke-NovaCliParsedCommand {
         return & $ActionCommand @options @mutatingCommonParameters
     }
     
    +function Invoke-NovaCliTestRouteCommand {
    +    [CmdletBinding()]
    +    param(
    +        [Parameter(Mandatory)][pscustomobject]$InvocationContext
    +    )
    +
    +    $options = ConvertFrom-NovaTestCliArgument -Arguments $InvocationContext.Arguments
    +    $actionCommand = if ($options.ContainsKey('Build') -and [bool]$options.Build) {
    +        'Test-NovaBuild'
    +    } else {
    +        'Invoke-NovaTest'
    +    }
    +
    +    $mutatingCommonParameters = $InvocationContext.MutatingCommonParameters
    +    $options.Remove('Build') | Out-Null
    +    return & $actionCommand @options @mutatingCommonParameters
    +}
    +
     function Invoke-NovaCliBumpCommand {
         [CmdletBinding()]
         param(
    @@ -180,7 +198,7 @@ function Invoke-NovaCliCommandRoute {
                 return Invoke-NovaCliParsedCommand -InvocationContext $InvocationContext -ParserCommand 'ConvertFrom-NovaBuildCliArgument' -ActionCommand 'Invoke-NovaBuild'
             }
             'test' {
    -            return Invoke-NovaCliParsedCommand -InvocationContext $InvocationContext -ParserCommand 'ConvertFrom-NovaTestCliArgument' -ActionCommand 'Test-NovaBuild'
    +            return Invoke-NovaCliTestRouteCommand -InvocationContext $InvocationContext
             }
             'package' {
                 return Invoke-NovaCliParsedCommand -InvocationContext $InvocationContext -ParserCommand 'ConvertFrom-NovaPackageCliArgument' -ActionCommand 'New-NovaModulePackage'
    diff --git a/src/private/quality/GetNovaPesterRunPath.ps1 b/src/private/quality/GetNovaPesterRunPath.ps1
    index 00e5405..307dfa1 100644
    --- a/src/private/quality/GetNovaPesterRunPath.ps1
    +++ b/src/private/quality/GetNovaPesterRunPath.ps1
    @@ -1,12 +1,43 @@
     function Get-NovaPesterRunPath {
         [CmdletBinding()]
         param(
    -        [Parameter(Mandatory)][object]$ProjectInfo
    +        [Parameter(Mandatory)][object]$ProjectInfo,
    +        [Parameter()][string]$IncludePattern = '*.Tests.ps1',
    +        [Parameter()][string[]]$ExcludePattern = @()
         )
     
    -    if ($ProjectInfo.BuildRecursiveFolders) {
    -        return $ProjectInfo.TestsDir
    +    $testFile = Get-ChildItem -LiteralPath $ProjectInfo.TestsDir -File -Filter $IncludePattern -Recurse:$ProjectInfo.BuildRecursiveFolders
    +    $testPath = $testFile | Sort-Object -Property FullName | ForEach-Object -Process { $_.FullName }
    +
    +    if ($ExcludePattern.Count -eq 0) {
    +        return @($testPath)
    +    }
    +
    +    return @(
    +        $testPath | Where-Object -FilterScript {
    +            -not (Test-NovaPesterExcludedTestPath -TestPath $_ -ExcludePattern $ExcludePattern)
    +        }
    +    )
    +}
    +
    +function Test-NovaPesterExcludedTestPath {
    +    [CmdletBinding()]
    +    param(
    +        [Parameter(Mandatory)][string]$TestPath,
    +        [Parameter(Mandatory)][string[]]$ExcludePattern
    +    )
    +
    +    $testFileName = [System.IO.Path]::GetFileName($TestPath)
    +    foreach ($pattern in $ExcludePattern) {
    +        $wildcardPattern = [System.Management.Automation.WildcardPattern]::new(
    +            $pattern,
    +            [System.Management.Automation.WildcardOptions]::IgnoreCase
    +        )
    +
    +        if ($wildcardPattern.IsMatch($testFileName) -or $wildcardPattern.IsMatch($TestPath)) {
    +            return $true
    +        }
         }
     
    -    return [System.IO.Path]::Join($ProjectInfo.TestsDir, '*.Tests.ps1')
    +    return $false
     }
    diff --git a/src/private/quality/GetNovaPesterTestResultPath.ps1 b/src/private/quality/GetNovaPesterTestResultPath.ps1
    index b49b88d..52c8065 100644
    --- a/src/private/quality/GetNovaPesterTestResultPath.ps1
    +++ b/src/private/quality/GetNovaPesterTestResultPath.ps1
    @@ -1,8 +1,9 @@
     function Get-NovaPesterTestResultPath {
         [CmdletBinding()]
         param(
    -        [Parameter(Mandatory)][string]$ProjectRoot
    +        [Parameter(Mandatory)][string]$ProjectRoot,
    +        [Parameter()][string]$FileName = 'TestResults.xml'
         )
     
    -    return [System.IO.Path]::Join($ProjectRoot, 'artifacts', 'TestResults.xml')
    +    return [System.IO.Path]::Join($ProjectRoot, 'artifacts', $FileName)
     }
    diff --git a/src/private/quality/GetNovaTestWorkflowContext.ps1 b/src/private/quality/GetNovaTestWorkflowContext.ps1
    index 0694654..11df8c6 100644
    --- a/src/private/quality/GetNovaTestWorkflowContext.ps1
    +++ b/src/private/quality/GetNovaTestWorkflowContext.ps1
    @@ -1,14 +1,14 @@
     function Get-NovaTestWorkflowOperation {
         [CmdletBinding()]
         param(
    -        [Parameter(Mandatory)][bool]$BuildRequested
    +        [Parameter(Mandatory)][string]$TestMode
         )
     
    -    if ($BuildRequested) {
    -        return 'Build project, run Pester tests, and write test results'
    +    if ($TestMode -eq 'BuildValidation') {
    +        return 'Build project, run build-validation integration tests, and write test results'
         }
     
    -    return 'Run Pester tests and write test results'
    +    return 'Run unit tests and write test results'
     }
     
     function Assert-NovaPesterAvailable {
    @@ -16,7 +16,7 @@ function Assert-NovaPesterAvailable {
         param()
     
         if (-not (Get-Module -Name Pester -ListAvailable)) {
    -        Stop-NovaOperation -Message 'The module Pester must be installed for Test-NovaBuild to run. Install Pester 5.7.1 and try again.' -ErrorId 'Nova.Dependency.PesterDependencyMissing' -Category ResourceUnavailable -TargetObject 'Pester'
    +        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'
         }
     }
     
    @@ -30,10 +30,18 @@ function Get-NovaTestWorkflowContext {
         Test-ProjectSchema | Out-Null
         Assert-NovaPesterAvailable
         $projectInfo = Get-NovaProjectInfo
    +    $workflowProfile = Get-NovaTestWorkflowProfile -TestOption $TestOption
         $pesterConfig = New-PesterConfiguration -Hashtable $projectInfo.Pester
    -    Initialize-NovaPesterCoverageConfiguration -PesterConfig $pesterConfig -ProjectInfo $projectInfo
    +    $coverageConfiguration = Get-NovaPesterCoverageConfigurationState -ProjectInfo $projectInfo -CoverageEnabled:$workflowProfile.CoverageEnabled
    +    $pesterConfig.CodeCoverage.Enabled = $coverageConfiguration.Enabled
    +    $pesterConfig.CodeCoverage.Path = $coverageConfiguration.Path
    +    if ($null -ne $coverageConfiguration.CoveragePercentTarget) {
    +        $pesterConfig.CodeCoverage.CoveragePercentTarget = $coverageConfiguration.CoveragePercentTarget
    +    }
     
    -    $pesterConfig.Run.Path = Get-NovaPesterRunPath -ProjectInfo $projectInfo
    +    $pesterConfig.Run.Path = @(
    +        Get-NovaPesterRunPath -ProjectInfo $projectInfo -IncludePattern $workflowProfile.IncludePattern -ExcludePattern $workflowProfile.ExcludePattern
    +    )
         $pesterConfig.Run.PassThru = $true
         $pesterConfig.Run.Exit = $true
         $pesterConfig.Run.Throw = $true
    @@ -41,13 +49,14 @@ function Get-NovaTestWorkflowContext {
         $pesterConfig.Filter.ExcludeTag = Get-NovaTestOptionValue -TestOption $TestOption -Name ExcludeTagFilter
         Initialize-NovaPesterExecutionConfiguration -PesterConfig $pesterConfig -BoundParameters $BoundParameters -OutputVerbosity (Get-NovaTestOptionValue -TestOption $TestOption -Name OutputVerbosity) -OutputRenderMode (Get-NovaTestOptionValue -TestOption $TestOption -Name OutputRenderMode)
     
    -    $testResultPath = Get-NovaPesterTestResultPath -ProjectRoot $projectInfo.ProjectRoot
    -    $buildRequested = [bool](Get-NovaTestOptionValue -TestOption $TestOption -Name Build)
    +    $testResultPath = Get-NovaPesterTestResultPath -ProjectRoot $projectInfo.ProjectRoot -FileName $workflowProfile.TestResultFileName
     
         return [pscustomobject]@{
    -        BuildRequested = $buildRequested
    +        BuildRequested = $workflowProfile.BuildRequested
    +        CommandName = $workflowProfile.CommandName
             OverrideWarningRequested = $BoundParameters.ContainsKey('OverrideWarning') -and [bool]$BoundParameters.OverrideWarning
             ProjectInfo = $projectInfo
    +        PesterSettings = Get-NovaTestWorkflowPesterConfiguration -ProjectPesterSettings $projectInfo.Pester -CoverageEnabled:$workflowProfile.CoverageEnabled
             PesterConfig = $pesterConfig
             TestResultPath = $testResultPath
             TestResultDirectory = Split-Path -Parent $testResultPath
    @@ -55,7 +64,7 @@ function Get-NovaTestWorkflowContext {
             TestResultReportWriter = Get-Command -Name Write-NovaPesterTestResultReport -CommandType Function -ErrorAction Stop
             WorkflowParams = Get-NovaShouldProcessForwardingParameter -WhatIfEnabled:($BoundParameters.ContainsKey('WhatIf') -and [bool]$BoundParameters.WhatIf)
             Target = $testResultPath
    -        Operation = Get-NovaTestWorkflowOperation -BuildRequested:$buildRequested
    +        Operation = Get-NovaTestWorkflowOperation -TestMode $workflowProfile.Mode
         }
     }
     
    @@ -73,6 +82,68 @@ function Get-NovaTestOptionValue {
         return $null
     }
     
    +function Get-NovaTestWorkflowProfile {
    +    [CmdletBinding()]
    +    param(
    +        [Parameter(Mandatory)][hashtable]$TestOption
    +    )
    +
    +    $testMode = Get-NovaTestWorkflowMode -TestOption $TestOption
    +    if ($testMode -eq 'BuildValidation') {
    +        return [pscustomobject]@{
    +            Mode = $testMode
    +            BuildRequested = $true
    +            CommandName = 'Test-NovaBuild'
    +            CoverageEnabled = $false
    +            IncludePattern = '*.Integration.Tests.ps1'
    +            ExcludePattern = @()
    +            TestResultFileName = 'TestResults.xml'
    +        }
    +    }
    +
    +    return [pscustomobject]@{
    +        Mode = $testMode
    +        BuildRequested = $false
    +        CommandName = 'Invoke-NovaTest'
    +        CoverageEnabled = $true
    +        IncludePattern = '*.Tests.ps1'
    +        ExcludePattern = @('*.Integration.Tests.ps1')
    +        TestResultFileName = 'UnitTestResults.xml'
    +    }
    +}
    +
    +function Get-NovaTestWorkflowMode {
    +    [CmdletBinding()]
    +    param(
    +        [Parameter(Mandatory)][hashtable]$TestOption
    +    )
    +
    +    $requestedMode = Get-NovaTestOptionValue -TestOption $TestOption -Name TestMode
    +    if ([string]::IsNullOrWhiteSpace([string]$requestedMode)) {
    +        return 'Unit'
    +    }
    +
    +    return [string]$requestedMode
    +}
    +
    +function Get-NovaTestWorkflowPesterConfiguration {
    +    [CmdletBinding()]
    +    param(
    +        [AllowNull()][object]$ProjectPesterSettings,
    +        [Parameter(Mandatory)][bool]$CoverageEnabled
    +    )
    +
    +    if ($CoverageEnabled) {
    +        return $ProjectPesterSettings
    +    }
    +
    +    return [pscustomobject]@{
    +        CodeCoverage = [pscustomobject]@{
    +            Enabled = $false
    +        }
    +    }
    +}
    +
     function Get-NovaConfiguredPesterCoveragePercentTarget {
         [CmdletBinding()]
         param(
    @@ -109,27 +180,40 @@ function Get-NovaConfiguredPesterCoveragePath {
         )
     }
     
    -function Initialize-NovaPesterCoverageConfiguration {
    -    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Mutates PesterConfiguration state, not user-facing resources. ShouldProcess is not appropriate here.')]
    +function Get-NovaPesterCoverageConfigurationState {
         [CmdletBinding()]
         param(
    -        [Parameter(Mandatory)][object]$PesterConfig,
    -        [Parameter(Mandatory)][pscustomobject]$ProjectInfo
    +        [Parameter(Mandatory)][pscustomobject]$ProjectInfo,
    +        [Parameter(Mandatory)][bool]$CoverageEnabled
         )
     
    +    if (-not $CoverageEnabled) {
    +        return Get-NovaDisabledPesterCoverageConfiguration
    +    }
    +
         $codeCoverageSettings = Get-NovaPesterSettingValue -InputObject $ProjectInfo.Pester -Name 'CodeCoverage'
         if ($true -ne [bool](Get-NovaPesterSettingValue -InputObject $codeCoverageSettings -Name 'Enabled')) {
    -        return
    +        return Get-NovaDisabledPesterCoverageConfiguration
         }
     
         $coveragePercentTarget = Get-NovaConfiguredPesterCoveragePercentTarget -ProjectPesterSettings $ProjectInfo.Pester
    -    if ($null -ne $coveragePercentTarget) {
    -        $PesterConfig.CodeCoverage.CoveragePercentTarget = $coveragePercentTarget
    +    $resolvedCoveragePath = @(Get-NovaResolvedPesterCoveragePath -ProjectInfo $ProjectInfo)
    +
    +    return [pscustomobject]@{
    +        Enabled = $true
    +        CoveragePercentTarget = $coveragePercentTarget
    +        Path = $resolvedCoveragePath
         }
    +}
     
    -    $resolvedCoveragePath = @(Get-NovaResolvedPesterCoveragePath -ProjectInfo $ProjectInfo)
    -    if ($resolvedCoveragePath.Count -gt 0) {
    -        $PesterConfig.CodeCoverage.Path = $resolvedCoveragePath
    +function Get-NovaDisabledPesterCoverageConfiguration {
    +    [CmdletBinding()]
    +    param()
    +
    +    return [pscustomobject]@{
    +        Enabled = $false
    +        CoveragePercentTarget = $null
    +        Path = @()
         }
     }
     
    diff --git a/src/private/quality/InvokeNovaTestWorkflow.ps1 b/src/private/quality/InvokeNovaTestWorkflow.ps1
    index 14b763c..9c80426 100644
    --- a/src/private/quality/InvokeNovaTestWorkflow.ps1
    +++ b/src/private/quality/InvokeNovaTestWorkflow.ps1
    @@ -440,7 +440,8 @@ function Get-NovaTestWorkflowFailureMessage {
             [Parameter(Mandatory)][pscustomobject]$WorkflowContext
         )
     
    -    return "Pester reported one or more failing tests. Review the output above and the test result file at $( $WorkflowContext.TestResultPath ), then rerun Test-NovaBuild."
    +    $commandName = Get-NovaTestWorkflowCommandName -WorkflowContext $WorkflowContext
    +    return "Pester reported one or more failing tests. Review the output above and the test result file at $( $WorkflowContext.TestResultPath ), then rerun $commandName."
     }
     
     function Write-NovaTestWorkflowResult {
    @@ -459,7 +460,7 @@ function Write-NovaTestWorkflowResult {
             Write-Message $coverageMessage
         }
     
    -    foreach ($line in (Get-NovaTestWorkflowNextStepLine -WhatIfEnabled:$WhatIfEnabled)) {
    +    foreach ($line in (Get-NovaTestWorkflowNextStepLine -WorkflowContext $WorkflowContext -WhatIfEnabled:$WhatIfEnabled)) {
             Write-Message $line
         }
     }
    @@ -513,13 +514,15 @@ function Get-NovaTestWorkflowCoverageMessage {
     function Get-NovaTestWorkflowNextStepLine {
         [CmdletBinding()]
         param(
    +        [Parameter(Mandatory)][pscustomobject]$WorkflowContext,
             [switch]$WhatIfEnabled
         )
     
         if ($WhatIfEnabled) {
    +        $commandName = Get-NovaTestWorkflowCommandName -WorkflowContext $WorkflowContext
             return @(
                 'Next step:'
    -            'Run Test-NovaBuild without -WhatIf when you are ready to execute the test workflow.'
    +            "Run $commandName without -WhatIf when you are ready to execute the test workflow."
             )
         }
     
    @@ -595,18 +598,20 @@ function Get-NovaCoverageTargetAssertionScriptBlock {
             'CodeCoverage'
         }
     
    -    return Get-NovaDefaultCoverageTargetAssertionScriptBlock -CoveragePercentTarget $coveragePercentTarget -TargetObject $targetObject
    +    return Get-NovaDefaultCoverageTargetAssertionScriptBlock -CoveragePercentTarget $coveragePercentTarget -TargetObject $targetObject -CommandName (Get-NovaTestWorkflowCommandName -WorkflowContext $WorkflowContext)
     }
     
     function Get-NovaDefaultCoverageTargetAssertionScriptBlock {
         [CmdletBinding()]
         param(
             [AllowNull()][Nullable[double]]$CoveragePercentTarget,
    -        [Parameter(Mandatory)][string]$TargetObject
    +        [Parameter(Mandatory)][string]$TargetObject,
    +        [Parameter(Mandatory)][string]$CommandName
         )
     
         $resolvedCoveragePercentTarget = $CoveragePercentTarget
         $resolvedTargetObject = $TargetObject
    +    $resolvedCommandName = $CommandName
         $propertyReader = (Get-Command -Name Get-NovaPropertyValue -CommandType Function -ErrorAction Stop).ScriptBlock
         $percentFormatter = (Get-Command -Name Format-NovaCoveragePercentValue -CommandType Function -ErrorAction Stop).ScriptBlock
     
    @@ -634,7 +639,7 @@ function Get-NovaDefaultCoverageTargetAssertionScriptBlock {
             }
     
             $formattedCoverage = & $percentFormatter -Value $coveragePercent
    -        $exception = [System.InvalidOperationException]::new("Code coverage $formattedCoverage% did not meet the configured target $formattedTarget%. Review the failing tests or coverage settings, then rerun Test-NovaBuild.")
    +        $exception = [System.InvalidOperationException]::new("Code coverage $formattedCoverage% did not meet the configured target $formattedTarget%. Review the failing tests or coverage settings, then rerun $resolvedCommandName.")
             $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, 'Nova.Workflow.CodeCoverageTargetNotMet', [System.Management.Automation.ErrorCategory]::InvalidOperation, $resolvedTargetObject)
             throw $errorRecord
         }.GetNewClosure()
    @@ -646,8 +651,11 @@ function Get-NovaConfiguredCoveragePercentTarget {
             [Parameter(Mandatory)][pscustomobject]$WorkflowContext
         )
     
    -    $projectInfo = Get-NovaPropertyValue -InputObject $WorkflowContext -Name 'ProjectInfo'
    -    $pesterSettings = Get-NovaPropertyValue -InputObject $projectInfo -Name 'Pester'
    +    $pesterSettings = Get-NovaPropertyValue -InputObject $WorkflowContext -Name 'PesterSettings'
    +    if ($null -eq $pesterSettings) {
    +        $projectInfo = Get-NovaPropertyValue -InputObject $WorkflowContext -Name 'ProjectInfo'
    +        $pesterSettings = Get-NovaPropertyValue -InputObject $projectInfo -Name 'Pester'
    +    }
         $codeCoverageSettings = Get-NovaPropertyValue -InputObject $pesterSettings -Name 'CodeCoverage'
     
         if ($true -ne [bool](Get-NovaPropertyValue -InputObject $codeCoverageSettings -Name 'Enabled')) {
    @@ -662,6 +670,20 @@ function Get-NovaConfiguredCoveragePercentTarget {
         return [double]$coveragePercentTarget
     }
     
    +function Get-NovaTestWorkflowCommandName {
    +    [CmdletBinding()]
    +    param(
    +        [Parameter(Mandatory)][pscustomobject]$WorkflowContext
    +    )
    +
    +    $commandName = Get-NovaPropertyValue -InputObject $WorkflowContext -Name 'CommandName'
    +    if ([string]::IsNullOrWhiteSpace([string]$commandName)) {
    +        return 'Invoke-NovaTest'
    +    }
    +
    +    return [string]$commandName
    +}
    +
     function Get-NovaPropertyValue {
         [CmdletBinding()]
         param(
    diff --git a/src/private/quality/NewNovaTestDynamicParameterDictionary.ps1 b/src/private/quality/NewNovaTestDynamicParameterDictionary.ps1
    deleted file mode 100644
    index 949610a..0000000
    --- a/src/private/quality/NewNovaTestDynamicParameterDictionary.ps1
    +++ /dev/null
    @@ -1,12 +0,0 @@
    -function New-NovaTestDynamicParameterDictionary {
    -    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'This helper only returns runtime parameter metadata and does not mutate state.')]
    -    [CmdletBinding()]
    -    param()
    -
    -    $attributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
    -    $attributeCollection.Add([System.Management.Automation.ParameterAttribute]::new())
    -    $dictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
    -    $dictionary.Add('Build',[System.Management.Automation.RuntimeDefinedParameter]::new('Build', [switch],$attributeCollection))
    -    $dictionary.Add('OverrideWarning',[System.Management.Automation.RuntimeDefinedParameter]::new('OverrideWarning', [switch],$attributeCollection))
    -    return $dictionary
    -}
    diff --git a/src/private/release/InvokeNovaReleaseWorkflow.ps1 b/src/private/release/InvokeNovaReleaseWorkflow.ps1
    index 75def13..ca4360e 100644
    --- a/src/private/release/InvokeNovaReleaseWorkflow.ps1
    +++ b/src/private/release/InvokeNovaReleaseWorkflow.ps1
    @@ -66,6 +66,7 @@ function Invoke-NovaReleaseWorkflow {
     
             if (-not $skipTestsRequested) {
                 Invoke-NovaReleaseWorkflowStep -Activity $progressActivity -Status 'Running pre-release tests' -PercentComplete 35 -Action {
    +                Invoke-NovaTest @testWorkflowParams
                     Test-NovaBuild @testWorkflowParams
                 }
             }
    diff --git a/src/private/scaffold/InitializeNovaModuleAgenticCopilotScaffold.ps1 b/src/private/scaffold/InitializeNovaModuleAgenticCopilotScaffold.ps1
    index e1b1e03..ef0f144 100644
    --- a/src/private/scaffold/InitializeNovaModuleAgenticCopilotScaffold.ps1
    +++ b/src/private/scaffold/InitializeNovaModuleAgenticCopilotScaffold.ps1
    @@ -32,7 +32,7 @@ Use this repository as the starting point for your module.
     
     - Review `README.md`, `CONTRIBUTING.md`, and `.github/copilot-instructions.md`.
     - Use `Invoke-NovaBuild` / `% nova build` to produce the first local build.
    -- Use `Test-NovaBuild` / `% nova test` before opening a pull request.
    +- Use `Invoke-NovaTest` / `% nova test` for unit tests and `Test-NovaBuild` / `% nova test --build` for build-validation integration tests before opening a pull request.
     '@
         }
     }
    diff --git a/src/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.ps1 b/src/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.ps1
    index e479578..b08efe0 100644
    --- a/src/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.ps1
    +++ b/src/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.ps1
    @@ -111,6 +111,7 @@ function Get-NovaAgenticCopilotScaffoldNextStepLine {
         return @(
             'Next steps:'
             'Review AGENTS.md and CONTRIBUTING.md'
    +        'Invoke-NovaTest'
             'Test-NovaBuild'
         )
     }
    diff --git a/src/private/scaffold/InvokeNovaModuleInitializationWorkflow.ps1 b/src/private/scaffold/InvokeNovaModuleInitializationWorkflow.ps1
    index 611ae79..76cb193 100644
    --- a/src/private/scaffold/InvokeNovaModuleInitializationWorkflow.ps1
    +++ b/src/private/scaffold/InvokeNovaModuleInitializationWorkflow.ps1
    @@ -70,6 +70,7 @@ function Get-NovaModuleInitializationNextStepLine {
         )
     
         if ($WorkflowContext.Example) {
    +        $nextSteps += 'Invoke-NovaTest'
             $nextSteps += 'Test-NovaBuild'
             return $nextSteps
         }
    diff --git a/src/private/shared/InvokeNovaBuildValidation.ps1 b/src/private/shared/InvokeNovaBuildValidation.ps1
    index ea110e5..c004e7b 100644
    --- a/src/private/shared/InvokeNovaBuildValidation.ps1
    +++ b/src/private/shared/InvokeNovaBuildValidation.ps1
    @@ -9,9 +9,10 @@ function Invoke-NovaBuildValidation {
         $skipTestsRequested = ($WorkflowContext.PSObject.Properties.Name -contains 'SkipTestsRequested') -and $WorkflowContext.SkipTestsRequested
         Invoke-NovaBuild @workflowParams
         if (-not $skipTestsRequested) {
    +        Invoke-NovaTest @workflowParams
             Test-NovaBuild @workflowParams
             return
         }
     
    -    Write-Verbose 'Skipping Test-NovaBuild because SkipTests was requested for this workflow.'
    +    Write-Verbose 'Skipping Invoke-NovaTest and Test-NovaBuild because SkipTests was requested for this workflow.'
     }
    diff --git a/src/private/shared/NewNovaDynamicSkipTestsParameterDictionary.ps1 b/src/private/shared/NewNovaDynamicSkipTestsParameterDictionary.ps1
    index 525636d..5c85021 100644
    --- a/src/private/shared/NewNovaDynamicSkipTestsParameterDictionary.ps1
    +++ b/src/private/shared/NewNovaDynamicSkipTestsParameterDictionary.ps1
    @@ -23,56 +23,38 @@ function Get-NovaDynamicParameterAttributeCollection {
         return $attributeCollection
     }
     
    -function Add-NovaDynamicSwitchParameter {
    +function Add-NovaDynamicTypedParameter {
         [CmdletBinding()]
         param(
             [Parameter(Mandatory)][System.Management.Automation.RuntimeDefinedParameterDictionary]$ParameterDictionary,
             [Parameter(Mandatory)][string]$Name,
    +        [Parameter(Mandatory)][type]$ParameterType,
             [string[]]$ParameterSetNameList = @(),
             [switch]$Mandatory
         )
     
         $attributeCollection = Get-NovaDynamicParameterAttributeCollection -ParameterSetNameList $ParameterSetNameList -Mandatory:$Mandatory
    -    $runtimeParameter = [System.Management.Automation.RuntimeDefinedParameter]::new($Name, [switch],$attributeCollection)
    +    $runtimeParameter = [System.Management.Automation.RuntimeDefinedParameter]::new($Name, $ParameterType, $attributeCollection)
         $ParameterDictionary.Add($Name, $runtimeParameter)
     }
     
    -function Add-NovaDynamicStringParameter {
    -    [CmdletBinding()]
    -    param(
    -        [Parameter(Mandatory)][System.Management.Automation.RuntimeDefinedParameterDictionary]$ParameterDictionary,
    -        [Parameter(Mandatory)][string]$Name,
    -        [string[]]$ParameterSetNameList = @(),
    -        [switch]$Mandatory
    -    )
    -
    -    $attributeCollection = Get-NovaDynamicParameterAttributeCollection -ParameterSetNameList $ParameterSetNameList -Mandatory:$Mandatory
    -    $runtimeParameter = [System.Management.Automation.RuntimeDefinedParameter]::new($Name, [string],$attributeCollection)
    -    $ParameterDictionary.Add($Name, $runtimeParameter)
    -}
    -
    -function Add-NovaDynamicHashtableParameter {
    +function Get-NovaDynamicDeliveryParameterDictionary {
         [CmdletBinding()]
    -    param(
    -        [Parameter(Mandatory)][System.Management.Automation.RuntimeDefinedParameterDictionary]$ParameterDictionary,
    -        [Parameter(Mandatory)][string]$Name,
    -        [string[]]$ParameterSetNameList = @(),
    -        [switch]$Mandatory
    -    )
    +    param()
     
    -    $attributeCollection = Get-NovaDynamicParameterAttributeCollection -ParameterSetNameList $ParameterSetNameList -Mandatory:$Mandatory
    -    $runtimeParameter = [System.Management.Automation.RuntimeDefinedParameter]::new($Name, [hashtable],$attributeCollection)
    -    $ParameterDictionary.Add($Name, $runtimeParameter)
    +    $parameterDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
    +    Add-NovaDynamicTypedParameter -ParameterDictionary $parameterDictionary -Name 'SkipTests' -ParameterType ([switch])
    +    Add-NovaDynamicTypedParameter -ParameterDictionary $parameterDictionary -Name 'ContinuousIntegration' -ParameterType ([switch])
    +    Add-NovaDynamicTypedParameter -ParameterDictionary $parameterDictionary -Name 'OverrideWarning' -ParameterType ([switch])
    +    return $parameterDictionary
     }
     
    -function Get-NovaDynamicDeliveryParameterDictionary {
    +function Get-NovaDynamicOverrideWarningParameterDictionary {
         [CmdletBinding()]
         param()
     
         $parameterDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
    -    Add-NovaDynamicSwitchParameter -ParameterDictionary $parameterDictionary -Name 'SkipTests'
    -    Add-NovaDynamicSwitchParameter -ParameterDictionary $parameterDictionary -Name 'ContinuousIntegration'
    -    Add-NovaDynamicSwitchParameter -ParameterDictionary $parameterDictionary -Name 'OverrideWarning'
    +    Add-NovaDynamicTypedParameter -ParameterDictionary $parameterDictionary -Name 'OverrideWarning' -ParameterType ([switch])
         return $parameterDictionary
     }
     
    @@ -81,8 +63,8 @@ function Get-NovaDynamicReleaseParameterDictionary {
         param()
     
         $parameterDictionary = Get-NovaDynamicDeliveryParameterDictionary
    -    Add-NovaDynamicStringParameter -ParameterDictionary $parameterDictionary -Name 'Path' -ParameterSetNameList @('Local', 'Repository', 'PublishOption')
    +    Add-NovaDynamicTypedParameter -ParameterDictionary $parameterDictionary -Name 'Path' -ParameterType ([string]) -ParameterSetNameList @('Local', 'Repository', 'PublishOption')
         # TODO: Remove the legacy PublishOption dynamic parameter this was deprecated on: 2026-05-03.
    -    Add-NovaDynamicHashtableParameter -ParameterDictionary $parameterDictionary -Name 'PublishOption' -ParameterSetNameList @('PublishOption') -Mandatory
    +    Add-NovaDynamicTypedParameter -ParameterDictionary $parameterDictionary -Name 'PublishOption' -ParameterType ([hashtable]) -ParameterSetNameList @('PublishOption') -Mandatory
         return $parameterDictionary
     }
    diff --git a/src/public/InvokeNovaTest.ps1 b/src/public/InvokeNovaTest.ps1
    new file mode 100644
    index 0000000..82e9084
    --- /dev/null
    +++ b/src/public/InvokeNovaTest.ps1
    @@ -0,0 +1,32 @@
    +function Invoke-NovaTest {
    +    [CmdletBinding(SupportsShouldProcess = $true)]
    +    param (
    +        [string[]]$TagFilter,
    +        [string[]]$ExcludeTagFilter,
    +        [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')]
    +        [string]$OutputVerbosity,
    +        [ValidateSet('Auto', 'Ansi')]
    +        [string]$OutputRenderMode
    +    )
    +
    +    dynamicparam {
    +        return Get-NovaDynamicOverrideWarningParameterDictionary
    +    }
    +
    +    end {
    +        $workflowContext = Get-NovaTestWorkflowContext -TestOption @{
    +            TestMode = 'Unit'
    +            TagFilter = $TagFilter
    +            ExcludeTagFilter = $ExcludeTagFilter
    +            OutputVerbosity = $OutputVerbosity
    +            OutputRenderMode = $OutputRenderMode
    +        } -BoundParameters $PSBoundParameters
    +
    +        $shouldRun = $PSCmdlet.ShouldProcess($workflowContext.Target, $workflowContext.Operation)
    +        if (-not $shouldRun -and -not $WhatIfPreference) {
    +            return
    +        }
    +
    +        Invoke-NovaTestWorkflow -WorkflowContext $workflowContext -ShouldRun:$shouldRun
    +    }
    +}
    diff --git a/src/public/TestNovaBuild.ps1 b/src/public/TestNovaBuild.ps1
    index aceb54a..f4283c3 100644
    --- a/src/public/TestNovaBuild.ps1
    +++ b/src/public/TestNovaBuild.ps1
    @@ -10,12 +10,12 @@ function Test-NovaBuild {
         )
     
         dynamicparam {
    -        return New-NovaTestDynamicParameterDictionary
    +        return Get-NovaDynamicOverrideWarningParameterDictionary
         }
     
         end {
             $workflowContext = Get-NovaTestWorkflowContext -TestOption @{
    -            Build = $PSBoundParameters.ContainsKey('Build')
    +            TestMode = 'BuildValidation'
                 TagFilter = $TagFilter
                 ExcludeTagFilter = $ExcludeTagFilter
                 OutputVerbosity = $OutputVerbosity
    diff --git a/src/resources/agentic-copilot/.github/agents/powershell-developer.agent.md b/src/resources/agentic-copilot/.github/agents/powershell-developer.agent.md
    index f4ccb9d..e076339 100644
    --- a/src/resources/agentic-copilot/.github/agents/powershell-developer.agent.md
    +++ b/src/resources/agentic-copilot/.github/agents/powershell-developer.agent.md
    @@ -44,7 +44,9 @@ Implement PowerShell command and helper changes in the {{ProjectName}} style.
     - Preserve existing command names, warning semantics, and output shape.
     - Keep new or heavily changed source functions aligned with `.github/instructions/code-quality-matrix.instructions.md`: short, single-purpose, low-duplication, and split by clear responsibility unless the scope explicitly justifies otherwise.
     - Prefer `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` and the repository quality loop, when present, for normal analyzer loops; use direct `Invoke-ScriptAnalyzer` only for focused local checks that reuse the repository-approved settings.
    -- Validate Nova-managed project tests through `Test-NovaBuild`; do not call `Invoke-Pester` directly.
    +- Validate Nova-managed project tests through `Invoke-NovaTest` for unit validation and `Test-NovaBuild` for build-validation integration validation; do not call `Invoke-Pester` directly.
    +- For public commands, keep unit coverage in `tests/public/.Tests.ps1` and keep per-command integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation.
    +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output behavior.
     - When help files change, keep `docs/{{ProjectName}}/en-US/*.md` valid for `Import-MarkdownCommandHelp`: build and import the dist module first (`Import-Module ./dist/{{ProjectName}}/{{ProjectName}}.psd1 -Force`), then use `New-MarkdownCommandHelp` for new files, `Update-MarkdownCommandHelp` after command-surface changes, and `Test-MarkdownCommandHelp` before handoff. Generating help without the module imported causes `external help file` to default to the command name instead of the module name, producing per-command XML files that the manifest cannot find. A new public `src/public/*.ps1` file is not done until its matching help file exists.
     
     ## Definition of done
    @@ -53,7 +55,8 @@ Implement PowerShell command and helper changes in the {{ProjectName}} style.
     - Build output still comes from Nova-generated `dist/` files, not hand-authored module files in `src/`.
     - Public/private file ownership still follows the one externally called function per file rule, with private helpers kept as sibling top-level functions instead of nested function declarations.
     - Every new public entry point has its matching help file.
    -- Project test validation ran through `Test-NovaBuild`.
    +- Project test validation ran through `Invoke-NovaTest` for unit coverage and `Test-NovaBuild` for build-validation integration coverage.
    +- Public-command integration coverage stays owned by `tests/public/.Integration.Tests.ps1` when built-module behavior needs validation.
     - Any ScriptAnalyzer findings reported by the repository quality loop or `Invoke-ScriptAnalyzerCI.ps1` are resolved.
     - Every changed or generated text file has been checked and ends with exactly one trailing newline and no extra blank lines at the bottom.
     - Docs/changelog review is complete.
    diff --git a/src/resources/agentic-copilot/.github/agents/reviewer.agent.md b/src/resources/agentic-copilot/.github/agents/reviewer.agent.md
    index 4a64d0b..ac371f9 100644
    --- a/src/resources/agentic-copilot/.github/agents/reviewer.agent.md
    +++ b/src/resources/agentic-copilot/.github/agents/reviewer.agent.md
    @@ -27,7 +27,9 @@ Review changes for correctness, maintainability, test coverage, workflow safety,
     - Review changed `src/**/*.ps1` against `.github/instructions/code-quality-matrix.instructions.md` and `tests/**/*.ps1` against `.github/instructions/testing-policy.instructions.md`; flag new or heavily changed code that ignores those maintainability rules without a clear, explicit reason.
     - Flag public files that do not keep exactly one top-level function, and flag private files that group multiple externally called functions instead of limiting extra functions to related same-file top-level support helpers. Also flag file/function name mismatches for public commands or externally called private helpers, and flag nested function declarations inside PowerShell functions.
     - Flag broad catch-all test files when focused source-mirrored tests would make ownership clearer.
    -- Flag Nova-managed validation that bypasses `Test-NovaBuild` with direct `Invoke-Pester`.
    +- Flag public-command changes that skip `tests/public/.Tests.ps1` or the owning `tests/public/.Integration.Tests.ps1` without a clear cross-cutting justification.
    +- Flag destructive or environment-coupled public-command integrations that should have used safe `-WhatIf` coverage but did not.
    +- Flag Nova-managed validation that bypasses `Invoke-NovaTest` or `Test-NovaBuild` with direct `Invoke-Pester`.
     - Flag any PSScriptAnalyzer rule excludes or suppressions; the code should be fixed instead.
     - Flag unresolved ScriptAnalyzer findings from the repository quality loop or `Invoke-ScriptAnalyzerCI.ps1`; they should be fixed instead of deferred.
     - Flag every changed or generated text file if they do not exactly have one trailing newline with no extra blank lines at the bottom.
    diff --git a/src/resources/agentic-copilot/.github/agents/test-engineer.agent.md b/src/resources/agentic-copilot/.github/agents/test-engineer.agent.md
    index 5bc9e25..6908eb3 100644
    --- a/src/resources/agentic-copilot/.github/agents/test-engineer.agent.md
    +++ b/src/resources/agentic-copilot/.github/agents/test-engineer.agent.md
    @@ -35,22 +35,24 @@ Improve or maintain the repository's Pester coverage, coverage-gate behavior, an
     
     ## Constraints
     
    -- Prefer the smallest `Test-NovaBuild` scope the project already supports, then the full repo quality loop.
    +- Prefer the smallest `Invoke-NovaTest` scope for unit behavior, then `Test-NovaBuild` for build-validation integration coverage, then the full repo quality loop.
     - Keep test files maintainable; passing tests are not enough if maintainability degrades.
     - Reuse existing fixture and support patterns before adding new ones.
     - Do not group unrelated source files into one broad test file when mirrored `tests/public`, `tests/private`, or `tests/classes` ownership is possible.
    +- For public commands, keep unit coverage in `tests/public/.Tests.ps1` and per-command integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior needs coverage.
    +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves the command wiring and `ShouldProcess` behavior.
     - Do not introduce PowerShell 7.x-only test syntax or APIs into a project that targets `5.1` unless compatibility coverage is explicitly part of the scope.
     - If quality tooling flags a regression, refactor the tests or helpers instead of suppressing the finding.
     - Keep new or heavily changed tests focused, isolated, and easy to scan; split setup or assertion helpers when a test stops being readable.
     - Use `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` as the normal analyzer entrypoint for changed test/helpers, and only fall back to direct `Invoke-ScriptAnalyzer` for focused local investigation with the repository-approved settings.
    -- Use `Test-NovaBuild` as the test entrypoint for Nova-managed projects; do not validate with direct `Invoke-Pester`.
    +- Use `Invoke-NovaTest` as the unit-test entrypoint and `Test-NovaBuild` as the build-validation integration-test entrypoint for Nova-managed projects; do not validate with direct `Invoke-Pester`.
     
     ## Definition of done
     
     - The changed behavior is covered.
     - Each new or changed `src/**/*.ps1` file has a matching source-mirrored test, or the cross-cutting owner test is named explicitly.
     - The touched tests are readable and low-duplication.
    -- Validation uses `Test-NovaBuild` for project test execution.
    +- Validation uses `Invoke-NovaTest` for unit execution and `Test-NovaBuild` for build-validation integration execution.
     - Validation and quality tooling implications are addressed.
     - The pre-commit quality tooling safeguard is clean before the work is treated as commit-ready when local quality tooling is available.
     
    diff --git a/src/resources/agentic-copilot/.github/copilot-instructions.md b/src/resources/agentic-copilot/.github/copilot-instructions.md
    index 9af879b..3f7ce9c 100644
    --- a/src/resources/agentic-copilot/.github/copilot-instructions.md
    +++ b/src/resources/agentic-copilot/.github/copilot-instructions.md
    @@ -21,7 +21,7 @@ For new or not-yet-scoped work, use the `architect` agent with `.github/prompts/
     
     - `src/public/` — public PowerShell commands; one top-level function per file, file name matches function name
     - `src/private/` — domain-grouped helpers (`build/`, `cli/`, `package/`, `quality/`, `release/`, `scaffold/`, `shared/`, `update/`); one externally called helper per file
    -- `tests/` — Pester tests and shared test-support scripts
    +- `tests/` — Pester tests and shared test-support scripts; public command unit tests live under `tests/public/.Tests.ps1` and per-command build-validation integration tests live under `tests/public/.Integration.Tests.ps1`
     - `scripts/build/` — local analyzer and build helpers
     - `scripts/build/ci/` — CI coverage, quality tooling, and artifact helpers
     - workflow files, when present — GitHub Actions CI, analyzer, dependency review, publish automation
    diff --git a/src/resources/agentic-copilot/.github/instructions/code-quality-matrix.instructions.md b/src/resources/agentic-copilot/.github/instructions/code-quality-matrix.instructions.md
    index 5dab39c..5b0076b 100644
    --- a/src/resources/agentic-copilot/.github/instructions/code-quality-matrix.instructions.md
    +++ b/src/resources/agentic-copilot/.github/instructions/code-quality-matrix.instructions.md
    @@ -111,7 +111,7 @@ This file tells Agentic Copilot how to shape source code from the start. Test-sp
     - How to apply in PowerShell:
         - Add or update a source-mirrored `tests//.Tests.ps1` file for every changed `src/**/*.ps1` file.
         - Cover both the happy path and the meaningful unhappy, invalid, and boundary cases that the change introduces.
    -    - Use `Test-NovaBuild` as the authoritative test entrypoint in Nova-managed projects. Do not call `Invoke-Pester` directly.
    +    - Use `Invoke-NovaTest` as the unit-test entrypoint and `Test-NovaBuild` as the build-validation integration-test entrypoint in Nova-managed projects. Do not call `Invoke-Pester` directly.
         - Isolate collaborators with mocks/stubs when verifying side effects or branching. Keep tests order-independent.
         - See `.github/instructions/testing-policy.instructions.md` and the `pester-testing` skill for the full testing rules.
     - Common objection: "It is too small to test." If it is too small to test, it is too small to be a change worth landing.
    diff --git a/src/resources/agentic-copilot/.github/instructions/powershell-coding-standards.instructions.md b/src/resources/agentic-copilot/.github/instructions/powershell-coding-standards.instructions.md
    index f5251f3..99c17b6 100644
    --- a/src/resources/agentic-copilot/.github/instructions/powershell-coding-standards.instructions.md
    +++ b/src/resources/agentic-copilot/.github/instructions/powershell-coding-standards.instructions.md
    @@ -38,7 +38,7 @@ Use this file when changing `src/public/`, `src/private/`, or PowerShell build/r
     - Follow `.github/instructions/psscriptanalyzer.instructions.md` as the ScriptAnalyzer workflow source of truth. Use the repository analyzer wrapper for normal runs and reuse repository-approved settings when invoking `Invoke-ScriptAnalyzer` directly on a focused path.
     - When public command help changes, follow `.github/instructions/platyps-help.instructions.md` and use `New-MarkdownCommandHelp`, `Update-MarkdownCommandHelp`, and `Test-MarkdownCommandHelp` instead of hand-authoring the help structure.
     - Do not add PSScriptAnalyzer `ExcludeRule`, `ExcludeRules`, suppression attributes, or generated settings that hide analyzer findings. Fix the rule violation instead.
    -- Keep local quality wrappers ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Test-NovaBuild`.
    +- Keep local quality wrappers ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Invoke-NovaTest`, then `Test-NovaBuild`.
     - If the repository quality loop or `Invoke-ScriptAnalyzerCI.ps1` reports ScriptAnalyzer findings, fix them before treating the change as complete.
     
     ## Formatting rules
    diff --git a/src/resources/agentic-copilot/.github/instructions/repository-conventions.instructions.md b/src/resources/agentic-copilot/.github/instructions/repository-conventions.instructions.md
    index f6674e6..76c196a 100644
    --- a/src/resources/agentic-copilot/.github/instructions/repository-conventions.instructions.md
    +++ b/src/resources/agentic-copilot/.github/instructions/repository-conventions.instructions.md
    @@ -27,8 +27,8 @@ Canonical rule source for cross-cutting {{ProjectName}} conventions that apply t
     
     Use the smallest validation set that proves the change, then run the repository quality loop before finishing code work:
     
    -- local quality loop: use the repository quality wrapper when one exists; otherwise run ScriptAnalyzer, build, and `Test-NovaBuild` in the documented project order
    -- test validation: `Test-NovaBuild`
    +- local quality loop: use the repository quality wrapper when one exists; otherwise run ScriptAnalyzer, build, `Invoke-NovaTest`, and `Test-NovaBuild` in the documented project order
    +- test validation: `Invoke-NovaTest` for unit-test validation, then `Test-NovaBuild` for build-validation integration coverage
     - analyzer only: `./scripts/build/Invoke-ScriptAnalyzerCI.ps1`
     - CI-parity coverage flow: use the repository-specific CI helper when one exists
     
    diff --git a/src/resources/agentic-copilot/.github/instructions/testing-policy.instructions.md b/src/resources/agentic-copilot/.github/instructions/testing-policy.instructions.md
    index a777486..939df1d 100644
    --- a/src/resources/agentic-copilot/.github/instructions/testing-policy.instructions.md
    +++ b/src/resources/agentic-copilot/.github/instructions/testing-policy.instructions.md
    @@ -30,6 +30,14 @@ BeforeAll {
     
     Legacy tests that still use `Import-Module $project.OutputModuleDir` + `InModuleScope` can still exist while maintainers finish migrations, but the mirrored pattern itself now supports enabled repository coverage gates. Generated project templates still ship `CodeCoverage.Enabled = false` until maintainers opt into the coverage gate for their own project.
     
    +## Integration tests may import `dist/`
    +
    +- New mirrored unit tests should keep dot-sourcing `src/**/*.ps1` files directly.
    +- Public command integration ownership lives in `tests/public/.Integration.Tests.ps1` when the purpose is validating built-module behavior, exported command shape, command wiring, or build-validation smoke coverage.
    +- Cross-cutting `*.Integration.Tests.ps1` files may `Import-Module ./dist//.psd1` when the behavior genuinely spans multiple source files.
    +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output behavior.
    +- Keep that `dist/` import limited to integration scenarios; do not use it as the default pattern for mirrored unit tests.
    +
     ## Cross-cutting tests are still allowed
     
     Use a cross-cutting test file (not a mirrored one) when the behavior under test truly spans multiple source files:
    @@ -46,6 +54,7 @@ Cross-cutting files should be **named for the behavior** they validate (e.g., `*
     | Source file                       | Mirrored test file                        |
     |-----------------------------------|-------------------------------------------|
     | `src/public/.ps1`           | `tests/public/.Tests.ps1`           |
    +| `src/public/.ps1`           | `tests/public/.Integration.Tests.ps1` for built-module/public-command integration ownership |
     | `src/private//.ps1` | `tests/private//.Tests.ps1` |
     | `src/classes/.ps1`          | `tests/classes/.Tests.ps1`          |
     
    @@ -54,8 +63,10 @@ A non-blocking mirror status helper is available at `scripts/build/Get-TestMirro
     ## Test expectations
     
     - Behavior changes require Pester coverage.
    -- Use `Test-NovaBuild` as the authoritative test entrypoint in Nova-managed projects. Do not validate with direct `Invoke-Pester`, because it can bypass Nova's build/import/StrictMode flow and disagree with what users see later.
    -- Prefer the smallest `Test-NovaBuild` scope the project already supports before running the full quality loop.
    +- `Invoke-NovaTest` is the unit-test entrypoint and `Test-NovaBuild` is the build-validation integration-test entrypoint in Nova-managed projects. Do not validate with direct `Invoke-Pester`, because it can bypass Nova's build/import/StrictMode flow and disagree with what users see later.
    +- Prefer the smallest supported test scope first: `Invoke-NovaTest` for unit behavior, then `Test-NovaBuild` when the change needs built-module or integration validation, before running the full quality loop.
    +- For public commands, keep unit coverage in `tests/public/.Tests.ps1` and keep per-command integration ownership in `tests/public/.Integration.Tests.ps1` when the built command behavior itself needs validation.
    +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output semantics.
     - Keep test names explicit about the behavior being proven.
     - Reuse `*.TestSupport.ps1` helpers where possible.
     - For every new or changed `src/**/*.ps1` file, add or update the matching source-mirrored `.Tests.ps1` file.
    @@ -71,6 +82,7 @@ A non-blocking mirror status helper is available at `scripts/build/Get-TestMirro
     ## Repository test structure
     
     - `tests/public/.Tests.ps1`, `tests/private//.Tests.ps1`, `tests/classes/.Tests.ps1` - mirrored unit tests (preferred for new and migrated tests)
    +- `tests/public/.Integration.Tests.ps1` - per-command build-validation integration tests for public commands
     - `tests/*Architecture*.Tests.ps1` - layering and adapter boundaries
     - `tests/*Command*.Tests.ps1` - public command, CLI, and workflow behavior (legacy bucket, being migrated)
     - `tests/*TestSupport.ps1`, `tests/TestHelpers/` - shared helpers, reusable fixtures, dot-source helpers
    @@ -79,7 +91,8 @@ A non-blocking mirror status helper is available at `scripts/build/Get-TestMirro
     ## Coverage and quality tooling
     
     - CI coverage is generated by the repository-specific CI helper when one exists.
    -- `Test-NovaBuild` runs once and produces a JaCoCo coverage report at `artifacts/coverage.xml`.
    +- `Invoke-NovaTest` produces the repository JaCoCo coverage report at `artifacts/coverage.xml`.
    +- `Test-NovaBuild` focuses on build-validation integration coverage and produces NUnit results without source coverage enforcement.
     - The JaCoCo artifact is reused by the quality tooling PR coverage gate and by the develop/manual quality tooling analysis flow.
     - The quality tooling analysis upload sends coverage twice: once for `line-coverage` and once for `branch-coverage`.
     - Coverage paths in `project.json` must point at `src/**/*.ps1`, not at the built `dist` psm1. Nova does not override `CodeCoverage.Path`.
    @@ -99,6 +112,7 @@ A non-blocking mirror status helper is available at `scripts/build/Get-TestMirro
     
     ## Verification
     
    +- `Invoke-NovaTest`
     - `Test-NovaBuild`
     - `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` when PowerShell code changed
     - the repository quality loop before completion
    diff --git a/src/resources/agentic-copilot/.github/prompts/implement-issue.prompt.md b/src/resources/agentic-copilot/.github/prompts/implement-issue.prompt.md
    index d0bea69..d71208f 100644
    --- a/src/resources/agentic-copilot/.github/prompts/implement-issue.prompt.md
    +++ b/src/resources/agentic-copilot/.github/prompts/implement-issue.prompt.md
    @@ -27,13 +27,14 @@ Implement the issue in the {{ProjectName}} repository using the repository-local
     12. Keep file/function ownership explicit: one externally called function per file, with private-file extras limited to related same-file top-level support helpers, keep the file name aligned to the entry function, and do not declare functions inside functions.
     13. Before handoff, review every changed or generated text file and normalize it to exactly one trailing newline with no extra blank lines at the bottom.
     14. Add or update the matching source-mirrored Pester file for every changed `src/**/*.ps1` file; if the behavior is genuinely cross-cutting, document which integration/guardrail test owns it and why a mirrored unit test is not practical.
    -15. Validate Nova-managed project tests through `Test-NovaBuild`; do not call `Invoke-Pester` directly because it can bypass Nova's build/import/StrictMode flow.
    -16. Add or update valid PlatyPS-compatible help under `docs/{{ProjectName}}/en-US/` when public commands or public classes change. Use `New-MarkdownCommandHelp` for new help, `Update-MarkdownCommandHelp` after command-surface changes, and `Test-MarkdownCommandHelp` before handoff; do not replace command help with plain Markdown prose.
    -17. For every new public entry point, create its matching help file immediately in the same change.
    -18. Review `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `RELEASE_NOTE.md`, help docs, and project docs as applicable.
    -19. If a commit message is requested, derive it from `$GIT_BRANCH_NAME` and the implemented change using the repository's Conventional Commit rules.
    -20. Run the relevant validation, then summarize what changed, why, and how it was verified.
    -21. If that summary is returned as Markdown or copy-ready UI output, format it according to the `markdown-authoring` skill (`.github/skills/markdown-authoring/SKILL.md`).
    +15. Validate Nova-managed project tests through `Invoke-NovaTest` for unit validation and `Test-NovaBuild` for build-validation integration validation; do not call `Invoke-Pester` directly because it can bypass Nova's build/import/StrictMode flow.
    +16. For public commands, keep unit coverage in `tests/public/.Tests.ps1` and keep per-command integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation. For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output behavior.
    +17. Add or update valid PlatyPS-compatible help under `docs/{{ProjectName}}/en-US/` when public commands or public classes change. Use `New-MarkdownCommandHelp` for new help, `Update-MarkdownCommandHelp` after command-surface changes, and `Test-MarkdownCommandHelp` before handoff; do not replace command help with plain Markdown prose.
    +18. For every new public entry point, create its matching help file immediately in the same change.
    +19. Review `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `RELEASE_NOTE.md`, help docs, and project docs as applicable.
    +20. If a commit message is requested, derive it from `$GIT_BRANCH_NAME` and the implemented change using the repository's Conventional Commit rules.
    +21. Run the relevant validation, then summarize what changed, why, and how it was verified.
    +22. If that summary is returned as Markdown or copy-ready UI output, format it according to the `markdown-authoring` skill (`.github/skills/markdown-authoring/SKILL.md`).
     
     ## Repository-specific reminders
     
    diff --git a/src/resources/agentic-copilot/.github/prompts/improve-test-coverage.prompt.md b/src/resources/agentic-copilot/.github/prompts/improve-test-coverage.prompt.md
    index 5d58928..403cf07 100644
    --- a/src/resources/agentic-copilot/.github/prompts/improve-test-coverage.prompt.md
    +++ b/src/resources/agentic-copilot/.github/prompts/improve-test-coverage.prompt.md
    @@ -14,12 +14,14 @@ Improve changed-code coverage in {{ProjectName}} without lowering maintainabilit
     6. Follow `.github/instructions/psscriptanalyzer.instructions.md` when test code or test helpers change. Prefer `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` for repo-standard analyzer runs, and use direct `Invoke-ScriptAnalyzer` only for focused local checks that reuse the repo-approved settings.
     7. Add the smallest test that proves the missing behavior.
     8. If setup is duplicated, refactor the tests before adding more assertions.
    -9. Re-run `Test-NovaBuild`, then the repository quality loop if code changed. Do not validate a Nova-managed project with direct `Invoke-Pester`.
    +9. Re-run `Invoke-NovaTest` for unit coverage, then `Test-NovaBuild` when build-validation integration ownership changed, then the repository quality loop if code changed. Do not validate a Nova-managed project with direct `Invoke-Pester`.
     10. Recheck quality tooling coverage or maintainability if that was the original failure.
     
     ## Repository-specific reminders
     
     - Many tests expect a built `dist/{{ProjectName}}` module.
    -- The CI coverage flow writes `artifacts/pester-coverage.cobertura.xml`.
    -- Use `Test-NovaBuild` as the project test entrypoint; direct `Invoke-Pester` can miss Nova-specific strict-mode behavior.
    +- The CI coverage flow writes `artifacts/coverage.xml`.
    +- Use `Invoke-NovaTest` for unit coverage validation and `Test-NovaBuild` for build-validation integration coverage; direct `Invoke-Pester` can miss Nova-specific strict-mode behavior.
    +- Keep public command unit coverage in `tests/public/.Tests.ps1` and per-command integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation.
    +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output behavior.
     - Do not "fix" coverage by weakening assertions or suppressing quality tooling warnings.
    diff --git a/src/resources/agentic-copilot/.github/prompts/review-change.prompt.md b/src/resources/agentic-copilot/.github/prompts/review-change.prompt.md
    index a4ffd0a..86f77f2 100644
    --- a/src/resources/agentic-copilot/.github/prompts/review-change.prompt.md
    +++ b/src/resources/agentic-copilot/.github/prompts/review-change.prompt.md
    @@ -12,7 +12,7 @@ Review a {{ProjectName}} change set with emphasis on correctness, maintainabilit
     3. Check changed `src/**/*.ps1` against `.github/instructions/code-quality-matrix.instructions.md` and `tests/**/*.ps1` against `.github/instructions/testing-policy.instructions.md`.
     4. Check changed PowerShell validation flow against `.github/instructions/psscriptanalyzer.instructions.md`; flag direct analyzer usage that bypasses the repository wrapper or repo-approved settings without a clear reason.
     5. Check changed `docs/{{ProjectName}}/en-US/*.md` against `.github/instructions/platyps-help.instructions.md` when command help was added or updated; flag files that do not follow the `New-MarkdownCommandHelp` / `Update-MarkdownCommandHelp` / `Test-MarkdownCommandHelp` workflow, miss a new public entry point's matching help file, or break the required PlatyPS section structure.
    -6. Check whether tests, docs, and changelog updates match the change, and flag Nova-managed validation that bypasses `Test-NovaBuild` with direct `Invoke-Pester`.
    +6. Check whether tests, docs, and changelog updates match the change, and flag Nova-managed validation that bypasses `Invoke-NovaTest` or `Test-NovaBuild` with direct `Invoke-Pester`.
     7. Call out the smallest set of meaningful issues first.
     8. Note any missing validation or follow-up work.
     9. If the review is returned as Markdown or copy-ready UI text, format it according to the `markdown-authoring` skill (`.github/skills/markdown-authoring/SKILL.md`).
    diff --git a/src/resources/agentic-copilot/.github/skills/building-maintainable-code/SKILL.md b/src/resources/agentic-copilot/.github/skills/building-maintainable-code/SKILL.md
    index b1ad6b0..38fce45 100644
    --- a/src/resources/agentic-copilot/.github/skills/building-maintainable-code/SKILL.md
    +++ b/src/resources/agentic-copilot/.github/skills/building-maintainable-code/SKILL.md
    @@ -28,6 +28,7 @@ The companion instruction file is `.github/instructions/code-quality-matrix.inst
     - `tests/*Architecture*.Tests.ps1`
     - the repository quality loop, when present
     - `./scripts/build/Invoke-ScriptAnalyzerCI.ps1`
    +- `Invoke-NovaTest`
     - `Test-NovaBuild`
     
     ## Guideline order and precedence
    @@ -67,7 +68,7 @@ Follow these steps for any non-trivial PowerShell change.
     1. Read the changed function and surrounding file. Note where it sits in `src/public/`, `src/private//`, or `scripts/`.
     2. For each new or heavily changed function, walk the checklist below in order.
     3. If a step requires a refactor, make it the smallest structural step that fixes the specific finding. Do not bundle unrelated cleanup.
    -4. After meaningful steps, run the repository quality loop when present (typically analyzer → build → `Test-NovaBuild`).
    +4. After meaningful steps, run the repository quality loop when present (typically analyzer → build → `Invoke-NovaTest` → `Test-NovaBuild`).
     5. Before handoff, normalize every changed text file to exactly one trailing newline.
     
     ### Step 1 — Short units (≤ 15 lines)
    @@ -112,7 +113,8 @@ Refactor options in PowerShell:
       ```powershell
       $script:NovaCommandMap = @{
           build    = { param($Args) Invoke-NovaBuild @Args }
    -      test     = { param($Args) Test-NovaBuild @Args }
    +      test     = { param($Args) Invoke-NovaTest @Args }
    +      testBuild = { param($Args) Test-NovaBuild @Args }
           package  = { param($Args) New-NovaPackage @Args }
       }
     
    @@ -201,7 +203,7 @@ Use parameter sets only for genuinely different calling shapes, not to hide 8 pa
     
     - Add or update one source-mirrored `tests//.Tests.ps1` for every changed `src/**/*.ps1` file.
     - Cover happy path plus the meaningful unhappy/invalid/boundary cases that the change introduces.
    -- Validate with `Test-NovaBuild`. Do not call `Invoke-Pester` directly.
    +- Validate with `Invoke-NovaTest` for unit behavior and `Test-NovaBuild` for build-validation integration behavior. Do not call `Invoke-Pester` directly.
     - For full rules, follow the `pester-testing` skill and `.github/instructions/testing-policy.instructions.md`.
     
     ### Step 10 — Clean code
    @@ -229,5 +231,6 @@ Run this short pass before handoff on every changed source file:
     
     - the repository quality loop, when present
     - `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` when iterating quickly on PowerShell changes
    -- `Test-NovaBuild` for behavior validation
    +- `Invoke-NovaTest` for unit behavior validation
    +- `Test-NovaBuild` for build-validation integration behavior
     
    diff --git a/src/resources/agentic-copilot/.github/skills/pester-testing/SKILL.md b/src/resources/agentic-copilot/.github/skills/pester-testing/SKILL.md
    index 50f42d5..a12efcb 100644
    --- a/src/resources/agentic-copilot/.github/skills/pester-testing/SKILL.md
    +++ b/src/resources/agentic-copilot/.github/skills/pester-testing/SKILL.md
    @@ -13,6 +13,7 @@ Use this skill when adding tests, closing coverage gaps, fixing regressions, or
     
     - `tests/*.Tests.ps1`
     - `tests/*TestSupport.ps1`
    +- `Invoke-NovaTest`
     - `Test-NovaBuild`
     - the repository quality loop, when present
     - `./scripts/build/ci/Invoke-{{ProjectName}}CI.ps1 -OutputDirectory ./artifacts`
    @@ -22,12 +23,14 @@ Use this skill when adding tests, closing coverage gaps, fixing regressions, or
     - Match existing `Describe` / `It` naming style.
     - Prefer support helpers for repeated setup.
     - For new and migrated tests, dot-source `src/**/*.ps1` files directly in `BeforeAll`. Do not `Import-Module $project.OutputModuleDir` in mirrored unit tests, and do not use `InModuleScope {{ProjectName}} { ... }` - the function under test is already in scope after dot-sourcing.
    -- Use `Test-NovaBuild` as the authoritative test entrypoint in Nova-managed projects. Do not validate with direct `Invoke-Pester`, because it can miss the Nova build/import/StrictMode flow.
    +- Use `Invoke-NovaTest` as the unit-test entrypoint and `Test-NovaBuild` as the build-validation integration-test entrypoint in Nova-managed projects. Do not validate with direct `Invoke-Pester`, because it can miss the Nova build/import/StrictMode flow.
     - Add coverage for both happy paths and explicit warnings/errors when behavior changed.
     - For every new or changed `src/**/*.ps1` file, add or update one focused test file that mirrors the source path under `tests/`.
     - Keep test files and helpers compatible with `project.json` `Manifest.PowerShellHostVersion`; if a project targets `5.1`, do not introduce PowerShell 7.x-only syntax, cmdlets, parameters, or APIs in the tests.
     - Use `.github/instructions/testing-policy.instructions.md` as the test-design source of truth. Cover normal, boundary, and unhappy paths; isolate collaborators with mocks/stubs where needed; and extract setup or assertion helpers when a test stops being easy to scan.
     - Keep shared setup in `tests/TestHelpers/` or `*TestSupport.ps1`; do not hide unrelated source-file coverage in broad catch-all test files.
    +- Public command unit tests belong in `tests/public/.Tests.ps1`; public command integration ownership belongs in `tests/public/.Integration.Tests.ps1` when the built-module behavior itself needs coverage.
    +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves command wiring, `ShouldProcess`, and output semantics.
     - If a mirrored test is not practical because the behavior is genuinely cross-cutting, document the reason in the handoff and point to the owning integration or guardrail test.
     
     ## Mirrored layout example
    @@ -51,7 +54,7 @@ Describe 'Initialize-NovaPesterCoverageConfiguration' {
     - Using `InModuleScope {{ProjectName}} { ... }` in new mirrored tests - the function is already in scope after dot-sourcing.
     - Duplicating setup instead of extending `*.TestSupport.ps1` or `tests/TestHelpers/`.
     - Exporting helper functions at the wrong time in test lifecycle.
    -- Validating a Nova-managed project with direct `Invoke-Pester` instead of `Test-NovaBuild`.
    +- Validating a Nova-managed project with direct `Invoke-Pester` instead of `Invoke-NovaTest` and `Test-NovaBuild`.
     - Passing tests while still degrading maintainability through duplication.
     - Grouping unrelated source files into one large test file when a source-mirrored layout would make ownership clearer.
     - Adding source files without a matching mirrored test or an explicit cross-cutting-test justification.
    @@ -59,6 +62,7 @@ Describe 'Initialize-NovaPesterCoverageConfiguration' {
     
     ## Verification
     
    +- Run `Invoke-NovaTest`
     - Run `Test-NovaBuild`
     - Run the repository quality loop when one exists before finishing code changes
     - If coverage is the goal, inspect `artifacts/coverage.xml` produced by the CI helper flow. Coverage is JaCoCo and references source files under `src/**/*.ps1` directly.
    diff --git a/src/resources/agentic-copilot/.github/skills/powershell-module-development/SKILL.md b/src/resources/agentic-copilot/.github/skills/powershell-module-development/SKILL.md
    index f0adeb0..2091449 100644
    --- a/src/resources/agentic-copilot/.github/skills/powershell-module-development/SKILL.md
    +++ b/src/resources/agentic-copilot/.github/skills/powershell-module-development/SKILL.md
    @@ -35,12 +35,14 @@ Use this skill when changing public commands, private helpers, CLI routing suppo
     - Follow the repository's PowerShell style rules: 4-space indentation, same-line opening braces, restrained blank lines, full cmdlet names, and readable operator spacing.
     - Follow `.github/instructions/psscriptanalyzer.instructions.md` as the ScriptAnalyzer workflow source of truth. Prefer `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` and the repository quality loop, when present, and use direct `Invoke-ScriptAnalyzer` only for focused local checks or deliberate analyzer-tooling work.
     - Keep ScriptAnalyzer strict: do not add excluded rules, suppression attributes, or settings that hide analyzer findings.
    -- Keep local quality checks ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Test-NovaBuild` when the project defines a combined wrapper.
    +- Keep local quality checks ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Invoke-NovaTest`, then `Test-NovaBuild` when the project defines a combined wrapper.
     - If the repository quality loop or `Invoke-ScriptAnalyzerCI.ps1` reports ScriptAnalyzer findings, fix them before handoff instead of just reporting the failure.
     - Before handoff, review every changed or generated text file and normalize it to exactly one trailing newline with no extra blank lines at the end.
     - Add or update valid PlatyPS-compatible help under `docs/{{ProjectName}}/en-US/` when public commands or public classes change. Always build and import the dist module first (`Import-Module ./dist/{{ProjectName}}/{{ProjectName}}.psd1 -Force`) before running `New-MarkdownCommandHelp` or `Update-MarkdownCommandHelp`, so PlatyPS writes the module name — not the command name — into `external help file` and `Module Name`. Use `Test-MarkdownCommandHelp` to validate structure before handoff instead of writing plain Markdown from scratch.
     - For every new public `src/public/*.ps1` file, create the matching help file immediately in the same change.
     - Add or update the source-mirrored Pester test file for every changed `src/**/*.ps1` file.
    +- For public commands, keep unit coverage in `tests/public/.Tests.ps1` and keep per-command integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation.
    +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess`, routing, and output behavior.
     
     ## Common pitfalls
     
    @@ -62,6 +64,7 @@ Use this skill when changing public commands, private helpers, CLI routing suppo
     
     ## Verification
     
    -- `Test-NovaBuild` for the changed behavior
    +- `Invoke-NovaTest` for unit-level behavior changes
    +- `Test-NovaBuild` when the change needs built-module or integration validation
     - `tests/*Architecture*.Tests.ps1` implications checked
     - the repository quality loop, when present
    diff --git a/src/resources/agentic-copilot/AGENTS.md b/src/resources/agentic-copilot/AGENTS.md
    index 0b29962..9ce71cc 100644
    --- a/src/resources/agentic-copilot/AGENTS.md
    +++ b/src/resources/agentic-copilot/AGENTS.md
    @@ -23,7 +23,7 @@ For new or not-yet-scoped work, use the `architect` agent with `.github/prompts/
     
     - `src/public/` — public PowerShell commands; one top-level function per file, file name matches function name
     - `src/private/` — domain-grouped helpers (`build/`, `cli/`, `package/`, `quality/`, `release/`, `scaffold/`, `shared/`, `update/`); one externally called helper per file
    -- `tests/` — Pester tests and shared test-support scripts
    +- `tests/` — Pester tests and shared test-support scripts; public command unit tests live under `tests/public/.Tests.ps1` and per-command build-validation integration tests live under `tests/public/.Integration.Tests.ps1`
     - `scripts/build/` — local analyzer and build helpers
     - `scripts/build/ci/` — CI coverage, quality tooling, and artifact helpers
     - workflow files, when present — GitHub Actions CI, analyzer, dependency review, publish automation
    diff --git a/src/resources/agentic-copilot/CONTRIBUTING.md b/src/resources/agentic-copilot/CONTRIBUTING.md
    index eea7966..cc8f1d7 100644
    --- a/src/resources/agentic-copilot/CONTRIBUTING.md
    +++ b/src/resources/agentic-copilot/CONTRIBUTING.md
    @@ -11,8 +11,11 @@ Before opening a pull request:
     - when you add a new public function, create its matching help file in the same change
     - use Nova commands and `project.json` for build, test, package, and release behavior
     - keep PowerShell code, tests, and examples compatible with `project.json` `Manifest.PowerShellHostVersion`; if the project targets `5.1`, do not add PowerShell 7.x-only features
    -- keep local quality checks ordered as ScriptAnalyzer, then `Invoke-NovaBuild`, then `Test-NovaBuild` when your project defines a combined wrapper
    -- use `Test-NovaBuild` as the project test entrypoint; do not validate with direct `Invoke-Pester`
    +- keep local quality checks ordered as ScriptAnalyzer, then `Invoke-NovaBuild`, then `Invoke-NovaTest`, then `Test-NovaBuild` when your project defines both test flows
    +- use `Invoke-NovaTest` for unit validation and `Test-NovaBuild` for build-validation integration runs; do not validate with direct `Invoke-Pester`
    +- keep public command unit ownership in `tests/public/.Tests.ps1`
    +- keep per-command public integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation
    +- for destructive or environment-coupled commands, prefer safe `-WhatIf` integration coverage when appropriate
     - if the repository quality loop or `Invoke-ScriptAnalyzerCI.ps1` reports ScriptAnalyzer findings, fix them before you ask for review
     - follow `.github/instructions/psscriptanalyzer.instructions.md` as the ScriptAnalyzer workflow source of truth; use direct `Invoke-ScriptAnalyzer` only for focused local checks that reuse the repo-approved settings
     - keep one externally called function per file and match the file name to that function; private files may keep extra related functions only as same-file top-level support helpers, and PowerShell functions must not declare nested functions inside their bodies
    diff --git a/src/resources/agentic-copilot/README.md b/src/resources/agentic-copilot/README.md
    index f84414c..6e5b1be 100644
    --- a/src/resources/agentic-copilot/README.md
    +++ b/src/resources/agentic-copilot/README.md
    @@ -20,8 +20,10 @@ Follow this workflow when working with Copilot in this repository.
     
     - Use Nova commands and `project.json` for build, test, package, and release behavior.
     - Treat `project.json` `Manifest.PowerShellHostVersion` as the compatibility target for PowerShell code, tests, and examples. If it is `5.1`, do not introduce PowerShell 7.x-only features.
    -- Keep local quality checks ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Test-NovaBuild` when your project defines a combined wrapper.
    -- Use `Test-NovaBuild` as the project test entrypoint. Do not validate with direct `Invoke-Pester`, because it can bypass Nova's build/import/StrictMode flow and disagree with later user-visible test runs.
    +- Keep local quality checks ordered as ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Invoke-NovaTest`, then `Test-NovaBuild` when your project defines both test flows.
    +- Use `Invoke-NovaTest` for unit validation and `Test-NovaBuild` for build-validation integration runs. Do not validate with direct `Invoke-Pester`, because it can bypass Nova's build/import/StrictMode flow and disagree with later user-visible test runs.
    +- Keep public command unit coverage in `tests/public/.Tests.ps1` and keep per-command integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation.
    +- For destructive or environment-coupled public commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess` wiring and command behavior.
     - If the repository quality loop or `Invoke-ScriptAnalyzerCI.ps1` reports ScriptAnalyzer findings, fix them before review or handoff.
     - Follow `.github/instructions/psscriptanalyzer.instructions.md` as the ScriptAnalyzer workflow source of truth. Prefer `./scripts/build/Invoke-ScriptAnalyzerCI.ps1` and the repository quality loop, when present, and use direct `Invoke-ScriptAnalyzer` only for focused local checks that reuse the repo-approved settings.
     - Keep one externally called function per file and match the file name to that function. Public files own one command each; private files may keep extra related functions only as same-file top-level support helpers, and PowerShell functions must not declare nested functions inside their bodies.
    @@ -33,6 +35,7 @@ Follow this workflow when working with Copilot in this repository.
     - Do not hand-create module `.psm1` or module `.psd1` files in source; Nova generates them under `dist/{{ProjectName}}/`.
     - Add PlatyPS-compatible help under `docs/{{ProjectName}}/en-US/` when public commands or public classes change.
     - Keep tests mirrored to source files: every new or changed `src/**/*.ps1` file should have one focused `.Tests.ps1` file, for example `src/private/foo/Get-Thing.ps1` -> `tests/private/foo/Get-Thing.Tests.ps1`.
    +- For public commands, the mirrored unit-test owner is `tests/public/.Tests.ps1`; add `tests/public/.Integration.Tests.ps1` when the built public command behavior itself needs integration coverage.
     - Put shared test setup in `tests/TestHelpers/` or a test-support file instead of grouping unrelated source coverage into broad catch-all tests.
     
     ## Start here
    diff --git a/tests/AgenticCopilotScaffoldSync.Tests.ps1 b/tests/AgenticCopilotScaffoldSync.Tests.ps1
    index 8fa13ea..123d0ff 100644
    --- a/tests/AgenticCopilotScaffoldSync.Tests.ps1
    +++ b/tests/AgenticCopilotScaffoldSync.Tests.ps1
    @@ -31,6 +31,7 @@ BeforeAll {
                 DeveloperSkill = Get-Content -LiteralPath (Join-Path $script:scaffoldRoot '.github/skills/powershell-module-development/SKILL.md') -Raw
                 PesterSkill = Get-Content -LiteralPath (Join-Path $script:scaffoldRoot '.github/skills/pester-testing/SKILL.md') -Raw
                 ImplementPrompt = Get-Content -LiteralPath (Join-Path $script:scaffoldRoot '.github/prompts/implement-issue.prompt.md') -Raw
    +            ImproveCoveragePrompt = Get-Content -LiteralPath (Join-Path $script:scaffoldRoot '.github/prompts/improve-test-coverage.prompt.md') -Raw
                 ReviewPrompt = Get-Content -LiteralPath (Join-Path $script:scaffoldRoot '.github/prompts/review-change.prompt.md') -Raw
                 Agents = Get-Content -LiteralPath (Join-Path $script:scaffoldRoot 'AGENTS.md') -Raw
                 Contributing = Get-Content -LiteralPath (Join-Path $script:scaffoldRoot 'CONTRIBUTING.md') -Raw
    @@ -90,7 +91,8 @@ Describe 'Agentic Copilot scaffold sync' {
             $content.RepositoryConventions | Should -Match 'src/private/` files expose at most one externally called function per file'
             $content.RepositoryConventions | Should -Match 'file name matches the function name'
             $content.RepositoryConventions | Should -Match 'review every changed or created text file and ensure it ends with exactly one trailing newline'
    -        $content.RepositoryConventions | Should -Match 'test validation: `Test-NovaBuild`'
    +        $content.RepositoryConventions | Should -Match 'local quality loop: use the repository quality wrapper when one exists; otherwise run ScriptAnalyzer, build, `Invoke-NovaTest`, and `Test-NovaBuild` in the documented project order'
    +        $content.RepositoryConventions | Should -Match 'test validation: `Invoke-NovaTest` for unit-test validation, then `Test-NovaBuild` for build-validation integration coverage'
             $content.RepositoryConventions | Should -Match 'Invoke-ScriptAnalyzerCI\.ps1'
             $content.RepositoryConventions | Should -Match 'Conventional Commit format'
         }
    @@ -182,35 +184,55 @@ Describe 'Agentic Copilot scaffold sync' {
             $content.Readme | Should -Match 'New-MarkdownCommandHelp'
         }
     
    -    It 'documents Test-NovaBuild-only project test guidance' {
    +    It 'documents Nova-managed project test guidance' {
             $content = & $script:getAgenticScaffoldGuidanceContent
     
    -        $content.RepositoryConventions | Should -Match 'test validation: `Test-NovaBuild`'
    +        $content.RepositoryConventions | Should -Match 'test validation: `Invoke-NovaTest` for unit-test validation, then `Test-NovaBuild` for build-validation integration coverage'
    +        $content.RepositoryConventions | Should -Match 'local quality loop: use the repository quality wrapper when one exists; otherwise run ScriptAnalyzer, build, `Invoke-NovaTest`, and `Test-NovaBuild` in the documented project order'
             $content.RepositoryConventions | Should -Not -Match 'Invoke-Pester -Path'
     
    -        $content.TestingPolicy | Should -Match 'Use `Test-NovaBuild` as the authoritative test entrypoint'
    +        $content.TestingPolicy | Should -Match '`Invoke-NovaTest` is the unit-test entrypoint and `Test-NovaBuild` is the build-validation integration-test entrypoint'
    +        $content.TestingPolicy | Should -Match 'Integration tests may import `dist/`'
    +        $content.TestingPolicy | Should -Match '\*\.Integration\.Tests\.ps1'
    +        $content.TestingPolicy | Should -Match 'tests/public/\.Integration\.Tests\.ps1'
    +        $content.TestingPolicy | Should -Match '-WhatIf'
             $content.TestingPolicy | Should -Match 'Do not validate with direct `Invoke-Pester`'
             $content.TestingPolicy | Should -Match 'normal path and the meaningful unhappy, invalid, or boundary cases'
             $content.TestingPolicy | Should -Match 'mocks or stubs'
             $content.TestingPolicy | Should -Match 'isolated and order-independent'
             $content.TestingPolicy | Should -Not -Match 'Invoke-Pester -Path'
     
    +        $content.PesterSkill | Should -Match 'Invoke-NovaTest'
             $content.PesterSkill | Should -Match 'Test-NovaBuild'
    +        $content.PesterSkill | Should -Match 'tests/public/\.Integration\.Tests\.ps1'
             $content.PesterSkill | Should -Match 'Do not validate with direct `Invoke-Pester`'
             $content.PesterSkill | Should -Match 'normal, boundary, and unhappy paths'
             $content.PesterSkill | Should -Match 'mocks/stubs'
             $content.PesterSkill | Should -Not -Match 'Invoke-Pester -Path'
     
    -        $content.DeveloperSkill | Should -Match '`Test-NovaBuild` for the changed behavior'
    -        $content.DeveloperAgent | Should -Match 'Validate Nova-managed project tests through `Test-NovaBuild`'
    -        $content.TestEngineerAgent | Should -Match 'Use `Test-NovaBuild` as the test entrypoint'
    +        $content.DeveloperSkill | Should -Match '`Invoke-NovaTest` for unit-level behavior changes'
    +        $content.DeveloperSkill | Should -Match '`Test-NovaBuild` when the change needs built-module or integration validation'
    +        $content.DeveloperSkill | Should -Match 'tests/public/\.Integration\.Tests\.ps1'
    +        $content.DeveloperSkill | Should -Match '-WhatIf'
    +        $content.DeveloperAgent | Should -Match 'Validate Nova-managed project tests through `Invoke-NovaTest` for unit validation and `Test-NovaBuild` for build-validation integration validation'
    +        $content.DeveloperAgent | Should -Match 'tests/public/\.Integration\.Tests\.ps1'
    +        $content.DeveloperAgent | Should -Match '-WhatIf'
    +        $content.TestEngineerAgent | Should -Match 'Use `Invoke-NovaTest` as the unit-test entrypoint and `Test-NovaBuild` as the build-validation integration-test entrypoint'
             $content.TestEngineerAgent | Should -Match 'testing-policy\.instructions\.md'
    -        $content.ReviewerAgent | Should -Match 'bypasses `Test-NovaBuild` with direct `Invoke-Pester`'
    -        $content.ImplementPrompt | Should -Match 'Validate Nova-managed project tests through `Test-NovaBuild`'
    -        $content.ReviewPrompt | Should -Match 'bypasses `Test-NovaBuild` with direct `Invoke-Pester`'
    -
    -        $content.Contributing | Should -Match 'use `Test-NovaBuild` as the project test entrypoint'
    -        $content.Readme | Should -Match 'Use `Test-NovaBuild` as the project test entrypoint'
    +        $content.ReviewerAgent | Should -Match 'tests/public/\.Integration\.Tests\.ps1'
    +        $content.ReviewerAgent | Should -Match 'bypasses `Invoke-NovaTest` or `Test-NovaBuild` with direct `Invoke-Pester`'
    +        $content.ImplementPrompt | Should -Match 'Validate Nova-managed project tests through `Invoke-NovaTest` for unit validation and `Test-NovaBuild` for build-validation integration validation'
    +        $content.ImplementPrompt | Should -Match 'tests/public/\.Integration\.Tests\.ps1'
    +        $content.ImplementPrompt | Should -Match '-WhatIf'
    +        $content.ReviewPrompt | Should -Match 'bypasses `Invoke-NovaTest` or `Test-NovaBuild` with direct `Invoke-Pester`'
    +        $content.ImproveCoveragePrompt | Should -Match 'tests/public/\.Integration\.Tests\.ps1'
    +        $content.ImproveCoveragePrompt | Should -Match '-WhatIf'
    +
    +        $content.Contributing | Should -Match 'use `Invoke-NovaTest` for unit validation and `Test-NovaBuild` for build-validation integration runs'
    +        $content.Contributing | Should -Match 'tests/public/\.Integration\.Tests\.ps1'
    +        $content.Readme | Should -Match 'Use `Invoke-NovaTest` for unit validation and `Test-NovaBuild` for build-validation integration runs'
    +        $content.Readme | Should -Match 'tests/public/\.Integration\.Tests\.ps1'
    +        $content.Readme | Should -Match '-WhatIf'
         }
     
         It 'documents proper PSScriptAnalyzer usage guidance' {
    @@ -268,7 +290,7 @@ Describe 'Agentic Copilot scaffold sync' {
             $content.Contributing | Should -Match 'TextFileFormatting\.Tests\.ps1'
             $content.Contributing | Should -Match 'make every changed or generated text file end immediately after exactly one newline terminator with no blank spacer line at the bottom'
     
    -        $content.Readme | Should -Match 'ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Test-NovaBuild`'
    +        $content.Readme | Should -Match 'ScriptAnalyzer first, then `Invoke-NovaBuild`, then `Invoke-NovaTest`, then `Test-NovaBuild`'
             $content.Readme | Should -Match 'If the repository quality loop or `Invoke-ScriptAnalyzerCI\.ps1` reports ScriptAnalyzer findings, fix them before review or handoff'
             $content.Readme | Should -Match 'Keep one externally called function per file and match the file name to that function'
             $content.Readme | Should -Match 'must not declare nested functions inside their bodies'
    diff --git a/tests/ArchitectureGuardrails.Tests.ps1 b/tests/ArchitectureGuardrails.Tests.ps1
    index b1fe434..83b980b 100644
    --- a/tests/ArchitectureGuardrails.Tests.ps1
    +++ b/tests/ArchitectureGuardrails.Tests.ps1
    @@ -90,11 +90,12 @@ Describe 'Architecture guardrails' {
                 [pscustomobject]@{Path = 'src/public/InvokeNovaBuild.ps1'; ExpectedHelpers = @('Get-NovaBuildWorkflowContext', 'Invoke-NovaBuildWorkflow')}
                 [pscustomobject]@{Path = 'src/public/InvokeNovaAgenticCopilotScaffold.ps1'; ExpectedHelpers = @('Get-NovaAgenticCopilotScaffoldWorkflowContext', 'Invoke-NovaAgenticCopilotScaffoldWorkflow')}
                 [pscustomobject]@{Path = 'src/public/InvokeNovaCli.ps1'; ExpectedHelpers = @('Get-NovaCliInvocationContext', 'Invoke-NovaCliCommandRoute')}
    +            [pscustomobject]@{Path = 'src/public/InvokeNovaTest.ps1'; ExpectedHelpers = @('Get-NovaDynamicOverrideWarningParameterDictionary', 'Get-NovaTestWorkflowContext', 'Invoke-NovaTestWorkflow')}
                 [pscustomobject]@{Path = 'src/public/InvokeNovaRelease.ps1'; ExpectedHelpers = @('Get-NovaDynamicReleaseParameterDictionary', 'Get-NovaProjectInfo', 'Get-NovaPublishWorkflowContext', 'Get-NovaReleasePublishOption', 'Get-NovaReleaseRequest', 'Get-NovaReleaseRequestedPath', 'Get-NovaShouldProcessForwardingParameter', 'Invoke-NovaReleaseWorkflow', 'Write-NovaPublishWorkflowContext')}
                 [pscustomobject]@{Path = 'src/public/NewNovaModulePackage.ps1'; ExpectedHelpers = @('Get-NovaPackageWorkflowContext', 'Get-NovaShouldProcessForwardingParameter', 'Invoke-NovaPackageWorkflow')}
                 [pscustomobject]@{Path = 'src/public/PublishNovaModule.ps1'; ExpectedHelpers = @('Get-NovaDynamicDeliveryParameterDictionary', 'Get-NovaProjectInfo', 'Get-NovaPublishWorkflowContext', 'Get-NovaShouldProcessForwardingParameter', 'Invoke-NovaPublishWorkflow', 'Write-NovaPublishWorkflowContext')}
                 [pscustomobject]@{Path = 'src/public/SetNovaUpdateNotificationPreference.ps1'; ExpectedHelpers = @('Get-NovaUpdateNotificationPreferenceChangeContext', 'Invoke-NovaUpdateNotificationPreferenceChange')}
    -            [pscustomobject]@{Path = 'src/public/TestNovaBuild.ps1'; ExpectedHelpers = @('Get-NovaTestWorkflowContext', 'Invoke-NovaTestWorkflow', 'New-NovaTestDynamicParameterDictionary')}
    +            [pscustomobject]@{Path = 'src/public/TestNovaBuild.ps1'; ExpectedHelpers = @('Get-NovaDynamicOverrideWarningParameterDictionary', 'Get-NovaTestWorkflowContext', 'Invoke-NovaTestWorkflow')}
                 [pscustomobject]@{Path = 'src/public/UpdateNovaModuleTools.ps1'; ExpectedHelpers = @('Confirm-NovaPrereleaseModuleUpdate', 'Get-NovaModuleSelfUpdateWorkflowContext', 'Invoke-NovaModuleSelfUpdateWorkflow', 'Write-NovaModuleReleaseNotesLink')}
                 [pscustomobject]@{Path = 'src/public/UpdateNovaModuleVersion.ps1'; ExpectedHelpers = @('Get-NovaVersionUpdateWorkflowContext', 'Invoke-NovaVersionUpdateCiActivation', 'Invoke-NovaVersionUpdateWorkflow', 'Write-NovaVersionUpdateResultOutput')}
             )
    diff --git a/tests/BuildValidationSmoke.Integration.Tests.ps1 b/tests/BuildValidationSmoke.Integration.Tests.ps1
    new file mode 100644
    index 0000000..2242b60
    --- /dev/null
    +++ b/tests/BuildValidationSmoke.Integration.Tests.ps1
    @@ -0,0 +1,39 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent $PSScriptRoot
    +    $script:moduleName = 'NovaModuleTools'
    +    $script:moduleManifestPath = Join-Path $script:projectRoot 'dist/NovaModuleTools/NovaModuleTools.psd1'
    +    $publicDir = Join-Path $script:projectRoot 'src/public'
    +
    +    Remove-Module $script:moduleName -ErrorAction SilentlyContinue
    +    Import-Module $script:moduleManifestPath -Force -ErrorAction Stop
    +
    +    $script:expectedCommandName = @(
    +        foreach ($file in (Get-ChildItem -LiteralPath $publicDir -Filter '*.ps1' -File | Sort-Object -Property Name)) {
    +            $null = $token = $parseError = $null
    +            $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$token, [ref]$parseError)
    +            $definition = $ast.FindAll({
    +                    param($node)
    +                    $node -is [System.Management.Automation.Language.FunctionDefinitionAst]
    +                }, $false)
    +
    +            foreach ($item in $definition) {
    +                $item.Name
    +            }
    +        }
    +    ) | Sort-Object
    +    $script:exportedCommandName = @(
    +        Get-Command -Module $script:moduleName -CommandType Function |
    +            Select-Object -ExpandProperty Name |
    +            Sort-Object
    +    )
    +}
    +
    +Describe 'Build validation smoke integration' {
    +    It 'imports the built module without error' {
    +        (Get-Module -Name $script:moduleName) | Should -Not -BeNullOrEmpty
    +    }
    +
    +    It 'exports every public command from src/public' {
    +        $script:exportedCommandName | Should -Be $script:expectedCommandName
    +    }
    +}
    diff --git a/tests/Invoke-NovaModuleToolsCI.Tests.ps1 b/tests/Invoke-NovaModuleToolsCI.Tests.ps1
    index 4c53c7c..848f5fb 100644
    --- a/tests/Invoke-NovaModuleToolsCI.Tests.ps1
    +++ b/tests/Invoke-NovaModuleToolsCI.Tests.ps1
    @@ -33,14 +33,17 @@ exit 1
     }
     
     Describe 'Invoke-NovaModuleToolsCI' {
    -    It 'forwards ExcludeTag to Test-NovaBuild and copies the NUnit artifact to the requested output directory' {
    +    It 'runs Invoke-NovaTest before Test-NovaBuild, forwards ExcludeTag, and copies both NUnit artifacts' {
             $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'
    -        $testResultPath = Join-Path $projectRoot 'artifacts/TestResults.xml'
    +        $unitResultPath = Join-Path $projectRoot 'artifacts/UnitTestResults.xml'
    +        $integrationResultPath = Join-Path $projectRoot 'artifacts/TestResults.xml'
     
    -        New-Item -ItemType Directory -Path (Split-Path -Parent $testResultPath) -Force | Out-Null
    -        Set-Content -LiteralPath $testResultPath -Value '' -Encoding utf8
    +        New-Item -ItemType Directory -Path (Split-Path -Parent $unitResultPath) -Force | Out-Null
    +        Set-Content -LiteralPath $unitResultPath -Value '' -Encoding utf8
    +        Set-Content -LiteralPath $integrationResultPath -Value '' -Encoding utf8
     
             $runnerContent = @"
     function Import-Module {
    @@ -66,29 +69,39 @@ function Remove-Module {
         param([string]`$Name)
     }
     
    -function Test-NovaBuild {
    +function Invoke-NovaTest {
         [CmdletBinding()]
         param([string[]]`$ExcludeTagFilter)
     
    +    Add-Content -LiteralPath '$callLogPath' -Value 'Invoke-NovaTest'
         Set-Content -LiteralPath '$excludeTagLogPath' -Value (`$ExcludeTagFilter -join ',') -Encoding utf8
     }
     
    +function Test-NovaBuild {
    +    [CmdletBinding()]
    +    param([string[]]`$ExcludeTagFilter)
    +
    +    Add-Content -LiteralPath '$callLogPath' -Value 'Test-NovaBuild'
    +}
    +
     & '$script:novaModuleToolsCiScriptPath' -OutputDirectory '$outputDirectory' -ExcludeTag 'slow','integration'
     "@
             $result = Invoke-NovaModuleToolsCIRunner -RunnerContent $runnerContent
     
             $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'
    -        (Get-Content -LiteralPath (Join-Path $outputDirectory 'novamoduletools-nunit.xml') -Raw).Trim() | Should -Be ''
    +        (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 ''
         }
     
    -    It 'returns a non-zero exit code after copying the test result when Test-NovaBuild fails' {
    +    It 'returns a non-zero exit code after copying available artifacts when a test command fails' {
             $projectRoot = Join-Path $TestDrive 'project-failure'
             $outputDirectory = Join-Path $TestDrive 'artifacts-out-failure'
    -        $testResultPath = Join-Path $projectRoot 'artifacts/TestResults.xml'
    +        $unitResultPath = Join-Path $projectRoot 'artifacts/UnitTestResults.xml'
     
    -        New-Item -ItemType Directory -Path (Split-Path -Parent $testResultPath) -Force | Out-Null
    -        Set-Content -LiteralPath $testResultPath -Value '' -Encoding utf8
    +        New-Item -ItemType Directory -Path (Split-Path -Parent $unitResultPath) -Force | Out-Null
    +        Set-Content -LiteralPath $unitResultPath -Value '' -Encoding utf8
     
             $runnerContent = @"
     function Import-Module {
    @@ -114,17 +127,20 @@ function Remove-Module {
         param([string]`$Name)
     }
     
    -function Test-NovaBuild {
    +function Invoke-NovaTest {
         throw 'boom'
     }
     
    +function Test-NovaBuild {}
    +
     & '$script:novaModuleToolsCiScriptPath' -OutputDirectory '$outputDirectory'
     "@
             $result = Invoke-NovaModuleToolsCIRunner -RunnerContent $runnerContent
             $outputText = $result.Output -join [Environment]::NewLine
     
             $result.ExitCode | Should -Be 1
    -        $outputText | Should -Match 'Test-NovaBuild failed: boom'
    -        (Get-Content -LiteralPath (Join-Path $outputDirectory 'novamoduletools-nunit.xml') -Raw).Trim() | Should -Be ''
    +        $outputText | Should -Match 'Nova test workflow failed: boom'
    +        (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/PublicCommandTestOwnership.Tests.ps1 b/tests/PublicCommandTestOwnership.Tests.ps1
    new file mode 100644
    index 0000000..f0a8100
    --- /dev/null
    +++ b/tests/PublicCommandTestOwnership.Tests.ps1
    @@ -0,0 +1,33 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent $PSScriptRoot
    +    $script:publicSourceDirectory = Join-Path $script:projectRoot 'src/public'
    +    $script:publicTestDirectory = Join-Path $script:projectRoot 'tests/public'
    +    $script:sourceStemList = @(
    +        Get-ChildItem -LiteralPath $script:publicSourceDirectory -Filter '*.ps1' -File |
    +            ForEach-Object BaseName |
    +            Sort-Object
    +    )
    +}
    +
    +Describe 'Public command test ownership' {
    +    It 'keeps mirrored unit tests for every public command source file' {
    +        $unitTestStemList = @(
    +            Get-ChildItem -LiteralPath $script:publicTestDirectory -Filter '*.Tests.ps1' -File |
    +                Where-Object Name -notlike '*.Integration.Tests.ps1' |
    +                ForEach-Object { $_.Name -replace '\.Tests\.ps1$', '' } |
    +                Sort-Object
    +        )
    +
    +        $unitTestStemList | Should -Be $script:sourceStemList
    +    }
    +
    +    It 'keeps mirrored integration tests for every public command source file' {
    +        $integrationTestStemList = @(
    +            Get-ChildItem -LiteralPath $script:publicTestDirectory -Filter '*.Integration.Tests.ps1' -File |
    +                ForEach-Object { $_.Name -replace '\.Integration\.Tests\.ps1$', '' } |
    +                Sort-Object
    +        )
    +
    +        $integrationTestStemList | Should -Be $script:sourceStemList
    +    }
    +}
    diff --git a/tests/TestHelpers/PublicCommandIntegration.ps1 b/tests/TestHelpers/PublicCommandIntegration.ps1
    new file mode 100644
    index 0000000..e1587ec
    --- /dev/null
    +++ b/tests/TestHelpers/PublicCommandIntegration.ps1
    @@ -0,0 +1,48 @@
    +function Get-NovaPublicCommandIntegrationProjectInfo {
    +    [CmdletBinding()]
    +    param(
    +        [Parameter(Mandatory)][string]$ProjectRoot
    +    )
    +
    +    return [pscustomobject]@{
    +        ProjectName = 'NovaModuleTools'
    +        OutputModuleDir = Join-Path $ProjectRoot 'dist/NovaModuleTools'
    +    }
    +}
    +
    +function Import-NovaPublicCommandIntegrationModule {
    +    [CmdletBinding()]
    +    param(
    +        [Parameter(Mandatory)][string]$ProjectRoot
    +    )
    +
    +    . (Join-Path $ProjectRoot 'src/private/shared/ImportNovaBuiltModuleForCi.ps1')
    +
    +    $projectInfo = Get-NovaPublicCommandIntegrationProjectInfo -ProjectRoot $ProjectRoot
    +    return Import-NovaBuiltModuleForCi -ProjectRoot $ProjectRoot -ProjectInfo $projectInfo
    +}
    +
    +function Invoke-NovaPublicCommandIntegrationInLocation {
    +    [CmdletBinding()]
    +    param(
    +        [Parameter(Mandatory)][string]$Path,
    +        [Parameter(Mandatory)][scriptblock]$ScriptBlock
    +    )
    +
    +    Push-Location -LiteralPath $Path
    +    try {
    +        return & $ScriptBlock
    +    } finally {
    +        Pop-Location
    +    }
    +}
    +
    +function Invoke-NovaPublicCommandIntegrationInProjectRoot {
    +    [CmdletBinding()]
    +    param(
    +        [Parameter(Mandatory)][string]$ProjectRoot,
    +        [Parameter(Mandatory)][scriptblock]$ScriptBlock
    +    )
    +
    +    return Invoke-NovaPublicCommandIntegrationInLocation -Path $ProjectRoot -ScriptBlock $ScriptBlock
    +}
    diff --git a/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1 b/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1
    index ccc6acc..f3808d3 100644
    --- a/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1
    +++ b/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1
    @@ -43,12 +43,18 @@ Describe 'Invoke-NovaBuildWorkflow' {
                     $Status -eq 'Checking update notifications' -and $PercentComplete -eq 94
                 }
                 Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed}
    -            Assert-MockCalled Write-Message -Times 3
    +            Assert-MockCalled Write-Message -Times 5
                 Assert-MockCalled Write-Message -Times 1 -ParameterFilter {
                     $Text -eq 'Built Nova module: NovaModuleTools' -and $color -eq 'Green'
                 }
                 Assert-MockCalled Write-Message -Times 1 -ParameterFilter {
    -                $Text -eq 'Next step: Test-NovaBuild'
    +                $Text -eq 'Next steps:'
    +            }
    +            Assert-MockCalled Write-Message -Times 1 -ParameterFilter {
    +                $Text -eq 'Invoke-NovaTest'
    +            }
    +            Assert-MockCalled Write-Message -Times 1 -ParameterFilter {
    +                $Text -eq 'Test-NovaBuild'
                 }
             } finally {
                 Remove-Variable -Name steps -Scope Global -ErrorAction SilentlyContinue
    diff --git a/tests/private/cli/InvokeNovaCliCommandRoute.TestSupport.ps1 b/tests/private/cli/InvokeNovaCliCommandRoute.TestSupport.ps1
    index d23a4c4..0f62a6d 100644
    --- a/tests/private/cli/InvokeNovaCliCommandRoute.TestSupport.ps1
    +++ b/tests/private/cli/InvokeNovaCliCommandRoute.TestSupport.ps1
    @@ -84,6 +84,8 @@ function Update-NovaModuleVersion {}
     
     function Invoke-NovaBuild {}
     
    +function Invoke-NovaTest {}
    +
     function Test-NovaBuild {}
     
     function New-NovaModulePackage {}
    diff --git a/tests/private/cli/InvokeNovaCliCommandRoute.Tests.ps1 b/tests/private/cli/InvokeNovaCliCommandRoute.Tests.ps1
    index c3c260a..e9c1b61 100644
    --- a/tests/private/cli/InvokeNovaCliCommandRoute.Tests.ps1
    +++ b/tests/private/cli/InvokeNovaCliCommandRoute.Tests.ps1
    @@ -40,6 +40,41 @@ Describe 'Invoke-NovaCliParsedCommand' {
         }
     }
     
    +Describe 'Invoke-NovaCliTestRouteCommand' {
    +    It 'routes plain nova test to Invoke-NovaTest' {
    +        Mock ConvertFrom-NovaTestCliArgument { @{TagFilter = @('fast')} }
    +        Mock Invoke-NovaTest { param([string[]]$TagFilter, [switch]$WhatIf) "unit:$($TagFilter -join ','):$($WhatIf.IsPresent)" }
    +        Mock Test-NovaBuild {}
    +
    +        $result = Invoke-NovaCliTestRouteCommand -InvocationContext (New-TestContext -Arguments @('--tag', 'fast') -MutatingCommonParameters @{WhatIf = $true})
    +
    +        $result | Should -Be 'unit:fast:True'
    +        Assert-MockCalled Test-NovaBuild -Times 0
    +    }
    +
    +    It 'forwards override-warning to Invoke-NovaTest for plain nova test' {
    +        Mock ConvertFrom-NovaTestCliArgument { @{OverrideWarning = $true} }
    +        Mock Invoke-NovaTest { param([switch]$OverrideWarning, [switch]$WhatIf) "unit:$($OverrideWarning.IsPresent):$($WhatIf.IsPresent)" }
    +        Mock Test-NovaBuild {}
    +
    +        $result = Invoke-NovaCliTestRouteCommand -InvocationContext (New-TestContext -Arguments @('--override-warning') -MutatingCommonParameters @{WhatIf = $true})
    +
    +        $result | Should -Be 'unit:True:True'
    +        Assert-MockCalled Test-NovaBuild -Times 0
    +    }
    +
    +    It 'routes nova test --build to Test-NovaBuild without forwarding the Build switch' {
    +        Mock ConvertFrom-NovaTestCliArgument { @{Build = $true; OverrideWarning = $true} }
    +        Mock Test-NovaBuild { param([switch]$OverrideWarning, [switch]$WhatIf) "integration:$($OverrideWarning.IsPresent):$($WhatIf.IsPresent)" }
    +        Mock Invoke-NovaTest {}
    +
    +        $result = Invoke-NovaCliTestRouteCommand -InvocationContext (New-TestContext -Arguments @('--build') -MutatingCommonParameters @{WhatIf = $true})
    +
    +        $result | Should -Be 'integration:True:True'
    +        Assert-MockCalled Invoke-NovaTest -Times 0
    +    }
    +}
    +
     Describe 'Read-NovaCliCapturedOutput' {
         It 'separates warning records from result' {
             $warn = [System.Management.Automation.WarningRecord]::new('hi')
    @@ -162,8 +197,8 @@ Describe 'Invoke-NovaCliCommandRoute' {
             Assert-MockCalled Invoke-NovaCliParsedCommand -Times 1 -ParameterFilter {$ParserCommand -eq 'ConvertFrom-NovaBuildCliArgument' -and $ActionCommand -eq 'Invoke-NovaBuild'}
         }
     
    -    It 'dispatches test through parsed command pipeline' {
    -        Mock Invoke-NovaCliParsedCommand { 'tested' }
    +    It 'dispatches test through the dedicated test router' {
    +        Mock Invoke-NovaCliTestRouteCommand { 'tested' }
             Mock Confirm-NovaCliRoutedCommand {}
             Invoke-NovaCliCommandRoute -InvocationContext (New-TestContext -Command 'test') | Should -Be 'tested'
         }
    diff --git a/tests/private/quality/GetNovaPesterRunPath.Tests.ps1 b/tests/private/quality/GetNovaPesterRunPath.Tests.ps1
    index 76a0d04..baeb7e2 100644
    --- a/tests/private/quality/GetNovaPesterRunPath.Tests.ps1
    +++ b/tests/private/quality/GetNovaPesterRunPath.Tests.ps1
    @@ -4,13 +4,45 @@ BeforeAll {
     }
     
     Describe 'Get-NovaPesterRunPath' {
    -    It 'returns the tests directory when BuildRecursiveFolders is enabled' {
    -        $info = [pscustomobject]@{BuildRecursiveFolders = $true; TestsDir = '/p/tests'}
    -        Get-NovaPesterRunPath -ProjectInfo $info | Should -Be '/p/tests'
    +    It 'returns matching recursive test files when BuildRecursiveFolders is enabled' {
    +        $testsDir = Join-Path $TestDrive 'tests-recursive'
    +        $nestedDir = Join-Path $testsDir 'private/quality'
    +        New-Item -ItemType Directory -Path $nestedDir -Force | Out-Null
    +
    +        $topLevelPath = Join-Path $testsDir 'TopLevel.Tests.ps1'
    +        $nestedPath = Join-Path $nestedDir 'Nested.Tests.ps1'
    +        Set-Content -LiteralPath $topLevelPath -Value '# test'
    +        Set-Content -LiteralPath $nestedPath -Value '# test'
    +
    +        $info = [pscustomobject]@{BuildRecursiveFolders = $true; TestsDir = $testsDir}
    +        $expectedPath = @($nestedPath, $topLevelPath) | Sort-Object
    +        Get-NovaPesterRunPath -ProjectInfo $info | Should -Be $expectedPath
    +    }
    +
    +    It 'returns only top-level matching files when BuildRecursiveFolders is disabled' {
    +        $testsDir = Join-Path $TestDrive 'tests-flat'
    +        $nestedDir = Join-Path $testsDir 'private'
    +        New-Item -ItemType Directory -Path $nestedDir -Force | Out-Null
    +
    +        $topLevelPath = Join-Path $testsDir 'TopLevel.Tests.ps1'
    +        $nestedPath = Join-Path $nestedDir 'Nested.Tests.ps1'
    +        Set-Content -LiteralPath $topLevelPath -Value '# test'
    +        Set-Content -LiteralPath $nestedPath -Value '# test'
    +
    +        $info = [pscustomobject]@{BuildRecursiveFolders = $false; TestsDir = $testsDir}
    +        Get-NovaPesterRunPath -ProjectInfo $info | Should -Be @($topLevelPath)
         }
     
    -    It 'returns the *.Tests.ps1 glob when BuildRecursiveFolders is disabled' {
    -        $info = [pscustomobject]@{BuildRecursiveFolders = $false; TestsDir = '/p/tests'}
    -        Get-NovaPesterRunPath -ProjectInfo $info | Should -Be ([System.IO.Path]::Join('/p/tests', '*.Tests.ps1'))
    +    It 'excludes integration tests when an exclude pattern is provided' {
    +        $testsDir = Join-Path $TestDrive 'tests-filtered'
    +        New-Item -ItemType Directory -Path $testsDir -Force | Out-Null
    +
    +        $unitPath = Join-Path $testsDir 'Alpha.Tests.ps1'
    +        $integrationPath = Join-Path $testsDir 'Alpha.Integration.Tests.ps1'
    +        Set-Content -LiteralPath $unitPath -Value '# unit'
    +        Set-Content -LiteralPath $integrationPath -Value '# integration'
    +
    +        $info = [pscustomobject]@{BuildRecursiveFolders = $false; TestsDir = $testsDir}
    +        Get-NovaPesterRunPath -ProjectInfo $info -IncludePattern '*.Tests.ps1' -ExcludePattern '*.Integration.Tests.ps1' | Should -Be @($unitPath)
         }
     }
    diff --git a/tests/private/quality/GetNovaPesterTestResultPath.Tests.ps1 b/tests/private/quality/GetNovaPesterTestResultPath.Tests.ps1
    index 3abcbad..72c8929 100644
    --- a/tests/private/quality/GetNovaPesterTestResultPath.Tests.ps1
    +++ b/tests/private/quality/GetNovaPesterTestResultPath.Tests.ps1
    @@ -7,4 +7,8 @@ Describe 'Get-NovaPesterTestResultPath' {
         It 'returns the artifacts/TestResults.xml path under the project root' {
             Get-NovaPesterTestResultPath -ProjectRoot '/p' | Should -Be ([System.IO.Path]::Join('/p', 'artifacts', 'TestResults.xml'))
         }
    +
    +    It 'uses the requested result file name when provided' {
    +        Get-NovaPesterTestResultPath -ProjectRoot '/p' -FileName 'UnitTestResults.xml' | Should -Be ([System.IO.Path]::Join('/p', 'artifacts', 'UnitTestResults.xml'))
    +    }
     }
    diff --git a/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 b/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1
    index 8c4277b..1d4bd7d 100644
    --- a/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1
    +++ b/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1
    @@ -2,8 +2,27 @@ 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) return 'tests' }
    -function Get-NovaPesterTestResultPath {param($ProjectRoot) return (Join-Path $ProjectRoot 'TestResults.xml')}
    +function Get-NovaPesterRunPath {
    +    param($ProjectInfo, $IncludePattern, $ExcludePattern)
    +
    +    $script:lastRunPathRequest = [pscustomobject]@{
    +        ProjectInfo = $ProjectInfo
    +        IncludePattern = $IncludePattern
    +        ExcludePattern = $ExcludePattern
    +    }
    +
    +    return @('tests/Example.Tests.ps1')
    +}
    +function Get-NovaPesterTestResultPath {
    +    param($ProjectRoot, $FileName)
    +
    +    $script:lastResultPathRequest = [pscustomobject]@{
    +        ProjectRoot = $ProjectRoot
    +        FileName = $FileName
    +    }
    +
    +    return (Join-Path $ProjectRoot $FileName)
    +}
     function Initialize-NovaPesterExecutionConfiguration {param($PesterConfig, $BoundParameters, $OutputVerbosity, $OutputRenderMode)}
     function Get-NovaShouldProcessForwardingParameter {param([switch]$WhatIfEnabled) return @{}}
     function Write-NovaPesterTestResultArtifact {}
    @@ -22,14 +41,15 @@ $script:getPesterConfig = {
     $script:getProjectInfo = {
         param(
             [Parameter(Mandatory)][object]$PesterSettings,
    -        [string]$ProjectRoot = '/tmp/nova-project'
    +        [string]$ProjectRoot = (Join-Path $TestDrive 'nova-project')
         )
     
         [pscustomobject]@{
             Pester = $PesterSettings
    -        BuildRecursiveFolders = $false
    -        TestsDir = 'tests'
    +        BuildRecursiveFolders = $true
    +        TestsDir = (Join-Path $ProjectRoot 'tests')
             ProjectRoot = $ProjectRoot
    +        ProjectName = 'NovaProject'
             ModuleFilePSM1 = (Join-Path $ProjectRoot 'dist/TestProject/TestProject.psm1')
         }
     }
    diff --git a/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 b/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1
    index abc97c4..d041b1f 100644
    --- a/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1
    +++ b/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1
    @@ -7,53 +7,62 @@ BeforeAll {
     
     Describe 'Get-NovaTestWorkflowContext' {
         BeforeEach {
    +        $script:lastRunPathRequest = $null
    +        $script:lastResultPathRequest = $null
    +
             Mock Test-ProjectSchema {}
             Mock Get-Module {[pscustomobject]@{Name = 'Pester'}} -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable}
             Mock Get-Command {[pscustomobject]@{ScriptBlock = {}}} -ParameterFilter {$CommandType -eq 'Function'}
         }
     
    -    It 'configures coverage settings for ' -ForEach @(
    -        @{
    -            Name = 'an explicit CoveragePercentTarget'
    -            PesterSettings = [ordered]@{CodeCoverage = [ordered]@{Enabled = $true; CoveragePercentTarget = 99}}
    -            AssertResult = {
    -                param($Result)
    -                $Result.PesterConfig.CodeCoverage.CoveragePercentTarget | Should -Be 99
    -            }
    -        }
    -        @{
    -            Name = 'an omitted CoveragePercentTarget'
    -            PesterSettings = [ordered]@{CodeCoverage = [ordered]@{Enabled = $true}}
    -            AssertResult = {
    -                param($Result)
    -                $Result.PesterConfig.CodeCoverage.CoveragePercentTarget | Should -Be 80
    -            }
    -        }
    -        @{
    -            Name = 'enabled coverage path ownership in project.json'
    -            PesterSettings = [ordered]@{CodeCoverage = [ordered]@{Enabled = $true; CoveragePercentTarget = 90}}
    -            AssertResult = {
    -                param($Result)
    -                $Result.PesterConfig.CodeCoverage.Path | Should -BeNullOrEmpty
    -            }
    -        }
    -        @{
    -            Name = 'disabled coverage'
    -            PesterSettings = [ordered]@{CodeCoverage = [ordered]@{Enabled = $false}}
    -            AssertResult = {
    -                param($Result)
    -                $Result.PesterConfig.CodeCoverage.Path | Should -BeNullOrEmpty
    +    It 'configures unit-test execution with coverage enabled and integration tests excluded' {
    +        $pesterConfig = & $script:getPesterConfig
    +        $projectInfo = & $script:getProjectInfo -PesterSettings ([ordered]@{
    +            CodeCoverage = [ordered]@{
    +                Enabled = $true
    +                CoveragePercentTarget = 99
                 }
    -        }
    -    ) {
    +        })
    +
    +        Mock Get-NovaProjectInfo {$projectInfo}
    +        Mock New-PesterConfiguration {$pesterConfig}
    +
    +        $result = Get-NovaTestWorkflowContext -TestOption @{TestMode = 'Unit'} -BoundParameters @{}
    +
    +        $result.BuildRequested | Should -BeFalse
    +        $result.CommandName | Should -Be 'Invoke-NovaTest'
    +        $result.PesterConfig.CodeCoverage.CoveragePercentTarget | Should -Be 99
    +        $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.Operation | Should -Be 'Run unit tests and write test results'
    +    }
    +
    +    It 'configures build-validation execution with coverage disabled and integration-only test discovery' {
             $pesterConfig = & $script:getPesterConfig
    -        $projectInfo = & $script:getProjectInfo -PesterSettings $PesterSettings
    +        $projectInfo = & $script:getProjectInfo -PesterSettings ([ordered]@{
    +            CodeCoverage = [ordered]@{
    +                Enabled = $true
    +                CoveragePercentTarget = 99
    +            }
    +        })
     
             Mock Get-NovaProjectInfo {$projectInfo}
             Mock New-PesterConfiguration {$pesterConfig}
     
    -        $result = Get-NovaTestWorkflowContext -TestOption @{} -BoundParameters @{}
    -        & $AssertResult $result
    +        $result = Get-NovaTestWorkflowContext -TestOption @{TestMode = 'BuildValidation'} -BoundParameters @{OverrideWarning = $true}
    +
    +        $result.BuildRequested | Should -BeTrue
    +        $result.CommandName | Should -Be 'Test-NovaBuild'
    +        $result.OverrideWarningRequested | Should -BeTrue
    +        $result.PesterConfig.CodeCoverage.Enabled | Should -BeFalse
    +        $result.PesterConfig.CodeCoverage.Path | Should -BeNullOrEmpty
    +        $result.PesterSettings.CodeCoverage.Enabled | Should -BeFalse
    +        $script:lastRunPathRequest.IncludePattern | Should -Be '*.Integration.Tests.ps1'
    +        $script:lastRunPathRequest.ExcludePattern | Should -Be @()
    +        $script:lastResultPathRequest.FileName | Should -Be 'TestResults.xml'
    +        $result.Operation | Should -Be 'Build project, run build-validation integration tests, and write test results'
         }
     
         It 'expands configured coverage paths into concrete project-relative source files' {
    @@ -85,7 +94,7 @@ Describe 'Get-NovaTestWorkflowContext' {
             Mock Get-NovaProjectInfo {$projectInfo}
             Mock New-PesterConfiguration {$pesterConfig}
     
    -        $result = Get-NovaTestWorkflowContext -TestOption @{} -BoundParameters @{}
    +        $result = Get-NovaTestWorkflowContext -TestOption @{TestMode = 'Unit'} -BoundParameters @{}
     
             $result.PesterConfig.CodeCoverage.Path | Should -Be @(
                 'src/public/GetAlpha.ps1'
    @@ -98,11 +107,12 @@ Describe 'Get-NovaTestWorkflowContext' {
     }
     
     Describe 'Get-NovaTestWorkflowOperation' {
    -    It 'mentions the build step when BuildRequested is true' {
    -        Get-NovaTestWorkflowOperation -BuildRequested $true | Should -Match 'Build project'
    +    It 'returns the build-validation operation text' {
    +        Get-NovaTestWorkflowOperation -TestMode 'BuildValidation' | Should -Match 'build-validation integration tests'
         }
    -    It 'omits the build step when BuildRequested is false' {
    -        Get-NovaTestWorkflowOperation -BuildRequested $false | Should -Be 'Run Pester tests and write test results'
    +
    +    It 'returns the unit-test operation text' {
    +        Get-NovaTestWorkflowOperation -TestMode 'Unit' | Should -Be 'Run unit tests and write test results'
         }
     }
     
    @@ -111,16 +121,57 @@ Describe 'Assert-NovaPesterAvailable' {
             Mock Get-Module {@()} -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable}
             {Assert-NovaPesterAvailable} | Should -Throw
         }
    +
         It 'returns silently when Pester is available' {
             Mock Get-Module {@([pscustomobject]@{Name = 'Pester'})} -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable}
             {Assert-NovaPesterAvailable} | Should -Not -Throw
         }
     }
     
    +Describe 'Get-NovaTestWorkflowProfile' {
    +    It 'defaults to the unit-test profile' {
    +        $result = Get-NovaTestWorkflowProfile -TestOption @{}
    +
    +        $result.Mode | Should -Be 'Unit'
    +        $result.CommandName | Should -Be 'Invoke-NovaTest'
    +        $result.CoverageEnabled | Should -BeTrue
    +    }
    +
    +    It 'returns the build-validation profile when requested' {
    +        $result = Get-NovaTestWorkflowProfile -TestOption @{TestMode = 'BuildValidation'}
    +
    +        $result.Mode | Should -Be 'BuildValidation'
    +        $result.CommandName | Should -Be 'Test-NovaBuild'
    +        $result.CoverageEnabled | Should -BeFalse
    +    }
    +}
    +
    +Describe 'Get-NovaTestWorkflowMode' {
    +    It 'defaults to Unit when the option is absent' {
    +        Get-NovaTestWorkflowMode -TestOption @{} | Should -Be 'Unit'
    +    }
    +
    +    It 'returns the requested mode when present' {
    +        Get-NovaTestWorkflowMode -TestOption @{TestMode = 'BuildValidation'} | Should -Be 'BuildValidation'
    +    }
    +}
    +
    +Describe 'Get-NovaTestWorkflowPesterConfiguration' {
    +    It 'returns the project settings unchanged when coverage is enabled' {
    +        $settings = [pscustomobject]@{CodeCoverage = [pscustomobject]@{Enabled = $true}}
    +        Get-NovaTestWorkflowPesterConfiguration -ProjectPesterSettings $settings -CoverageEnabled:$true | Should -Be $settings
    +    }
    +
    +    It 'returns disabled coverage settings for build validation' {
    +        (Get-NovaTestWorkflowPesterConfiguration -ProjectPesterSettings $null -CoverageEnabled:$false).CodeCoverage.Enabled | Should -BeFalse
    +    }
    +}
    +
     Describe 'Get-NovaTestOptionValue' {
         It 'returns the option value when present' {
             Get-NovaTestOptionValue -TestOption @{TagFilter = @('a', 'b')} -Name 'TagFilter' | Should -Be @('a', 'b')
         }
    +
         It 'returns null when the option is absent' {
             Get-NovaTestOptionValue -TestOption @{} -Name 'TagFilter' | Should -BeNullOrEmpty
         }
    @@ -130,9 +181,7 @@ Describe 'Get-NovaConfiguredPesterCoveragePercentTarget' {
         It 'returns null when CodeCoverage is disabled' {
             Get-NovaConfiguredPesterCoveragePercentTarget -ProjectPesterSettings ([ordered]@{CodeCoverage = [ordered]@{Enabled = $false}}) | Should -BeNullOrEmpty
         }
    -    It 'returns null when CoveragePercentTarget is empty' {
    -        Get-NovaConfiguredPesterCoveragePercentTarget -ProjectPesterSettings ([ordered]@{CodeCoverage = [ordered]@{Enabled = $true; CoveragePercentTarget = ''}}) | Should -BeNullOrEmpty
    -    }
    +
         It 'returns the configured value as double when set' {
             Get-NovaConfiguredPesterCoveragePercentTarget -ProjectPesterSettings ([ordered]@{CodeCoverage = [ordered]@{Enabled = $true; CoveragePercentTarget = 99}}) | Should -Be 99.0
         }
    @@ -148,20 +197,64 @@ Describe 'Get-NovaConfiguredPesterCoveragePath' {
         }
     }
     
    +Describe 'Get-NovaDisabledPesterCoverageConfiguration' {
    +    It 'returns disabled coverage settings with empty paths' {
    +        $result = Get-NovaDisabledPesterCoverageConfiguration
    +
    +        $result.Enabled | Should -BeFalse
    +        $result.Path | Should -BeNullOrEmpty
    +        $result.CoveragePercentTarget | Should -BeNullOrEmpty
    +    }
    +}
    +
    +Describe 'Get-NovaPesterCoverageConfigurationState' {
    +    It 'returns disabled coverage settings when coverage is disabled for the workflow' {
    +        $projectInfo = & $script:getProjectInfo -PesterSettings ([ordered]@{
    +            CodeCoverage = [ordered]@{
    +                Enabled = $true
    +                CoveragePercentTarget = 99
    +                Path = @('src/public/*.ps1')
    +            }
    +        })
    +
    +        $result = Get-NovaPesterCoverageConfigurationState -ProjectInfo $projectInfo -CoverageEnabled:$false
    +
    +        $result.Enabled | Should -BeFalse
    +        $result.Path | Should -BeNullOrEmpty
    +        $result.CoveragePercentTarget | Should -BeNullOrEmpty
    +    }
    +
    +    It 'returns configured coverage settings when coverage is enabled' {
    +        $projectRoot = Join-Path $TestDrive 'coverage-values-project'
    +        $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]@{
    +            CodeCoverage = [ordered]@{
    +                Enabled = $true
    +                CoveragePercentTarget = 99
    +                Path = @('src/public/*.ps1')
    +            }
    +        })
    +
    +        $result = Get-NovaPesterCoverageConfigurationState -ProjectInfo $projectInfo -CoverageEnabled:$true
    +
    +        $result.Enabled | Should -BeTrue
    +        $result.CoveragePercentTarget | Should -Be 99.0
    +        $result.Path | Should -Be @('src/public/GetAlpha.ps1')
    +    }
    +}
    +
     Describe 'Get-NovaPesterSettingValue' {
         It 'returns null for null input' {
             Get-NovaPesterSettingValue -InputObject $null -Name 'X' | Should -BeNullOrEmpty
         }
    +
         It 'reads from IDictionary by key' {
             Get-NovaPesterSettingValue -InputObject @{X = 'value'} -Name 'X' | Should -Be 'value'
         }
    -    It 'returns null from IDictionary when key is missing' {
    -        Get-NovaPesterSettingValue -InputObject @{Other = 1} -Name 'X' | Should -BeNullOrEmpty
    -    }
    +
         It 'reads named property from PSCustomObject' {
             Get-NovaPesterSettingValue -InputObject ([pscustomobject]@{X = 'value'}) -Name 'X' | Should -Be 'value'
         }
    -    It 'returns null when PSCustomObject lacks the property' {
    -        Get-NovaPesterSettingValue -InputObject ([pscustomobject]@{Other = 1}) -Name 'X' | Should -BeNullOrEmpty
    -    }
     }
    diff --git a/tests/private/quality/InvokeNovaTestWorkflow.TestSupport.ps1 b/tests/private/quality/InvokeNovaTestWorkflow.TestSupport.ps1
    index 10366f0..cab5bc8 100644
    --- a/tests/private/quality/InvokeNovaTestWorkflow.TestSupport.ps1
    +++ b/tests/private/quality/InvokeNovaTestWorkflow.TestSupport.ps1
    @@ -14,18 +14,24 @@ function Write-Message {param([string]$Text, [string]$color)}
     function Write-Progress {param([string]$Activity, [string]$Status, [int]$PercentComplete, [switch]$Completed)}
     function New-NovaInvokeNovaTestWorkflowContext {
         param(
    -        [hashtable]$PesterSettings = @{},
    -        [hashtable]$WorkflowParams = @{},
    -        [bool]$BuildRequested = $false,
    -        [string]$TestResultDirectory = '/tmp/nova-project/artifacts'
    +        [hashtable]$Option = @{}
         )
     
    +    $pesterSettings = if ($Option.ContainsKey('PesterSettings')) {$Option.PesterSettings} else {@{}}
    +    $workflowParams = if ($Option.ContainsKey('WorkflowParams')) {$Option.WorkflowParams} else {@{}}
    +    $buildRequested = if ($Option.ContainsKey('BuildRequested')) {[bool]$Option.BuildRequested} else {$false}
    +    $testResultDirectory = if ($Option.ContainsKey('TestResultDirectory')) {[string]$Option.TestResultDirectory} else {'/tmp/nova-project/artifacts'}
    +    $commandName = if ($Option.ContainsKey('CommandName')) {[string]$Option.CommandName} else {'Invoke-NovaTest'}
    +    $testResultFileName = if ($Option.ContainsKey('TestResultFileName')) {[string]$Option.TestResultFileName} else {'UnitTestResults.xml'}
    +
         return [pscustomobject]@{
    -        BuildRequested = $BuildRequested
    -        WorkflowParams = $WorkflowParams
    -        ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'; Pester = $PesterSettings}
    -        TestResultDirectory = $TestResultDirectory
    -        TestResultPath = Join-Path $TestResultDirectory 'TestResults.xml'
    +        BuildRequested = $buildRequested
    +        CommandName = $commandName
    +        WorkflowParams = $workflowParams
    +        ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'; Pester = $pesterSettings}
    +        PesterSettings = $pesterSettings
    +        TestResultDirectory = $testResultDirectory
    +        TestResultPath = Join-Path $testResultDirectory $testResultFileName
             PesterConfig = [pscustomobject]@{TestResult = [pscustomobject]@{OutputPath = $null}}
             TestResultArtifactWriter = [pscustomobject]@{ScriptBlock = {}}
             TestResultReportWriter = [pscustomobject]@{ScriptBlock = {}}
    diff --git a/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1 b/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1
    index e8ca414..eed4cc4 100644
    --- a/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1
    +++ b/tests/private/quality/InvokeNovaTestWorkflow.Tests.ps1
    @@ -17,7 +17,8 @@ Describe 'Invoke-NovaTestWorkflow' {
             $workflowContext = [pscustomobject]@{
                 ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'; Pester = [ordered]@{}}
                 TestResultDirectory = '/tmp/nova-project/artifacts'
    -            TestResultPath = '/tmp/nova-project/artifacts/TestResults.xml'
    +            CommandName = 'Invoke-NovaTest'
    +            TestResultPath = '/tmp/nova-project/artifacts/UnitTestResults.xml'
                 PesterConfig = [pscustomobject]@{TestResult = [pscustomobject]@{OutputPath = $null}}
                 TestResultArtifactWriter = [pscustomobject]@{ScriptBlock = {}}
                 TestResultReportWriter = [pscustomobject]@{ScriptBlock = {}}
    @@ -58,14 +59,14 @@ Describe 'Invoke-NovaTestWorkflow' {
             @{
                 Name = 'coverage below target'
                 PesterResult = [pscustomobject]@{Result = 'Passed'; CodeCoverage = [pscustomobject]@{CoveragePercent = 66.67}}
    -            ExpectedMessage = 'Code coverage 66.67% did not meet the configured target 90%. Review the failing tests or coverage settings, then rerun Test-NovaBuild.'
    +            ExpectedMessage = 'Code coverage 66.67% did not meet the configured target 90%. Review the failing tests or coverage settings, then rerun Invoke-NovaTest.'
                 ExpectedErrorId = 'Nova.Workflow.CodeCoverageTargetNotMet'
                 ExpectedCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation
             }
             @{
                 Name = 'missing measured coverage'
                 PesterResult = [pscustomobject]@{Result = 'Passed'}
    -            ExpectedMessage = 'Code coverage target 90% is configured, but the Pester result did not include a coverage percentage. Review the coverage settings in project.json and the test result file at /tmp/nova-project/artifacts/TestResults.xml.'
    +            ExpectedMessage = 'Code coverage target 90% is configured, but the Pester result did not include a coverage percentage. Review the coverage settings in project.json and the test result file at /tmp/nova-project/artifacts/UnitTestResults.xml.'
                 ExpectedErrorId = 'Nova.Workflow.CodeCoveragePercentMissing'
                 ExpectedCategory = [System.Management.Automation.ErrorCategory]::InvalidData
             }
    @@ -76,7 +77,8 @@ Describe 'Invoke-NovaTestWorkflow' {
                     Pester = [ordered]@{CodeCoverage = [ordered]@{Enabled = $true; CoveragePercentTarget = 90}}
                 }
                 TestResultDirectory = '/tmp/nova-project/artifacts'
    -            TestResultPath = '/tmp/nova-project/artifacts/TestResults.xml'
    +            CommandName = 'Invoke-NovaTest'
    +            TestResultPath = '/tmp/nova-project/artifacts/UnitTestResults.xml'
                 PesterConfig = [pscustomobject]@{TestResult = [pscustomobject]@{OutputPath = $null}}
                 TestResultArtifactWriter = [pscustomobject]@{ScriptBlock = {}}
                 TestResultReportWriter = [pscustomobject]@{ScriptBlock = {}}
    @@ -92,14 +94,15 @@ Describe 'Invoke-NovaTestWorkflow' {
             $thrown.Exception.Message | Should -Be $ExpectedMessage
             $thrown.FullyQualifiedErrorId | Should -Be $ExpectedErrorId
             $thrown.CategoryInfo.Category | Should -Be $ExpectedCategory
    -        $thrown.TargetObject | Should -Be '/tmp/nova-project/artifacts/TestResults.xml'
    +        $thrown.TargetObject | Should -Be '/tmp/nova-project/artifacts/UnitTestResults.xml'
         }
     
         It 'does not enforce a coverage threshold when project.json does not configure one' {
             $workflowContext = [pscustomobject]@{
                 ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'; Pester = [ordered]@{}}
                 TestResultDirectory = '/tmp/nova-project/artifacts'
    -            TestResultPath = '/tmp/nova-project/artifacts/TestResults.xml'
    +            CommandName = 'Invoke-NovaTest'
    +            TestResultPath = '/tmp/nova-project/artifacts/UnitTestResults.xml'
                 PesterConfig = [pscustomobject]@{TestResult = [pscustomobject]@{OutputPath = $null}}
                 TestResultArtifactWriter = [pscustomobject]@{ScriptBlock = {}}
                 TestResultReportWriter = [pscustomobject]@{ScriptBlock = {}}
    @@ -111,7 +114,7 @@ Describe 'Invoke-NovaTestWorkflow' {
             }
     
             {Invoke-NovaTestWorkflow -WorkflowContext $workflowContext} | Should -Not -Throw
    -        $workflowContext.PesterConfig.TestResult.OutputPath | Should -Be '/tmp/nova-project/artifacts/TestResults.xml'
    +        $workflowContext.PesterConfig.TestResult.OutputPath | Should -Be '/tmp/nova-project/artifacts/UnitTestResults.xml'
             Assert-MockCalled Write-Message -Times 5
             Assert-MockCalled Write-Message -Times 1 -ParameterFilter {
                 $Text -eq 'Measured code coverage: 10%'
    @@ -195,7 +198,10 @@ Describe 'Invoke-NovaTestWorkflow' {
                 ExpectedMessageCount = 0
             }
         ) {
    -        $workflowContext = New-NovaInvokeNovaTestWorkflowContext -BuildRequested $BuildRequested -WorkflowParams $WorkflowParams
    +        $workflowContext = New-NovaInvokeNovaTestWorkflowContext -Option @{
    +            BuildRequested = $BuildRequested
    +            WorkflowParams = $WorkflowParams
    +        }
             Mock Invoke-NovaBuild {}
             Mock Test-Path {$true}
             Mock Invoke-NovaPesterWithSuppressedProgress {$PesterResult}
    @@ -216,7 +222,7 @@ Describe 'Invoke-NovaTestWorkflow' {
                 $thrown | Should -Not -BeNullOrEmpty
                 $thrown.FullyQualifiedErrorId | Should -Be $ExpectedErrorId
                 if ($ExpectedErrorId -eq 'Nova.Workflow.TestRunFailed') {
    -                $thrown.Exception.Message | Should -Be 'Pester reported one or more failing tests. Review the output above and the test result file at /tmp/nova-project/artifacts/TestResults.xml, then rerun Test-NovaBuild.'
    +                $thrown.Exception.Message | Should -Be 'Pester reported one or more failing tests. Review the output above and the test result file at /tmp/nova-project/artifacts/UnitTestResults.xml, then rerun Invoke-NovaTest.'
                 }
             }
             else {
    @@ -233,7 +239,8 @@ Describe 'Invoke-NovaTestWorkflow' {
             $workflowContext = [pscustomobject]@{
                 ProjectInfo = [pscustomobject]@{ProjectName = 'NovaModuleTools'; Pester = [ordered]@{}}
                 TestResultDirectory = $tempDir
    -            TestResultPath = (Join-Path $tempDir 'TestResults.xml')
    +            CommandName = 'Invoke-NovaTest'
    +            TestResultPath = (Join-Path $tempDir 'UnitTestResults.xml')
                 PesterConfig = [pscustomobject]@{TestResult = [pscustomobject]@{OutputPath = $null}}
                 TestResultArtifactWriter = [pscustomobject]@{ScriptBlock = {}}
                 TestResultReportWriter = [pscustomobject]@{ScriptBlock = {}}
    @@ -248,10 +255,14 @@ Describe 'Invoke-NovaTestWorkflow' {
         }
     
         It 'writes a test plan summary in WhatIf mode after previewing the nested build step' {
    -        $workflowContext = New-NovaInvokeNovaTestWorkflowContext -BuildRequested $true -WorkflowParams @{WhatIf = $true} -PesterSettings @{
    -            CodeCoverage = [ordered]@{
    -                Enabled = $true
    -                CoveragePercentTarget = 99
    +        $workflowContext = New-NovaInvokeNovaTestWorkflowContext -Option @{
    +            BuildRequested = $true
    +            WorkflowParams = @{WhatIf = $true}
    +            PesterSettings = @{
    +                CodeCoverage = [ordered]@{
    +                    Enabled = $true
    +                    CoveragePercentTarget = 99
    +                }
                 }
             }
             Mock Invoke-NovaBuild {}
    @@ -274,7 +285,7 @@ Describe 'Invoke-NovaTestWorkflow' {
                 $Text -eq 'Configured coverage target: 99%'
             }
             Assert-MockCalled Write-Message -Times 1 -ParameterFilter {
    -            $Text -eq 'Run Test-NovaBuild without -WhatIf when you are ready to execute the test workflow.'
    +            $Text -eq 'Run Invoke-NovaTest without -WhatIf when you are ready to execute the test workflow.'
             }
         }
     
    diff --git a/tests/private/quality/NewNovaTestDynamicParameterDictionary.Tests.ps1 b/tests/private/quality/NewNovaTestDynamicParameterDictionary.Tests.ps1
    deleted file mode 100644
    index 0327b33..0000000
    --- a/tests/private/quality/NewNovaTestDynamicParameterDictionary.Tests.ps1
    +++ /dev/null
    @@ -1,19 +0,0 @@
    -BeforeAll {
    -    $projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
    -    . (Join-Path $projectRoot 'src/private/quality/NewNovaTestDynamicParameterDictionary.ps1')
    -}
    -
    -Describe 'New-NovaTestDynamicParameterDictionary' {
    -    It 'returns a RuntimeDefinedParameterDictionary' {
    -        $result = New-NovaTestDynamicParameterDictionary
    -        $result | Should -BeOfType [System.Management.Automation.RuntimeDefinedParameterDictionary]
    -    }
    -
    -    It 'exposes the expected switch parameters' {
    -        $result = New-NovaTestDynamicParameterDictionary
    -        $result.Keys | Should -Contain 'Build'
    -        $result.Keys | Should -Contain 'OverrideWarning'
    -        $result['Build'].ParameterType | Should -Be ([switch])
    -        $result['OverrideWarning'].ParameterType | Should -Be ([switch])
    -    }
    -}
    diff --git a/tests/private/release/InvokeNovaReleaseWorkflow.TestSupport.ps1 b/tests/private/release/InvokeNovaReleaseWorkflow.TestSupport.ps1
    index c9b2a69..5f0a7e6 100644
    --- a/tests/private/release/InvokeNovaReleaseWorkflow.TestSupport.ps1
    +++ b/tests/private/release/InvokeNovaReleaseWorkflow.TestSupport.ps1
    @@ -18,10 +18,16 @@ function Invoke-NovaBuild {
         $script:buildCalls += 1
     }
     
    +function Invoke-NovaTest {
    +    param()
    +
    +    $script:unitTestCalls += 1
    +}
    +
     function Test-NovaBuild {
         param()
     
    -    $script:testCalls += 1
    +    $script:integrationTestCalls += 1
     }
     
     function Update-NovaModuleVersion {
    diff --git a/tests/private/release/InvokeNovaReleaseWorkflow.Tests.ps1 b/tests/private/release/InvokeNovaReleaseWorkflow.Tests.ps1
    index deadf18..0821c2e 100644
    --- a/tests/private/release/InvokeNovaReleaseWorkflow.Tests.ps1
    +++ b/tests/private/release/InvokeNovaReleaseWorkflow.Tests.ps1
    @@ -67,7 +67,8 @@ Describe 'Invoke-NovaReleaseWorkflow' {
         BeforeEach {
             . (Join-Path $projectRoot 'src/private/release/InvokeNovaReleaseWorkflow.ps1')
             $script:buildCalls = 0
    -        $script:testCalls = 0
    +        $script:unitTestCalls = 0
    +        $script:integrationTestCalls = 0
             $script:versionCalls = 0
             $script:restoreCalls = 0
             $script:publishCalls = 0
    @@ -95,7 +96,8 @@ Describe 'Invoke-NovaReleaseWorkflow' {
             $r = Invoke-NovaReleaseWorkflow -WorkflowContext $ctx
             $r.Version | Should -Be '1.0.0'
             $script:buildCalls | Should -Be 2
    -        $script:testCalls | Should -Be 1
    +        $script:unitTestCalls | Should -Be 1
    +        $script:integrationTestCalls | Should -Be 1
             $script:versionCalls | Should -Be 1
             $script:publishCalls | Should -Be 1
             $script:restoreCalls | Should -Be 1
    @@ -133,7 +135,8 @@ Describe 'Invoke-NovaReleaseWorkflow' {
                 SkipTestsRequested = $true
             }
             $null = Invoke-NovaReleaseWorkflow -WorkflowContext $ctx
    -        $script:testCalls | Should -Be 0
    +        $script:unitTestCalls | Should -Be 0
    +        $script:integrationTestCalls | Should -Be 0
             $script:restoreCalls | Should -Be 0
             Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {
                 $Status -eq 'Publishing release to the local module path' -and $PercentComplete -eq 90
    diff --git a/tests/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.Tests.ps1 b/tests/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.Tests.ps1
    index 90bede5..9969b59 100644
    --- a/tests/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.Tests.ps1
    +++ b/tests/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.Tests.ps1
    @@ -117,10 +117,13 @@ Describe 'Invoke-NovaAgenticCopilotScaffoldWorkflow' {
                 $Status -eq 'Refreshing managed scaffold files' -and $PercentComplete -eq 75
             }
             Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed}
    -        Assert-MockCalled Write-Message -Times 5
    +        Assert-MockCalled Write-Message -Times 6
             Assert-MockCalled Write-Message -Times 1 -ParameterFilter {
                 $Message -eq 'Agentic Copilot scaffold applied to Demo' -and $color -eq 'Green'
             }
    +        Assert-MockCalled Write-Message -Times 1 -ParameterFilter {
    +            $Message -eq 'Invoke-NovaTest'
    +        }
             Assert-MockCalled Write-Message -Times 1 -ParameterFilter {
                 $Message -eq 'Test-NovaBuild'
             }
    diff --git a/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1 b/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1
    index 70f2bd3..5b68780 100644
    --- a/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1
    +++ b/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.Tests.ps1
    @@ -71,7 +71,7 @@ Describe 'Invoke-NovaModuleInitializationWorkflow' {
             Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed}
         }
     
    -    It 'suggests Test-NovaBuild as the next step for the example scaffold' {
    +    It 'suggests unit and build-validation tests as the next steps for the example scaffold' {
             Mock Initialize-NovaModuleScaffold {}
             Mock Write-NovaModuleProjectJson {}
             Mock Write-NovaVsCodeSettings {}
    @@ -88,8 +88,7 @@ Describe 'Invoke-NovaModuleInitializationWorkflow' {
             Invoke-NovaModuleInitializationWorkflow -WorkflowContext $exampleContext
     
             Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed}
    -        Assert-MockCalled Write-Message -Times 1 -ParameterFilter {
    -            $InputObject -eq 'Test-NovaBuild'
    -        }
    +        Assert-MockCalled Write-Message -Times 1 -ParameterFilter {$InputObject -eq 'Invoke-NovaTest'}
    +        Assert-MockCalled Write-Message -Times 1 -ParameterFilter {$InputObject -eq 'Test-NovaBuild'}
         }
     }
    diff --git a/tests/private/shared/InvokeNovaBuildValidation.Tests.ps1 b/tests/private/shared/InvokeNovaBuildValidation.Tests.ps1
    index 22998f9..e0a851a 100644
    --- a/tests/private/shared/InvokeNovaBuildValidation.Tests.ps1
    +++ b/tests/private/shared/InvokeNovaBuildValidation.Tests.ps1
    @@ -6,30 +6,34 @@ BeforeAll {
             return $WorkflowParams
         }
         function Invoke-NovaBuild {param()}
    +    function Invoke-NovaTest {param()}
         function Test-NovaBuild {param()}
     }
     
     Describe 'Invoke-NovaBuildValidation' {
         BeforeEach {
             Mock Invoke-NovaBuild {}
    +        Mock Invoke-NovaTest {}
             Mock Test-NovaBuild {}
         }
     
    -    It 'invokes both build and test by default' {
    +    It 'invokes build, unit tests, and build validation by default' {
             $context = [pscustomobject]@{WorkflowParams = @{Path = '/p'}}
     
             Invoke-NovaBuildValidation -WorkflowContext $context
     
             Assert-MockCalled Invoke-NovaBuild -Times 1
    +        Assert-MockCalled Invoke-NovaTest -Times 1
             Assert-MockCalled Test-NovaBuild -Times 1
         }
     
    -    It 'skips Test-NovaBuild when SkipTestsRequested is true' {
    +    It 'skips both test commands when SkipTestsRequested is true' {
             $context = [pscustomobject]@{WorkflowParams = @{Path = '/p'}; SkipTestsRequested = $true}
     
             Invoke-NovaBuildValidation -WorkflowContext $context
     
             Assert-MockCalled Invoke-NovaBuild -Times 1
    +        Assert-MockCalled Invoke-NovaTest -Times 0
             Assert-MockCalled Test-NovaBuild -Times 0
         }
     }
    diff --git a/tests/private/shared/NewNovaDynamicSkipTestsParameterDictionary.Tests.ps1 b/tests/private/shared/NewNovaDynamicSkipTestsParameterDictionary.Tests.ps1
    index f8636a6..d5acc2a 100644
    --- a/tests/private/shared/NewNovaDynamicSkipTestsParameterDictionary.Tests.ps1
    +++ b/tests/private/shared/NewNovaDynamicSkipTestsParameterDictionary.Tests.ps1
    @@ -19,20 +19,20 @@ Describe 'Get-NovaDynamicParameterAttributeCollection' {
         }
     }
     
    -Describe 'Add-NovaDynamic*Parameter' {
    +Describe 'Add-NovaDynamicTypedParameter' {
         It 'adds a switch parameter with the right type' {
             $dict = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
    -        Add-NovaDynamicSwitchParameter -ParameterDictionary $dict -Name 'X'
    +        Add-NovaDynamicTypedParameter -ParameterDictionary $dict -Name 'X' -ParameterType ([switch])
             $dict['X'].ParameterType | Should -Be ([switch])
         }
         It 'adds a string parameter with the right type' {
             $dict = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
    -        Add-NovaDynamicStringParameter -ParameterDictionary $dict -Name 'Y' -ParameterSetNameList @('S')
    +        Add-NovaDynamicTypedParameter -ParameterDictionary $dict -Name 'Y' -ParameterType ([string]) -ParameterSetNameList @('S')
             $dict['Y'].ParameterType | Should -Be ([string])
         }
         It 'adds a hashtable parameter with the right type' {
             $dict = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
    -        Add-NovaDynamicHashtableParameter -ParameterDictionary $dict -Name 'Z' -Mandatory
    +        Add-NovaDynamicTypedParameter -ParameterDictionary $dict -Name 'Z' -ParameterType ([hashtable]) -Mandatory
             $dict['Z'].ParameterType | Should -Be ([hashtable])
         }
     }
    @@ -46,6 +46,14 @@ Describe 'Get-NovaDynamicDeliveryParameterDictionary' {
         }
     }
     
    +Describe 'Get-NovaDynamicOverrideWarningParameterDictionary' {
    +    It 'contains only the OverrideWarning switch' {
    +        $dict = Get-NovaDynamicOverrideWarningParameterDictionary
    +        $dict.Keys | Should -Be @('OverrideWarning')
    +        $dict['OverrideWarning'].ParameterType | Should -Be ([switch])
    +    }
    +}
    +
     Describe 'Get-NovaDynamicReleaseParameterDictionary' {
         It 'extends the delivery dictionary with Path and PublishOption' {
             $dict = Get-NovaDynamicReleaseParameterDictionary
    diff --git a/tests/public/DeployNovaPackage.Integration.Tests.ps1 b/tests/public/DeployNovaPackage.Integration.Tests.ps1
    new file mode 100644
    index 0000000..fa476b2
    --- /dev/null
    +++ b/tests/public/DeployNovaPackage.Integration.Tests.ps1
    @@ -0,0 +1,18 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Deploy-NovaPackage integration' {
    +    It 'supports WhatIf from the built module' {
    +        $packagePath = Join-Path $TestDrive 'NovaModuleTools.0.0.0.nupkg'
    +        Set-Content -LiteralPath $packagePath -Value 'placeholder'
    +
    +        $result = Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock {
    +            Deploy-NovaPackage -PackagePath $packagePath -Url 'https://example.test' -WhatIf
    +        }
    +
    +        @($result).Count | Should -Be 0
    +    }
    +}
    diff --git a/tests/public/GetNovaProjectInfo.Integration.Tests.ps1 b/tests/public/GetNovaProjectInfo.Integration.Tests.ps1
    new file mode 100644
    index 0000000..e9597e5
    --- /dev/null
    +++ b/tests/public/GetNovaProjectInfo.Integration.Tests.ps1
    @@ -0,0 +1,15 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Get-NovaProjectInfo integration' {
    +    It 'returns project metadata from the built module' {
    +        $result = Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock {
    +            Get-NovaProjectInfo
    +        }
    +
    +        $result.ProjectName | Should -Be 'NovaModuleTools'
    +    }
    +}
    diff --git a/tests/public/GetNovaUpdateNotificationPreference.Integration.Tests.ps1 b/tests/public/GetNovaUpdateNotificationPreference.Integration.Tests.ps1
    new file mode 100644
    index 0000000..9ad6aad
    --- /dev/null
    +++ b/tests/public/GetNovaUpdateNotificationPreference.Integration.Tests.ps1
    @@ -0,0 +1,38 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Get-NovaUpdateNotificationPreference integration' {
    +    It 'returns the default preference from an isolated settings root in the built module' {
    +        $isolatedSettingsRoot = Join-Path $TestDrive 'config-root'
    +        $expectedSettingsPath = Join-Path (Join-Path $isolatedSettingsRoot 'NovaModuleTools') 'settings.json'
    +        $originalAppData = $env:APPDATA
    +        $originalXdgConfigHome = $env:XDG_CONFIG_HOME
    +
    +        New-Item -ItemType Directory -Path $isolatedSettingsRoot -Force | Out-Null
    +        $env:APPDATA = $isolatedSettingsRoot
    +        $env:XDG_CONFIG_HOME = $isolatedSettingsRoot
    +
    +        try {
    +            $result = Get-NovaUpdateNotificationPreference
    +        } finally {
    +            if ($null -eq $originalAppData) {
    +                Remove-Item Env:APPDATA -ErrorAction SilentlyContinue
    +            } else {
    +                $env:APPDATA = $originalAppData
    +            }
    +
    +            if ($null -eq $originalXdgConfigHome) {
    +                Remove-Item Env:XDG_CONFIG_HOME -ErrorAction SilentlyContinue
    +            } else {
    +                $env:XDG_CONFIG_HOME = $originalXdgConfigHome
    +            }
    +        }
    +
    +        $result.PrereleaseNotificationsEnabled | Should -BeTrue
    +        $result.StableReleaseNotificationsEnabled | Should -BeTrue
    +        $result.SettingsPath | Should -Be $expectedSettingsPath
    +    }
    +}
    diff --git a/tests/public/InitializeNovaModule.Integration.Tests.ps1 b/tests/public/InitializeNovaModule.Integration.Tests.ps1
    new file mode 100644
    index 0000000..df737dc
    --- /dev/null
    +++ b/tests/public/InitializeNovaModule.Integration.Tests.ps1
    @@ -0,0 +1,15 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Initialize-NovaModule integration' {
    +    It 'exposes the scaffold entrypoint from the built module' {
    +        $command = Get-Command -Name 'Initialize-NovaModule'
    +
    +        $command.Parameters.Keys | Should -Contain 'Path'
    +        $command.Parameters.Keys | Should -Contain 'Example'
    +        $command.CmdletBinding | Should -BeTrue
    +    }
    +}
    diff --git a/tests/public/InstallNovaCli.Integration.Tests.ps1 b/tests/public/InstallNovaCli.Integration.Tests.ps1
    new file mode 100644
    index 0000000..578daac
    --- /dev/null
    +++ b/tests/public/InstallNovaCli.Integration.Tests.ps1
    @@ -0,0 +1,17 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Install-NovaCli integration' {
    +    It 'supports WhatIf for a destination directory from the built module' {
    +        $destinationDirectory = Join-Path $TestDrive 'bin'
    +
    +        {
    +            Install-NovaCli -DestinationDirectory $destinationDirectory -WhatIf
    +        } | Should -Not -Throw
    +
    +        (Test-Path -LiteralPath (Join-Path $destinationDirectory 'nova')) | Should -BeFalse
    +    }
    +}
    diff --git a/tests/public/InvokeNovaAgenticCopilotScaffold.Integration.Tests.ps1 b/tests/public/InvokeNovaAgenticCopilotScaffold.Integration.Tests.ps1
    new file mode 100644
    index 0000000..967a057
    --- /dev/null
    +++ b/tests/public/InvokeNovaAgenticCopilotScaffold.Integration.Tests.ps1
    @@ -0,0 +1,15 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Invoke-NovaAgenticCopilotScaffold integration' {
    +    It 'supports WhatIf for a valid Nova project from the built module' {
    +        {
    +            Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock {
    +                Invoke-NovaAgenticCopilotScaffold -Path $script:projectRoot -ShortName 'NMT' -WhatIf
    +            }
    +        } | Should -Not -Throw
    +    }
    +}
    diff --git a/tests/public/InvokeNovaBuild.Integration.Tests.ps1 b/tests/public/InvokeNovaBuild.Integration.Tests.ps1
    new file mode 100644
    index 0000000..13eb0e5
    --- /dev/null
    +++ b/tests/public/InvokeNovaBuild.Integration.Tests.ps1
    @@ -0,0 +1,15 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Invoke-NovaBuild integration' {
    +    It 'supports WhatIf from the built module' {
    +        {
    +            Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock {
    +                Invoke-NovaBuild -WhatIf
    +            }
    +        } | Should -Not -Throw
    +    }
    +}
    diff --git a/tests/public/InvokeNovaCli.Integration.Tests.ps1 b/tests/public/InvokeNovaCli.Integration.Tests.ps1
    new file mode 100644
    index 0000000..4b45c7a
    --- /dev/null
    +++ b/tests/public/InvokeNovaCli.Integration.Tests.ps1
    @@ -0,0 +1,13 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Invoke-NovaCli integration' {
    +    It 'returns root help from the built module' {
    +        $result = Invoke-NovaCli
    +
    +        $result | Should -Match 'nova'
    +    }
    +}
    diff --git a/tests/public/InvokeNovaRelease.Integration.Tests.ps1 b/tests/public/InvokeNovaRelease.Integration.Tests.ps1
    new file mode 100644
    index 0000000..1d97cb4
    --- /dev/null
    +++ b/tests/public/InvokeNovaRelease.Integration.Tests.ps1
    @@ -0,0 +1,15 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Invoke-NovaRelease integration' {
    +    It 'supports local WhatIf from the built module' {
    +        {
    +            Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock {
    +                Invoke-NovaRelease -Local -WhatIf
    +            }
    +        } | Should -Not -Throw
    +    }
    +}
    diff --git a/tests/public/InvokeNovaTest.Integration.Tests.ps1 b/tests/public/InvokeNovaTest.Integration.Tests.ps1
    new file mode 100644
    index 0000000..479098a
    --- /dev/null
    +++ b/tests/public/InvokeNovaTest.Integration.Tests.ps1
    @@ -0,0 +1,15 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Invoke-NovaTest integration' {
    +    It 'supports WhatIf from the built module' {
    +        {
    +            Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock {
    +                Invoke-NovaTest -WhatIf
    +            }
    +        } | Should -Not -Throw
    +    }
    +}
    diff --git a/tests/public/InvokeNovaTest.Tests.ps1 b/tests/public/InvokeNovaTest.Tests.ps1
    new file mode 100644
    index 0000000..a3e5ecd
    --- /dev/null
    +++ b/tests/public/InvokeNovaTest.Tests.ps1
    @@ -0,0 +1,53 @@
    +BeforeAll {
    +    $projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $projectRoot 'src/public/InvokeNovaTest.ps1')
    +
    +    function Get-NovaDynamicOverrideWarningParameterDictionary {
    +        $dictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
    +        $attributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
    +        $attributeCollection.Add([System.Management.Automation.ParameterAttribute]::new())
    +        $runtimeParameter = [System.Management.Automation.RuntimeDefinedParameter]::new('OverrideWarning', [switch], $attributeCollection)
    +        $dictionary.Add('OverrideWarning', $runtimeParameter)
    +        return $dictionary
    +    }
    +
    +    function Get-NovaTestWorkflowContext {
    +        param($TestOption, $BoundParameters)
    +
    +        $script:testOption = $TestOption
    +        $script:boundParameters = $BoundParameters
    +        return [pscustomobject]@{Target = '/proj'; Operation = 'Test'}
    +    }
    +
    +    function Invoke-NovaTestWorkflow {
    +        param($WorkflowContext, [switch]$ShouldRun)
    +
    +        $script:shouldRun = [bool]$ShouldRun
    +    }
    +}
    +
    +Describe 'Invoke-NovaTest' {
    +    BeforeEach {
    +        $script:testOption = $null
    +        $script:boundParameters = $null
    +        $script:shouldRun = $null
    +    }
    +
    +    It 'forwards unit-test options to the workflow context' {
    +        Invoke-NovaTest -OverrideWarning -TagFilter 'fast' -ExcludeTagFilter 'integration' -OutputVerbosity 'Detailed'
    +
    +        $script:testOption.TestMode | Should -Be 'Unit'
    +        $script:testOption.TagFilter | Should -Be @('fast')
    +        $script:testOption.ExcludeTagFilter | Should -Be @('integration')
    +        $script:testOption.OutputVerbosity | Should -Be 'Detailed'
    +        $script:boundParameters.OverrideWarning | Should -BeTrue
    +        $script:shouldRun | Should -BeTrue
    +    }
    +
    +    It 'invokes the workflow with ShouldRun=$false when -WhatIf is set' {
    +        Invoke-NovaTest -WhatIf | Out-Null
    +
    +        $script:boundParameters.WhatIf | Should -BeTrue
    +        $script:shouldRun | Should -BeFalse
    +    }
    +}
    diff --git a/tests/public/NewNovaModulePackage.Integration.Tests.ps1 b/tests/public/NewNovaModulePackage.Integration.Tests.ps1
    new file mode 100644
    index 0000000..b64ead8
    --- /dev/null
    +++ b/tests/public/NewNovaModulePackage.Integration.Tests.ps1
    @@ -0,0 +1,15 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'New-NovaModulePackage integration' {
    +    It 'supports WhatIf from the built module' {
    +        {
    +            Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock {
    +                New-NovaModulePackage -WhatIf
    +            }
    +        } | Should -Not -Throw
    +    }
    +}
    diff --git a/tests/public/PublishNovaModule.Integration.Tests.ps1 b/tests/public/PublishNovaModule.Integration.Tests.ps1
    new file mode 100644
    index 0000000..3d9440e
    --- /dev/null
    +++ b/tests/public/PublishNovaModule.Integration.Tests.ps1
    @@ -0,0 +1,15 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Publish-NovaModule integration' {
    +    It 'supports local WhatIf from the built module' {
    +        {
    +            Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock {
    +                Publish-NovaModule -Local -WhatIf
    +            }
    +        } | Should -Not -Throw
    +    }
    +}
    diff --git a/tests/public/SetNovaUpdateNotificationPreference.Integration.Tests.ps1 b/tests/public/SetNovaUpdateNotificationPreference.Integration.Tests.ps1
    new file mode 100644
    index 0000000..d6d9d6b
    --- /dev/null
    +++ b/tests/public/SetNovaUpdateNotificationPreference.Integration.Tests.ps1
    @@ -0,0 +1,13 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Set-NovaUpdateNotificationPreference integration' {
    +    It 'supports WhatIf from the built module' {
    +        {
    +            Set-NovaUpdateNotificationPreference -EnablePrereleaseNotifications -WhatIf
    +        } | Should -Not -Throw
    +    }
    +}
    diff --git a/tests/public/TestNovaBuild.Integration.Tests.ps1 b/tests/public/TestNovaBuild.Integration.Tests.ps1
    new file mode 100644
    index 0000000..906b926
    --- /dev/null
    +++ b/tests/public/TestNovaBuild.Integration.Tests.ps1
    @@ -0,0 +1,15 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Test-NovaBuild integration' {
    +    It 'supports WhatIf from the built module' {
    +        {
    +            Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock {
    +                Test-NovaBuild -WhatIf
    +            }
    +        } | Should -Not -Throw
    +    }
    +}
    diff --git a/tests/public/TestNovaBuild.Tests.ps1 b/tests/public/TestNovaBuild.Tests.ps1
    index d7bf8a2..88af35c 100644
    --- a/tests/public/TestNovaBuild.Tests.ps1
    +++ b/tests/public/TestNovaBuild.Tests.ps1
    @@ -2,29 +2,51 @@ BeforeAll {
         $projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
         . (Join-Path $projectRoot 'src/public/TestNovaBuild.ps1')
     
    -    function New-NovaTestDynamicParameterDictionary {return New-Object 'System.Management.Automation.RuntimeDefinedParameterDictionary'}
    -    function Get-NovaTestWorkflowContext {param($TestOption, $BoundParameters)
    +    function Get-NovaDynamicOverrideWarningParameterDictionary {
    +        $dictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
    +        $attributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
    +        $attributeCollection.Add([System.Management.Automation.ParameterAttribute]::new())
    +        $runtimeParameter = [System.Management.Automation.RuntimeDefinedParameter]::new('OverrideWarning', [switch], $attributeCollection)
    +        $dictionary.Add('OverrideWarning', $runtimeParameter)
    +        return $dictionary
    +    }
    +
    +    function Get-NovaTestWorkflowContext {
    +        param($TestOption, $BoundParameters)
    +
             $script:testOption = $TestOption
    -        return [pscustomobject]@{Target='/proj'; Operation='Test'}
    +        $script:boundParameters = $BoundParameters
    +        return [pscustomobject]@{Target = '/proj'; Operation = 'Test'}
    +    }
    +
    +    function Invoke-NovaTestWorkflow {
    +        param($WorkflowContext, [switch]$ShouldRun)
    +
    +        $script:shouldRun = [bool]$ShouldRun
         }
    -    function Invoke-NovaTestWorkflow {param($WorkflowContext, [switch]$ShouldRun) $script:shouldRun = [bool]$ShouldRun}
     }
     
     Describe 'Test-NovaBuild' {
         BeforeEach {
             $script:testOption = $null
    +        $script:boundParameters = $null
             $script:shouldRun = $null
         }
     
    -    It 'forwards filter and verbosity parameters to the workflow context' {
    -        Test-NovaBuild -TagFilter 'fast' -OutputVerbosity 'Detailed'
    +    It 'forwards build-validation options to the workflow context' {
    +        Test-NovaBuild -OverrideWarning -TagFilter 'fast' -OutputVerbosity 'Detailed'
    +
    +        $script:testOption.TestMode | Should -Be 'BuildValidation'
             $script:testOption.TagFilter | Should -Be @('fast')
             $script:testOption.OutputVerbosity | Should -Be 'Detailed'
    +        $script:boundParameters.OverrideWarning | Should -BeTrue
             $script:shouldRun | Should -BeTrue
         }
     
         It 'invokes the workflow with ShouldRun=$false when -WhatIf is set' {
             Test-NovaBuild -WhatIf | Out-Null
    +
    +        $script:boundParameters.WhatIf | Should -BeTrue
             $script:shouldRun | Should -BeFalse
         }
     }
    diff --git a/tests/public/UpdateNovaModuleTools.Integration.Tests.ps1 b/tests/public/UpdateNovaModuleTools.Integration.Tests.ps1
    new file mode 100644
    index 0000000..6532cb9
    --- /dev/null
    +++ b/tests/public/UpdateNovaModuleTools.Integration.Tests.ps1
    @@ -0,0 +1,17 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Update-NovaModuleTool integration' {
    +    It 'exports the legacy alias from the built module' {
    +        (Get-Alias -Name 'Update-NovaModuleTools').Definition | Should -Be 'Update-NovaModuleTool'
    +    }
    +
    +    It 'supports WhatIf from the built module' {
    +        {
    +            Update-NovaModuleTool -WhatIf | Out-Null
    +        } | Should -Not -Throw
    +    }
    +}
    diff --git a/tests/public/UpdateNovaModuleVersion.Integration.Tests.ps1 b/tests/public/UpdateNovaModuleVersion.Integration.Tests.ps1
    new file mode 100644
    index 0000000..39a5939
    --- /dev/null
    +++ b/tests/public/UpdateNovaModuleVersion.Integration.Tests.ps1
    @@ -0,0 +1,15 @@
    +BeforeAll {
    +    $script:projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    +    . (Join-Path $script:projectRoot 'tests/TestHelpers/PublicCommandIntegration.ps1')
    +    Import-NovaPublicCommandIntegrationModule -ProjectRoot $script:projectRoot | Out-Null
    +}
    +
    +Describe 'Update-NovaModuleVersion integration' {
    +    It 'supports WhatIf from the built module' {
    +        {
    +            Invoke-NovaPublicCommandIntegrationInProjectRoot -ProjectRoot $script:projectRoot -ScriptBlock {
    +                Update-NovaModuleVersion -WhatIf
    +            }
    +        } | Should -Not -Throw
    +    }
    +}
    
    From c237138abda956d8dedf80df54bad8e68d750995 Mon Sep 17 00:00:00 2001
    From: Stiwi Gabriel Courage 
    Date: Sun, 31 May 2026 13:10:43 +0200
    Subject: [PATCH 12/12] =?UTF-8?q?feat(#249):=20Add=20support=20for=20runti?=
     =?UTF-8?q?me-only=20data=20injection=20in=20Invoke-Nova=E2=80=A6=20(#253)?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    * #249 feat: Add support for runtime-only data injection in Invoke-NovaTest through -PesterConfigurationOverride
    
    * #249 feat: Add test for handling null inputs in Nova Pester override functions
    ---
     CHANGELOG.md                                  |   1 +
     CONTRIBUTING.md                               |  17 ++
     README.md                                     |   1 +
     RELEASE_NOTE.md                               |   1 +
     docs/NovaModuleTools/en-US/Invoke-NovaTest.md |  44 +++-
     docs/commands.html                            |  11 +-
     docs/core-workflows.html                      |  10 +-
     .../quality/GetNovaTestWorkflowContext.ps1    |   8 +-
     ...ializeNovaPesterExecutionConfiguration.ps1 | 211 ++++++++++++++-
     src/public/InvokeNovaTest.ps1                 |  13 +-
     ...GetNovaTestWorkflowContext.TestSupport.ps1 |  10 +-
     .../GetNovaTestWorkflowContext.Tests.ps1      |  37 +++
     ...NovaPesterExecutionConfiguration.Tests.ps1 | 240 +++++++++++++++++-
     .../InvokeNovaTest.Integration.Tests.ps1      |  38 +++
     tests/public/InvokeNovaTest.Tests.ps1         |  15 +-
     15 files changed, 637 insertions(+), 20 deletions(-)
    
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index ce7f52c..82477f7 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
     - Added `Invoke-NovaTest` as the dedicated NovaModuleTools unit-test command.
         - `% nova test` now routes to `Invoke-NovaTest`.
         - `Invoke-NovaTest` writes unit-test NUnit output to `artifacts/UnitTestResults.xml` and remains the code-coverage entry point.
    +    - `Invoke-NovaTest` now accepts a guarded `-PesterConfigurationOverride` hook for runtime-only unit-test data injection through `Run.Container`, including `PSCredential` values supplied through `New-PesterContainer -Data`.
     
     ### Changed
     
    diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
    index 4ed10b0..dabc22e 100644
    --- a/CONTRIBUTING.md
    +++ b/CONTRIBUTING.md
    @@ -72,6 +72,23 @@ Please also make sure your contribution includes the right kind of follow-up wor
       - keep public command unit ownership in `tests/public/.Tests.ps1`
       - keep per-command public integration ownership in `tests/public/.Integration.Tests.ps1` when built-module behavior itself needs validation
       - for destructive or environment-coupled commands, prefer safe `-WhatIf` integration coverage when appropriate
    +  - when a unit test needs runtime-only data such as `PSCredential`, keep the secret out of the mirrored `*.Tests.ps1` file and inject it at execution time through `Invoke-NovaTest -PesterConfigurationOverride`
    +
    +    ```powershell
    +    param($Credential)
    +
    +    Describe 'Publish-NovaModule credential flow' {
    +        It 'receives the runtime-only credential' {
    +            $Credential | Should -Not -BeNullOrEmpty
    +        }
    +    }
    +
    +    $credential = Get-Credential
    +    $container = New-PesterContainer -Path 'tests/public/PublishNovaModule.Tests.ps1' -Data @{ Credential = $credential }
    +    Invoke-NovaTest -PesterConfigurationOverride @{ Run = @{ Container = @($container) } }
    +    ```
    +
    +  - Nova v1 only accepts `Run.Container`, and each container must target a unit-test file that Nova already discovered for the current run
     - update help files in `docs/` when a command changes
     - update `README.md` when repository workflow, architecture, or contributor expectations change
     - update `CHANGELOG.md` when the change is relevant to users, maintainers, or future contributors
    diff --git a/README.md b/README.md
    index 3126be1..ecfbf39 100644
    --- a/README.md
    +++ b/README.md
    @@ -107,6 +107,7 @@ PS> Test-NovaBuild
     - `Invoke-NovaTest` is the unit-test entrypoint. Keep public command unit ownership in `tests/public/.Tests.ps1`.
     - `Test-NovaBuild` is the build-validation integration-test entrypoint. Keep per-command public integration ownership in `tests/public/.Integration.Tests.ps1` when the built command behavior itself needs coverage.
     - For destructive or environment-coupled commands, prefer safe `-WhatIf` integration coverage when that still proves `ShouldProcess` wiring and command behavior.
    +- `Invoke-NovaTest` also supports a PowerShell-only `-PesterConfigurationOverride` hook for runtime-only unit-test data injection. In v1 Nova accepts only `Run.Container`, so you can pass `New-PesterContainer -Path ... -Data @{ Credential = $credential }` without turning the managed test workflow into arbitrary Pester passthrough.
     
     NovaModuleTools can self-update the installed module from PowerShell or the `nova` CLI launcher.
     
    diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md
    index 150d489..d1bf3a9 100644
    --- a/RELEASE_NOTE.md
    +++ b/RELEASE_NOTE.md
    @@ -11,6 +11,7 @@ This file summarizes the release notes for NovaModuleTools. **UNRELEASED** chang
     - Added `Invoke-NovaTest` as the dedicated PowerShell unit-test command.
         - `% nova test` now maps to `Invoke-NovaTest`.
         - Unit-test NUnit results are now written to `artifacts/UnitTestResults.xml`.
    +    - `Invoke-NovaTest` now supports guarded runtime-only test-data injection through `-PesterConfigurationOverride`, limited in v1 to `Run.Container` entries such as `New-PesterContainer -Data @{ Credential = $credential }`.
     
     ### Changed
     
    diff --git a/docs/NovaModuleTools/en-US/Invoke-NovaTest.md b/docs/NovaModuleTools/en-US/Invoke-NovaTest.md
    index e3ac37f..9c496e5 100644
    --- a/docs/NovaModuleTools/en-US/Invoke-NovaTest.md
    +++ b/docs/NovaModuleTools/en-US/Invoke-NovaTest.md
    @@ -21,8 +21,9 @@ Runs the NovaModuleTools unit-test workflow for the current project.
     
     ```text
     PS> Invoke-NovaTest [[-TagFilter] ] [[-ExcludeTagFilter] ]
    - [[-OutputVerbosity] ] [[-OutputRenderMode] ] [-OverrideWarning]
    - [-WhatIf] [-Confirm] []
    + [[-OutputVerbosity] ] [[-OutputRenderMode] ]
    + [[-PesterConfigurationOverride] ] [-OverrideWarning] [-WhatIf]
    + [-Confirm] []
     ```
     
     ## ALIASES
    @@ -37,6 +38,8 @@ When `Pester.CodeCoverage.Enabled` is `true`, Nova also writes JaCoCo coverage t
     
     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.
    +
     Use `-OverrideWarning` only when you intentionally want to bypass Nova's public-file export guard for the current unit-test run.
     
     This command supports `-WhatIf` and `-Confirm` through PowerShell `SupportsShouldProcess`. Use `-WhatIf` to preview the planned unit-test run and output paths without invoking Pester.
    @@ -77,13 +80,23 @@ Overrides the Pester console output settings for the current unit-test run.
     
     ### EXAMPLE 5
     
    +```text
    +PS> $credential = Get-Credential
    +PS> $container = New-PesterContainer -Path 'tests/public/PublishNovaModule.Tests.ps1' -Data @{ Credential = $credential }
    +PS> Invoke-NovaTest -PesterConfigurationOverride @{ Run = @{ Container = @($container) } }
    +```
    +
    +Runs the standard Nova unit-test discovery set while injecting a runtime-only `PSCredential` into the selected test file through `Run.Container`.
    +
    +### EXAMPLE 6
    +
     ```text
     PS> Invoke-NovaTest -OverrideWarning
     ```
     
     Runs the unit-test workflow while explicitly bypassing Nova's public-file export guard for this invocation.
     
    -### EXAMPLE 6
    +### EXAMPLE 7
     
     ```text
     PS> Invoke-NovaTest -WhatIf
    @@ -184,6 +197,27 @@ AcceptedValues:
     HelpMessage: ''
     ```
     
    +### -PesterConfigurationOverride
    +
    +Advanced Pester configuration override for the current unit-test invocation. In v1, Nova accepts only `Run.Container`, and each container must be a file-backed `New-PesterContainer -Path` entry that matches a unit-test file already discovered by Nova.
    +
    +```yaml
    +Type: System.Collections.Hashtable
    +DefaultValue: ''
    +SupportsWildcards: false
    +Aliases: []
    +ParameterSets:
    +- Name: (All)
    +  Position: 4
    +  IsRequired: false
    +  ValueFromPipeline: false
    +  ValueFromPipelineByPropertyName: false
    +  ValueFromRemainingArguments: false
    +DontShow: false
    +AcceptedValues: []
    +HelpMessage: ''
    +```
    +
     ### -OverrideWarning
     
     Bypasses Nova's public-file export guard for this unit-test invocation.
    @@ -269,5 +303,5 @@ Returns the Pester result object from the managed unit-test run.
     
     ## RELATED LINKS
     
    -[Test-NovaBuild](Test-NovaBuild.md)
    -[Invoke-NovaBuild](Invoke-NovaBuild.md)
    +- [Test-NovaBuild](./Test-NovaBuild.md)
    +- [Invoke-NovaBuild](./Invoke-NovaBuild.md)
    diff --git a/docs/commands.html b/docs/commands.html
    index 7cc65d1..e253f8d 100644
    --- a/docs/commands.html
    +++ b/docs/commands.html
    @@ -356,8 +356,10 @@ 

    Run the test workflow

  • Cmdlet extras: Invoke-NovaTest supports -TagFilter, -ExcludeTagFilter, -OutputVerbosity, and - -OutputRenderMode. Test-NovaBuild supports the same options - plus -OverrideWarning.
  • + -OutputRenderMode, and the guarded + -PesterConfigurationOverride hook for Run.Container. + Test-NovaBuild supports the same output options plus + -OverrideWarning.
    @@ -373,7 +375,10 @@

    Run the test workflow

    PS> Invoke-NovaTest
     PS> Test-NovaBuild
    -PS> Invoke-NovaTest -TagFilter unit,fast
    +PS> Invoke-NovaTest -TagFilter unit,fast +PS> $credential = Get-Credential +PS> $container = New-PesterContainer -Path 'tests/public/PublishNovaModule.Tests.ps1' -Data @{ Credential = $credential } +PS> Invoke-NovaTest -PesterConfigurationOverride @{ Run = @{ Container = @($container) } }

    See the test workflow

    diff --git a/docs/core-workflows.html b/docs/core-workflows.html index bf946c1..0b9d86d 100644 --- a/docs/core-workflows.html +++ b/docs/core-workflows.html @@ -246,7 +246,10 @@

    Run the tests

    PS> Test-NovaBuild PS> Invoke-NovaTest -TagFilter unit,fast PS> Test-NovaBuild -ExcludeTagFilter slow -PS> Invoke-NovaTest -OutputVerbosity Detailed -OutputRenderMode Ansi + PS> Invoke-NovaTest -OutputVerbosity Detailed -OutputRenderMode Ansi + PS> $credential = Get-Credential + PS> $container = New-PesterContainer -Path 'tests/public/PublishNovaModule.Tests.ps1' -Data @{ Credential = $credential } + PS> Invoke-NovaTest -PesterConfigurationOverride @{ Run = @{ Container = @($container) } } +

    Invoke-NovaTest also supports a guarded + -PesterConfigurationOverride hook for runtime-only unit-test data. In v1 Nova accepts + only Run.Container, so you can inject values such as PSCredential with + file-backed New-PesterContainer -Path objects while Nova still owns discovery, + coverage, result files, and the rest of the test workflow.

    Use -WhatIf if you want to preview the test run and output path without creating the results directory or invoking Pester.

    diff --git a/src/private/quality/GetNovaTestWorkflowContext.ps1 b/src/private/quality/GetNovaTestWorkflowContext.ps1 index 11df8c6..570ab85 100644 --- a/src/private/quality/GetNovaTestWorkflowContext.ps1 +++ b/src/private/quality/GetNovaTestWorkflowContext.ps1 @@ -47,8 +47,12 @@ function Get-NovaTestWorkflowContext { $pesterConfig.Run.Throw = $true $pesterConfig.Filter.Tag = Get-NovaTestOptionValue -TestOption $TestOption -Name TagFilter $pesterConfig.Filter.ExcludeTag = Get-NovaTestOptionValue -TestOption $TestOption -Name ExcludeTagFilter - Initialize-NovaPesterExecutionConfiguration -PesterConfig $pesterConfig -BoundParameters $BoundParameters -OutputVerbosity (Get-NovaTestOptionValue -TestOption $TestOption -Name OutputVerbosity) -OutputRenderMode (Get-NovaTestOptionValue -TestOption $TestOption -Name OutputRenderMode) - + Initialize-NovaPesterExecutionConfiguration -PesterConfig $pesterConfig -BoundParameters $BoundParameters -ExecutionOption @{ + PesterConfigurationOverride = Get-NovaTestOptionValue -TestOption $TestOption -Name PesterConfigurationOverride + ProjectRoot = $projectInfo.ProjectRoot + OutputVerbosity = Get-NovaTestOptionValue -TestOption $TestOption -Name OutputVerbosity + OutputRenderMode = Get-NovaTestOptionValue -TestOption $TestOption -Name OutputRenderMode + } $testResultPath = Get-NovaPesterTestResultPath -ProjectRoot $projectInfo.ProjectRoot -FileName $workflowProfile.TestResultFileName return [pscustomobject]@{ diff --git a/src/private/quality/InitializeNovaPesterExecutionConfiguration.ps1 b/src/private/quality/InitializeNovaPesterExecutionConfiguration.ps1 index 84fc3c0..45c3ff1 100644 --- a/src/private/quality/InitializeNovaPesterExecutionConfiguration.ps1 +++ b/src/private/quality/InitializeNovaPesterExecutionConfiguration.ps1 @@ -3,11 +3,10 @@ function Initialize-NovaPesterExecutionConfiguration { param( [Parameter(Mandatory)][object]$PesterConfig, [Parameter(Mandatory)][hashtable]$BoundParameters, - [string]$OutputVerbosity, - [string]$OutputRenderMode + [AllowNull()][hashtable]$ExecutionOption ) - $outputOptionOverrides = Get-NovaPesterOutputOptionOverride -PesterConfig $PesterConfig -BoundParameters $BoundParameters -OutputVerbosity $OutputVerbosity -OutputRenderMode $OutputRenderMode + $outputOptionOverrides = Get-NovaPesterOutputOptionOverride -PesterConfig $PesterConfig -BoundParameters $BoundParameters -OutputVerbosity (Get-NovaPesterOverrideValue -InputObject $ExecutionOption -Name 'OutputVerbosity') -OutputRenderMode (Get-NovaPesterOverrideValue -InputObject $ExecutionOption -Name 'OutputRenderMode') if ($null -ne $outputOptionOverrides) { if ($null -ne $outputOptionOverrides.Verbosity) { $PesterConfig.Output.Verbosity = $outputOptionOverrides.Verbosity @@ -21,4 +20,210 @@ function Initialize-NovaPesterExecutionConfiguration { if ($PesterConfig.TestResult.PSObject.Properties.Name -contains 'Enabled') { $PesterConfig.TestResult.Enabled = $false } + + $pesterConfigurationOverride = Get-NovaPesterOverrideValue -InputObject $ExecutionOption -Name 'PesterConfigurationOverride' + if ($null -ne $pesterConfigurationOverride) { + Initialize-NovaPesterContainerOverride -PesterConfig $PesterConfig -PesterConfigurationOverride $pesterConfigurationOverride -ProjectRoot (Get-NovaPesterOverrideValue -InputObject $ExecutionOption -Name 'ProjectRoot') + } +} + +function Initialize-NovaPesterContainerOverride { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$PesterConfig, + [Parameter(Mandatory)][object]$PesterConfigurationOverride, + [string]$ProjectRoot + ) + + $overridePropertyNames = @(Get-NovaPesterOverridePropertyName -InputObject $PesterConfigurationOverride) + if ($overridePropertyNames.Count -eq 0) { + return + } + + Assert-NovaAllowedPesterConfigurationOverridePath -PropertyName $overridePropertyNames -AllowedPropertyName 'Run' -Prefix '' + $runOverride = Get-NovaPesterOverrideValue -InputObject $PesterConfigurationOverride -Name 'Run' + $runPropertyNames = @(Get-NovaPesterOverridePropertyName -InputObject $runOverride) + Assert-NovaAllowedPesterConfigurationOverridePath -PropertyName $runPropertyNames -AllowedPropertyName 'Container' -Prefix 'Run' + + $containerOverride = Get-NovaPesterOverrideValue -InputObject $runOverride -Name 'Container' + if ($null -eq $containerOverride) { + $containerOverride = @() + } + else { + $containerOverride = @($containerOverride) + } + + if ($containerOverride.Count -eq 0) { + Stop-NovaOperation -Message "Invoke-NovaTest only supports PesterConfigurationOverride.Run.Container in v1. Provide one or more file-backed containers created with New-PesterContainer -Path." -ErrorId 'Nova.Validation.UnsupportedPesterConfigurationOverride' -Category InvalidArgument -TargetObject 'PesterConfigurationOverride.Run.Container' + } + + $resolvedRunPath = @(Get-NovaResolvedPesterOverridePathSet -Path (Get-NovaPesterOverridePathInput -Path $PesterConfig.Run.Path) -ProjectRoot $ProjectRoot) + $providedContainerByPath = @{} + foreach ($container in $containerOverride) { + $resolvedContainerPath = Get-NovaValidatedPesterOverrideContainerPath -Container $container -ResolvedRunPath $resolvedRunPath -ProjectRoot $ProjectRoot + if ($providedContainerByPath.ContainsKey($resolvedContainerPath)) { + Stop-NovaOperation -Message "PesterConfigurationOverride.Run.Container cannot contain multiple containers for the same test path: $resolvedContainerPath" -ErrorId 'Nova.Validation.UnsupportedPesterConfigurationOverride' -Category InvalidArgument -TargetObject $resolvedContainerPath + } + + $providedContainerByPath[$resolvedContainerPath] = $container + } + + $finalContainer = foreach ($path in $resolvedRunPath) { + Resolve-NovaPesterContainerSelection -Path $path -ProvidedContainerByPath $providedContainerByPath + } + + $PesterConfig.Run.Container = @($finalContainer) + $PesterConfig.Run.Path = @() +} + +function Resolve-NovaPesterContainerSelection { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][hashtable]$ProvidedContainerByPath + ) + + if ($ProvidedContainerByPath.ContainsKey($Path)) { + return $ProvidedContainerByPath[$Path] + } + + return New-PesterContainer -Path $Path +} + +function Assert-NovaAllowedPesterConfigurationOverridePath { + [CmdletBinding()] + param( + [AllowEmptyCollection()][string[]]$PropertyName, + [Parameter(Mandatory)][string]$AllowedPropertyName, + [Parameter(Mandatory)][AllowEmptyString()][string]$Prefix + ) + + foreach ($name in @($PropertyName | Where-Object {-not [string]::IsNullOrWhiteSpace([string]$_)})) { + if ($name -eq $AllowedPropertyName) { + continue + } + + $path = @($Prefix, $name | Where-Object {-not [string]::IsNullOrWhiteSpace([string]$_)}) -join '.' + Stop-NovaOperation -Message "Invoke-NovaTest only supports PesterConfigurationOverride.Run.Container in v1. Unsupported override path: $path" -ErrorId 'Nova.Validation.UnsupportedPesterConfigurationOverride' -Category InvalidArgument -TargetObject $path + } +} + +function Get-NovaValidatedPesterOverrideContainerPath { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$Container, + [Parameter(Mandatory)][string[]]$ResolvedRunPath, + [string]$ProjectRoot + ) + + $containerType = [string](Get-NovaPesterOverrideValue -InputObject $Container -Name 'Type') + if ($containerType -ne 'File') { + Stop-NovaOperation -Message "Invoke-NovaTest only supports file-backed PesterConfigurationOverride.Run.Container entries created with New-PesterContainer -Path. ScriptBlock and other container types are not supported." -ErrorId 'Nova.Validation.UnsupportedPesterConfigurationOverride' -Category InvalidArgument -TargetObject $Container + } + + $containerPath = [string](Get-NovaPesterOverrideValue -InputObject $Container -Name 'Item') + if ([string]::IsNullOrWhiteSpace($containerPath)) { + Stop-NovaOperation -Message 'PesterConfigurationOverride.Run.Container entries must expose a file path in the Item property.' -ErrorId 'Nova.Validation.UnsupportedPesterConfigurationOverride' -Category InvalidArgument -TargetObject $Container + } + + $resolvedContainerPath = Resolve-NovaPesterOverridePath -Path $containerPath -ProjectRoot $ProjectRoot + if ($ResolvedRunPath -notcontains $resolvedContainerPath) { + Stop-NovaOperation -Message "Invoke-NovaTest only accepts PesterConfigurationOverride.Run.Container entries for unit-test files discovered by Nova. Unsupported container path: $resolvedContainerPath" -ErrorId 'Nova.Validation.UnsupportedPesterConfigurationOverride' -Category InvalidArgument -TargetObject $resolvedContainerPath + } + + return $resolvedContainerPath +} + +function Get-NovaResolvedPesterOverridePathSet { + [CmdletBinding()] + param( + [AllowEmptyCollection()][string[]]$Path, + [string]$ProjectRoot + ) + + $resolvedPath = New-Object 'System.Collections.Generic.List[string]' + foreach ($item in @($Path | Where-Object {-not [string]::IsNullOrWhiteSpace([string]$_)})) { + $resolvedItem = Resolve-NovaPesterOverridePath -Path $item -ProjectRoot $ProjectRoot + if (-not $resolvedPath.Contains($resolvedItem)) { + $resolvedPath.Add($resolvedItem) + } + } + + return @($resolvedPath | Sort-Object) +} + +function Get-NovaPesterOverridePathInput { + [CmdletBinding()] + param( + [AllowNull()][object]$Path + ) + + $pathValue = Get-NovaPesterOverrideValue -InputObject $Path -Name 'Value' + if ($null -ne $pathValue) { + return @($pathValue) + } + + return @($Path) +} + +function Resolve-NovaPesterOverridePath { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path, + [string]$ProjectRoot + ) + + $candidatePath = $Path + if (-not [System.IO.Path]::IsPathRooted($candidatePath) -and -not [string]::IsNullOrWhiteSpace($ProjectRoot)) { + $candidatePath = Join-Path $ProjectRoot $candidatePath + } + + if (-not (Test-Path -LiteralPath $candidatePath -PathType Leaf)) { + Stop-NovaOperation -Message "PesterConfigurationOverride.Run.Container path does not exist: $candidatePath" -ErrorId 'Nova.Validation.UnsupportedPesterConfigurationOverride' -Category InvalidArgument -TargetObject $candidatePath + } + + return (Resolve-Path -LiteralPath $candidatePath -ErrorAction Stop).Path +} + +function Get-NovaPesterOverridePropertyName { + [CmdletBinding()] + param( + [AllowNull()][object]$InputObject + ) + + if ($null -eq $InputObject) { + return @() + } + + if ($InputObject -is [System.Collections.IDictionary]) { + return @($InputObject.Keys) + } + + return @($InputObject.PSObject.Properties.Name) +} + +function Get-NovaPesterOverrideValue { + [CmdletBinding()] + param( + [AllowNull()][object]$InputObject, + [Parameter(Mandatory)][string]$Name + ) + + if ($null -eq $InputObject) { + return $null + } + + if ($InputObject -is [System.Collections.IDictionary]) { + if ($InputObject.Contains($Name)) { + return $InputObject[$Name] + } + + return $null + } + + if ($InputObject.PSObject.Properties.Name -contains $Name) { + return $InputObject.$Name + } + + return $null } diff --git a/src/public/InvokeNovaTest.ps1 b/src/public/InvokeNovaTest.ps1 index 82e9084..044bb56 100644 --- a/src/public/InvokeNovaTest.ps1 +++ b/src/public/InvokeNovaTest.ps1 @@ -10,16 +10,27 @@ function Invoke-NovaTest { ) dynamicparam { - return Get-NovaDynamicOverrideWarningParameterDictionary + $dictionary = Get-NovaDynamicOverrideWarningParameterDictionary + $attributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() + $attributeCollection.Add([System.Management.Automation.ParameterAttribute]::new()) + $runtimeParameter = [System.Management.Automation.RuntimeDefinedParameter]::new('PesterConfigurationOverride', [hashtable], $attributeCollection) + $dictionary.Add('PesterConfigurationOverride', $runtimeParameter) + return $dictionary } end { + $pesterConfigurationOverride = $null + if ($PSBoundParameters.ContainsKey('PesterConfigurationOverride')) { + $pesterConfigurationOverride = [hashtable]$PSBoundParameters.PesterConfigurationOverride + } + $workflowContext = Get-NovaTestWorkflowContext -TestOption @{ TestMode = 'Unit' TagFilter = $TagFilter ExcludeTagFilter = $ExcludeTagFilter OutputVerbosity = $OutputVerbosity OutputRenderMode = $OutputRenderMode + PesterConfigurationOverride = $pesterConfigurationOverride } -BoundParameters $PSBoundParameters $shouldRun = $PSCmdlet.ShouldProcess($workflowContext.Target, $workflowContext.Operation) diff --git a/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 b/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 index 1d4bd7d..3760e10 100644 --- a/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 +++ b/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1 @@ -23,7 +23,15 @@ function Get-NovaPesterTestResultPath { return (Join-Path $ProjectRoot $FileName) } -function Initialize-NovaPesterExecutionConfiguration {param($PesterConfig, $BoundParameters, $OutputVerbosity, $OutputRenderMode)} +function Initialize-NovaPesterExecutionConfiguration { + param($PesterConfig, $BoundParameters, $ExecutionOption) + + $script:lastExecutionConfigurationRequest = [pscustomobject]@{ + PesterConfig = $PesterConfig + BoundParameters = $BoundParameters + ExecutionOption = $ExecutionOption + } +} function Get-NovaShouldProcessForwardingParameter {param([switch]$WhatIfEnabled) return @{}} function Write-NovaPesterTestResultArtifact {} function Write-NovaPesterTestResultReport {} diff --git a/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 b/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 index d041b1f..a103191 100644 --- a/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 +++ b/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1 @@ -9,6 +9,7 @@ Describe 'Get-NovaTestWorkflowContext' { BeforeEach { $script:lastRunPathRequest = $null $script:lastResultPathRequest = $null + $script:lastExecutionConfigurationRequest = $null Mock Test-ProjectSchema {} Mock Get-Module {[pscustomobject]@{Name = 'Pester'}} -ParameterFilter {$Name -eq 'Pester' -and $ListAvailable} @@ -104,6 +105,42 @@ Describe 'Get-NovaTestWorkflowContext' { 'src/classes/NovaThing.ps1' ) } + + It 'forwards the guarded Pester configuration override to the execution configuration initializer' { + $pesterConfig = & $script:getPesterConfig + $projectInfo = & $script:getProjectInfo -PesterSettings ([ordered]@{}) + $containerOverride = [pscustomobject]@{ + Type = 'File' + Item = (Join-Path $projectInfo.ProjectRoot 'tests/Example.Tests.ps1') + Data = @{ Credential = 'placeholder' } + } + $override = @{ + Run = @{ + Container = @($containerOverride) + } + } + + Mock Get-NovaProjectInfo {$projectInfo} + Mock New-PesterConfiguration {$pesterConfig} + + $null = Get-NovaTestWorkflowContext -TestOption @{ + TestMode = 'Unit' + OutputVerbosity = 'Diagnostic' + OutputRenderMode = 'Ansi' + PesterConfigurationOverride = $override + } -BoundParameters @{} + + $script:lastExecutionConfigurationRequest.ExecutionOption.Keys | Sort-Object | Should -Be @( + 'OutputRenderMode' + 'OutputVerbosity' + 'PesterConfigurationOverride' + 'ProjectRoot' + ) + $script:lastExecutionConfigurationRequest.ExecutionOption.ProjectRoot | Should -Be $projectInfo.ProjectRoot + $script:lastExecutionConfigurationRequest.ExecutionOption.OutputVerbosity | Should -Be 'Diagnostic' + $script:lastExecutionConfigurationRequest.ExecutionOption.OutputRenderMode | Should -Be 'Ansi' + $script:lastExecutionConfigurationRequest.ExecutionOption.PesterConfigurationOverride | Should -Be $override + } } Describe 'Get-NovaTestWorkflowOperation' { diff --git a/tests/private/quality/InitializeNovaPesterExecutionConfiguration.Tests.ps1 b/tests/private/quality/InitializeNovaPesterExecutionConfiguration.Tests.ps1 index 8caba09..37eb7d1 100644 --- a/tests/private/quality/InitializeNovaPesterExecutionConfiguration.Tests.ps1 +++ b/tests/private/quality/InitializeNovaPesterExecutionConfiguration.Tests.ps1 @@ -4,13 +4,79 @@ BeforeAll { . (Join-Path $projectRoot 'src/private/quality/InitializeNovaPesterExecutionConfiguration.ps1') } +function script:Stop-NovaOperation { + param($Message, $ErrorId, $Category, $TargetObject) + + throw $Message +} + +function script:New-NovaPesterExecutionConfigurationScenario { + param( + [string]$ProjectName = 'project', + [string[]]$TestFileName = @('Alpha.Tests.ps1') + ) + + $projectPath = Join-Path $TestDrive $ProjectName + $testPath = foreach ($name in $TestFileName) { + $path = Join-Path $projectPath (Join-Path 'tests' $name) + New-Item -ItemType Directory -Path (Split-Path -Parent $path) -Force | Out-Null + Set-Content -LiteralPath $path -Value "# $name" + $path + } + + $config = New-PesterConfiguration + $config.Run.Path = @($testPath) + + return [pscustomobject]@{ + ProjectPath = $projectPath + TestPath = @($testPath) + Config = $config + } +} + +function script:New-NovaPesterExecutionConfigurationExecutionOption { + param( + [Parameter(Mandatory)][string]$ProjectPath, + [Parameter(Mandatory)][object[]]$Container + ) + + return @{ + PesterConfigurationOverride = @{ + Run = @{ + Container = @($Container) + } + } + ProjectRoot = $ProjectPath + } +} + +function script:Assert-NovaPesterExecutionConfigurationOverrideFailure { + param( + [Parameter(Mandatory)][object]$Config, + [Parameter(Mandatory)][string]$ProjectPath, + [Parameter(Mandatory)][object]$Override, + [Parameter(Mandatory)][string]$ExpectedMessage + ) + + { + Initialize-NovaPesterExecutionConfiguration -PesterConfig $Config -BoundParameters @{} -ExecutionOption @{ + PesterConfigurationOverride = $Override + ProjectRoot = $ProjectPath + } + } | Should -Throw $ExpectedMessage +} + Describe 'Initialize-NovaPesterExecutionConfiguration' { It 'applies bound Verbosity/RenderMode and disables TestResult' { $config = [pscustomobject]@{ + Run = [pscustomobject]@{Path=@(); Container=@()} Output = [pscustomobject]@{Verbosity='Default'; RenderMode='Default'} TestResult = [pscustomobject]@{Enabled=$true} } - Initialize-NovaPesterExecutionConfiguration -PesterConfig $config -BoundParameters @{OutputVerbosity='Detailed'; OutputRenderMode='Plaintext'} -OutputVerbosity 'Detailed' -OutputRenderMode 'Plaintext' + Initialize-NovaPesterExecutionConfiguration -PesterConfig $config -BoundParameters @{OutputVerbosity='Detailed'; OutputRenderMode='Plaintext'} -ExecutionOption @{ + OutputVerbosity = 'Detailed' + OutputRenderMode = 'Plaintext' + } $config.Output.Verbosity | Should -Be 'Detailed' $config.Output.RenderMode | Should -Be 'Plaintext' $config.TestResult.Enabled | Should -BeFalse @@ -18,10 +84,11 @@ Describe 'Initialize-NovaPesterExecutionConfiguration' { It 'leaves Output untouched when nothing is bound' { $config = [pscustomobject]@{ + Run = [pscustomobject]@{Path=@(); Container=@()} Output = [pscustomobject]@{Verbosity='Default'; RenderMode='Default'} TestResult = [pscustomobject]@{Enabled=$true} } - Initialize-NovaPesterExecutionConfiguration -PesterConfig $config -BoundParameters @{} + Initialize-NovaPesterExecutionConfiguration -PesterConfig $config -BoundParameters @{} -ExecutionOption @{} $config.Output.Verbosity | Should -Be 'Default' $config.Output.RenderMode | Should -Be 'Default' $config.TestResult.Enabled | Should -BeFalse @@ -29,9 +96,176 @@ Describe 'Initialize-NovaPesterExecutionConfiguration' { It 'does not touch TestResult when it has no Enabled property' { $config = [pscustomobject]@{ + Run = [pscustomobject]@{Path=@(); Container=@()} Output = [pscustomobject]@{Verbosity='Default'; RenderMode='Default'} TestResult = [pscustomobject]@{Other='x'} } - { Initialize-NovaPesterExecutionConfiguration -PesterConfig $config -BoundParameters @{} } | Should -Not -Throw + { Initialize-NovaPesterExecutionConfiguration -PesterConfig $config -BoundParameters @{} -ExecutionOption @{} } | Should -Not -Throw + } + + It 'converts Nova-owned run paths to container runs and overlays the approved container override' { + $scenario = New-NovaPesterExecutionConfigurationScenario -TestFileName @('Alpha.Tests.ps1', 'Beta.Tests.ps1') + $testPathA = $scenario.TestPath[0] + $testPathB = $scenario.TestPath[1] + $providedContainer = New-PesterContainer -Path $testPathA -Data @{ Credential = 'placeholder' } + + Initialize-NovaPesterExecutionConfiguration -PesterConfig $scenario.Config -BoundParameters @{} -ExecutionOption (New-NovaPesterExecutionConfigurationExecutionOption -ProjectPath $scenario.ProjectPath -Container $providedContainer) + + $scenario.Config.Run.Path.Value | Should -BeNullOrEmpty + $scenario.Config.Run.Container.Value | Should -HaveCount 2 + $scenario.Config.Run.Container.Value[0] | Should -Be $providedContainer + $scenario.Config.Run.Container.Value[1].Item | Should -Be $testPathB + $scenario.Config.Run.Container.Value[1].Data.Count | Should -Be 0 + } + + It 'fails fast when a disallowed override path is provided' { + $config = [pscustomobject]@{ + Run = [pscustomobject]@{Path=@(); Container=@()} + Output = [pscustomobject]@{Verbosity='Default'; RenderMode='Default'} + TestResult = [pscustomobject]@{Enabled=$true} + } + + { + Initialize-NovaPesterExecutionConfiguration -PesterConfig $config -BoundParameters @{} -ExecutionOption @{ + PesterConfigurationOverride = @{ + Output = @{ + Verbosity = 'Diagnostic' + } + } + } + } | Should -Throw '*Unsupported override path: Output*' + } + + It 'fails fast when ' -TestCases @( + @{ + Name = 'Run does not provide any containers' + Override = @{ + Run = @{} + } + ExpectedMessage = '*Provide one or more file-backed containers*' + } + @{ + Name = 'Run.Container is empty' + Override = @{ + Run = @{ + Container = @() + } + } + ExpectedMessage = '*Provide one or more file-backed containers*' + } + @{ + Name = 'a file-backed container does not expose an Item path' + Override = @{ + Run = [pscustomobject]@{ + Container = @( + [pscustomobject]@{ + Type = 'File' + Data = $null + } + ) + } + } + ExpectedMessage = '*must expose a file path in the Item property*' + } + @{ + Name = 'a relative container path does not exist' + Override = @{ + Run = @{ + Container = @( + [pscustomobject]@{ + Type = 'File' + Item = 'tests/Missing.Tests.ps1' + Data = $null + } + ) + } + } + ExpectedMessage = '*path does not exist*' + } + ) { + param($Override, $ExpectedMessage) + + $scenario = New-NovaPesterExecutionConfigurationScenario + Assert-NovaPesterExecutionConfigurationOverrideFailure -Config $scenario.Config -ProjectPath $scenario.ProjectPath -Override $Override -ExpectedMessage $ExpectedMessage + } + + It 'resolves relative container paths against ProjectRoot' { + $scenario = New-NovaPesterExecutionConfigurationScenario + $relativePath = [System.IO.Path]::GetRelativePath($scenario.ProjectPath, $scenario.TestPath[0]) + $providedContainer = New-PesterContainer -Path $scenario.TestPath[0] + $providedContainer.Item = $relativePath + + Initialize-NovaPesterExecutionConfiguration -PesterConfig $scenario.Config -BoundParameters @{} -ExecutionOption ( + New-NovaPesterExecutionConfigurationExecutionOption -ProjectPath $scenario.ProjectPath -Container $providedContainer + ) + + $scenario.Config.Run.Container.Value | Should -HaveCount 1 + $scenario.Config.Run.Container.Value[0] | Should -Be $providedContainer + } + + It 'fails fast when a container points outside the Nova-discovered unit-test set' { + $projectPath = Join-Path $TestDrive 'project' + $testPath = Join-Path $projectPath 'tests/Alpha.Tests.ps1' + $otherPath = Join-Path $projectPath 'tests/Other.Tests.ps1' + New-Item -ItemType Directory -Path (Split-Path -Parent $testPath) -Force | Out-Null + Set-Content -LiteralPath $testPath -Value '# alpha' + Set-Content -LiteralPath $otherPath -Value '# other' + + $config = [pscustomobject]@{ + Run = [pscustomobject]@{ + Path = @($testPath) + Container = @() + } + Output = [pscustomobject]@{Verbosity='Default'; RenderMode='Default'} + TestResult = [pscustomobject]@{Enabled=$true} + } + + { + Initialize-NovaPesterExecutionConfiguration -PesterConfig $config -BoundParameters @{} -ExecutionOption @{ + PesterConfigurationOverride = @{ + Run = @{ + Container = @( + [pscustomobject]@{ + Type = 'File' + Item = $otherPath + Data = @{} + } + ) + } + } + ProjectRoot = $projectPath + } + } | Should -Throw '*Unsupported container path*' + } + + It 'fails fast when the override contains duplicate containers for the same resolved test path' { + $scenario = New-NovaPesterExecutionConfigurationScenario + + { + Initialize-NovaPesterExecutionConfiguration -PesterConfig $scenario.Config -BoundParameters @{} -ExecutionOption ( + New-NovaPesterExecutionConfigurationExecutionOption -ProjectPath $scenario.ProjectPath -Container @( + (New-PesterContainer -Path $scenario.TestPath[0] -Data @{ Name = 'first' }) + (New-PesterContainer -Path $scenario.TestPath[0] -Data @{ Name = 'second' }) + ) + ) + } | Should -Throw '*multiple containers for the same test path*' + } + + It 'fails fast when the override contains a non-file container type' { + $scenario = New-NovaPesterExecutionConfigurationScenario + + { + Initialize-NovaPesterExecutionConfiguration -PesterConfig $scenario.Config -BoundParameters @{} -ExecutionOption ( + New-NovaPesterExecutionConfigurationExecutionOption -ProjectPath $scenario.ProjectPath -Container (New-PesterContainer -ScriptBlock {}) + ) + } | Should -Throw '*ScriptBlock and other container types are not supported*' + } + + It 'returns empty metadata when override helper inputs are null' { + $propertyNames = Get-NovaPesterOverridePropertyName -InputObject $null + $value = Get-NovaPesterOverrideValue -InputObject $null -Name 'Container' + + $propertyNames | Should -HaveCount 0 + $value | Should -BeNullOrEmpty } } diff --git a/tests/public/InvokeNovaTest.Integration.Tests.ps1 b/tests/public/InvokeNovaTest.Integration.Tests.ps1 index 479098a..f260407 100644 --- a/tests/public/InvokeNovaTest.Integration.Tests.ps1 +++ b/tests/public/InvokeNovaTest.Integration.Tests.ps1 @@ -12,4 +12,42 @@ Describe 'Invoke-NovaTest integration' { } } | Should -Not -Throw } + + 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) + } + } + } + } | Should -Not -Throw + } + + 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) + } + } + } + } | Should -Throw '*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') + } + } + } + } | Should -Throw '*Unsupported override path: Run.Path*' + } } diff --git a/tests/public/InvokeNovaTest.Tests.ps1 b/tests/public/InvokeNovaTest.Tests.ps1 index a3e5ecd..5f74b37 100644 --- a/tests/public/InvokeNovaTest.Tests.ps1 +++ b/tests/public/InvokeNovaTest.Tests.ps1 @@ -34,12 +34,25 @@ Describe 'Invoke-NovaTest' { } It 'forwards unit-test options to the workflow context' { - Invoke-NovaTest -OverrideWarning -TagFilter 'fast' -ExcludeTagFilter 'integration' -OutputVerbosity 'Detailed' + $override = @{ + Run = @{ + Container = @( + [pscustomobject]@{ + Type = 'File' + Item = '/proj/tests/Example.Tests.ps1' + Data = @{ Credential = 'placeholder' } + } + ) + } + } + + Invoke-NovaTest -OverrideWarning -TagFilter 'fast' -ExcludeTagFilter 'integration' -OutputVerbosity 'Detailed' -PesterConfigurationOverride $override $script:testOption.TestMode | Should -Be 'Unit' $script:testOption.TagFilter | Should -Be @('fast') $script:testOption.ExcludeTagFilter | Should -Be @('integration') $script:testOption.OutputVerbosity | Should -Be 'Detailed' + $script:testOption.PesterConfigurationOverride | Should -Be $override $script:boundParameters.OverrideWarning | Should -BeTrue $script:shouldRun | Should -BeTrue }
    Pester.TestResult.OutputFormat Passed through by the current project template and examples. NUnitXmlDetermines the result format written to artifacts/TestResults.xml.Determines the result format written to the managed NUnit result artifacts.
    Pester.CodeCoverage.Enabled Pester.Output.Verbosity Controls Pester console verbosity. Detailed in the current project templateAffects what you see during Test-NovaBuild and % nova test. + Affects what you see during Invoke-NovaTest, Test-NovaBuild, and % nova test.