public/Update-PesterTest.ps1
| 
                                function Update-PesterTest { <# .SYNOPSIS Updates Pester tests to v5 format for module-specific commands. .DESCRIPTION Updates existing Pester tests to v5 format for module-specific commands. This function processes test files and converts them to use the newer Pester v5 parameter validation syntax. It skips files that have already been converted or exceed the specified size limit. .PARAMETER InputObject Array of objects that can be either file paths, FileInfo objects, or command objects (from Get-Command). If not specified, will process commands from the specified module. .PARAMETER First Specifies the maximum number of commands to process. .PARAMETER Skip Specifies the number of commands to skip before processing. .PARAMETER Limit Maximum number of items to process. Default: 5 .PARAMETER PromptFilePath The path to the template file containing the prompt structure. Defaults to the prompt.md file in the module's prompts directory. .PARAMETER ContextFilePath The path to files containing additional context (conventions, examples, etc.). Defaults to style.md and migration.md files in the module's prompts directory. .PARAMETER MaxFileSize The maximum size of test files to process, in bytes. Files larger than this will be skipped. Defaults to 500kb. .PARAMETER Model The AI model to use. Overrides configured default. .PARAMETER Tool The AI coding tool to use. Valid values: ClaudeCode, Aider, Gemini, GitHubCopilot, Codex Default: ClaudeCode .PARAMETER Raw Run the command directly without capturing output or assigning to variables. Useful for interactive scenarios like Jupyter notebooks where output handling can cause issues. .NOTES Tags: Testing, Pester Author: Chrissy LeMaire .EXAMPLE PS C:/> Update-PesterTest Updates all eligible Pester tests to v5 format using default parameters. .EXAMPLE PS C:/> Update-PesterTest -Tool Aider -First 10 -Skip 5 Updates 10 test files starting from the 6th command, skipping the first 5, using Aider. .EXAMPLE PS C:/> "C:/tests/Get-DbaDatabase.Tests.ps1" | Update-PesterTest Updates the specified test file to v5 format. .EXAMPLE PS C:/> Get-Command -Module dbatools -Name "*Database*" | Update-PesterTest Updates test files for all commands in dbatools module that match "*Database*". .EXAMPLE PS C:/> Get-ChildItem ./tests/Add-DbaRegServer.Tests.ps1 | Update-PesterTest -Verbose Updates the specific test file from a Get-ChildItem result. #> [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(ValueFromPipeline)] [Alias('FullName', 'Path', 'FilePath')] [PSObject[]]$InputObject, [int]$First = 10000, [int]$Skip, [int]$Limit = 10000, [string]$PromptFilePath = (Resolve-Path "$script:ModuleRoot/prompts/prompt.md" -ErrorAction SilentlyContinue).Path, [string[]]$ContextFilePath = @( (Resolve-Path "$script:ModuleRoot/prompts/style.md" -ErrorAction SilentlyContinue).Path, (Resolve-Path "$script:ModuleRoot/prompts/migration.md" -ErrorAction SilentlyContinue).Path ), [int]$MaxFileSize = 500kb, [string]$Model, [string]$Tool, [switch]$Raw ) begin { # Flag to track if initialization succeeded $script:initSucceeded = $false # Track modules we've attempted to load $script:attemptedModules = @{} # Use default tool if not specified if (-not $Tool) { $Tool = Get-PSFConfigValue -FullName 'AITools.DefaultTool' -Fallback $null if (-not $Tool) { Write-PSFMessage -Level Error -Message "No tool specified and no default tool configured. Run Initialize-AIToolDefault or specify -Tool parameter." return } Write-PSFMessage -Level Verbose -Message "Using default tool: $Tool" } Write-PSFMessage -Level Verbose -Message "Starting Update-PesterTest with tool: $Tool" # Check if piping input with Gemini - warn about potential output quirks $isPiping = $MyInvocation.PipelinePosition -gt 1 -or $MyInvocation.ExpectingInput $suppressWarning = Get-PSFConfigValue -FullName 'AITools.SuppressGeminiPipelineWarning' -Fallback $false # Load prompt template $promptTemplate = if ($PromptFilePath -and (Test-Path $PromptFilePath)) { Get-Content $PromptFilePath -Raw } else { Write-PSFMessage -Level Error -Message "Prompt template not found at $PromptFilePath" return } # Validate context files exist $validContextFiles = @() foreach ($contextPath in $ContextFilePath) { if ($contextPath -and (Test-Path $contextPath)) { $validContextFiles += $contextPath Write-PSFMessage -Level Verbose -Message "Added context file: $contextPath" } else { Write-PSFMessage -Level Warning -Message "Context file not found: $contextPath" } } $commonParameters = [System.Management.Automation.PSCmdlet]::CommonParameters [System.Collections.ArrayList]$commandsToProcess = @() # Add counters for early filtering in the pipeline $pipelineIndex = 0 $collectedCount = 0 # Mark initialization as successful $script:initSucceeded = $true } process { # Skip processing if initialization failed if (-not $script:initSucceeded) { return } if ($InputObject) { foreach ($item in $InputObject) { $pipelineIndex++ # EARLY FILTERING - reject before any expensive processing if ($Skip -gt 0 -and $pipelineIndex -le $Skip) { Write-PSFMessage -Level Debug -Message "Skipping pipeline item $pipelineIndex" continue } if ($First -lt 10000 -and $collectedCount -ge $First) { Write-PSFMessage -Level Debug -Message "Reached First limit, ignoring remaining items" continue } if ($collectedCount -ge $Limit) { Write-PSFMessage -Level Debug -Message "Reached Limit, ignoring remaining items" continue } $collectedCount++ Write-PSFMessage -Level Debug -Message "Processing input object of type: $($item.GetType().FullName)" if ($item -is [System.Management.Automation.CommandInfo]) { [void]$commandsToProcess.Add($item) } elseif ($item -is [System.IO.FileInfo]) { $path = $item.FullName Write-PSFMessage -Level Debug -Message "Processing FileInfo path: $path" if ($path -like "*.Tests.ps1" -and (Test-Path $path)) { $cmdName = [System.IO.Path]::GetFileNameWithoutExtension($path) -replace '\.Tests$', '' # Try to get the actual command to retrieve its parameters $actualCommand = Get-Command -Name $cmdName -ErrorAction SilentlyContinue # If command not found, try to determine and load the module if (-not $actualCommand) { # Try to extract module name from path (common pattern: ModuleName/tests/CommandName.Tests.ps1) $pathParts = $path -split [regex]::Escape([System.IO.Path]::DirectorySeparatorChar) $testsIndex = $pathParts.IndexOf('tests') if ($testsIndex -gt 0) { $potentialModuleName = $pathParts[$testsIndex - 1] # Only attempt to load if we haven't tried this module before if (-not $script:attemptedModules.ContainsKey($potentialModuleName)) { try { Import-Module $potentialModuleName -Verbose:$false -ErrorAction Stop Write-PSFMessage -Level Verbose -Message "Loaded module $potentialModuleName" $script:attemptedModules[$potentialModuleName] = $true } catch { Write-PSFMessage -Level Verbose -Message "Could not load module $potentialModuleName : $_" $script:attemptedModules[$potentialModuleName] = $false } } # Try to get the command again if module was successfully loaded if ($script:attemptedModules[$potentialModuleName]) { $actualCommand = Get-Command -Name $cmdName -ErrorAction SilentlyContinue } } } $testFileCommand = [PSCustomObject]@{ Name = $cmdName TestFilePath = $path IsTestFile = $true Parameters = if ($actualCommand) { $actualCommand.Parameters } else { @{} } Source = if ($actualCommand) { $actualCommand.Source } else { $null } } [void]$commandsToProcess.Add($testFileCommand) } else { Write-PSFMessage -Level Warning -Message "FileInfo object is not a valid test file: $path" } } elseif ($item -is [string]) { Write-PSFMessage -Level Debug -Message "Processing string path: $item" try { $resolvedItem = (Resolve-Path $item -ErrorAction Stop).Path if ($resolvedItem -like "*.Tests.ps1" -and (Test-Path $resolvedItem)) { $cmdName = [System.IO.Path]::GetFileNameWithoutExtension($resolvedItem) -replace '\.Tests$', '' # Try to get the actual command to retrieve its parameters $actualCommand = Get-Command -Name $cmdName -ErrorAction SilentlyContinue # If command not found, try to determine and load the module if (-not $actualCommand) { # Try to extract module name from path (common pattern: ModuleName/tests/CommandName.Tests.ps1) $pathParts = $resolvedItem -split [regex]::Escape([System.IO.Path]::DirectorySeparatorChar) $testsIndex = $pathParts.IndexOf('tests') if ($testsIndex -gt 0) { $potentialModuleName = $pathParts[$testsIndex - 1] # Only attempt to load if we haven't tried this module before if (-not $script:attemptedModules.ContainsKey($potentialModuleName)) { try { Import-Module $potentialModuleName -ErrorAction Stop -Verbose:$false Write-PSFMessage -Level Verbose -Message "Loaded module $potentialModuleName" $script:attemptedModules[$potentialModuleName] = $true } catch { Write-PSFMessage -Level Verbose -Message "Could not load module $potentialModuleName : $_" $script:attemptedModules[$potentialModuleName] = $false } } # Try to get the command again if module was successfully loaded if ($script:attemptedModules[$potentialModuleName]) { $actualCommand = Get-Command -Name $cmdName -ErrorAction SilentlyContinue } } } $testFileCommand = [PSCustomObject]@{ Name = $cmdName TestFilePath = $resolvedItem IsTestFile = $true Parameters = if ($actualCommand) { $actualCommand.Parameters } else { @{} } Source = if ($actualCommand) { $actualCommand.Source } else { $null } } [void]$commandsToProcess.Add($testFileCommand) } else { Write-PSFMessage -Level Warning -Message "String path is not a valid test file: $resolvedItem" } } catch { Write-PSFMessage -Level Warning -Message "Could not resolve path: $item" } } else { Write-PSFMessage -Level Warning -Message "Unsupported input type: $($item.GetType().FullName)" } } } } end { # Skip end processing if initialization failed if (-not $script:initSucceeded) { return } # Get commands from module if no input provided if (-not $commandsToProcess -and -not $PSBoundParameters.ContainsKey('InputObject')) { Write-PSFMessage -Level Verbose -Message "No input objects provided, processing would require module commands" return } if (-not $commandsToProcess) { Write-PSFMessage -Level Warning -Message "No commands to process" return } # Skip/First/Limit filtering already done in process block $totalCommands = $commandsToProcess.Count Write-PSFMessage -Level Debug -Message "Processing $totalCommands test file(s) with $Tool..." # Collect all valid file paths [System.Collections.ArrayList]$filesToProcess = @() foreach ($command in $commandsToProcess) { # Determine file path if ($command.IsTestFile) { $cmdName = $command.Name $filename = $command.TestFilePath } else { $cmdName = $command.Name # Get the module root from the command's source module $moduleRoot = (Get-Module $command.Source | Select-Object -First 1).ModuleBase if ($moduleRoot) { # Search for tests within the module root $getChildItemParams = @{ Path = $moduleRoot Recurse = $true Filter = "$cmdName.Tests.ps1" ErrorAction = 'SilentlyContinue' } $testFile = Get-ChildItem @getChildItemParams | Select-Object -First 1 $filename = $testFile.FullName } } Write-PSFMessage -Level Debug -Message "Validating test file: $cmdName" Write-PSFMessage -Level Verbose -Message "Test file path: $filename" # Validate file exists if (-not $filename -or -not (Test-Path $filename)) { Write-PSFMessage -Level Warning -Message "No tests found for $cmdName, file not found" continue } # Check file size $fileSize = (Get-Item $filename).Length if ($fileSize -gt $MaxFileSize) { Write-PSFMessage -Level Warning -Message "Skipping $cmdName because file size ($fileSize bytes) exceeds limit ($MaxFileSize bytes)" continue } # Add to files to process [void]$filesToProcess.Add($filename) } if ($filesToProcess.Count -eq 0) { Write-PSFMessage -Level Warning -Message "No valid test files to process after filtering" return } Write-PSFMessage -Level Verbose -Message "Collected $($filesToProcess.Count) test file(s) to process" # Use a simplified prompt template without per-file placeholders $genericPrompt = if ($promptTemplate) { # Remove placeholder lines if they exist $promptTemplate -replace '--FILEPATH--.*', '' -replace '--CMDNAME--.*', '' -replace '--PARMZ--.*', '' } else { "Update these Pester tests to v5 format" } # Build Invoke-AITool parameters for batch processing $invokeParams = @{ Tool = $Tool Prompt = $genericPrompt Path = $filesToProcess Context = $validContextFiles } if ($Model) { $invokeParams.Model = $Model } if ($Raw) { $invokeParams.Raw = $true } Write-PSFMessage -Level Verbose -Message "Invoking $Tool to update $($filesToProcess.Count) test files" # Call Invoke-AITool once with all files - it will handle progress display if ($PSCmdlet.ShouldProcess("$($filesToProcess.Count) test files", "Update Pester tests to v5 format using $Tool")) { Invoke-AITool @invokeParams } Write-PSFMessage -Level Debug -Message "Processing complete. Updated $($filesToProcess.Count) file(s)." } }  |