tests/CommandWatch.Tests.ps1
|
<#
Pester tests for CommandWatch module. Run via Invoke-Pester -Script '.\CommandWatch\tests'. #> $moduleManifest = Join-Path $PSScriptRoot '..\CommandWatch.psd1' $invokeOncePath = Join-Path $PSScriptRoot '..\Private\Invoke-Once.ps1' function New-CommandWatchTestConfigPath { Join-Path ([System.IO.Path]::GetTempPath()) ("CommandWatchTests_{0}.json" -f ([guid]::NewGuid())) } Describe 'Invoke-Once helper' { BeforeAll { . $invokeOncePath } It 'returns output and exit code for expression mode' { $result = Invoke-Once -Command "'helper-expr'" $result.Output | Should Match 'helper-expr' $result.ExitCode | Should Be 0 } It 'returns exit code from exec mode' { $result = Invoke-Once -Command 'cmd.exe' -Args '/c','exit 3' -UseExec $result.ExitCode | Should Be 3 } } Describe 'Invoke-CommandWatch public surface' { BeforeAll { $script:publicConfigPath = New-CommandWatchTestConfigPath $env:COMMANDWATCH_CONFIG_PATH = $script:publicConfigPath Remove-Item -LiteralPath $script:publicConfigPath -ErrorAction SilentlyContinue Import-Module $moduleManifest -Force } AfterAll { Remove-Item -LiteralPath $script:publicConfigPath -ErrorAction SilentlyContinue Remove-Item Env:COMMANDWATCH_CONFIG_PATH -ErrorAction SilentlyContinue Remove-Module CommandWatch -ErrorAction SilentlyContinue -Force } It 'exports expected alias' { (Get-Alias 'Watch-Command' -ErrorAction Stop).Definition | Should Be 'Invoke-CommandWatch' } It 'records expression parameter set metadata' { $result = Invoke-CommandWatch -Command "'binding-expr'" -Count 1 -NoTitle -NoClear -NoWrap -PassThru -InformationAction SilentlyContinue $payload = @($result)[0] $payload.ParameterSet | Should Be 'Expression' $payload.DisplayCommand | Should Be "'binding-expr'" } It 'records exec parameter set metadata' { $result = Invoke-CommandWatch -Command 'cmd.exe' -UseExec -Args '/c','exit 0' -Count 1 -NoTitle -NoClear -NoWrap -PassThru -InformationAction SilentlyContinue $payload = @($result)[0] $payload.ParameterSet | Should Be 'Exec' $payload.DisplayCommand | Should Be 'cmd.exe /c exit 0' } It 'accepts exec mode and returns PassThru objects' { $result = Invoke-CommandWatch -Command 'cmd.exe' -UseExec -Args '/c','exit 0' -Count 1 -NoTitle -NoClear -NoWrap -PassThru -InformationAction SilentlyContinue $result | Should Not BeNullOrEmpty (@($result)[0]).ExitCode | Should Be 0 ((@($result)[0]).PSTypeNames -contains 'CommandWatch.TickResult') | Should Be $true } It 'supports disabling host effects for CI scenarios' { $result = Invoke-CommandWatch -Command "'ci-mode'" -Count 1 -NoTitle -NoClear -NoWrap -PassThru -InformationAction SilentlyContinue ((@($result)[0]).DisplayLines -join [Environment]::NewLine) | Should Match 'ci-mode' } It 'omits header rendering when -NoTitle is specified' { Mock -CommandName Format-Header -ModuleName CommandWatch Invoke-CommandWatch -Command "'headerless'" -Count 1 -NoTitle -NoClear -NoWrap -InformationAction SilentlyContinue | Out-Null Assert-MockCalled -CommandName Format-Header -ModuleName CommandWatch -Times 0 } It 'keeps scheduled timestamps within acceptable skew' { $interval = 0.2 $result = Invoke-CommandWatch -Command "'schedule-test'" -Count 3 -Interval $interval -Precise -NoTitle -NoClear -NoWrap -PassThru -InformationAction SilentlyContinue $ticks = @($result) $ticks.Count | Should Be 3 $deltas = @() for ($i = 1; $i -lt $ticks.Count; $i++) { $deltas += ($ticks[$i].Timestamp - $ticks[$i-1].Timestamp).TotalSeconds } foreach ($delta in $deltas) { $delta | Should BeGreaterThan 0 ([math]::Abs($delta - $interval)) | Should BeLessThan 0.26 } } It 'honors legacy wait parameters' { $result = Invoke-CommandWatch -Command "'legacy-mode'" -waitTime 1 -waitInterval s -Count 1 -NoTitle -NoClear -NoWrap -PassThru -InformationAction SilentlyContinue (@($result)[0]).Iteration | Should Be 1 } It 'produces diff metadata when -Differences is supplied' { $global:CommandWatchDiffCounter = 0 $expr = '($global:CommandWatchDiffCounter = $global:CommandWatchDiffCounter + 1); "diff-$($global:CommandWatchDiffCounter)"' $result = Invoke-CommandWatch -Command $expr -Count 2 -NoTitle -NoClear -NoWrap -PassThru -Differences -InformationAction SilentlyContinue ((@($result)[1]).DiffLines -join [Environment]::NewLine) | Should Match '\+ diff-2' Remove-Variable -Name CommandWatchDiffCounter -Scope Global -ErrorAction SilentlyContinue } It 'exits when -ChangeExit is specified' { $global:CommandWatchChangeCounter = 0 $expr = '($global:CommandWatchChangeCounter = $global:CommandWatchChangeCounter + 1); "val-$($global:CommandWatchChangeCounter)"' $result = Invoke-CommandWatch -Command $expr -Count 5 -NoTitle -NoClear -NoWrap -PassThru -ChangeExit -InformationAction SilentlyContinue (@($result)).Count | Should Be 2 Remove-Variable -Name CommandWatchChangeCounter -Scope Global -ErrorAction SilentlyContinue } It 'emits informational reason when -ChangeExit stops execution' { $global:CommandWatchChangeInfo = 0 $expr = '($global:CommandWatchChangeInfo = $global:CommandWatchChangeInfo + 1); "info-$($global:CommandWatchChangeInfo)"' $info = $null Invoke-CommandWatch -Command $expr -Count 5 -NoTitle -NoClear -NoWrap -PassThru -ChangeExit -InformationAction Continue -InformationVariable info | Out-Null $messages = @($info | ForEach-Object { $_.MessageData }) ($messages | Where-Object { $_ -like '*-ChangeExit*' }).Count | Should BeGreaterThan 0 (@($messages)[-1]) | Should Match 'reason: ChangeExit' Remove-Variable -Name CommandWatchChangeInfo -Scope Global -ErrorAction SilentlyContinue } It 'exits when -ErrorExit is specified' { $result = Invoke-CommandWatch -Command 'cmd.exe' -UseExec -Args '/c','exit 7' -Count 3 -NoTitle -NoClear -NoWrap -PassThru -ErrorExit -InformationAction SilentlyContinue (@($result)).Count | Should Be 1 (@($result)[0]).ExitCode | Should Be 7 } It 'emits informational reason when -ErrorExit stops execution' { $info = $null Invoke-CommandWatch -Command 'cmd.exe' -UseExec -Args '/c','exit 9' -Count 4 -NoTitle -NoClear -NoWrap -ErrorExit -InformationAction Continue -InformationVariable info | Out-Null $messages = @($info | ForEach-Object { $_.MessageData }) ($messages | Where-Object { $_ -like '*-ErrorExit*9*' }).Count | Should BeGreaterThan 0 (@($messages)[-1]) | Should Match 'reason: ErrorExit' } It 'stops once count iterations are reached and reports the reason' { $info = $null $result = Invoke-CommandWatch -Command "'count-check'" -Count 2 -NoTitle -NoClear -NoWrap -PassThru -InformationAction Continue -InformationVariable info (@($result)).Count | Should Be 2 $messages = @($info | ForEach-Object { $_.MessageData }) ($messages | Where-Object { $_ -like '*iteration count*' }).Count | Should BeGreaterThan 0 (@($messages)[-1]) | Should Match 'reason: Count' } } Describe 'CommandWatch configuration' { BeforeAll { $script:configPath = New-CommandWatchTestConfigPath $env:COMMANDWATCH_CONFIG_PATH = $script:configPath Import-Module $moduleManifest -Force } AfterAll { Remove-Item -LiteralPath $script:configPath -ErrorAction SilentlyContinue Remove-Item Env:COMMANDWATCH_CONFIG_PATH -ErrorAction SilentlyContinue Remove-Module CommandWatch -ErrorAction SilentlyContinue -Force } It 'returns defaults when config is absent' { Remove-Item -LiteralPath $script:configPath -ErrorAction SilentlyContinue $cfg = Get-CommandWatchConfig $cfg.Defaults.Interval | Should Be 2 } It 'persists overrides to disk' { $overrides = @{ Interval = 1.5; NoClear = $true; LogPath = (Join-Path ([System.IO.Path]::GetTempPath()) 'cw-log.txt') } Set-CommandWatchConfig -Defaults $overrides | Out-Null $cfg = Get-CommandWatchConfig $cfg.Defaults.Interval | Should Be 1.5 $cfg.Defaults.NoClear | Should Be $true $cfg.Defaults.LogPath | Should Be $overrides.LogPath } It 'applies defaults to Invoke-CommandWatch and writes log' { $logFileName = 'cw-log-{0}.txt' -f ([guid]::NewGuid()) $logPath = Join-Path ([System.IO.Path]::GetTempPath()) $logFileName Set-CommandWatchConfig -Defaults @{ LogPath = $logPath; NoTitle = $true; NoClear = $true; NoWrap = $true } | Out-Null Invoke-CommandWatch -Command "'cfg-test'" -Count 1 -PassThru -InformationAction SilentlyContinue | Out-Null Test-Path -LiteralPath $logPath | Should Be $true (Get-Content -LiteralPath $logPath) | Should Match 'cfg-test' Remove-Item -LiteralPath $logPath -ErrorAction SilentlyContinue } } Describe 'CommandWatch helper functions' { BeforeAll { Import-Module $moduleManifest -Force } AfterAll { Remove-Module CommandWatch -ErrorAction SilentlyContinue -Force } It 'formats headers with trimmed intervals and metadata' { InModuleScope CommandWatch { $header = Format-Header -Interval 2.3456 -Command 'pwsh.exe' -Timestamp '2025-01-01 00:00:00' -ExitCode 3 -Iteration 5 $header } | Should Be 'Every 2.346s: pwsh.exe 2025-01-01 00:00:00 [exit:3] [iter:5]' } It 'detects added and removed lines for deterministic strings' { $diffEntries = InModuleScope CommandWatch { Get-CommandWatchDifference -Reference @('alpha','beta') -Current @('beta','gamma') } $added = @($diffEntries | Where-Object Type -eq 'Added').Text $removed = @($diffEntries | Where-Object Type -eq 'Removed').Text ($added -contains '+ gamma') | Should Be $true ($removed -contains '- alpha') | Should Be $true } } |