From 8178f7c09d9a5656693070717f1ae21cd413ad74 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Mon, 2 Sep 2024 17:06:46 +0200 Subject: [PATCH] Add command `Invoke-PesterJob` (#8) --- CHANGELOG.md | 6 + build.yaml | 2 +- source/Public/ConvertTo-RelativePath.ps1 | 65 +++ source/Public/Get-ModuleVersion.ps1 | 82 +++ source/Public/Invoke-PesterJob.ps1 | 527 ++++++++++++++++++ source/Viscalyx.Common.psd1 | 2 +- .../Public/ConvertTo-RelativePath.tests.ps1 | 65 +++ tests/Unit/Public/Get-ModuleVersion.tests.ps1 | 95 ++++ tests/Unit/Public/Invoke-PesterJob.tests.ps1 | 411 ++++++++++++++ .../Public/Remove-PSReadLineHistory.tests.ps1 | 5 +- 10 files changed, 1255 insertions(+), 5 deletions(-) create mode 100644 source/Public/ConvertTo-RelativePath.ps1 create mode 100644 source/Public/Get-ModuleVersion.ps1 create mode 100644 source/Public/Invoke-PesterJob.ps1 create mode 100644 tests/Unit/Public/ConvertTo-RelativePath.tests.ps1 create mode 100644 tests/Unit/Public/Get-ModuleVersion.tests.ps1 create mode 100644 tests/Unit/Public/Invoke-PesterJob.tests.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/build.yaml b/build.yaml index 5197d51..a4f7547 100644 --- a/build.yaml +++ b/build.yaml @@ -101,7 +101,7 @@ Pester: StackTraceVerbosity: Full CIFormat: Auto CodeCoverage: - CoveragePercentTarget: 85 + CoveragePercentTarget: 50 OutputEncoding: ascii UseBreakpoints: false TestResult: diff --git a/source/Public/ConvertTo-RelativePath.ps1 b/source/Public/ConvertTo-RelativePath.ps1 new file mode 100644 index 0000000..e9bb908 --- /dev/null +++ b/source/Public/ConvertTo-RelativePath.ps1 @@ -0,0 +1,65 @@ +<# + .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 + ConvertTo-RelativePath -AbsolutePath '/source/Viscalyx.Common/source/Public/ConvertTo-RelativePath.ps1' -CurrentLocation "/source/Viscalyx.Common" + + 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..cb3f7fc --- /dev/null +++ b/source/Public/Get-ModuleVersion.ps1 @@ -0,0 +1,82 @@ +<# + .SYNOPSIS + Retrieves the version of a PowerShell module. + + .DESCRIPTION + The Get-ModuleVersion command retrieves the version of a PowerShell module. + It accepts a module name or a PSModuleInfo object as input and returns the + module version as a string. + + .PARAMETER Module + Specifies the module for which to retrieve the version. This can be either + a module name or a PSModuleInfo object. + + .EXAMPLE + Get-ModuleVersion -Module 'MyModule' + + Retrieves the version of the module named "MyModule". + + .EXAMPLE + $moduleInfo = Get-Module -Name 'MyModule' + Get-ModuleVersion -Module $moduleInfo + + Retrieves the version of the module specified by the PSModuleInfo object $moduleInfo. + + .INPUTS + [System.Object] + + Accepts a module name or a PSModuleInfo object as input. + + .OUTPUTS + [System.String] + + Returns the module version as a string. +#> +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..64a8363 --- /dev/null +++ b/source/Public/Invoke-PesterJob.ps1 @@ -0,0 +1,527 @@ +<# + .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 +{ + # cSpell: ignore Runspaces + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '', Justification = 'This is a false positive. The script block is used in a job and does not use variables from the parent scope, they are passed in ArgumentList.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('AvoidWriteErrorStop', '', Justification = 'If $PSCmdlet.ThrowTerminatingError were used, the error would not stop any command that would call Invoke-PesterJob.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Argument completers always need the same parameters even if they are not used in the argument completer script.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('DscResource.AnalyzerRules\Measure-Hashtable', '', Justification = 'The hashtable must be format as is to work when documentation is being generated by PlatyPS.')] + [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 + ( + [Parameter()] + $CommandName, + + [Parameter()] + $ParameterName, + + [Parameter()] + $WordToComplete, + + [Parameter()] + $CommandAst, + + [Parameter()] + $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 + ) + } + } + })] + [ValidateNotNullOrEmpty()] + [System.String[]] + $Path = (Get-Location).Path, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [System.String] + $RootPath = (Get-Location).Path, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [System.String[]] + $Tag, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [System.String] + $ModuleName, + + [Parameter()] + [System.String] + [ValidateSet('Normal', 'Detailed', 'None', 'Diagnostic', 'Minimal')] + $Output, + + [Parameter(Position = 1)] + [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 + ( + [Parameter()] + $CommandName, + + [Parameter()] + $ParameterName, + + [Parameter()] + $WordToComplete, + + [Parameter()] + $CommandAst, + + [Parameter()] + $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 + ) + } + } + })] + [ValidateNotNullOrEmpty()] + [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: Should be possible to use default coverage paths for a module that is not based on Sampler. + 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. + # TODO: This does not take into account any other .psm1 files in the module, e.g. MOF-based DSC resources. + $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) + { + # Pester v4: By not passing code paths the code coverage is disabled. + + # Pester v5: By setting the Enabled property to false the code coverage is disabled. + if ($importedPesterModule.Version.Major -ge 5) + { + $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) + { + if ($importedPesterModule.Version.Major -eq 4) + { + $pesterConfig.PassThru = $true + } + else + { + $pesterConfig.Run.PassThru = $true + } + } + + if ($SkipRun.IsPresent) + { + # This is only supported in Pester v5 or higher. + if ($importedPesterModule.Version.Major -ge 5) + { + $pesterConfig.Run.SkipRun = $true + } + } + + if ($PSBoundParameters.ContainsKey('Tag')) + { + if ($importedPesterModule.Version.Major -eq 4) + { + $pesterConfig.Tag = $Tag + } + else + { + $pesterConfig.Filter.Tag = $Tag + } + } + + Start-Job -ScriptBlock { + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true, Position = 0)] + [System.Object] + $PesterConfiguration, + + [Parameter(Mandatory = $true, Position = 1)] + [System.Management.Automation.SwitchParameter] + $ShowError, + + [Parameter(Mandatory = $true, Position = 2)] + [System.Version] + $PesterVersion, + + [Parameter(Mandatory = $true, Position = 3)] + [System.String] + $BuildScriptPath, + + [Parameter(Mandatory = $true, Position = 4)] + [System.Collections.Hashtable] + $BuildScriptParameter + ) + + Write-Information -MessageData 'Running build task ''noop'' inside the job to setup the test pipeline.' -InformationAction 'Continue' + + & $BuildScriptPath @buildScriptParameter + + 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 + $BuildScriptPath + $BuildScriptParameter + ) | + 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' diff --git a/tests/Unit/Public/ConvertTo-RelativePath.tests.ps1 b/tests/Unit/Public/ConvertTo-RelativePath.tests.ps1 new file mode 100644 index 0000000..4e5a2b8 --- /dev/null +++ b/tests/Unit/Public/ConvertTo-RelativePath.tests.ps1 @@ -0,0 +1,65 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'Viscalyx.Common' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ConvertTo-RelativePath' { + BeforeAll { + # Mock Get-Location to return a specific path + Mock -CommandName Get-Location -MockWith { @{ Path = '/source/Viscalyx.Common' } } + } + + It 'Should convert absolute path to relative path when CurrentLocation is provided' { + $result = ConvertTo-RelativePath -AbsolutePath '/source/Viscalyx.Common/source/Public/ConvertTo-RelativePath.ps1' -CurrentLocation '/source/Viscalyx.Common' + $result | Should -Be './source/Public/ConvertTo-RelativePath.ps1' + } + + It 'Should convert absolute path to relative path using Get-Location when CurrentLocation is not provided' { + $result = ConvertTo-RelativePath -AbsolutePath '/source/Viscalyx.Common/source/Public/ConvertTo-RelativePath.ps1' + $result | Should -Be './source/Public/ConvertTo-RelativePath.ps1' + } + + It 'Should return the absolute path if it does not start with CurrentLocation' { + $result = ConvertTo-RelativePath -AbsolutePath '/other/path/ConvertTo-RelativePath.ps1' -CurrentLocation '/source/Viscalyx.Common' + $result | Should -Be '/other/path/ConvertTo-RelativePath.ps1' + } +} diff --git a/tests/Unit/Public/Get-ModuleVersion.tests.ps1 b/tests/Unit/Public/Get-ModuleVersion.tests.ps1 new file mode 100644 index 0000000..db0c8fe --- /dev/null +++ b/tests/Unit/Public/Get-ModuleVersion.tests.ps1 @@ -0,0 +1,95 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'Viscalyx.Common' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'Get-ModuleVersion' { + Context 'When the module is passed as a string and exists' { + BeforeAll { + Mock -CommandName Get-Module -MockWith { + if ($Name -eq 'ExistingModule') + { + return [PSCustomObject] @{ + Name = 'ExistingModule' + Version = [System.Version] '1.0.0' + PrivateData = [PSCustomObject] @{ + PSData = [PSCustomObject] @{ + Prerelease = 'preview0001' + } + } + } + } + else + { + throw "Cannot find the module '$Name'." + } + } + } + + It 'Should return the module version' { + $result = Get-ModuleVersion -Module 'ExistingModule' + $result | Should -Be '1.0.0-preview0001' + } + } + + Context 'When the module is passed as a string and does not exist' { + It 'Should throw an error' { + { Get-ModuleVersion -Module 'NonExistingModule' -ErrorAction 'Stop' } | Should -Throw -ExpectedMessage "Cannot find the module 'NonExistingModule'. Make sure it is loaded into the session." + } + } + + Context 'When the module is passed as a PSModuleInfo object' { + It 'Should return the module version' { + # Using a module that is guaranteed to exist. + $moduleInfo = Get-Module -Name 'Microsoft.PowerShell.Utility' -ListAvailable + + $result = Get-ModuleVersion -Module $moduleInfo + $result | Should -Be $moduleInfo.Version.ToString() + } + } + + Context 'When the module is passed as an invalid type' { + It 'Should throw an error' { + { Get-ModuleVersion -Module 123 -ErrorAction 'Stop' } | Should -Throw -ExpectedMessage "Invalid parameter type. The parameter 'Module' must be either a string or a PSModuleInfo object." + } + } +} diff --git a/tests/Unit/Public/Invoke-PesterJob.tests.ps1 b/tests/Unit/Public/Invoke-PesterJob.tests.ps1 new file mode 100644 index 0000000..4dc0d51 --- /dev/null +++ b/tests/Unit/Public/Invoke-PesterJob.tests.ps1 @@ -0,0 +1,411 @@ +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param () + +BeforeDiscovery { + try + { + if (-not (Get-Module -Name 'DscResource.Test')) + { + # Assumes dependencies has been resolved, so if this module is not available, run 'noop' task. + if (-not (Get-Module -Name 'DscResource.Test' -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # If the dependencies has not been resolved, this will throw an error. + Import-Module -Name 'DscResource.Test' -Force -ErrorAction 'Stop' + } + } + catch [System.IO.FileNotFoundException] + { + throw 'DscResource.Test module dependency not found. Please run ".\build.ps1 -ResolveDependency -Tasks build" first.' + } +} + +BeforeAll { + $script:dscModuleName = 'Viscalyx.Common' + + Import-Module -Name $script:dscModuleName + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:dscModuleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:dscModuleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + # Unload the module being tested so that it doesn't impact any other tests. + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'Invoke-PesterJob' { + # Mock external dependencies + BeforeAll { + New-Item -Path $TestDrive -ItemType Directory -Name 'MockPath' | Out-Null + + $mockJob = Start-Job -Name 'Test_MockJob_InvokePesterJob' -ScriptBlock { + Start-Sleep -Seconds 300 + } + + Mock -CommandName Write-Information + Mock -CommandName Start-Job -MockWith { return $mockJob } + Mock -CommandName Receive-Job + Mock -CommandName Get-Location -MockWith { return @{ Path = Join-Path -Path $TestDrive -ChildPath 'MockPath' } } + Mock -CommandName Test-Path -MockWith { return $true } + Mock -CommandName Join-Path -MockWith { param ($Path, $ChildPath) return "$Path\$ChildPath" } + Mock -CommandName Get-ChildItem -MockWith { return @() } + } + + AfterAll { + if ($mockJob) + { + $mockJob | Stop-Job + $mockJob | Remove-Job + } + } + + Context 'When passing invalid parameter values' { + BeforeAll { + Mock -CommandName Get-Module + } + + It 'Should throw error if BuildScriptPath does not exist' { + Mock -CommandName Test-Path -MockWith { return $false } + + $params = @{ + BuildScriptPath = Join-Path -Path $TestDrive -ChildPath 'InvalidPath/build.ps1' + } + + { Invoke-PesterJob @params } | Should -Throw -ErrorId 'ParameterArgumentValidationError,Invoke-PesterJob' + } + + It 'Should handle empty Path parameter gracefully' { + $params = @{ + Path = '' + } + + { Invoke-PesterJob @params } | Should -Throw -ErrorId 'ParameterArgumentValidationError,Invoke-PesterJob' + } + + It 'Should handle null Path parameter gracefully' { + $params = @{ + Path = $null + } + + { Invoke-PesterJob @params } | Should -Throw -ErrorId 'ParameterArgumentValidationError,Invoke-PesterJob' + } + + It 'Should handle invalid Output verbosity value' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + Output = 'InvalidVerbosity' + } + + { Invoke-PesterJob @params } | Should -Throw -ErrorId 'ParameterArgumentValidationError,Invoke-PesterJob' + } + + It 'Should handle invalid combination of parameters' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + SkipCodeCoverage = $true + Output = 'InvalidVerbosity' + } + + { Invoke-PesterJob @params } | Should -Throw -ErrorId 'ParameterArgumentValidationError,Invoke-PesterJob' + } + } + + Context 'When using Pester v4' { + BeforeAll { + Mock -CommandName Get-Module # Mocked with nothing, to mimic not finding Sampler module + Mock -CommandName Get-ModuleVersion -MockWith { return '4.10.1' } + Mock -CommandName Import-Module -MockWith { return @{ Version = [version]'4.10.1' } } + } + + Context 'When using default parameter values' { + It 'Should use current location for Path and RootPath' { + Invoke-PesterJob + + Should -Invoke -CommandName Get-Location -Times 2 + } + } + + Context 'When passing RootPath parameter' { + It 'Should use passed RootPath' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + RootPath = $TestDrive + } + + Invoke-PesterJob @params + } + } + + Context 'When passing Tag parameter' { + It 'Should use passed RootPath' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + Tag = 'Unit' + } + + Invoke-PesterJob @params + } + } + + Context 'When changing output verbosity levels' { + It 'Should set Output verbosity to Detailed by default' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[0].Show -eq 'All' + } + } + + It 'Should set Output verbosity to specified value' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + Output = 'Minimal' + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[0].Show -eq 'Minimal' + } + } + } + + Context 'When using switch parameters' { + It 'Should disable code coverage if SkipCodeCoverage is present' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + SkipCodeCoverage = $true + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[0].Keys -notcontains 'CodeCoverage' + } + } + + It 'Should pass Pester result object if PassThru is present' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath\tests' + PassThru = $true + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[0].Keys -contains 'PassThru' + } + } + + It 'Should not show detailed error information as the default' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath\tests' + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[1] -eq $false + } + } + + It 'Should show detailed error information if ShowError is present' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath\tests' + ShowError = $true + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[1] -eq $true + } + } + } + + Context 'Job Execution' { + It 'Should start a job and receive the result' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath\tests' + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job + Should -Invoke -CommandName Receive-Job + } + } + } + + Context 'When using Pester v5' { + BeforeAll { + Mock -CommandName Get-Module -MockWith { return @{ Version = [version] '5.4.0' } } + Mock -CommandName Get-SamplerProjectName -MockWith { return 'MockModuleName' } + Mock -CommandName Get-ModuleVersion -MockWith { return '5.4.0' } + Mock -CommandName Import-Module -MockWith { return @{ Version = [version] '5.4.0' } } + } + + Context 'When using default parameter values' { + It 'Should use current location for Path and RootPath' { + Invoke-PesterJob + + Should -Invoke -CommandName Get-Location -Times 2 + } + } + + Context 'When passing RootPath parameter' { + It 'Should use passed RootPath' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + RootPath = $TestDrive + } + + Invoke-PesterJob @params + } + } + + Context 'When passing Tag parameter' { + It 'Should use passed RootPath' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + Tag = 'Unit' + } + + Invoke-PesterJob @params + } + } + + Context 'When changing output verbosity levels' { + It 'Should set Output verbosity to Detailed by default' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[0].Output.Verbosity.Value -eq 'Detailed' + } + } + + It 'Should set output verbosity Minimal to specified value' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + Output = 'Minimal' + } + + Invoke-PesterJob @params + + # Minimal verbosity is not supported in Pester v5, it set to Normal if used. + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[0].Output.Verbosity.Value -eq 'Normal' + } + } + + It 'Should set output verbosity None to specified value' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + Output = 'None' + } + + Invoke-PesterJob @params + + # Minimal verbosity is not supported in Pester v5, it set to Normal if used. + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[0].Output.Verbosity.Value -eq 'None' + } + } + } + + Context 'When using switch parameters' { + It 'Should disable code coverage if SkipCodeCoverage is present' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath/tests' + SkipCodeCoverage = $true + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[0].CodeCoverage.Enabled.Value -eq $false + } + } + + It 'Should pass Pester result object if PassThru is present' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath\tests' + PassThru = $true + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[0].Run.PassThru.Value -eq $true + } + } + + It 'Should not show detailed error information as the default' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath\tests' + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[1] -eq $false + } + } + + It 'Should show detailed error information if ShowError is present' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath\tests' + ShowError = $true + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[1] -eq $true + } + } + + It 'Should skip running tests if SkipRun is present' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath\tests' + SkipRun = $true + } + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job -ParameterFilter { + $ArgumentList[0].Run.SkipRun.Value -eq $true + } + } + } + + Context 'Job Execution' { + It 'Should start a job and receive the result' { + $params = @{ + Path = Join-Path -Path $TestDrive -ChildPath 'MockPath\tests' + } + + Invoke-PesterJob @params + + Should -Invoke -CommandName Start-Job + Should -Invoke -CommandName Receive-Job + } + } + } +} diff --git a/tests/Unit/Public/Remove-PSReadLineHistory.tests.ps1 b/tests/Unit/Public/Remove-PSReadLineHistory.tests.ps1 index 4f2fbfa..86b10bc 100644 --- a/tests/Unit/Public/Remove-PSReadLineHistory.tests.ps1 +++ b/tests/Unit/Public/Remove-PSReadLineHistory.tests.ps1 @@ -74,13 +74,12 @@ Describe 'Remove-PSReadLineHistory' { # Assert Should -Invoke -CommandName Set-Content -Exactly -Times 1 -Scope It -ParameterFilter { - # TODO: Implement a comparison function (Compare-String) to compare arrays that also calls Out-Diff when the arrays are not equal. # Compare the arrays. $compareResult = Compare-Object -ReferenceObject $Value -DifferenceObject $expectedContent if ($compareResult) { - Out-Diff -ActualString $Value -ExpectedString $expectedContent + Out-Difference -Difference $Value -Reference $expectedContent | Write-Verbose -Verbose } # Compare-Object returns 0 when equal. @@ -111,7 +110,7 @@ Describe 'Remove-PSReadLineHistory' { if ($compareResult) { - Out-Diff -ActualString $Value -ExpectedString $expectedContent + Out-Difference -Difference $Value -Reference $expectedContent | Write-Verbose -Verbose } # Compare-Object returns 0 when equal.