From 1cd8c4ea1dec5fd626049d8b1380eba176102d95 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 14:07:12 +0200 Subject: [PATCH 01/13] Add framework boilerplate tests for type accelerators, IsWindows shim, and OnRemove cleanup --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 75 ++++++++++++++++++-- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index 8bf00f5..9c6524f 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -15,13 +15,38 @@ param( BeforeAll { $moduleName = Split-Path -Path (Split-Path -Path $Path -Parent) -Leaf $moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1" + $moduleRootPath = Join-Path -Path $Path -ChildPath "$moduleName.psm1" Write-Verbose "Module Manifest Path: [$moduleManifestPath]" + Write-Verbose "Module Root Path: [$moduleRootPath]" + + # Discover public classes and enums from the compiled module source. + # The class exporter region is injected by Build-PSModule when classes/public contains types. + $moduleContent = if (Test-Path -Path $moduleRootPath) { Get-Content -Path $moduleRootPath -Raw } else { '' } + $hasClassExporter = $moduleContent -match '#region\s+Class exporter' + + # Extract expected class and enum names from the class exporter block. + $expectedClassNames = @() + $expectedEnumNames = @() + if ($hasClassExporter) { + # Match $ExportableClasses = @( ... ) block + if ($moduleContent -match '\$ExportableClasses\s*=\s*@\(([\s\S]*?)\)') { + $expectedClassNames = [regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value } + } + # Match $ExportableEnums = @( ... ) block + if ($moduleContent -match '\$ExportableEnums\s*=\s*@\(([\s\S]*?)\)') { + $expectedEnumNames = [regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value } + } + } + $expectedTypeNames = @($expectedEnumNames) + @($expectedClassNames) | Where-Object { $_ } + Write-Host "Has class exporter: $hasClassExporter" + Write-Host "Expected classes: $($expectedClassNames -join ', ')" + Write-Host "Expected enums: $($expectedEnumNames -join ', ')" } Describe 'PSModule - Module tests' { Context 'Module' { It 'The module should be importable' { - { Import-Module -Name $moduleName } | Should -Not -Throw + { Import-Module -Name $moduleName -Force } | Should -Not -Throw } } @@ -36,9 +61,49 @@ Describe 'PSModule - Module tests' { $result | Should -Not -Be $null Write-Host "$($result | Format-List | Out-String)" } - # It 'has a valid license URL' {} - # It 'has a valid project URL' {} - # It 'has a valid icon URL' {} - # It 'has a valid help URL' {} + } + + Context 'Framework - IsWindows compatibility shim' { + It 'Should have $IsWindows defined' { + # The framework injects "$IsWindows = $true" for PowerShell 5.1 (Desktop edition). + # On PS 7+ (Core), $IsWindows is a built-in automatic variable. + # Either way, it must be defined after module import. + $variable = Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue + $variable | Should -Not -BeNullOrEmpty -Because 'the framework injects a compatibility shim for PS 5.1' + } + } + + Context 'Framework - Type accelerator registration' -Skip:(-not $hasClassExporter) { + BeforeAll { + $typeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get + } + + It 'Should register public enum [<_>] as a type accelerator' -ForEach $expectedEnumNames { + $typeAccelerators.Keys | Should -Contain $_ -Because 'the framework registers public enums as type accelerators' + } + + It 'Should register public class [<_>] as a type accelerator' -ForEach $expectedClassNames { + $typeAccelerators.Keys | Should -Contain $_ -Because 'the framework registers public classes as type accelerators' + } + } + + Context 'Framework - Module OnRemove cleanup' -Skip:(-not $hasClassExporter) { + It 'Should clean up type accelerators when the module is removed' { + # Capture type names before removal + $typeNames = $expectedTypeNames.Clone() + $typeNames | Should -Not -BeNullOrEmpty -Because 'there should be types to verify cleanup for' + + # Remove the module to trigger the OnRemove hook + Remove-Module -Name $moduleName -Force + + # Verify type accelerators are cleaned up + $typeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get + foreach ($typeName in $typeNames) { + $typeAccelerators.Keys | Should -Not -Contain $typeName -Because "the OnRemove hook should remove type accelerator [$typeName]" + } + + # Re-import the module for any subsequent tests + Import-Module -Name $moduleName -Force + } } } From fffbcdb17243cc60486a0846c4aefd0700744884 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 14:14:35 +0200 Subject: [PATCH 02/13] Fix PSScriptAnalyzer warnings and test IsWindows from module scope --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 21 +++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index 9c6524f..d5945fe 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -37,7 +37,6 @@ BeforeAll { $expectedEnumNames = [regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value } } } - $expectedTypeNames = @($expectedEnumNames) + @($expectedClassNames) | Where-Object { $_ } Write-Host "Has class exporter: $hasClassExporter" Write-Host "Expected classes: $($expectedClassNames -join ', ')" Write-Host "Expected enums: $($expectedEnumNames -join ', ')" @@ -64,33 +63,31 @@ Describe 'PSModule - Module tests' { } Context 'Framework - IsWindows compatibility shim' { - It 'Should have $IsWindows defined' { + It 'Should have $IsWindows defined in the module scope' { # The framework injects "$IsWindows = $true" for PowerShell 5.1 (Desktop edition). # On PS 7+ (Core), $IsWindows is a built-in automatic variable. - # Either way, it must be defined after module import. - $variable = Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue - $variable | Should -Not -BeNullOrEmpty -Because 'the framework injects a compatibility shim for PS 5.1' + # The variable is set inside the module scope and is not exported, so we must check from within the module. + $isWindowsDefined = & (Get-Module $moduleName) { Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue } + $isWindowsDefined | Should -Not -BeNullOrEmpty -Because 'the framework injects a compatibility shim for PS 5.1' } } Context 'Framework - Type accelerator registration' -Skip:(-not $hasClassExporter) { - BeforeAll { - $typeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get - } - It 'Should register public enum [<_>] as a type accelerator' -ForEach $expectedEnumNames { - $typeAccelerators.Keys | Should -Contain $_ -Because 'the framework registers public enums as type accelerators' + $registered = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get + $registered.Keys | Should -Contain $_ -Because 'the framework registers public enums as type accelerators' } It 'Should register public class [<_>] as a type accelerator' -ForEach $expectedClassNames { - $typeAccelerators.Keys | Should -Contain $_ -Because 'the framework registers public classes as type accelerators' + $registered = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get + $registered.Keys | Should -Contain $_ -Because 'the framework registers public classes as type accelerators' } } Context 'Framework - Module OnRemove cleanup' -Skip:(-not $hasClassExporter) { It 'Should clean up type accelerators when the module is removed' { # Capture type names before removal - $typeNames = $expectedTypeNames.Clone() + $typeNames = @(@($expectedEnumNames) + @($expectedClassNames) | Where-Object { $_ }) $typeNames | Should -Not -BeNullOrEmpty -Because 'there should be types to verify cleanup for' # Remove the module to trigger the OnRemove hook From c694fad1d0c694ea324a7b99cc2d8f52e7baed8d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 14:56:25 +0200 Subject: [PATCH 03/13] fix: module OnRemove test no longer skips for modules without class exporter --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 23 ++++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index d5945fe..36c2e91 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -84,23 +84,22 @@ Describe 'PSModule - Module tests' { } } - Context 'Framework - Module OnRemove cleanup' -Skip:(-not $hasClassExporter) { - It 'Should clean up type accelerators when the module is removed' { - # Capture type names before removal - $typeNames = @(@($expectedEnumNames) + @($expectedClassNames) | Where-Object { $_ }) - $typeNames | Should -Not -BeNullOrEmpty -Because 'there should be types to verify cleanup for' - + Context 'Framework - Module OnRemove cleanup' { + It 'Should remove the module cleanly and re-import without errors' { # Remove the module to trigger the OnRemove hook - Remove-Module -Name $moduleName -Force + { Remove-Module -Name $moduleName -Force } | Should -Not -Throw - # Verify type accelerators are cleaned up - $typeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get - foreach ($typeName in $typeNames) { - $typeAccelerators.Keys | Should -Not -Contain $typeName -Because "the OnRemove hook should remove type accelerator [$typeName]" + # Verify type accelerators are cleaned up when class exporter is present + if ($hasClassExporter) { + $typeNames = @(@($expectedEnumNames) + @($expectedClassNames) | Where-Object { $_ }) + $typeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get + foreach ($typeName in $typeNames) { + $typeAccelerators.Keys | Should -Not -Contain $typeName -Because "the OnRemove hook should remove type accelerator [$typeName]" + } } # Re-import the module for any subsequent tests - Import-Module -Name $moduleName -Force + { Import-Module -Name $moduleName -Force } | Should -Not -Throw } } } From 17f9456dfbeb704955eb397d9373c2966ea7f00f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 15:01:08 +0200 Subject: [PATCH 04/13] Revert "fix: module OnRemove test no longer skips for modules without class exporter" This reverts commit c694fad1d0c694ea324a7b99cc2d8f52e7baed8d. --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 23 ++++++++++---------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index 36c2e91..d5945fe 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -84,22 +84,23 @@ Describe 'PSModule - Module tests' { } } - Context 'Framework - Module OnRemove cleanup' { - It 'Should remove the module cleanly and re-import without errors' { + Context 'Framework - Module OnRemove cleanup' -Skip:(-not $hasClassExporter) { + It 'Should clean up type accelerators when the module is removed' { + # Capture type names before removal + $typeNames = @(@($expectedEnumNames) + @($expectedClassNames) | Where-Object { $_ }) + $typeNames | Should -Not -BeNullOrEmpty -Because 'there should be types to verify cleanup for' + # Remove the module to trigger the OnRemove hook - { Remove-Module -Name $moduleName -Force } | Should -Not -Throw + Remove-Module -Name $moduleName -Force - # Verify type accelerators are cleaned up when class exporter is present - if ($hasClassExporter) { - $typeNames = @(@($expectedEnumNames) + @($expectedClassNames) | Where-Object { $_ }) - $typeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get - foreach ($typeName in $typeNames) { - $typeAccelerators.Keys | Should -Not -Contain $typeName -Because "the OnRemove hook should remove type accelerator [$typeName]" - } + # Verify type accelerators are cleaned up + $typeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get + foreach ($typeName in $typeNames) { + $typeAccelerators.Keys | Should -Not -Contain $typeName -Because "the OnRemove hook should remove type accelerator [$typeName]" } # Re-import the module for any subsequent tests - { Import-Module -Name $moduleName -Force } | Should -Not -Throw + Import-Module -Name $moduleName -Force } } } From 1021f29a033cce66700354e926a9c3627c9e1d0d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 15:04:11 +0200 Subject: [PATCH 05/13] test: add class exporter and IsWindows shim boilerplate to test module --- .../module/PSModuleTest/PSModuleTest.psm1 | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 b/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 index 63117b6..1ea7053 100644 --- a/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 +++ b/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 @@ -1,10 +1,24 @@ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains long links.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidAssignmentToAutomaticVariable', 'IsWindows', + Justification = 'IsWindows does not exist in PS5.1' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', 'IsWindows', + Justification = 'IsWindows does not exist in PS5.1' +)] [CmdletBinding()] param() $scriptName = $MyInvocation.MyCommand.Name Write-Debug "[$scriptName] Importing module" +#region - IsWindows compatibility shim +if ($PSEdition -eq 'Desktop') { + $IsWindows = $true +} +#endregion - IsWindows compatibility shim + #region - Data import Write-Debug "[$scriptName] - [data] - Processing folder" $dataFolder = (Join-Path $PSScriptRoot 'data') @@ -377,6 +391,48 @@ Write-Verbose '------------------------------' Write-Debug "[$scriptName] - [/finally.ps1] - Done" #endregion - From /finally.ps1 +#region Class exporter +# Get the internal TypeAccelerators class to use its static methods. +$TypeAcceleratorsClass = [psobject].Assembly.GetType( + 'System.Management.Automation.TypeAccelerators' +) +# Ensure none of the types would clobber an existing type accelerator. +# If a type accelerator with the same name exists, throw an exception. +$ExistingTypeAccelerators = $TypeAcceleratorsClass::Get +# Define the types to export with type accelerators. +$ExportableEnums = @( +) +$ExportableEnums | Foreach-Object { Write-Verbose "Exporting enum '$($_.FullName)'." } +foreach ($Type in $ExportableEnums) { + if ($Type.FullName -in $ExistingTypeAccelerators.Keys) { + Write-Verbose "Enum already exists [$($Type.FullName)]. Skipping." + } else { + Write-Verbose "Importing enum '$Type'." + $TypeAcceleratorsClass::Add($Type.FullName, $Type) + } +} +$ExportableClasses = @( + [Book] + [BookList] +) +$ExportableClasses | Foreach-Object { Write-Verbose "Exporting class '$($_.FullName)'." } +foreach ($Type in $ExportableClasses) { + if ($Type.FullName -in $ExistingTypeAccelerators.Keys) { + Write-Verbose "Class already exists [$($Type.FullName)]. Skipping." + } else { + Write-Verbose "Importing class '$Type'." + $TypeAcceleratorsClass::Add($Type.FullName, $Type) + } +} + +# Remove type accelerators when the module is removed. +$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { + foreach ($Type in ($ExportableEnums + $ExportableClasses)) { + $null = $TypeAcceleratorsClass::Remove($Type.FullName) + } +}.GetNewClosure() +#endregion Class exporter + $exports = @{ Cmdlet = '' Alias = '*' From cb811a72af0de5ad61914e38b601e5d2f3b38b38 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 15:08:31 +0200 Subject: [PATCH 06/13] fix: address review - import by manifest path, self-contained contexts, try/finally in OnRemove --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 33 ++++++++++++------- .../module/PSModuleTest/PSModuleTest.psm1 | 8 ++--- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index d5945fe..82583c8 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -45,7 +45,7 @@ BeforeAll { Describe 'PSModule - Module tests' { Context 'Module' { It 'The module should be importable' { - { Import-Module -Name $moduleName -Force } | Should -Not -Throw + { Import-Module -Name $moduleManifestPath -Force } | Should -Not -Throw } } @@ -63,16 +63,22 @@ Describe 'PSModule - Module tests' { } Context 'Framework - IsWindows compatibility shim' { + BeforeAll { + $script:moduleRef = Import-Module -Name $moduleManifestPath -Force -PassThru + } It 'Should have $IsWindows defined in the module scope' { # The framework injects "$IsWindows = $true" for PowerShell 5.1 (Desktop edition). # On PS 7+ (Core), $IsWindows is a built-in automatic variable. # The variable is set inside the module scope and is not exported, so we must check from within the module. - $isWindowsDefined = & (Get-Module $moduleName) { Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue } + $isWindowsDefined = & $script:moduleRef { Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue } $isWindowsDefined | Should -Not -BeNullOrEmpty -Because 'the framework injects a compatibility shim for PS 5.1' } } Context 'Framework - Type accelerator registration' -Skip:(-not $hasClassExporter) { + BeforeAll { + Import-Module -Name $moduleManifestPath -Force + } It 'Should register public enum [<_>] as a type accelerator' -ForEach $expectedEnumNames { $registered = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get $registered.Keys | Should -Contain $_ -Because 'the framework registers public enums as type accelerators' @@ -85,22 +91,27 @@ Describe 'PSModule - Module tests' { } Context 'Framework - Module OnRemove cleanup' -Skip:(-not $hasClassExporter) { + BeforeAll { + Import-Module -Name $moduleManifestPath -Force + } It 'Should clean up type accelerators when the module is removed' { # Capture type names before removal $typeNames = @(@($expectedEnumNames) + @($expectedClassNames) | Where-Object { $_ }) $typeNames | Should -Not -BeNullOrEmpty -Because 'there should be types to verify cleanup for' - # Remove the module to trigger the OnRemove hook - Remove-Module -Name $moduleName -Force + try { + # Remove the module to trigger the OnRemove hook + Remove-Module -Name $moduleName -Force - # Verify type accelerators are cleaned up - $typeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get - foreach ($typeName in $typeNames) { - $typeAccelerators.Keys | Should -Not -Contain $typeName -Because "the OnRemove hook should remove type accelerator [$typeName]" + # Verify type accelerators are cleaned up + $typeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get + foreach ($typeName in $typeNames) { + $typeAccelerators.Keys | Should -Not -Contain $typeName -Because "the OnRemove hook should remove type accelerator [$typeName]" + } + } finally { + # Re-import the module for any subsequent tests + Import-Module -Name $moduleManifestPath -Force } - - # Re-import the module for any subsequent tests - Import-Module -Name $moduleName -Force } } } diff --git a/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 b/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 index 1ea7053..6a4cc3b 100644 --- a/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 +++ b/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 @@ -204,7 +204,7 @@ Write-Debug "[$scriptName] - [/private] - Processing folder" #region - From /private/Get-InternalPSModule.ps1 Write-Debug "[$scriptName] - [/private/Get-InternalPSModule.ps1] - Importing" -Function Get-InternalPSModule { +function Get-InternalPSModule { <# .SYNOPSIS Performs tests on a module. @@ -228,7 +228,7 @@ Write-Debug "[$scriptName] - [/private/Get-InternalPSModule.ps1] - Done" #region - From /private/Set-InternalPSModule.ps1 Write-Debug "[$scriptName] - [/private/Set-InternalPSModule.ps1] - Importing" -Function Set-InternalPSModule { +function Set-InternalPSModule { <# .SYNOPSIS Performs tests on a module. @@ -402,7 +402,7 @@ $ExistingTypeAccelerators = $TypeAcceleratorsClass::Get # Define the types to export with type accelerators. $ExportableEnums = @( ) -$ExportableEnums | Foreach-Object { Write-Verbose "Exporting enum '$($_.FullName)'." } +$ExportableEnums | ForEach-Object { Write-Verbose "Exporting enum '$($_.FullName)'." } foreach ($Type in $ExportableEnums) { if ($Type.FullName -in $ExistingTypeAccelerators.Keys) { Write-Verbose "Enum already exists [$($Type.FullName)]. Skipping." @@ -415,7 +415,7 @@ $ExportableClasses = @( [Book] [BookList] ) -$ExportableClasses | Foreach-Object { Write-Verbose "Exporting class '$($_.FullName)'." } +$ExportableClasses | ForEach-Object { Write-Verbose "Exporting class '$($_.FullName)'." } foreach ($Type in $ExportableClasses) { if ($Type.FullName -in $ExistingTypeAccelerators.Keys) { Write-Verbose "Class already exists [$($Type.FullName)]. Skipping." From b038dc1b7dc4a3e05953cc30cfffe0ced05559e4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 15:18:45 +0200 Subject: [PATCH 07/13] fix: use Get-Module after import to get single module reference --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index 82583c8..84c3ebe 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -64,7 +64,8 @@ Describe 'PSModule - Module tests' { Context 'Framework - IsWindows compatibility shim' { BeforeAll { - $script:moduleRef = Import-Module -Name $moduleManifestPath -Force -PassThru + Import-Module -Name $moduleManifestPath -Force + $script:moduleRef = Get-Module -Name $moduleName } It 'Should have $IsWindows defined in the module scope' { # The framework injects "$IsWindows = $true" for PowerShell 5.1 (Desktop edition). From f62b2ac5214b44cd3eb006789c4dd98b77f81219 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 15:37:05 +0200 Subject: [PATCH 08/13] fix: move discovery variables out of BeforeAll for Pester 5 compatibility Pester v5 evaluates -Skip and -ForEach during the Discovery phase, but BeforeAll only runs during the Run phase. This caused hasClassExporter, expectedClassNames, and expectedEnumNames to be null during discovery, skipping all framework type-accelerator tests. Move the module content reading and regex extraction to the top-level script scope so the variables are available when Pester discovers tests. --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 48 ++++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index 84c3ebe..061ac6a 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -12,35 +12,33 @@ param( [string] $Path ) -BeforeAll { - $moduleName = Split-Path -Path (Split-Path -Path $Path -Parent) -Leaf - $moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1" - $moduleRootPath = Join-Path -Path $Path -ChildPath "$moduleName.psm1" - Write-Verbose "Module Manifest Path: [$moduleManifestPath]" - Write-Verbose "Module Root Path: [$moduleRootPath]" +# Discovery-phase variables — must be set at script scope (outside BeforeAll) so that +# Pester's -Skip and -ForEach parameters can reference them during test discovery. +$moduleName = Split-Path -Path (Split-Path -Path $Path -Parent) -Leaf +$moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1" +$moduleRootPath = Join-Path -Path $Path -ChildPath "$moduleName.psm1" - # Discover public classes and enums from the compiled module source. - # The class exporter region is injected by Build-PSModule when classes/public contains types. - $moduleContent = if (Test-Path -Path $moduleRootPath) { Get-Content -Path $moduleRootPath -Raw } else { '' } - $hasClassExporter = $moduleContent -match '#region\s+Class exporter' +# Discover public classes and enums from the compiled module source. +# The class exporter region is injected by Build-PSModule when classes/public contains types. +$moduleContent = if (Test-Path -Path $moduleRootPath) { Get-Content -Path $moduleRootPath -Raw } else { '' } +$hasClassExporter = $moduleContent -match '#region\s+Class exporter' - # Extract expected class and enum names from the class exporter block. - $expectedClassNames = @() - $expectedEnumNames = @() - if ($hasClassExporter) { - # Match $ExportableClasses = @( ... ) block - if ($moduleContent -match '\$ExportableClasses\s*=\s*@\(([\s\S]*?)\)') { - $expectedClassNames = [regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value } - } - # Match $ExportableEnums = @( ... ) block - if ($moduleContent -match '\$ExportableEnums\s*=\s*@\(([\s\S]*?)\)') { - $expectedEnumNames = [regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value } - } +# Extract expected class and enum names from the class exporter block. +$expectedClassNames = @() +$expectedEnumNames = @() +if ($hasClassExporter) { + # Match $ExportableClasses = @( ... ) block + if ($moduleContent -match '\$ExportableClasses\s*=\s*@\(([\s\S]*?)\)') { + $expectedClassNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value }) + } + # Match $ExportableEnums = @( ... ) block + if ($moduleContent -match '\$ExportableEnums\s*=\s*@\(([\s\S]*?)\)') { + $expectedEnumNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value }) } - Write-Host "Has class exporter: $hasClassExporter" - Write-Host "Expected classes: $($expectedClassNames -join ', ')" - Write-Host "Expected enums: $($expectedEnumNames -join ', ')" } +Write-Host "Has class exporter: $hasClassExporter" +Write-Host "Expected classes: $($expectedClassNames -join ', ')" +Write-Host "Expected enums: $($expectedEnumNames -join ', ')" Describe 'PSModule - Module tests' { Context 'Module' { From bba415d3f3d20030d3f6c3630213090ef813b51d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 16:21:36 +0200 Subject: [PATCH 09/13] fix: bridge discovery-scope variables into Run phase via BeforeAll Pester v5 script-scope variables are visible during Discovery (for -Skip and -ForEach) but not inside It blocks during the Run phase. Add a BeforeAll inside the Describe block that re-exposes the discovery variables so It blocks can reference moduleName, moduleManifestPath, etc. Also fix Context blocks that were incorrectly renamed to Get-Context. --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index 061ac6a..a6029ea 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -41,6 +41,16 @@ Write-Host "Expected classes: $($expectedClassNames -join ', ')" Write-Host "Expected enums: $($expectedEnumNames -join ', ')" Describe 'PSModule - Module tests' { + BeforeAll { + # Re-expose discovery-phase variables for the Run phase (It blocks). + # Pester v5 script-scope variables are visible during Discovery but not inside It blocks. + $moduleName = $script:moduleName + $moduleManifestPath = $script:moduleManifestPath + $hasClassExporter = $script:hasClassExporter + $expectedClassNames = $script:expectedClassNames + $expectedEnumNames = $script:expectedEnumNames + } + Context 'Module' { It 'The module should be importable' { { Import-Module -Name $moduleManifestPath -Force } | Should -Not -Throw From 363030f3ddf0fb1c6da82fd84f67f9f220dbe2ab Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 16:32:46 +0200 Subject: [PATCH 10/13] fix: use top-level BeforeAll to recompute variables for Run phase Pester v5 Discovery and Run are separate executions. Script-level variables set during Discovery do not exist during Run, so referencing them via $script: in a Describe BeforeAll yields null. Replace the Describe-level BeforeAll bridge with a top-level BeforeAll that recomputes all variables from $Path, which Pester re-passes via container data to both phases. --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 33 ++++++++++++++------ 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index a6029ea..7c748ce 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -40,17 +40,32 @@ Write-Host "Has class exporter: $hasClassExporter" Write-Host "Expected classes: $($expectedClassNames -join ', ')" Write-Host "Expected enums: $($expectedEnumNames -join ', ')" -Describe 'PSModule - Module tests' { - BeforeAll { - # Re-expose discovery-phase variables for the Run phase (It blocks). - # Pester v5 script-scope variables are visible during Discovery but not inside It blocks. - $moduleName = $script:moduleName - $moduleManifestPath = $script:moduleManifestPath - $hasClassExporter = $script:hasClassExporter - $expectedClassNames = $script:expectedClassNames - $expectedEnumNames = $script:expectedEnumNames +# Run-phase setup — recompute from $Path (available in both Discovery and Run phases). +# Script-level variables above only exist during Discovery; BeforeAll runs only during Run. +BeforeAll { + $moduleName = Split-Path -Path (Split-Path -Path $Path -Parent) -Leaf + $moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1" + $moduleRootPath = Join-Path -Path $Path -ChildPath "$moduleName.psm1" + + $moduleContent = if (Test-Path -Path $moduleRootPath) { Get-Content -Path $moduleRootPath -Raw } else { '' } + $hasClassExporter = $moduleContent -match '#region\s+Class exporter' + + $expectedClassNames = @() + $expectedEnumNames = @() + if ($hasClassExporter) { + if ($moduleContent -match '\$ExportableClasses\s*=\s*@\(([\s\S]*?)\)') { + $expectedClassNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value }) + } + if ($moduleContent -match '\$ExportableEnums\s*=\s*@\(([\s\S]*?)\)') { + $expectedEnumNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value }) + } } + Write-Host "Run phase - Module: $moduleName" + Write-Host "Run phase - Manifest: $moduleManifestPath" + Write-Host "Run phase - Has class exporter: $hasClassExporter" +} +Describe 'PSModule - Module tests' { Context 'Module' { It 'The module should be importable' { { Import-Module -Name $moduleManifestPath -Force } | Should -Not -Throw From 3f458389e8d65bf6e33e7c178070276181e94aef Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 17:11:39 +0200 Subject: [PATCH 11/13] fix: suppress PSUseDeclaredVarsMoreThanAssignments for BeforeAll vars --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index 7c748ce..56318e6 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -6,6 +6,10 @@ 'PSAvoidUsingWriteHost', '', Justification = 'Log outputs to GitHub Actions logs.' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Variables are set in BeforeAll and consumed in Describe/It blocks.' +)] [CmdLetBinding()] param( [Parameter(Mandatory)] From 17372ea3d91ece916afdef9dc214dff6bb050a8f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 17:49:37 +0200 Subject: [PATCH 12/13] refactor: remove IsWindows compatibility shim test The $IsWindows PS 5.1 shim is being removed from Build-PSModule since PSModule targets PowerShell LTS (7.6+). Remove the corresponding test context and update the test repo output fixture. --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 14 -------------- .../outputs/module/PSModuleTest/PSModuleTest.psm1 | 14 -------------- 2 files changed, 28 deletions(-) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index 56318e6..cde3b7f 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -89,20 +89,6 @@ Describe 'PSModule - Module tests' { } } - Context 'Framework - IsWindows compatibility shim' { - BeforeAll { - Import-Module -Name $moduleManifestPath -Force - $script:moduleRef = Get-Module -Name $moduleName - } - It 'Should have $IsWindows defined in the module scope' { - # The framework injects "$IsWindows = $true" for PowerShell 5.1 (Desktop edition). - # On PS 7+ (Core), $IsWindows is a built-in automatic variable. - # The variable is set inside the module scope and is not exported, so we must check from within the module. - $isWindowsDefined = & $script:moduleRef { Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue } - $isWindowsDefined | Should -Not -BeNullOrEmpty -Because 'the framework injects a compatibility shim for PS 5.1' - } - } - Context 'Framework - Type accelerator registration' -Skip:(-not $hasClassExporter) { BeforeAll { Import-Module -Name $moduleManifestPath -Force diff --git a/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 b/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 index 6a4cc3b..a4e7432 100644 --- a/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 +++ b/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 @@ -1,24 +1,10 @@ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains long links.')] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSAvoidAssignmentToAutomaticVariable', 'IsWindows', - Justification = 'IsWindows does not exist in PS5.1' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', 'IsWindows', - Justification = 'IsWindows does not exist in PS5.1' -)] [CmdletBinding()] param() $scriptName = $MyInvocation.MyCommand.Name Write-Debug "[$scriptName] Importing module" -#region - IsWindows compatibility shim -if ($PSEdition -eq 'Desktop') { - $IsWindows = $true -} -#endregion - IsWindows compatibility shim - #region - Data import Write-Debug "[$scriptName] - [data] - Processing folder" $dataFolder = (Join-Path $PSScriptRoot 'data') From 20c2731f0d5abf23ac8f23790c71e50d7aa8c056 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Apr 2026 18:22:39 +0200 Subject: [PATCH 13/13] fix: address review comments - move Write-Host, clarify dual-computation, fix fixture comment --- src/tests/Module/PSModule/PSModule.Tests.ps1 | 16 ++++++++-------- .../module/PSModuleTest/PSModuleTest.psm1 | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index cde3b7f..12c6141 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -40,12 +40,12 @@ if ($hasClassExporter) { $expectedEnumNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value }) } } -Write-Host "Has class exporter: $hasClassExporter" -Write-Host "Expected classes: $($expectedClassNames -join ', ')" -Write-Host "Expected enums: $($expectedEnumNames -join ', ')" -# Run-phase setup — recompute from $Path (available in both Discovery and Run phases). -# Script-level variables above only exist during Discovery; BeforeAll runs only during Run. + +# Run-phase setup — recompute from $Path so that It/Context blocks can use these variables. +# Pester v5 Discovery and Run are separate executions. The script-scope variables above drive +# -Skip and -ForEach during Discovery. This BeforeAll recomputes the same values for the Run +# phase, where It blocks actually execute. The duplication is intentional and required. BeforeAll { $moduleName = Split-Path -Path (Split-Path -Path $Path -Parent) -Leaf $moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1" @@ -64,9 +64,9 @@ BeforeAll { $expectedEnumNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value }) } } - Write-Host "Run phase - Module: $moduleName" - Write-Host "Run phase - Manifest: $moduleManifestPath" - Write-Host "Run phase - Has class exporter: $hasClassExporter" + Write-Host "Has class exporter: $hasClassExporter" + Write-Host "Expected classes: $($expectedClassNames -join ', ')" + Write-Host "Expected enums: $($expectedEnumNames -join ', ')" } Describe 'PSModule - Module tests' { diff --git a/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 b/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 index a4e7432..62a7268 100644 --- a/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 +++ b/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 @@ -383,7 +383,7 @@ $TypeAcceleratorsClass = [psobject].Assembly.GetType( 'System.Management.Automation.TypeAccelerators' ) # Ensure none of the types would clobber an existing type accelerator. -# If a type accelerator with the same name exists, throw an exception. +# If a type accelerator with the same name already exists, skip adding it. $ExistingTypeAccelerators = $TypeAcceleratorsClass::Get # Define the types to export with type accelerators. $ExportableEnums = @(