Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 82 additions & 4 deletions src/functions/InModuleScope.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."
Expand Down
5 changes: 5 additions & 0 deletions src/functions/Mock.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
146 changes: 145 additions & 1 deletion tst/functions/InModuleScope.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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' {
Expand Down Expand Up @@ -340,4 +484,4 @@ Describe 'Working with manifest modules' {
$res = InModuleScope -ModuleName $moduleName -ScriptBlock { myPrivateFunction }
$res | Should -Be 'real'
}
}
}
Loading
Loading