From fe060d3d687b7f075332cd3b82ab5d64949b9672 Mon Sep 17 00:00:00 2001 From: nohwnd Date: Sat, 27 Jun 2026 21:03:47 +0200 Subject: [PATCH] Add Run.BeforeInvoke bootstrap for Invoke-Pester Introduce a Run.BeforeInvoke option that runs optional bootstrap code in the caller's scope as soon as Invoke-Pester starts, before the caller's $PesterPreference is read and before discovery. Use it to import dependencies and to provide configuration by defining or modifying $PesterPreference, which Pester then picks up as the caller preference. Two sources, mirroring the container-level convention: - Run.BeforeInvoke config option (scriptblock[]). When set, it wins. - Convention file: the first Pester.BeforeInvoke.ps1 found when walking up from each Run.Path towards Run.RepoRoot is dot-sourced (deduped, in order, never escaping the repo root). Each scriptblock is bound to the caller's SessionState and dot-sourced so imports, defined functions and $PesterPreference land in the caller's scope. Bootstrap runs for top-level runs only, so nested Pester-in-Pester does not re-run it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Main.ps1 | 23 ++ src/csharp/Pester/RunConfiguration.cs | 19 ++ src/en-US/about_PesterConfiguration.help.txt | 4 + src/functions/Pester.BeforeInvoke.ps1 | 125 +++++++++ tst/Pester.BeforeInvoke.ts.ps1 | 251 +++++++++++++++++++ 5 files changed, 422 insertions(+) create mode 100644 src/functions/Pester.BeforeInvoke.ps1 create mode 100644 tst/Pester.BeforeInvoke.ts.ps1 diff --git a/src/Main.ps1 b/src/Main.ps1 index 6b8d6863c..2608e47b9 100644 --- a/src/Main.ps1 +++ b/src/Main.ps1 @@ -488,6 +488,29 @@ function Invoke-Pester { $Configuration = . Convert-PesterSimpleParameterSet -BoundParameters $PSBoundParameters } + # Run.BeforeInvoke: as soon as Invoke-Pester starts (top-level runs only), run optional + # bootstrap code in the caller's scope, before the caller's $PesterPreference is read + # below and before discovery. Use it to import dependencies and provide configuration by + # defining or modifying $PesterPreference, which is then picked up as the caller preference. + # + # A preliminary preference is resolved first so the bootstrap has somewhere to discover + # itself from (Run.Path/RepoRoot/BeforeInvoke) and so error reporting still works if the + # bootstrap throws. It is replaced with the final caller-merged preference right after. + if (-not $runningPesterInPester) { + $beforeInvokeCallerPreference = [PesterConfiguration] $PSCmdlet.SessionState.PSVariable.GetValue("PesterPreference") + [PesterConfiguration] $PesterPreference = if ($null -ne $beforeInvokeCallerPreference -and $null -ne $Configuration) { + [PesterConfiguration]::Merge($beforeInvokeCallerPreference, $Configuration) + } + elseif ($null -ne $Configuration) { + [PesterConfiguration]::Merge([PesterConfiguration]::Default, $Configuration) + } + else { + [PesterConfiguration]::Default + } + + Invoke-PesterBeforeInvoke -Configuration $PesterPreference -SessionState $PSCmdlet.SessionState + } + # maybe -IgnorePesterPreference to avoid using $PesterPreference from the context $callerPreference = [PesterConfiguration] $PSCmdlet.SessionState.PSVariable.GetValue("PesterPreference") diff --git a/src/csharp/Pester/RunConfiguration.cs b/src/csharp/Pester/RunConfiguration.cs index 3e2efa0b2..d45aee78d 100644 --- a/src/csharp/Pester/RunConfiguration.cs +++ b/src/csharp/Pester/RunConfiguration.cs @@ -35,6 +35,7 @@ public class RunConfiguration : ConfigurationSection private StringOption _skipRemainingOnFailure; private BoolOption _failOnNullOrEmptyForEach; private StringOption _repoRoot; + private ScriptBlockArrayOption _beforeInvoke; public static RunConfiguration Default { get { return new RunConfiguration(); } } public static RunConfiguration ShallowClone(RunConfiguration configuration) @@ -58,6 +59,7 @@ public RunConfiguration(IDictionary configuration) : this() configuration.AssignObjectIfNotNull(nameof(SkipRemainingOnFailure), v => SkipRemainingOnFailure = v); configuration.AssignValueIfNotNull(nameof(FailOnNullOrEmptyForEach), v => FailOnNullOrEmptyForEach = v); configuration.AssignObjectIfNotNull(nameof(RepoRoot), v => RepoRoot = v); + configuration.AssignArrayIfNotNull(nameof(BeforeInvoke), v => BeforeInvoke = v); } } @@ -75,6 +77,7 @@ public RunConfiguration(IDictionary configuration) : this() SkipRemainingOnFailure = new StringOption("Skips remaining tests after failure for selected scope, options are None, Run, Container and Block.", "None"); FailOnNullOrEmptyForEach = new BoolOption("Fails discovery when -ForEach is provided $null or @() in a block or test. Can be overridden for a specific Describe/Context/It using -AllowNullOrEmptyForEach.", true); RepoRoot = new StringOption("Root directory of the repository. Found by searching for the .git directory recursively. When not found, the current working directory is used.", FindRepoRoot()); + BeforeInvoke = new ScriptBlockArrayOption("ScriptBlocks to run in the caller's scope as soon as Invoke-Pester starts, before the caller's $PesterPreference is read. Use it to import dependencies and provide configuration. In addition to these scriptblocks, the first Pester.BeforeInvoke.ps1 file found when walking up from each Run.Path to Run.RepoRoot is dot-sourced.", new ScriptBlock[0]); } public StringArrayOption Path @@ -269,6 +272,22 @@ public StringOption RepoRoot } } + public ScriptBlockArrayOption BeforeInvoke + { + get { return _beforeInvoke; } + set + { + if (_beforeInvoke == null) + { + _beforeInvoke = value; + } + else + { + _beforeInvoke = new ScriptBlockArrayOption(_beforeInvoke, value?.Value); + } + } + } + private static string FindRepoRoot() { var originalDir = Directory.GetCurrentDirectory(); diff --git a/src/en-US/about_PesterConfiguration.help.txt b/src/en-US/about_PesterConfiguration.help.txt index 71a9b421c..ee13ceb9a 100644 --- a/src/en-US/about_PesterConfiguration.help.txt +++ b/src/en-US/about_PesterConfiguration.help.txt @@ -77,6 +77,10 @@ SECTIONS AND OPTIONS Type: string Default value: '' + BeforeInvoke: ScriptBlocks to run in the caller's scope as soon as Invoke-Pester starts, before the caller's $PesterPreference is read. Use it to import dependencies and provide configuration. In addition to these scriptblocks, the first Pester.BeforeInvoke.ps1 file found when walking up from each Run.Path to Run.RepoRoot is dot-sourced. + Type: scriptblock[] + Default value: @() + Filter: Tag: Tags of Describe, Context or It to be run. Type: string[] diff --git a/src/functions/Pester.BeforeInvoke.ps1 b/src/functions/Pester.BeforeInvoke.ps1 new file mode 100644 index 000000000..c64e17cae --- /dev/null +++ b/src/functions/Pester.BeforeInvoke.ps1 @@ -0,0 +1,125 @@ +function Resolve-PesterBeforeInvoke { + <# + .SYNOPSIS + Returns the bootstrap ScriptBlocks to run in the caller's scope before Invoke-Pester reads + its configuration. + + .DESCRIPTION + EXPERIMENTAL. Invoke-Pester can run a bit of setup in the caller's scope as soon as it starts, + before the caller's $PesterPreference is read and before discovery. This resolves what to run: + + - If Run.BeforeInvoke is set, its ScriptBlocks win and are returned as-is. + - Otherwise Pester walks up from each Run.Path towards the repo root and dot-sources the first + 'Pester.BeforeInvoke.ps1' it finds, giving a zero-config per-repo bootstrap. The same file is + returned only once even when several paths discover it, and discovery never escapes the + repository (Run.RepoRoot). + + Returns an empty array when there is nothing to run. + #> + [OutputType([scriptblock[]])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + $Configuration + ) + + # Explicit scriptblocks win and apply as-is - the convention file is ignored when they are set. + $explicit = $Configuration.Run.BeforeInvoke.Value + if ($explicit -and 0 -lt @($explicit).Count) { + return [scriptblock[]]@($explicit) + } + + $repoRoot = $Configuration.Run.RepoRoot.Value + if (-not [string]::IsNullOrEmpty($repoRoot)) { + $repoRoot = $repoRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + } + + # Starting points: the paths handed to the run. A file contributes its directory, a directory + # itself; relative paths are resolved against the current location. Fall back to the current + # location when no usable path is configured (e.g. ScriptBlock/Container-only runs). + $startDirs = [System.Collections.Generic.List[string]]@() + foreach ($p in @($Configuration.Run.Path.Value)) { + if ([string]::IsNullOrWhiteSpace($p)) { continue } + $full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($p) + if (& $SafeCommands['Test-Path'] -LiteralPath $full -PathType Leaf) { + $startDirs.Add([System.IO.Path]::GetDirectoryName($full)) + } + else { + $startDirs.Add($full.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) + } + } + if (0 -eq $startDirs.Count) { + $startDirs.Add((& $SafeCommands['Get-Location']).ProviderPath) + } + + # Walk up from every starting directory and collect the first convention file found per path. + # An ordered dictionary keeps discovery order while making sure the same file runs only once. + $found = [System.Collections.Specialized.OrderedDictionary]::new() + foreach ($dir in $startDirs) { + $current = $dir + while (-not [string]::IsNullOrEmpty($current)) { + $candidate = & $SafeCommands['Join-Path'] $current 'Pester.BeforeInvoke.ps1' + if (& $SafeCommands['Test-Path'] -LiteralPath $candidate -PathType Leaf) { + if (-not $found.Contains($candidate)) { $found.Add($candidate, $true) } + break + } + + # Stop once the repo root has been checked so discovery does not escape the repository. + $trimmed = $current.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + if (-not [string]::IsNullOrEmpty($repoRoot) -and $trimmed -eq $repoRoot) { + break + } + + $parent = [System.IO.Path]::GetDirectoryName($current) + if ($parent -eq $current) { break } + $current = $parent + } + } + + if (0 -eq $found.Count) { + return [scriptblock[]]@() + } + + return [scriptblock[]]@(foreach ($file in $found.Keys) { + $escaped = "$file" -replace "'", "''" + [scriptblock]::Create(". '$escaped'") + }) +} + +function Invoke-PesterBeforeInvoke { + <# + .SYNOPSIS + Runs the Run.BeforeInvoke bootstrap (explicit ScriptBlocks or the discovered convention file) + in the caller's scope before Invoke-Pester reads its configuration. + + .DESCRIPTION + EXPERIMENTAL. Each resolved ScriptBlock is bound to the caller's SessionState and dot-sourced, + so anything it imports or defines - including $PesterPreference - lands in the caller's scope. + Invoke-Pester reads the caller's $PesterPreference right after this returns, so the bootstrap can + provide configuration simply by defining or modifying it. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + $Configuration, + + [Parameter(Mandatory)] + [System.Management.Automation.SessionState] + $SessionState + ) + + $scriptBlocks = Resolve-PesterBeforeInvoke -Configuration $Configuration + if ($null -eq $scriptBlocks -or 0 -eq @($scriptBlocks).Count) { + return + } + + foreach ($scriptBlock in $scriptBlocks) { + if ($null -eq $scriptBlock) { continue } + + # Bind to the caller's session state and dot-source (do not use & which opens a new scope) + # so assignments such as $PesterPreference, imported modules and defined functions are made + # in the caller's scope, where Invoke-Pester reads them next. + Set-ScriptBlockScope -ScriptBlock $scriptBlock -SessionState $SessionState + . $scriptBlock + } +} diff --git a/tst/Pester.BeforeInvoke.ts.ps1 b/tst/Pester.BeforeInvoke.ts.ps1 new file mode 100644 index 000000000..15b4492ba --- /dev/null +++ b/tst/Pester.BeforeInvoke.ts.ps1 @@ -0,0 +1,251 @@ +param ([switch] $PassThru, [switch] $NoBuild) + +Get-Module P, PTestHelpers, Pester, Axiom | Remove-Module + +Import-Module $PSScriptRoot\p.psm1 -DisableNameChecking +Import-Module $PSScriptRoot\axiom\Axiom.psm1 -DisableNameChecking + +if (-not $NoBuild) { & "$PSScriptRoot\..\build.ps1" } +Import-Module $PSScriptRoot\..\bin\Pester.psd1 + +$global:PesterPreference = @{ + Debug = @{ + ShowFullErrors = $true + WriteDebugMessages = $false + WriteDebugMessagesFrom = "Mock" + ReturnRawResultObject = $true + } + Output = @{ + Verbosity = "None" + } +} +$PSDefaultParameterValues = @{} + +function New-BeforeInvokeSandbox { + # Builds a temp repo-like tree: /.git, /sub and a test file under sub. When + # -Bootstrap is given it is written to /Pester.BeforeInvoke.ps1 (the convention file). + param( + [string] $Bootstrap, + [string] $Test = 'Describe "d" { It "untagged" { 1 | Should -Be 1 }; It "tagged" -Tag only { 1 | Should -Be 1 } }' + ) + + $root = Join-Path ([IO.Path]::GetTempPath()) ("PesterBeforeInvoke_" + [Guid]::NewGuid().ToString('N')) + $sub = Join-Path $root 'sub' + $null = New-Item -ItemType Directory -Path $sub -Force + $null = New-Item -ItemType Directory -Path (Join-Path $root '.git') -Force + + if ($PSBoundParameters.ContainsKey('Bootstrap')) { + $Bootstrap | Set-Content -LiteralPath (Join-Path $root 'Pester.BeforeInvoke.ps1') + } + + $Test | Set-Content -LiteralPath (Join-Path $sub 'demo.Tests.ps1') + + [PSCustomObject]@{ Root = $root; Sub = $sub } +} + +function New-BeforeInvokeConfiguration { + param($Sandbox) + + $cfg = New-PesterConfiguration + $cfg.Run.Path = $Sandbox.Sub + $cfg.Run.RepoRoot = $Sandbox.Root + $cfg.Run.PassThru = $true + $cfg.Output.Verbosity = 'None' + $cfg +} + +function Invoke-ResolveBeforeInvoke { + # Calls the internal resolver in the Pester module scope. + param($Configuration) + & (Get-Module Pester) { param($c) Resolve-PesterBeforeInvoke -Configuration $c } $Configuration +} + +i -PassThru:$PassThru { + b "Resolve-PesterBeforeInvoke" { + t "returns the explicit Run.BeforeInvoke scriptblocks and ignores the convention file" { + $sandbox = New-BeforeInvokeSandbox -Bootstrap 'throw "the convention file must be ignored"' + try { + $sb1 = { 'one' } + $sb2 = { 'two' } + $cfg = New-BeforeInvokeConfiguration -Sandbox $sandbox + $cfg.Run.BeforeInvoke = @($sb1, $sb2) + + $resolved = @(Invoke-ResolveBeforeInvoke -Configuration $cfg) + + $resolved.Count | Verify-Equal 2 + $resolved[0].ToString() | Verify-Equal $sb1.ToString() + $resolved[1].ToString() | Verify-Equal $sb2.ToString() + } + finally { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $sandbox.Root + } + } + + t "discovers the convention file by walking up from Run.Path to the repo root" { + $sandbox = New-BeforeInvokeSandbox -Bootstrap '$null = $null' + try { + $cfg = New-BeforeInvokeConfiguration -Sandbox $sandbox + + $resolved = @(Invoke-ResolveBeforeInvoke -Configuration $cfg) + + $resolved.Count | Verify-Equal 1 + $expected = Join-Path $sandbox.Root 'Pester.BeforeInvoke.ps1' + $resolved[0].ToString().Contains($expected) | Verify-True + } + finally { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $sandbox.Root + } + } + + t "does not look for the convention file above the repo root" { + $base = Join-Path ([IO.Path]::GetTempPath()) ("PesterBeforeInvoke_" + [Guid]::NewGuid().ToString('N')) + $outer = Join-Path $base 'outer' + $repo = Join-Path $outer 'repo' + $sub = Join-Path $repo 'sub' + try { + $null = New-Item -ItemType Directory -Path $sub -Force + $null = New-Item -ItemType Directory -Path (Join-Path $repo '.git') -Force + # Convention file lives ABOVE the repo root and must not be picked up. + '$null = $null' | Set-Content -LiteralPath (Join-Path $outer 'Pester.BeforeInvoke.ps1') + + $cfg = New-PesterConfiguration + $cfg.Run.Path = $sub + $cfg.Run.RepoRoot = $repo + + $resolved = @(Invoke-ResolveBeforeInvoke -Configuration $cfg) + + $resolved.Count | Verify-Equal 0 + } + finally { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $base + } + } + + t "returns each discovered convention file only once across multiple paths" { + $sandbox = New-BeforeInvokeSandbox -Bootstrap '$null = $null' + try { + $sub2 = Join-Path $sandbox.Root 'sub2' + $null = New-Item -ItemType Directory -Path $sub2 -Force + + $cfg = New-PesterConfiguration + $cfg.Run.Path = @($sandbox.Sub, $sub2) + $cfg.Run.RepoRoot = $sandbox.Root + + $resolved = @(Invoke-ResolveBeforeInvoke -Configuration $cfg) + + $resolved.Count | Verify-Equal 1 + } + finally { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $sandbox.Root + } + } + + t "returns nothing when there is no convention file and no option" { + $sandbox = New-BeforeInvokeSandbox + try { + $cfg = New-BeforeInvokeConfiguration -Sandbox $sandbox + + $resolved = @(Invoke-ResolveBeforeInvoke -Configuration $cfg) + + $resolved.Count | Verify-Equal 0 + } + finally { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $sandbox.Root + } + } + } + + b "Invoke-Pester with Run.BeforeInvoke" { + t "runs the convention file in the caller's scope before the run" { + $sandbox = New-BeforeInvokeSandbox -Bootstrap '$global:PesterBeforeInvokeMarker = "ran"' + $global:PesterBeforeInvokeMarker = $null + try { + $cfg = New-BeforeInvokeConfiguration -Sandbox $sandbox + $r = Invoke-Pester -Configuration $cfg + + $global:PesterBeforeInvokeMarker | Verify-Equal "ran" + $r | Verify-NotNull + } + finally { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $sandbox.Root + Remove-Variable -Scope Global -Name PesterBeforeInvokeMarker -ErrorAction SilentlyContinue + } + } + + t "lets the convention file provide configuration via `$PesterPreference" { + $bootstrap = @' +$PesterPreference = New-PesterConfiguration +$PesterPreference.Filter.Tag = 'only' +$PesterPreference.Output.Verbosity = 'None' +'@ + $sandbox = New-BeforeInvokeSandbox -Bootstrap $bootstrap + try { + $cfg = New-BeforeInvokeConfiguration -Sandbox $sandbox + + # Isolate the $PesterPreference the bootstrap defines to this child scope. + $r = & { Invoke-Pester -Configuration $cfg } + + # Only the 'only'-tagged test should run, proving the bootstrap's filter was applied. + $r.PassedCount | Verify-Equal 1 + $r.NotRunCount | Verify-Equal 1 + } + finally { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $sandbox.Root + } + } + + t "runs explicit Run.BeforeInvoke scriptblocks and ignores the convention file" { + $sandbox = New-BeforeInvokeSandbox -Bootstrap 'throw "the convention file must be ignored"' + $global:PesterBeforeInvokeExplicit = $null + try { + $cfg = New-BeforeInvokeConfiguration -Sandbox $sandbox + $cfg.Run.BeforeInvoke = { $global:PesterBeforeInvokeExplicit = "yes" } + + $r = Invoke-Pester -Configuration $cfg + + $global:PesterBeforeInvokeExplicit | Verify-Equal "yes" + $r | Verify-NotNull + } + finally { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $sandbox.Root + Remove-Variable -Scope Global -Name PesterBeforeInvokeExplicit -ErrorAction SilentlyContinue + } + } + + t "is skipped for nested Pester-in-Pester runs" { + # Inner tree with its own convention file - it must NOT run because it is invoked from + # inside a test (nested run). + $inner = New-BeforeInvokeSandbox -Bootstrap '$global:PesterBeforeInvokeCount++' -Test 'Describe "inner" { It "x" { 1 | Should -Be 1 } }' + $innerPath = $inner.Sub -replace '\\', '/' + $innerRoot = $inner.Root -replace '\\', '/' + + $outerTest = @" +Describe 'outer' { + It 'runs a nested Invoke-Pester' { + `$c = New-PesterConfiguration + `$c.Run.Path = '$innerPath' + `$c.Run.RepoRoot = '$innerRoot' + `$c.Run.PassThru = `$true + `$c.Output.Verbosity = 'None' + `$null = Invoke-Pester -Configuration `$c + 1 | Should -Be 1 + } +} +"@ + $outer = New-BeforeInvokeSandbox -Bootstrap '$global:PesterBeforeInvokeCount++' -Test $outerTest + $global:PesterBeforeInvokeCount = 0 + try { + $cfg = New-BeforeInvokeConfiguration -Sandbox $outer + $null = Invoke-Pester -Configuration $cfg + + # Only the top-level bootstrap ran; the nested one was skipped. + $global:PesterBeforeInvokeCount | Verify-Equal 1 + } + finally { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $outer.Root + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $inner.Root + Remove-Variable -Scope Global -Name PesterBeforeInvokeCount -ErrorAction SilentlyContinue + } + } + } +}