diff --git a/src/functions/InModuleScope.ps1 b/src/functions/InModuleScope.ps1 index 9fab76ca3..19132ad53 100644 --- a/src/functions/InModuleScope.ps1 +++ b/src/functions/InModuleScope.ps1 @@ -156,10 +156,10 @@ Set-ScriptBlockScope -ScriptBlock $ScriptBlock -SessionState $sessionState Set-ScriptBlockScope -ScriptBlock $wrapper -SessionState $sessionState $splat = @{ - ScriptBlock = $ScriptBlock - Parameters = $Parameters - ArgumentList = $ArgumentList - SessionState = $sessionState + ScriptBlock = $ScriptBlock + Parameters = $Parameters + ArgumentList = $ArgumentList + SessionState = $sessionState } Write-ScriptBlockInvocationHint -Hint "InModuleScope" -ScriptBlock $ScriptBlock @@ -173,6 +173,84 @@ function Get-CompatibleModule { [string] $ModuleName ) + # Slash/backslash notation: RootModule/NestedModule[/DeeperNestedModule...] + # Resolves the full nested path from left to right so deeply nested modules can be targeted. + if ($ModuleName -match '[/\\]') { + # Split on / or \ and trim whitespace from each segment. + $modulePathSegments = @($ModuleName -split '[/\\]') + $modulePathSegments = @($modulePathSegments | & $SafeCommands['ForEach-Object'] { $_.Trim() }) + $hasEmptySegment = @($modulePathSegments | & $SafeCommands['Where-Object'] { [string]::IsNullOrEmpty($_) }).Count -gt 0 + + # Require at least two non-empty segments (root + one nested level). + if ($modulePathSegments.Count -lt 2 -or $hasEmptySegment) { + throw "Invalid ModuleName format '$ModuleName'. Expected format: 'RootModuleName/NestedModuleName[/DeeperNestedModuleName...]'." + } + + # Seed the search with all copies of the root module (Get-Module -All covers reimports). + $rootModuleName = $modulePathSegments[0] + if ($PesterPreference.Debug.WriteDebugMessages.Value) { + Write-PesterDebugMessage -Scope Runtime "Nested path notation detected in ModuleName '$ModuleName'. Resolving from root module '$rootModuleName'." + } + + $currentModules = @(& $SafeCommands['Get-Module'] -Name $rootModuleName -All -ErrorAction SilentlyContinue) + if ($currentModules.Count -eq 0) { + throw "No modules named '$rootModuleName' are currently loaded." + } + + # Walk each path segment after the root, narrowing $currentModules to the matched nested module at each level. + $resolvedPath = $rootModuleName + for ($index = 1; $index -lt $modulePathSegments.Count; $index++) { + $nestedModuleName = $modulePathSegments[$index] + $nextModules = [System.Collections.Generic.List[object]]@() # matches for this segment + $availableNested = [System.Collections.Generic.List[string]]@() # all sibling names, for error messages + + if ($PesterPreference.Debug.WriteDebugMessages.Value) { + Write-PesterDebugMessage -Scope Runtime "Resolving nested module segment '$nestedModuleName' under '$resolvedPath'." + } + + # Scan NestedModules of every candidate at the current level. + foreach ($parentModule in $currentModules) { + foreach ($nestedModule in @($parentModule.NestedModules)) { + # Collect unique sibling names so the error message can list available options. + if (-not [string]::IsNullOrEmpty($nestedModule.Name) -and -not $availableNested.Contains($nestedModule.Name)) { + $availableNested.Add($nestedModule.Name) + } + + if ($nestedModule.Name -eq $nestedModuleName) { + $nextModules.Add($nestedModule) + } + } + } + + # No match: segment name is wrong or the nested module is not loaded. + if ($nextModules.Count -eq 0) { + $availableList = if ($availableNested.Count -gt 0) { $availableNested -join ', ' } else { '(none)' } + throw "No nested module named '$nestedModuleName' was found under '$resolvedPath'. Available nested modules: $availableList." + } + + # More than one match: ambiguous — multiple loaded copies of the same module exist. + if ($nextModules.Count -gt 1) { + throw "Multiple nested modules named '$nestedModuleName' were found under '$resolvedPath' across loaded module copies. Make sure to remove any extra copies of the module from your session before testing." + } + + $resolvedNested = $nextModules[0] + # Only Script/Manifest modules expose a usable session state for InModuleScope. + if ($resolvedNested.ModuleType -notin 'Script', 'Manifest') { + throw "Nested module '$nestedModuleName' in path '$resolvedPath/$nestedModuleName' is not a Script or Manifest module. Detected module type: '$($resolvedNested.ModuleType)'." + } + + # Narrow to the single resolved module and advance the path tracker for the next iteration. + $currentModules = @($resolvedNested) + $resolvedPath = "$resolvedPath/$nestedModuleName" + } + + if ($PesterPreference.Debug.WriteDebugMessages.Value) { + Write-PesterDebugMessage -Scope Runtime "Found nested module $($currentModules[0].Name) version $($currentModules[0].Version) in path $resolvedPath." + } + + return $currentModules[0] + } + try { if ($PesterPreference.Debug.WriteDebugMessages.Value) { Write-PesterDebugMessage -Scope Runtime "Searching for a module $ModuleName." diff --git a/src/functions/Mock.ps1 b/src/functions/Mock.ps1 index 914404a5b..63b03178a 100644 --- a/src/functions/Mock.ps1 +++ b/src/functions/Mock.ps1 @@ -662,6 +662,11 @@ function Resolve-Command { Write-PesterDebugMessage -Scope Mock "Found module $($module.Name) version $($module.Version)." } + # Normalize $ModuleName to the plain module name in case slash notation ('Root/Nested') + # was used. All downstream uses (TargetModule, mock-table keys, IsFromTargetModule) must + # use the plain name, not the slash string. + $ModuleName = $module.Name + # this is the target session state in which we will insert the mock $SessionState = $module.SessionState } diff --git a/tst/functions/InModuleScope.Tests.ps1 b/tst/functions/InModuleScope.Tests.ps1 index 9f3f74fd1..51b013086 100644 --- a/tst/functions/InModuleScope.Tests.ps1 +++ b/tst/functions/InModuleScope.Tests.ps1 @@ -125,6 +125,150 @@ Describe 'Get-CompatibleModule' { } } + Context 'when module name uses slash notation RootModule/NestedModule' { + BeforeAll { + $nestedModuleName = 'NestedModule' + $rootModuleName = 'RootWithNestedModule' + $moduleManifestPath = "TestDrive:/$rootModuleName.psd1" + $nestedScriptPath = "TestDrive:/$nestedModuleName.psm1" + Set-Content -Path $nestedScriptPath -Value '[string]$Script:NestedVar = "NestedValue"' + New-ModuleManifest -Path $moduleManifestPath -NestedModules ".\$nestedModuleName.psm1" + Import-Module $moduleManifestPath -Force + } + + AfterAll { + Get-Module $rootModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + } + + It 'should return the nested module via forward-slash delimiter' { + $moduleInfo = InPesterModuleScope { Get-CompatibleModule -ModuleName 'RootWithNestedModule/NestedModule' } + $moduleInfo | Should -Not -BeNullOrEmpty + @($moduleInfo).Count | Should -Be 1 + $moduleInfo.Name | Should -Be 'NestedModule' + $moduleInfo.ModuleType | Should -Be 'Script' + } + + It 'should return the nested module via backslash delimiter' { + $moduleInfo = InPesterModuleScope { Get-CompatibleModule -ModuleName 'RootWithNestedModule\NestedModule' } + $moduleInfo | Should -Not -BeNullOrEmpty + @($moduleInfo).Count | Should -Be 1 + $moduleInfo.Name | Should -Be 'NestedModule' + $moduleInfo.ModuleType | Should -Be 'Script' + } + + It 'should execute in the nested module session state' { + $name = InModuleScope -ModuleName 'RootWithNestedModule/NestedModule' -ScriptBlock { + $ExecutionContext.SessionState.Module.Name + } + $name | Should -Be 'NestedModule' + } + + It 'should read a variable defined in the nested module' { + InModuleScope -ModuleName 'RootWithNestedModule/NestedModule' -ScriptBlock { + $Script:NestedVar | Should -Be 'NestedValue' + } + } + + It 'should throw when root module is not loaded' { + $sb = { InPesterModuleScope { Get-CompatibleModule -ModuleName 'NonExistentRoot/NestedModule' } } + $sb | Should -Throw "No modules named 'NonExistentRoot' are currently loaded." + } + + It 'should throw with available names when nested module is not found in root' { + $sb = { InPesterModuleScope { Get-CompatibleModule -ModuleName 'RootWithNestedModule/NoSuchNested' } } + $sb | Should -Throw "No nested module named 'NoSuchNested' was found under 'RootWithNestedModule'*" + } + } + + Context 'when module name uses deep slash notation RootModule/NestedModule/LeafModule' { + BeforeAll { + $rootModuleName = 'DeepRootModule' + $midModuleName = 'DeepMidModule' + $leafModuleName = 'DeepLeafModule' + + $rootManifestPath = "TestDrive:/$rootModuleName.psd1" + $midManifestPath = "TestDrive:/$midModuleName.psd1" + $leafScriptPath = "TestDrive:/$leafModuleName.psm1" + + Set-Content -Path $leafScriptPath -Value '[string]$Script:DeepNestedVar = "DeepNestedValue"' + New-ModuleManifest -Path $midManifestPath -NestedModules ".\$leafModuleName.psm1" + New-ModuleManifest -Path $rootManifestPath -NestedModules ".\$midModuleName.psd1" + + Import-Module $rootManifestPath -Force + } + + AfterAll { + Get-Module $rootModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + } + + It 'should resolve the leaf module via forward-slash deep path' { + $moduleInfo = InPesterModuleScope { Get-CompatibleModule -ModuleName 'DeepRootModule/DeepMidModule/DeepLeafModule' } + $moduleInfo | Should -Not -BeNullOrEmpty + $moduleInfo.Name | Should -Be 'DeepLeafModule' + $moduleInfo.ModuleType | Should -Be 'Script' + } + + It 'should resolve the leaf module via mixed slash and backslash deep path' { + $moduleInfo = InPesterModuleScope { Get-CompatibleModule -ModuleName 'DeepRootModule\DeepMidModule/DeepLeafModule' } + $moduleInfo | Should -Not -BeNullOrEmpty + $moduleInfo.Name | Should -Be 'DeepLeafModule' + $moduleInfo.ModuleType | Should -Be 'Script' + } + + It 'should execute in the deeply nested module session state' { + $name = InModuleScope -ModuleName 'DeepRootModule/DeepMidModule/DeepLeafModule' -ScriptBlock { + $ExecutionContext.SessionState.Module.Name + } + $name | Should -Be 'DeepLeafModule' + } + + It 'should read a variable defined in the deeply nested module' { + InModuleScope -ModuleName 'DeepRootModule/DeepMidModule/DeepLeafModule' -ScriptBlock { + $Script:DeepNestedVar | Should -Be 'DeepNestedValue' + } + } + + It 'should throw with available names when a deeper nested segment is not found' { + $sb = { InPesterModuleScope { Get-CompatibleModule -ModuleName 'DeepRootModule/DeepMidModule/NoSuchLeaf' } } + $sb | Should -Throw "No nested module named 'NoSuchLeaf' was found under 'DeepRootModule/DeepMidModule'*" + } + } + + Context 'when two root modules share a nested module name (disambiguation use-case)' { + BeforeAll { + $sharedNestedName = 'SharedRepository' + $rootA = 'ClientA' + $rootB = 'ClientB' + $manifestA = "TestDrive:/$rootA.psd1" + $manifestB = "TestDrive:/$rootB.psd1" + $nestedA = "TestDrive:/$sharedNestedName`_A.psm1" + $nestedB = "TestDrive:/$sharedNestedName`_B.psm1" + # Both root modules have a nested module called 'SharedRepository' + Set-Content -Path $nestedA -Value '[string]$Script:RepoId = "RepoA"' + Set-Content -Path $nestedB -Value '[string]$Script:RepoId = "RepoB"' + # Rename nested copies so they each load as 'SharedRepository' (same base name) + $nestedPathA = "TestDrive:/$sharedNestedName.psm1" + $nestedPathB = "TestDrive:/B_$sharedNestedName.psm1" + Set-Content -Path $nestedPathA -Value '[string]$Script:RepoId = "RepoA"' + Set-Content -Path $nestedPathB -Value '[string]$Script:RepoId = "RepoB"' + New-ModuleManifest -Path $manifestA -NestedModules ".\$sharedNestedName.psm1" + New-ModuleManifest -Path $manifestB -NestedModules ".\B_$sharedNestedName.psm1" + Import-Module $manifestA -Force + Import-Module $manifestB -Force + } + + AfterAll { + Get-Module $rootA -ErrorAction SilentlyContinue | Remove-Module -Force + Get-Module $rootB -ErrorAction SilentlyContinue | Remove-Module -Force + } + + It 'should resolve to ClientA nested module via slash notation' { + $name = InModuleScope -ModuleName "$rootA/$sharedNestedName" -ScriptBlock { + $ExecutionContext.SessionState.Module.Name + } + $name | Should -Be $sharedNestedName + } + } } Describe 'InModuleScope arguments and parameter binding' { @@ -340,4 +484,4 @@ Describe 'Working with manifest modules' { $res = InModuleScope -ModuleName $moduleName -ScriptBlock { myPrivateFunction } $res | Should -Be 'real' } -} \ No newline at end of file +} diff --git a/tst/functions/Mock.Tests.ps1 b/tst/functions/Mock.Tests.ps1 index 575b8ee56..3f9d1da4d 100644 --- a/tst/functions/Mock.Tests.ps1 +++ b/tst/functions/Mock.Tests.ps1 @@ -3126,7 +3126,163 @@ Describe 'Mocking in manifest modules' { } } -Describe 'Mocking with nested Pester runs' { +Describe "Mocking using 'RootModule/NestedModule' slash notation" { + # Primary use-case: two simultaneously loaded root modules that each have a nested module + # with the same name (e.g. two REST clients both exposing a 'Repository' sub-module). + # Slash notation lets you target the correct one unambiguously. + + BeforeAll { + $nestedName = 'SlashNotationNested' + $rootName = 'SlashNotationRoot' + $manifestPath = "TestDrive:/$rootName.psd1" + $nestedPath = "TestDrive:/$nestedName.psm1" + + Set-Content -Path $nestedPath -Value { + function Get-InternalData { + 'real' + } + + function Get-PublicData { + Get-InternalData + } + } + New-ModuleManifest -Path $manifestPath -NestedModules ".\$nestedName.psm1" -FunctionsToExport 'Get-PublicData' + Import-Module $manifestPath -Force + } + + AfterAll { + Get-Module $rootName -ErrorAction SilentlyContinue | Remove-Module -Force + } + + It 'Should mock an internal command in the nested module using slash notation' { + Mock -CommandName 'Get-InternalData' -ModuleName "$rootName/$nestedName" -MockWith { 'mocked' } + $result = Get-PublicData + $result | Should -Be 'mocked' + } + + It 'Should-Invoke matches call history when using slash notation' { + Mock -CommandName 'Get-InternalData' -ModuleName "$rootName/$nestedName" -MockWith { 'mocked' } + $null = Get-PublicData + Should -Invoke -CommandName 'Get-InternalData' -ModuleName "$rootName/$nestedName" -Exactly -Times 1 + } + + It 'Should-NotInvoke passes when command was not called' { + Mock -CommandName 'Get-InternalData' -ModuleName "$rootName/$nestedName" -MockWith { 'mocked' } + Should -Not -Invoke -CommandName 'Get-InternalData' -ModuleName "$rootName/$nestedName" + } + + It 'Should-Invoke accepts plain nested module name after mock was set up with slash notation' { + # The mock was set up targeting the nested module; its TargetModule is the plain nested name. + Mock -CommandName 'Get-InternalData' -ModuleName "$rootName/$nestedName" -MockWith { 'mocked' } + $null = Get-PublicData + Should -Invoke -CommandName 'Get-InternalData' -ModuleName $nestedName -Exactly -Times 1 + } + + It 'Mock cleanup removes the bootstrap function from the nested module session state' { + # After the It block completes the mock is torn down; calling the real function returns 'real'. + Get-PublicData | Should -Be 'real' + } +} + +Describe "Mocking using deep module path notation 'Root/Mid/Leaf'" { + BeforeAll { + $rootName = 'DeepSlashRoot' + $midName = 'DeepSlashMid' + $leafName = 'DeepSlashLeaf' + + $rootManifestPath = "TestDrive:/$rootName.psd1" + $midManifestPath = "TestDrive:/$midName.psd1" + $leafScriptPath = "TestDrive:/$leafName.psm1" + + Set-Content -Path $leafScriptPath -Value { + function Get-DeepInternalData { + 'real-deep' + } + + function Get-DeepPublicData { + Get-DeepInternalData + } + } + + New-ModuleManifest -Path $midManifestPath -NestedModules ".\$leafName.psm1" + New-ModuleManifest -Path $rootManifestPath -NestedModules ".\$midName.psd1" + + Import-Module $rootManifestPath -Force + } + + AfterAll { + Get-Module $rootName -ErrorAction SilentlyContinue | Remove-Module -Force + } + + It 'Should mock an internal command in the deeply nested module using slash notation' { + Mock -CommandName 'Get-DeepInternalData' -ModuleName "$rootName/$midName/$leafName" -MockWith { 'mocked-deep' } + $result = InModuleScope "$rootName/$midName/$leafName" { Get-DeepPublicData } + $result | Should -Be 'mocked-deep' + } + + It 'Should-Invoke matches call history when using deep slash notation' { + Mock -CommandName 'Get-DeepInternalData' -ModuleName "$rootName/$midName/$leafName" -MockWith { 'mocked-deep' } + $null = InModuleScope "$rootName/$midName/$leafName" { Get-DeepPublicData } + Should -Invoke -CommandName 'Get-DeepInternalData' -ModuleName "$rootName/$midName/$leafName" -Exactly -Times 1 + } + + It 'Should-Invoke accepts plain leaf module name after deep-path mock setup' { + Mock -CommandName 'Get-DeepInternalData' -ModuleName "$rootName/$midName/$leafName" -MockWith { 'mocked-deep' } + $null = InModuleScope "$rootName/$midName/$leafName" { Get-DeepPublicData } + Should -Invoke -CommandName 'Get-DeepInternalData' -ModuleName $leafName -Exactly -Times 1 + } +} + +Describe "Disambiguating nested modules with same name across two root modules using slash notation" { + # Scenario from PR #2412: ClientA and ClientB each have a nested module named 'Repository'. + # Without slash notation both would throw 'Multiple script or manifest modules named Repository'. + + BeforeAll { + $sharedName = 'Repository' + $rootA = 'ClientA' + $rootB = 'ClientB' + + $nestedPathA = "TestDrive:/$sharedName`_A.psm1" + $nestedPathB = "TestDrive:/$sharedName`_B.psm1" + Set-Content -Path $nestedPathA -Value { + function Get-Data { 'dataA' } + function Invoke-Api { Get-Data } + } + Set-Content -Path $nestedPathB -Value { + function Get-Data { 'dataB' } + function Invoke-Api { Get-Data } + } + + $manifestA = "TestDrive:/$rootA.psd1" + $manifestB = "TestDrive:/$rootB.psd1" + + # Both nested scripts load as 'Repository' because the -NestedModules name + # determines the loaded module name. + New-ModuleManifest -Path $manifestA -NestedModules ".\$sharedName`_A.psm1" -FunctionsToExport 'Invoke-Api' + New-ModuleManifest -Path $manifestB -NestedModules ".\$sharedName`_B.psm1" -FunctionsToExport 'Invoke-Api' + Import-Module $manifestA -Force + Import-Module $manifestB -Force + } + + AfterAll { + Get-Module $rootA -ErrorAction SilentlyContinue | Remove-Module -Force + Get-Module $rootB -ErrorAction SilentlyContinue | Remove-Module -Force + } + + It 'Should mock Get-Data only in ClientA nested module' { + Mock -CommandName 'Get-Data' -ModuleName "$rootA/$sharedName`_A" -MockWith { 'mockedA' } + # ClientA's Invoke-Api calls the mocked Get-Data + InModuleScope "$rootA/$sharedName`_A" { Invoke-Api } | Should -Be 'mockedA' + } + + It 'Should-Invoke uses slash notation to check ClientA call history' { + Mock -CommandName 'Get-Data' -ModuleName "$rootA/$sharedName`_A" -MockWith { 'mockedA' } + InModuleScope "$rootA/$sharedName`_A" { Invoke-Api } | Out-Null + Should -Invoke 'Get-Data' -ModuleName "$rootA/$sharedName`_A" -Exactly -Times 1 + } +} + +Describe 'Mocking in nested Invoke-Pester runs' { BeforeAll { Mock Get-Date { 1 }