Skip to content
Draft
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
23 changes: 23 additions & 0 deletions src/Main.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions src/csharp/Pester/RunConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -58,6 +59,7 @@ public RunConfiguration(IDictionary configuration) : this()
configuration.AssignObjectIfNotNull<string>(nameof(SkipRemainingOnFailure), v => SkipRemainingOnFailure = v);
configuration.AssignValueIfNotNull<bool>(nameof(FailOnNullOrEmptyForEach), v => FailOnNullOrEmptyForEach = v);
configuration.AssignObjectIfNotNull<string>(nameof(RepoRoot), v => RepoRoot = v);
configuration.AssignArrayIfNotNull<ScriptBlock>(nameof(BeforeInvoke), v => BeforeInvoke = v);
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions src/en-US/about_PesterConfiguration.help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ SECTIONS AND OPTIONS
Type: string
Default value: '<path of .git>'

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[]
Expand Down
125 changes: 125 additions & 0 deletions src/functions/Pester.BeforeInvoke.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading