From 3ca16e4731e1ebe147bef7d0f7f209da0883cf6d Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sun, 1 Sep 2024 12:13:38 +0200 Subject: [PATCH] Add command `Invoke-PesterJob` --- CHANGELOG.md | 6 + source/Public/ConvertTo-RelativePath.ps1 | 69 ++++ source/Public/Get-ModuleVersion.ps1 | 48 +++ source/Public/Invoke-PesterJob.ps1 | 472 +++++++++++++++++++++++ source/Viscalyx.Common.psd1 | 2 +- 5 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 source/Public/ConvertTo-RelativePath.ps1 create mode 100644 source/Public/Get-ModuleVersion.ps1 create mode 100644 source/Public/Invoke-PesterJob.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index bd37dc9..f17d364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Public commands: + - `Invoke-PesterJob` + - `Get-ModuleVersion` + - `ConvertTo-RelativePath` + ## [0.2.0] - 2024-08-25 ### Added @@ -14,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ConvertTo-DifferenceString` - `Get-NumericalSequence` - `Get-PSReadLineHistory` + - `Invoke-PesterJob` - `New-SamplerGitHubReleaseTag` - `Out-Difference` - `Pop-VMLatestSnapShot` diff --git a/source/Public/ConvertTo-RelativePath.ps1 b/source/Public/ConvertTo-RelativePath.ps1 new file mode 100644 index 0000000..09ad348 --- /dev/null +++ b/source/Public/ConvertTo-RelativePath.ps1 @@ -0,0 +1,69 @@ +<# + .SYNOPSIS + Converts an absolute path to a relative path. + + .DESCRIPTION + The ConvertTo-RelativePath command takes an absolute path and converts it + to a relative path based on the current location. If the absolute path + starts with the current location, the function removes the current location + from the beginning of the path and inserts a '.' to indicate the relative path. + + .PARAMETER AbsolutePath + Specifies the absolute path that needs to be converted to a relative path. + + .PARAMETER CurrentLocation + Specifies the current location used as a reference for converting the absolute + path to a relative path. If not specified, the function uses the current + location obtained from Get-Location. + + .EXAMPLE + $convertToRelativePathParameters = @{ + AbsolutePath = '/source/Viscalyx.Common/source/Public/ConvertTo-RelativePath.ps1' + CurrentLocation = "/source/Viscalyx.Common" + } + ConvertTo-RelativePath @convertToRelativePathParameters + + Returns "./source/Public/ConvertTo-RelativePath.ps1", which is the + relative path of the given absolute path based on the current location. + + .INPUTS + [System.String] + + .OUTPUTS + [System.String] +#> +function ConvertTo-RelativePath +{ + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)] + [System.String] + $AbsolutePath, + + [Parameter(Position = 1)] + [System.String] + $CurrentLocation + ) + + begin + { + if (-not $PSBoundParameters.ContainsKey('CurrentLocation')) + { + $CurrentLocation = (Get-Location).Path + } + } + + process + { + $relativePath = $AbsolutePath + + if ($relativePath.StartsWith($CurrentLocation)) + { + $relativePath = $relativePath.Substring($CurrentLocation.Length).Insert(0, '.') + } + + return $relativePath + } +} diff --git a/source/Public/Get-ModuleVersion.ps1 b/source/Public/Get-ModuleVersion.ps1 new file mode 100644 index 0000000..47e43e6 --- /dev/null +++ b/source/Public/Get-ModuleVersion.ps1 @@ -0,0 +1,48 @@ +function Get-ModuleVersion +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)] + [System.Object] + $Module + ) + + process + { + $moduleInfo = $null + $moduleVersion = $null + + if ($Module -is [System.String]) + { + $moduleInfo = Get-Module -Name $Module -ErrorAction 'Stop' + + if (-not $moduleInfo) + { + Write-Error -Message "Cannot find the module '$Module'. Make sure it is loaded into the session." + } + } + elseif ($Module -is [System.Management.Automation.PSModuleInfo]) + { + $moduleInfo = $Module + } + else + { + Write-Error -Message "Invalid parameter type. The parameter 'Module' must be either a string or a PSModuleInfo object." + } + + if ($moduleInfo) + { + $moduleVersion = $moduleInfo.Version.ToString() + + $previewReleaseTag = $moduleInfo.PrivateData.PSData.Prerelease + + if ($previewReleaseTag) + { + $moduleVersion += '-{0}' -f $previewReleaseTag + } + } + + return $moduleVersion + } +} diff --git a/source/Public/Invoke-PesterJob.ps1 b/source/Public/Invoke-PesterJob.ps1 new file mode 100644 index 0000000..4ef303e --- /dev/null +++ b/source/Public/Invoke-PesterJob.ps1 @@ -0,0 +1,472 @@ +<# + .SYNOPSIS + Runs Pester tests using a job-based approach. + + .DESCRIPTION + The `Invoke-PesterJob` command runs Pester tests using a job-based approach. + It allows you to specify various parameters such as the test path, root path, + module name, output verbosity, code coverage path, and more. + + Its primary purpose is to run Pester tests in a separate job to avoid polluting + the current session with PowerShell classes and project specific assemblies + which can cause issues when building the project. + + It is helpful for projects based on the Sampler project template, but it can + also be used for other projects. + + .PARAMETER Path + Specifies one or more paths to the Pester test files. If not specified, the + current location is used. This also has tab completion support. Just write + part of the test script file name and press tab to get a list of available + test files matching the input, or if only one file matches, it will be + auto-completed. + + .PARAMETER RootPath + Specifies the root path for the Pester tests. If not specified, the current + location is used. + + .PARAMETER Tag + Specifies the tags to filter the Pester tests. + + .PARAMETER ModuleName + Specifies the name of the module to test. If not specified, it will be + inferred based on the project type. + + .PARAMETER Output + Specifies the output verbosity level. Valid values are 'Normal', 'Detailed', + 'None', 'Diagnostic', and 'Minimal'. Default is 'Detailed'. + + .PARAMETER CodeCoveragePath + Specifies the paths to one or more the code coverage files (script or module + script files). If not provided the default path for code coverage is the + content of the built module. This parameter also has tab completion support. + Just write part of the script file name and press tab to get a list of + available script files matching the input, or if only one file matches, + it will be auto-completed. + + .PARAMETER SkipCodeCoverage + Indicates whether to skip code coverage. + + .PARAMETER PassThru + Indicates whether to pass the Pester result object through. + + .PARAMETER ShowError + Indicates whether to display detailed error information. When using this + to debug a test it is recommended to run as few tests as possible, or just + the test having issues, to limit the amount of error information displayed. + + .PARAMETER SkipRun + Indicates whether to skip running the tests, this just runs the discovery + phase. This is useful when you want to see what tests would be run without + actually running them. To actually make use of this, the PassThru parameter + should also be specified. Suggest to also use the parameter SkipCodeCoverage. + + .PARAMETER BuildScriptPath + Specifies the path to the build script. If not specified, it defaults to + 'build.ps1' in the root path. This is used to ensure that the test environment + is configured correctly, for example required modules are available in the + session. It is also used to ensure to find the specific Pester module used + by the project. + + .PARAMETER BuildScriptParameter + Specifies a hashtable with the parameters to pass to the build script. + Defaults to parameter 'Task' with a value of 'noop'. + + .EXAMPLE + $invokePesterJobParameters = @{ + Path = './tests/Unit/DSC_SqlAlias.Tests.ps1' + CodeCoveragePath = './output/builtModule/SqlServerDsc/0.0.1/DSCResources/DSC_SqlAlias/DSC_SqlAlias.psm1' + } + Invoke-PesterJob @invokePesterJobParameters + + Runs the Pester test DSC_SqlAlias.Tests.ps1 located in the 'tests/Unit' + folder. The code coverage is based on the code in the DSC_SqlAlias.psm1 + file. + + .EXAMPLE + $invokePesterJobParameters = @{ + Path = './tests' + RootPath = 'C:\Projects\MyModule' + Tag = 'Unit' + Output = 'Detailed' + CodeCoveragePath = 'C:\Projects\MyModule\coverage' + } + Invoke-PesterJob @invokePesterJobParameters + + Runs Pester tests located in the 'tests' directory of the 'C:\Projects\MyModule' + root path. Only tests with the 'Unit' tag will be executed. Detailed output + will be displayed, and code coverage will be collected from the + 'C:\Projects\MyModule\coverage' directory. + + .EXAMPLE + $invokePesterJobParameters = @{ + Path = './tests/Unit' + SkipRun = $true + SkipCodeCoverage = $true + PassThru = $true + } + Invoke-PesterJob @invokePesterJobParameters + + Runs the discovery phase on all the Pester tests files located in the + 'tests/Unit' folder and outputs the Pester result object. + + .NOTES + This function requires the Pester module to be imported. If the module is + not available, it will attempt to run the build script to ensure the + required modules are available in the session. +#> +function Invoke-PesterJob +{ + [Alias('ipj')] + [CmdletBinding()] + param + ( + [Parameter(Position = 0)] + [ArgumentCompleter( + { + <# + This scriptblock is used to provide tab completion for the Path + parameter. The scriptblock could be a command, but then it would + need to be a public command. Also, if anything goes wrong in the + completer scriptblock, it will just fail silently and not provide + any completion results. + #> + param + ( + $CommandName, + $ParameterName, + $WordToComplete, + $CommandAst, + $FakeBoundParameters + ) + + # This parameter is from Invoke-PesterJob. + if (-not $FakeBoundParameters.ContainsKey('RootPath')) + { + $RootPath = (Get-Location).Path + } + + $testRoot = Join-Path -Path $RootPath -ChildPath 'tests/unit' + + $values = (Get-ChildItem -Path $testRoot -Recurse -Filter '*.tests.ps1' -File).FullName + + foreach ($val in $values) + { + if ($val -like "*$WordToComplete*") + { + New-Object -Type System.Management.Automation.CompletionResult -ArgumentList @( + (ConvertTo-RelativePath -AbsolutePath $val -CurrentLocation $RootPath) # completionText + (Split-Path -Path $val -Leaf) -replace '\.[Tt]ests.ps1' # listItemText + 'ParameterValue' # resultType + $val # toolTip + ) + } + } + })] + [System.String[]] + $Path = (Get-Location).Path, + + [Parameter()] + [System.String] + $RootPath = (Get-Location).Path, + + [Parameter()] + [System.String[]] + $Tag, + + [Parameter()] + [System.String] + $ModuleName, + + [Parameter()] + [System.String] + [ValidateSet('Normal', 'Detailed', 'None', 'Diagnostic', 'Minimal')] + $Output, + + [Parameter()] + [ArgumentCompleter( + { + <# + This scriptblock is used to provide tab completion for the + CodeCoveragePath parameter. The scriptblock could be a command, + but then it would need to be a public command. Also, if anything + goes wrong in the completer scriptblock, it will just fail + silently and not provide any completion results. + #> + param + ( + $CommandName, + $ParameterName, + $WordToComplete, + $CommandAst, + $FakeBoundParameters + ) + + # This parameter is from Invoke-PesterJob. + if (-not $FakeBoundParameters.ContainsKey('RootPath')) + { + $RootPath = (Get-Location).Path + } + + # TODO: builtModule should be dynamic. + $builtModuleCodePath = @( + Join-Path -Path $RootPath -ChildPath 'output/builtModule' + ) + + $paths = Get-ChildItem -Path $builtModuleCodePath -Recurse -Include @('*.psm1', '*.ps1') -File -ErrorAction 'SilentlyContinue' + + # Filter out the external Modules directory. + $values = $paths.FullName -notmatch 'Modules' + + $leafRegex = [regex]::new('([^\\/]+)$') + + foreach ($val in $values) + { + $leaf = $leafRegex.Match($val).Groups[1].Value + + if ($leaf -like "*$WordToComplete*") + { + New-Object -Type System.Management.Automation.CompletionResult -ArgumentList @( + (ConvertTo-RelativePath -AbsolutePath $val -CurrentLocation $RootPath) # completionText + $leaf -replace '\.(ps1|psm1)' # listItemText + 'ParameterValue' # resultType + $val # toolTip + ) + } + } + })] + [System.String[]] + $CodeCoveragePath, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $SkipCodeCoverage, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $PassThru, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $ShowError, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $SkipRun, + + [Parameter()] + [ValidateScript({ + if (-not (Test-Path $_ -PathType 'Leaf')) + { + throw "The file path '$_' does not exist or is a container." + } + + $true + })] + [System.String] + $BuildScriptPath, + + [Parameter()] + [System.Collections.Hashtable] + $BuildScriptParameter = @{ + Task = 'noop' + } + ) + + if (-not $PSBoundParameters.ContainsKey('BuildScriptPath')) + { + $BuildScriptPath = Join-Path -Path $RootPath -ChildPath 'build.ps1' + } + + $pesterModuleVersion = $null + + do + { + $triesCount = 0 + + try + { + $importedPesterModule = Import-Module -Name 'Pester' -MinimumVersion '4.10.1' -ErrorAction 'Stop' -PassThru + + $pesterModuleVersion = $importedPesterModule | Get-ModuleVersion + + <# + Assuming that the project is a Sampler project if the Sampler + module is available in the session. Also assuming that a Sampler + build task has been run prior to running the command. + #> + $isSamplerProject = $null -ne (Get-Module -Name 'Sampler') + } + catch + { + $triesCount++ + + if ($triesCount -eq 1 -and (Test-Path -Path $BuildScriptPath)) + { + Write-Information -MessageData 'Could not import Pester. Running build script to make sure required modules is available in session. This can take a few seconds.' -InformationAction 'Continue' + + # Redirect all streams to $null, except the error stream (stream 2) + & $BuildScriptPath @buildScriptParameter 2>&1 4>&1 5>&1 6>&1 > $null + } + else + { + Write-Error -ErrorRecord $_ -ErrorAction 'Stop' + } + } + } until ($importedPesterModule) + + Write-Information -MessageData ('Using imported Pester v{0}.' -f $pesterModuleVersion) -InformationAction 'Continue' + + if (-not $PSBoundParameters.ContainsKey('ModuleName')) + { + if ($isSamplerProject) + { + $ModuleName = Get-SamplerProjectName -BuildRoot $RootPath + } + else + { + $ModuleName = (Get-Item -Path $RootPath).BaseName + } + } + + $testResultsPath = Join-Path -Path $RootPath -ChildPath 'output/testResults' + + if (-not $PSBoundParameters.ContainsKey('CodeCoveragePath')) + { + <# + TODO: If it is not a Sampler project, then the user must provide the + path to the module to test. But it should be possible to also + use default coverage paths for the module to test. + #> + if ($isSamplerProject) + { + $BuiltModuleBase = Get-SamplerBuiltModuleBase -OutputDirectory "$RootPath/output" -BuiltModuleSubdirectory 'builtModule' -ModuleName $ModuleName + + # TODO: This does not take into account any .ps1 files in the module. + $CodeCoveragePath = '{0}/*/{1}.psm1' -f $BuiltModuleBase, $ModuleName + } + } + + if ($importedPesterModule.Version.Major -eq 4) + { + $pesterConfig = @{ + Script = $Path + } + } + else + { + $pesterConfig = New-PesterConfiguration -Hashtable @{ + CodeCoverage = @{ + Enabled = $true + Path = $CodeCoveragePath + OutputPath = (Join-Path -Path $testResultsPath -ChildPath 'PesterJob_coverage.xml') + UseBreakpoints = $false + } + Run = @{ + Path = $Path + } + } + } + + if ($PSBoundParameters.ContainsKey('Output')) + { + if ($importedPesterModule.Version.Major -eq 4) + { + $pesterConfig.Show = $Output + } + else + { + $pesterConfig.Output.Verbosity = $Output + } + } + else + { + if ($importedPesterModule.Version.Major -eq 4) + { + $pesterConfig.Show = 'All' + } + else + { + $pesterConfig.Output.Verbosity = 'Detailed' + } + } + + # Turn off code coverage if the user has specified that they don't want it + if ($SkipCodeCoverage.IsPresent) + { + $pesterConfig.CodeCoverage.Enabled = $false + } + else + { + # Pester 4: By passing code paths the code coverage is enabled. + if ($importedPesterModule.Version.Major -eq 4) + { + $pesterConfig.CodeCoverage = $CodeCoveragePath + } + } + + if ($PassThru.IsPresent) + { + $pesterConfig.Run.PassThru = $true + } + + if ($SkipRun.IsPresent) + { + $pesterConfig.Run.SkipRun = $true + } + + if ($PSBoundParameters.ContainsKey('Tag')) + { + $pesterConfig.Filter.Tag = $Tag + } + + Start-Job -ScriptBlock { + [CmdletBinding()] + param + ( + [Parameter(Position = 0)] + [Object] + $PesterConfiguration, + + [Parameter(Position = 1)] + [Switch] + $ShowError, + + [Parameter(Position = 2)] + [Version] + $PesterVersion + ) + + Write-Information -MessageData 'Running build task ''noop'' inside the job to setup the test pipeline.' -InformationAction 'Continue' + + # TODO: Use BuildScriptPath and BuildScriptParameter here + .\build.ps1 -Task noop + #$VerbosePreference = 'Continue' + + if ($ShowError.IsPresent) + { + $Error.Clear() + $ErrorView = 'DetailedView' + } + + if ($PesterVersion.Major -eq 4) + { + Invoke-Pester @PesterConfiguration + } + else + { + Invoke-Pester -Configuration $PesterConfiguration + } + + if ($ShowError.IsPresent) + { + 'Error count: {0}' -f $Error.Count + $Error | Out-String + } + } -ArgumentList @( + $pesterConfig + $ShowError.IsPresent + $importedPesterModule.Version + ) | + Receive-Job -AutoRemoveJob -Wait +} diff --git a/source/Viscalyx.Common.psd1 b/source/Viscalyx.Common.psd1 index 668653a..2bea847 100644 --- a/source/Viscalyx.Common.psd1 +++ b/source/Viscalyx.Common.psd1 @@ -45,7 +45,7 @@ PrivateData = @{ PSData = @{ # Tags applied to this module. These help with module discovery in online galleries. - Tags = @('Common', 'Utility') + Tags = @('Common', 'Utility', 'Pester', 'PSReadLine', 'Sampler') # A URL to the license for this module. LicenseUri = 'https://github.com/viscalyx/Viscalyx.Common/blob/main/LICENSE'