@@ -626,7 +644,7 @@ Pester settings
Pester.TestResult.OutputFormat |
Passed through by the current project template and examples. |
NUnitXml |
- Determines the result format written to artifacts/TestResults.xml. |
+ Determines the result format written to the managed NUnit result artifacts. |
Pester.CodeCoverage.Enabled |
@@ -668,7 +686,7 @@ Pester settings
Pester.Output.Verbosity |
Controls Pester console verbosity. |
Detailed in the current project template |
- Affects what you see during Test-NovaBuild and % nova test.
+ | Affects what you see during Invoke-NovaTest, Test-NovaBuild, and % nova test.
|
@@ -678,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/schema/v3/project.json b/docs/schema/v3/project.json
new file mode 100644
index 00000000..8efcc161
--- /dev/null
+++ b/docs/schema/v3/project.json
@@ -0,0 +1,355 @@
+{
+ "$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",
+ "enum": ["NuGet", "Zip", ".nupkg", ".zip"],
+ "description": "Valid values: NuGet, Zip, .nupkg, .zip (case-insensitive at runtime)."
+ }
+ },
+ "Latest": {
+ "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/docs/troubleshooting.html b/docs/troubleshooting.html
index bdee8c70..12dbe025 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/versioning-and-updates.html b/docs/versioning-and-updates.html
index eff00902..6108b060 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
% nova version
@@ -147,12 +148,6 @@ Choose the right version command
% nova -v
-
- 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/docs/working-with-modules.html b/docs/working-with-modules.html
index 15a9c79e..79013593 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/project.json b/project.json
index f4dc4864..824bc75e 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-preview02",
"Preamble": [
"Set-StrictMode -Version Latest",
"$ErrorActionPreference = 'Stop'"
diff --git a/scripts/build/Sync-AgenticCopilotScaffold.psd1 b/scripts/build/Sync-AgenticCopilotScaffold.psd1
index de05cf08..aa46739a 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.'
@@ -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/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1 b/scripts/build/ci/Invoke-NovaModuleToolsCI.ps1
index aea0ce00..81b38cdc 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 49e4f2df..64ce3579 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/BuildModule.ps1 b/src/private/build/BuildModule.ps1
index 95044427..baa421d4 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 00000000..23bd5244
--- /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/InvokeNovaBuildWorkflow.ps1 b/src/private/build/InvokeNovaBuildWorkflow.ps1
index b59ba8f1..db901d99 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,18 +74,14 @@ 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'
+ Write-Message 'Next steps:'
+ Write-Message 'Invoke-NovaTest'
+ Write-Message 'Test-NovaBuild'
}
function Invoke-NovaBuildDuplicateValidation {
diff --git a/src/private/build/TestProjectSchema.ps1 b/src/private/build/TestProjectSchema.ps1
index 31331dbb..bb278c54 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/cli/InvokeNovaCliCommandRoute.ps1 b/src/private/cli/InvokeNovaCliCommandRoute.ps1
index d63f4994..068a5b5d 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 00e5405e..307dfa1e 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 b49b88df..52c80657 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 fdf003de..570ab855 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'
}
}
@@ -27,27 +27,40 @@ function Get-NovaTestWorkflowContext {
[Parameter(Mandatory)][hashtable]$BoundParameters
)
- Test-ProjectSchema Pester | Out-Null
+ 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
$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)
-
- $testResultPath = Get-NovaPesterTestResultPath -ProjectRoot $projectInfo.ProjectRoot
- $buildRequested = [bool](Get-NovaTestOptionValue -TestOption $TestOption -Name Build)
+ 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]@{
- 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 +68,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 +86,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 +184,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/InitializeNovaPesterExecutionConfiguration.ps1 b/src/private/quality/InitializeNovaPesterExecutionConfiguration.ps1
index 84fc3c0d..45c3ff1c 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/private/quality/InvokeNovaTestWorkflow.ps1 b/src/private/quality/InvokeNovaTestWorkflow.ps1
index 9e314500..9c80426d 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 {
@@ -137,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 {
@@ -156,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
}
}
@@ -210,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."
)
}
@@ -292,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
@@ -331,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()
@@ -343,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')) {
@@ -359,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 949610a1..00000000
--- 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/InvokeNovaPublishWorkflow.ps1 b/src/private/release/InvokeNovaPublishWorkflow.ps1
index 97f34dc1..30903c51 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 9661b7ba..ca4360ee 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 {
@@ -63,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
}
}
@@ -88,7 +92,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 +151,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/src/private/scaffold/InitializeNovaModuleAgenticCopilotScaffold.ps1 b/src/private/scaffold/InitializeNovaModuleAgenticCopilotScaffold.ps1
index e1b1e03e..ef0f144a 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 e479578d..b08efe06 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 0b483c4c..76cb1930 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
@@ -66,6 +70,7 @@ function Get-NovaModuleInitializationNextStepLine {
)
if ($WorkflowContext.Example) {
+ $nextSteps += 'Invoke-NovaTest'
$nextSteps += 'Test-NovaBuild'
return $nextSteps
}
diff --git a/src/private/scaffold/WriteNovaModuleProjectJson.ps1 b/src/private/scaffold/WriteNovaModuleProjectJson.ps1
index 053f38aa..4fe53991 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/private/scaffold/WriteNovaVsCodeSettings.ps1 b/src/private/scaffold/WriteNovaVsCodeSettings.ps1
new file mode 100644
index 00000000..95923363
--- /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/private/shared/GetNovaResolvedProjectPackageSettings.ps1 b/src/private/shared/GetNovaResolvedProjectPackageSettings.ps1
index c04136e7..de501643 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/private/shared/InvokeNovaBuildValidation.ps1 b/src/private/shared/InvokeNovaBuildValidation.ps1
index ea110e52..c004e7be 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 525636dc..5c85021f 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/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1 b/src/private/update/InvokeNovaModuleSelfUpdateWorkflow.ps1
index 40b52668..6ad50db5 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 0bf68cd5..8c5f2505 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/src/public/InvokeNovaTest.ps1 b/src/public/InvokeNovaTest.ps1
new file mode 100644
index 00000000..044bb56b
--- /dev/null
+++ b/src/public/InvokeNovaTest.ps1
@@ -0,0 +1,43 @@
+function Invoke-NovaTest {
+ [CmdletBinding(SupportsShouldProcess = $true)]
+ param (
+ [string[]]$TagFilter,
+ [string[]]$ExcludeTagFilter,
+ [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')]
+ [string]$OutputVerbosity,
+ [ValidateSet('Auto', 'Ansi')]
+ [string]$OutputRenderMode
+ )
+
+ dynamicparam {
+ $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)
+ 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 aceb54a5..f4283c32 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/Schema-Build.json b/src/resources/Schema-Build.json
deleted file mode 100644
index f7359b0a..00000000
--- 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 78dddc0f..00000000
--- 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 00000000..8efcc161
--- /dev/null
+++ b/src/resources/Schema-Project.json
@@ -0,0 +1,355 @@
+{
+ "$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",
+ "enum": ["NuGet", "Zip", ".nupkg", ".zip"],
+ "description": "Valid values: NuGet, Zip, .nupkg, .zip (case-insensitive at runtime)."
+ }
+ },
+ "Latest": {
+ "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/agentic-copilot/.github/agents/powershell-developer.agent.md b/src/resources/agentic-copilot/.github/agents/powershell-developer.agent.md
index f4ccb9d0..e076339e 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 4a64d0bb..ac371f9d 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 5bc9e25d..6908eb38 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 9af879bd..3f7ce9c1 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 5dab39c0..5b0076bb 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 f5251f3f..99c17b6b 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 f6674e62..76c196a3 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/terminal-ux-design.instructions.md b/src/resources/agentic-copilot/.github/instructions/terminal-ux-design.instructions.md
index a6312f2d..f4c4fb00 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:
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 a777486c..939df1df 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 78941273..d71208fa 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.
@@ -26,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 5d58928d..403cf073 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/prepare-release.prompt.md b/src/resources/agentic-copilot/.github/prompts/prepare-release.prompt.md
index 18ece92a..d9fd8f18 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 89f2a200..86f77f2a 100644
--- a/src/resources/agentic-copilot/.github/prompts/review-change.prompt.md
+++ b/src/resources/agentic-copilot/.github/prompts/review-change.prompt.md
@@ -6,12 +6,13 @@ 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.
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 b1ad6b01..38fce455 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/markdown-authoring/SKILL.md b/src/resources/agentic-copilot/.github/skills/markdown-authoring/SKILL.md
index 2669308f..d820f134 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/src/resources/agentic-copilot/.github/skills/pester-testing/SKILL.md b/src/resources/agentic-copilot/.github/skills/pester-testing/SKILL.md
index 50f42d58..a12efcb9 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 f0adeb0b..20914490 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 0b29962f..9ce71cc5 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 eea79664..cc8f1d79 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 f84414c6..6e5b1be1 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/src/resources/example/project.json b/src/resources/example/project.json
index 0476b59f..87cf32f1 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/AgenticCopilotScaffoldSync.Tests.ps1 b/tests/AgenticCopilotScaffoldSync.Tests.ps1
index 8fa13eaf..123d0ff3 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 e9cf6190..83b980b8 100644
--- a/tests/ArchitectureGuardrails.Tests.ps1
+++ b/tests/ArchitectureGuardrails.Tests.ps1
@@ -83,18 +83,19 @@ 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')}
[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 00000000..2242b60f
--- /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 4c53c7c9..848f5fb2 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/ProgressWorkflowGuardrails.Tests.ps1 b/tests/ProgressWorkflowGuardrails.Tests.ps1
new file mode 100644
index 00000000..5734d485
--- /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/PublicCommandTestOwnership.Tests.ps1 b/tests/PublicCommandTestOwnership.Tests.ps1
new file mode 100644
index 00000000..f0a8100c
--- /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 00000000..e1587ec6
--- /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/BuildModule.TestSupport.ps1 b/tests/private/build/BuildModule.TestSupport.ps1
index 682bc5f0..27a0b43f 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 c89dca8f..81c50503 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 00000000..2ecd5ac3
--- /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/InvokeNovaBuildWorkflow.Tests.ps1 b/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1
index 6949da59..f3808d3c 100644
--- a/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1
+++ b/tests/private/build/InvokeNovaBuildWorkflow.Tests.ps1
@@ -36,12 +36,25 @@ Describe 'Invoke-NovaBuildWorkflow' {
Assert-MockCalled Invoke-NovaModuleUpdateNotificationSafely -Times 1
Assert-MockCalled Import-NovaBuiltModuleForCi -Times 0
Assert-MockCalled Write-Progress -Times 9
- Assert-MockCalled Write-Message -Times 3
+ 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 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
@@ -77,10 +90,46 @@ 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-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
+ }
+ }
+
+ 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
}
diff --git a/tests/private/build/TestProjectSchema.Tests.ps1 b/tests/private/build/TestProjectSchema.Tests.ps1
index 24591706..62af82ef 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/cli/InvokeNovaCliCommandRoute.TestSupport.ps1 b/tests/private/cli/InvokeNovaCliCommandRoute.TestSupport.ps1
index d23a4c4a..0f62a6d9 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 c3c260a8..e9c1b610 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/package/InvokeNovaPackageUploadWorkflow.Tests.ps1 b/tests/private/package/InvokeNovaPackageUploadWorkflow.Tests.ps1
index e15e66a1..d8232420 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 5dcad366..916883f7 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/GetNovaPesterRunPath.Tests.ps1 b/tests/private/quality/GetNovaPesterRunPath.Tests.ps1
index 76a0d048..baeb7e25 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 3abcbad8..72c8929d 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 8c4277b3..3760e107 100644
--- a/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1
+++ b/tests/private/quality/GetNovaTestWorkflowContext.TestSupport.ps1
@@ -2,9 +2,36 @@ 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 Initialize-NovaPesterExecutionConfiguration {param($PesterConfig, $BoundParameters, $OutputVerbosity, $OutputRenderMode)}
+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, $ExecutionOption)
+
+ $script:lastExecutionConfigurationRequest = [pscustomobject]@{
+ PesterConfig = $PesterConfig
+ BoundParameters = $BoundParameters
+ ExecutionOption = $ExecutionOption
+ }
+}
function Get-NovaShouldProcessForwardingParameter {param([switch]$WhatIfEnabled) return @{}}
function Write-NovaPesterTestResultArtifact {}
function Write-NovaPesterTestResultReport {}
@@ -22,14 +49,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 abc97c49..a103191b 100644
--- a/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1
+++ b/tests/private/quality/GetNovaTestWorkflowContext.Tests.ps1
@@ -7,53 +7,63 @@ BeforeAll {
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}
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 +95,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'
@@ -95,14 +105,51 @@ 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' {
- 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 +158,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 +218,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 +234,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/InitializeNovaPesterExecutionConfiguration.Tests.ps1 b/tests/private/quality/InitializeNovaPesterExecutionConfiguration.Tests.ps1
index 8caba09d..37eb7d1a 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/private/quality/InvokeNovaTestWorkflow.TestSupport.ps1 b/tests/private/quality/InvokeNovaTestWorkflow.TestSupport.ps1
index 10366f00..cab5bc87 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 c10bc180..eed4cc44 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' {
@@ -16,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 = {}}
@@ -27,12 +29,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'
}
@@ -48,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
}
@@ -66,14 +77,15 @@ 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 = {}}
}
Mock Test-Path {$true}
- Mock Invoke-NovaPester {$PesterResult}.GetNewClosure()
+ Mock Invoke-NovaPesterWithSuppressedProgress {$PesterResult}.GetNewClosure()
$thrown = $null
try {Invoke-NovaTestWorkflow -WorkflowContext $workflowContext} catch {$thrown = $_}
@@ -82,26 +94,27 @@ 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 = {}}
}
Mock Test-Path {$true}
- Mock Invoke-NovaPester {
+ Mock Invoke-NovaPesterWithSuppressedProgress {
[pscustomobject]@{Result = 'Passed'; CodeCoverage = [pscustomobject]@{CoveragePercent = 10}}
}
{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%'
@@ -139,60 +152,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'
@@ -239,10 +198,13 @@ 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-NovaPester {$PesterResult}
+ Mock Invoke-NovaPesterWithSuppressedProgress {$PesterResult}
$thrown = $null
try {
@@ -260,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 {
@@ -268,7 +230,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
}
@@ -277,12 +239,13 @@ 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 = {}}
}
- Mock Invoke-NovaPester {[pscustomobject]@{Result = 'Passed'}}
+ Mock Invoke-NovaPesterWithSuppressedProgress {[pscustomobject]@{Result = 'Passed'}}
try {
Invoke-NovaTestWorkflow -WorkflowContext $workflowContext
Test-Path -LiteralPath $tempDir | Should -BeTrue
@@ -292,24 +255,29 @@ 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 {}
- 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'
}
@@ -317,10 +285,198 @@ 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.'
+ }
+ }
+
+}
+
+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' {
@@ -345,3 +501,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/quality/NewNovaTestDynamicParameterDictionary.Tests.ps1 b/tests/private/quality/NewNovaTestDynamicParameterDictionary.Tests.ps1
deleted file mode 100644
index 0327b33e..00000000
--- 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/InvokeNovaPublishWorkflow.Tests.ps1 b/tests/private/release/InvokeNovaPublishWorkflow.Tests.ps1
index 59281dfb..1b95bca8 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.TestSupport.ps1 b/tests/private/release/InvokeNovaReleaseWorkflow.TestSupport.ps1
index c9b2a695..5f0a7e69 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 2bd26fcd..0821c2e1 100644
--- a/tests/private/release/InvokeNovaReleaseWorkflow.Tests.ps1
+++ b/tests/private/release/InvokeNovaReleaseWorkflow.Tests.ps1
@@ -65,12 +65,21 @@ Describe 'Test-NovaReleaseWorkflowShouldRestoreBuiltModule' {
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
- 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 {}
}
@@ -87,18 +96,34 @@ 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
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' {
@@ -110,14 +135,15 @@ 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-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 +159,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 8b83b75c..9969b594 100644
--- a/tests/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.Tests.ps1
+++ b/tests/private/scaffold/InvokeNovaAgenticCopilotScaffoldWorkflow.Tests.ps1
@@ -110,10 +110,20 @@ Describe 'Invoke-NovaAgenticCopilotScaffoldWorkflow' {
Assert-MockCalled Confirm-NovaAgenticCopilotScaffoldWarning -Times 1
Assert-MockCalled Initialize-NovaModuleAgenticCopilotScaffold -Times 1
Assert-MockCalled Write-Progress -Times 3
- Assert-MockCalled Write-Message -Times 5
+ 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 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.TestSupport.ps1 b/tests/private/scaffold/InvokeNovaModuleInitializationWorkflow.TestSupport.ps1
index 5a730fa6..31108a43 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 8b76241d..5b687803 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,8 +26,19 @@ 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 {
$InputObject -eq 'Created Nova module scaffold: DemoModule' -and $color -eq 'Green'
@@ -39,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 {}
@@ -51,12 +64,17 @@ 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
+ }
+ 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 {}
Mock Initialize-NovaModuleAgenticCopilotScaffold {}
Mock Write-Message {}
Mock Write-Progress {}
@@ -69,8 +87,8 @@ Describe 'Invoke-NovaModuleInitializationWorkflow' {
Invoke-NovaModuleInitializationWorkflow -WorkflowContext $exampleContext
- Assert-MockCalled Write-Message -Times 1 -ParameterFilter {
- $InputObject -eq 'Test-NovaBuild'
- }
+ Assert-MockCalled Write-Progress -Times 1 -ParameterFilter {$Completed}
+ 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/scaffold/WriteNovaModuleProjectJson.Tests.ps1 b/tests/private/scaffold/WriteNovaModuleProjectJson.Tests.ps1
index af3e09e1..bf3064f8 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
+ }
+ }
}
diff --git a/tests/private/scaffold/WriteNovaVsCodeSettings.Tests.ps1 b/tests/private/scaffold/WriteNovaVsCodeSettings.Tests.ps1
new file mode 100644
index 00000000..0df9e6e1
--- /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
+ }
+}
diff --git a/tests/private/shared/GetNovaResolvedProjectPackageSettings.Tests.ps1 b/tests/private/shared/GetNovaResolvedProjectPackageSettings.Tests.ps1
index 9c96b1f4..380fa788 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'
diff --git a/tests/private/shared/InvokeNovaBuildValidation.Tests.ps1 b/tests/private/shared/InvokeNovaBuildValidation.Tests.ps1
index 22998f98..e0a851aa 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 f8636a60..d5acc2a9 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/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1 b/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1
index 8cf42bc9..a74c7147 100644
--- a/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1
+++ b/tests/private/update/InvokeNovaModuleSelfUpdateWorkflow.Tests.ps1
@@ -148,8 +148,15 @@ 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 -Installed'}
+ Assert-MockCalled Write-Message -Times 1 -ParameterFilter {$Text -eq 'Get-NovaProjectInfo -InstalledNovaVersion'}
Assert-MockCalled Write-Progress -Times 3
}
}
diff --git a/tests/public/DeployNovaPackage.Integration.Tests.ps1 b/tests/public/DeployNovaPackage.Integration.Tests.ps1
new file mode 100644
index 00000000..fa476b2a
--- /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 00000000..e9597e53
--- /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/GetNovaProjectInfo.TestSupport.ps1 b/tests/public/GetNovaProjectInfo.TestSupport.ps1
index bd7ad3a6..1323e6a7 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 603ccc03..49c16d61 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' {
diff --git a/tests/public/GetNovaUpdateNotificationPreference.Integration.Tests.ps1 b/tests/public/GetNovaUpdateNotificationPreference.Integration.Tests.ps1
new file mode 100644
index 00000000..9ad6aad5
--- /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 00000000..df737dce
--- /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 00000000..578daac9
--- /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 00000000..967a0572
--- /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 00000000..13eb0e51
--- /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 00000000..4b45c7a8
--- /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 00000000..1d97cb49
--- /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 00000000..f2604079
--- /dev/null
+++ b/tests/public/InvokeNovaTest.Integration.Tests.ps1
@@ -0,0 +1,53 @@
+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
+ }
+
+ 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
new file mode 100644
index 00000000..5f74b37a
--- /dev/null
+++ b/tests/public/InvokeNovaTest.Tests.ps1
@@ -0,0 +1,66 @@
+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' {
+ $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
+ }
+
+ 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 00000000..b64ead8a
--- /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 00000000..3d9440e5
--- /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 00000000..d6d9d6ba
--- /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 00000000..906b9260
--- /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 d7bf8a25..88af35ce 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 00000000..6532cb93
--- /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 00000000..39a5939e
--- /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
+ }
+}
|