diff --git a/.github/skills/psframework-testing/SKILL.md b/.github/skills/psframework-testing/SKILL.md new file mode 100644 index 00000000..5885e860 --- /dev/null +++ b/.github/skills/psframework-testing/SKILL.md @@ -0,0 +1,70 @@ +--- +name: psframework-testing +description: 'Write and run Pester tests for PSFramework commands and C#-backed cmdlets. Use for new test files, updating assertions, validating LogEntry metadata, and running tests against the local workspace module instead of the installed module.' +argument-hint: 'Command/file under test, expected behavior, and scope (single test file or broader run)' +user-invocable: true +--- + +# PSFramework Testing + +## When To Use +- Add or update tests in PSFramework test folders, especially under PSFramework/tests/functions. +- Validate behavior of commands implemented in C# under library/PSFramework or behaviour of commands implemented in script under PSFramework/functions or PSFramework/internal/functions. +- Run Pester verification in a way that guarantees the local workspace module is used. + +## Key Rules +1. Match repository test style. +- Use Describe, Context, It with clear behavioral names. +- Prefer BeforeEach for Clear-PSFMessage when tests inspect message queues. +- Keep assertions specific and stable. + +2. Verify behavior through public surfaces. +- Prefer Get-PSFMessage and command outputs instead of internal/private state. +- When verifying command tests through the messages they write, validate LogEntry shape and metadata (Message, LogMessage, Level, Tags, Data, TargetObject, Runspace, FunctionName, ModuleName, File, Line, CallStack, ErrorRecord). + +3. Always test against the workspace module build. +- Do not rely on an installed PSFramework module for verification. +- Import from PSFramework/PSFramework.psd1 first. +- If the interactive session has assembly conflicts, run tests in an isolated job process. + +## Workflow +1. Inspect implementation and existing tests. +- For C#-based commands, read the command implementation in library/PSFramework/Commands. +- For script-based commands, read the command implementation in PSFramework/functions or PSFramework/internal/functions, including subfolders. +- Read related models in library/PSFramework/Message when asserting logged metadata. +- Mirror neighboring test layout in PSFramework/tests/functions//. + +2. Implement or update tests. +- Create a focused test file named .Tests.ps1 under the matching function area. +- Add contract tests (parameters/sets) where useful. +- Add behavior tests for happy path, metadata persistence, and edge cases. +- Add at least one behavior test per Parameter Set. + +3. Run tests with local-manifest import. +- Preferred command for single-file validation, replacing the path provided to Invoke-Pester with the path to the respective tests file being generated or updated: + +```powershell +$job = Start-Job -ScriptBlock { + Import-Module "c:\Code\github\psframework\PSFramework\PSFramework.psd1" -Force + $path = (Get-Module PSFramework).Path + $result = Invoke-Pester -Path "c:\Code\github\psframework\PSFramework\tests\functions\message\Write-PSFMessage.Tests.ps1" -PassThru + [PSCustomObject]@{ + ModulePath = $path + Passed = $result.PassedCount + Failed = $result.FailedCount + Total = $result.TotalCount + } +} +Wait-Job $job +Receive-Job $job +Remove-Job $job +``` + +4. Confirm module provenance in output. +- Verify ModulePath points to the workspace module path, not user/profile module directories. + +## Done Criteria +- Tests are readable and consistent with nearby repository tests. +- Assertions verify externally observable behavior. +- Verification run is executed with local module import from PSFramework.psd1. +- Report includes pass/fail counts and loaded module path. diff --git a/PSFramework/PSFramework.psd1 b/PSFramework/PSFramework.psd1 index b59b6e46..6073f77d 100644 --- a/PSFramework/PSFramework.psd1 +++ b/PSFramework/PSFramework.psd1 @@ -4,7 +4,7 @@ RootModule = 'PSFramework.psm1' # Version number of this module. - ModuleVersion = '1.14.450' + ModuleVersion = '1.14.454' # ID used to uniquely identify this module GUID = '8028b914-132b-431f-baa9-94a6952f21ff' diff --git a/PSFramework/bin/PSFramework.dll b/PSFramework/bin/PSFramework.dll index d4339c84..2d8de705 100644 Binary files a/PSFramework/bin/PSFramework.dll and b/PSFramework/bin/PSFramework.dll differ diff --git a/PSFramework/bin/PSFramework.pdb b/PSFramework/bin/PSFramework.pdb index 5c3b9835..fd54525a 100644 Binary files a/PSFramework/bin/PSFramework.pdb and b/PSFramework/bin/PSFramework.pdb differ diff --git a/PSFramework/bin/PSFramework.xml b/PSFramework/bin/PSFramework.xml index d27b9429..91457488 100644 --- a/PSFramework/bin/PSFramework.xml +++ b/PSFramework/bin/PSFramework.xml @@ -237,6 +237,14 @@ The key of the entry to remove + + + Verifies whether the key exists in the cache. + Expired entries are not considered. + + The key to check + Whether the key is in the cache + Read or write an entry in the cache @@ -11530,6 +11538,58 @@ The ScriptBlock to convert + + + Wraps a timespan and presents it in a human-friendly readable way. + + + + + How many digits after the dot are used for representing time + + + + + Creates a HumanizedTimeSpan from a TimeSpan object (not the hardest challenge) + + The timespan object to accept + + + + Creates a HumanizedTimeSpan from integer, assuming it to mean seconds + + The seconds to run + + + + Creates a HumanizedTimeSpan from a string object + + The string to interpret + + + + Creates a HumanizedTimeSpan from any kind of object it has been taught to understand + + The object to interpret + + + + Creates extra-nice timespan formats + + Humanly readable timespans + + + + Implicitly converts a DbaTimeSpan object into a TimeSpan object + + The original object to revert + + + + Implicitly converts a TimeSpan object into a DbaTimeSpan object + + The original object to wrap + Static class that holds useful regex patterns, ready for use diff --git a/PSFramework/changelog.md b/PSFramework/changelog.md index ee198f5f..4595f905 100644 --- a/PSFramework/changelog.md +++ b/PSFramework/changelog.md @@ -1,5 +1,12 @@ # CHANGELOG +## 1.14.454 (2026-07-01) + +- New: Type PSFramework.Utility.HumanizedTimeSpan - a human-friendly duration display alternative. +- Fix: PSFCache - The ContainsKey method will always return $false +- Fix: Write-PSFMessage - Error when using `-ErrorStack` parameter: "The input string '%CALLSTACK LINE%' was not in a correct format." +- Fix: Import-PSFJson - fails to process boolean values correctly + ## 1.14.450 (2026-06-19) - Fix: Get-PSFMessage - may fail in a runspace situation diff --git a/PSFramework/functions/data/Import-PSFJson.ps1 b/PSFramework/functions/data/Import-PSFJson.ps1 index 02e3d49e..cd26d9d1 100644 --- a/PSFramework/functions/data/Import-PSFJson.ps1 +++ b/PSFramework/functions/data/Import-PSFJson.ps1 @@ -70,6 +70,7 @@ 'System.Int64' 'System.Double' 'System.Bool' + 'System.Boolean' 'System.DateTime' ) } @@ -162,8 +163,10 @@ $jsonTypes = @( 'System.String' 'System.Int32' + 'System.Int64' 'System.Double' 'System.Bool' + 'System.Boolean' 'System.DateTime' ) diff --git a/PSFramework/internal/loggingProviders/eventlog.provider.ps1 b/PSFramework/internal/loggingProviders/eventlog.provider.ps1 index 33710914..a129b6c0 100644 --- a/PSFramework/internal/loggingProviders/eventlog.provider.ps1 +++ b/PSFramework/internal/loggingProviders/eventlog.provider.ps1 @@ -99,8 +99,8 @@ $source = Get-ConfigValue -Name Source $script:loggingID = [System.Guid]::NewGuid() - $startingMessage = "Starting new logging provider! | Process ID: $PID | Instance Name: $($script:Instance.Name) | Logging ID: $loggingID" - $data = $startingMessage, $PID, $script:Instance.Name, $loggingID + $startingMessage = "Starting new logging provider! | Process ID: $PID | Instance Name: $($script:Instance.Name) | Logging ID: $($script:loggingID)" + $data = $startingMessage, $PID, $script:Instance.Name, $script:loggingID try { Write-LogEntry -LogName $logName -Source $source -Type Information -Category (Get-ConfigValue -Name Category) -EventId 999 -Data $data $script:logName = $logName diff --git a/PSFramework/internal/loggingProviders/filesystem.provider.ps1 b/PSFramework/internal/loggingProviders/filesystem.provider.ps1 index 359be835..5e56a0c0 100644 --- a/PSFramework/internal/loggingProviders/filesystem.provider.ps1 +++ b/PSFramework/internal/loggingProviders/filesystem.provider.ps1 @@ -105,9 +105,9 @@ $start_event = { } else { $filesystem_root = Get-Item -Path $filesystem_path } - try { [int]$filesystem_num_Error = (Get-ChildItem -Path $filesystem_path.FullName -Filter "$($env:ComputerName)_$($pid)_error_*.xml" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern "(\d+)" -AllMatches).Matches[1].Value } + try { [int]$filesystem_num_Error = (Get-ChildItem -Path $filesystem_root.FullName -Filter "$($env:ComputerName)_$($pid)_error_*.xml" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern "(\d+)" -AllMatches).Matches[-1].Value } catch { } - try { [int]$filesystem_num_Message = (Get-ChildItem -Path $filesystem_path.FullName -Filter "$($env:ComputerName)_$($pid)_message_*.log" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern "(\d+)" -AllMatches).Matches[1].Value } + try { [int]$filesystem_num_Message = (Get-ChildItem -Path $filesystem_root.FullName -Filter "$($env:ComputerName)_$($pid)_message_*.log" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern "(\d+)" -AllMatches).Matches[-1].Value } catch { } if (-not ($filesystem_num_Error)) { $filesystem_num_Error = 0 } if (-not ($filesystem_num_Message)) { $filesystem_num_Message = 0 } diff --git a/PSFramework/tests/functions/caching/New-PSFCache.Tests.ps1 b/PSFramework/tests/functions/caching/New-PSFCache.Tests.ps1 new file mode 100644 index 00000000..6218ae52 --- /dev/null +++ b/PSFramework/tests/functions/caching/New-PSFCache.Tests.ps1 @@ -0,0 +1,172 @@ +Describe "New-PSFCache Unit Tests" -Tag "CI", "Pipeline", "Unit" { + BeforeEach { + $script:collectorCounter = 0 + } + + It "Should return a CacheMemoryConcurrent object" { + $cache = New-PSFCache + + $cache.GetType().FullName | Should -Be 'PSFramework.Caching.CacheMemoryConcurrent' + $cache.Count | Should -Be 0 + } + + It "Should configure max items and lifetime through constructor parameters" { + $cache = New-PSFCache -MaxItems 3 -Lifetime 30s + + $cache.GetMaxItems() | Should -Be 3 + $cache.GetLifetime().TotalSeconds | Should -Be 30 + } + + It "Should configure TryDispose, Collector and CollectNull" { + $collector = { "Collected:$($_)" } + $cache = New-PSFCache -TryDispose -Collector $collector -CollectNull + + $cache.GetTryDispose() | Should -BeTrue + $cache.GetCollector() | Should -Not -BeNullOrEmpty + $cache.GetCollector().ToString() | Should -Match 'Collected:' + $cache.GetCacheNull() | Should -BeTrue + } + + It "Should support case-insensitive key access and Contains operations" { + $cache = New-PSFCache + $cache.Add('Alpha', 1) + + $cache.ContainsKey('alpha') | Should -BeTrue + $cache.Contains('ALPHA') | Should -BeTrue + $cache['aLpHa'] | Should -Be 1 + } + + It "Should update values through indexer set and retrieve latest value" { + $cache = New-PSFCache + $cache['Item'] = 1 + $cache['Item'] = 2 + + $cache['Item'] | Should -Be 2 + $cache.Count | Should -Be 1 + } + + It "Should remove keys and ignore missing keys without error" { + $cache = New-PSFCache + $cache.Add('A', 1) + $cache.Remove('A') + + $cache.ContainsKey('A') | Should -BeFalse + { $cache.Remove('DoesNotExist') } | Should -Not -Throw + } + + It "Should enforce MaxItems by draining oldest entries" { + $cache = New-PSFCache -MaxItems 2 + $cache.Add('A', 1) + $cache.Add('B', 2) + $cache.Add('C', 3) + + $cache.Count | Should -Be 2 + $cache.ContainsKey('A') | Should -BeFalse + $cache.ContainsKey('B') | Should -BeTrue + $cache.ContainsKey('C') | Should -BeTrue + } + + It "Should expose non-expired entries through Count, Keys and Values" { + $cache = New-PSFCache + $cache.Add('One', 1) + $cache.Add('Two', 2) + + $cache.Count | Should -Be 2 + $cache.Keys | Should -Contain 'One' + $cache.Keys | Should -Contain 'Two' + $cache.Values | Should -Contain 1 + $cache.Values | Should -Contain 2 + } + + It "Should enumerate as key-value pairs of current cache values" { + $cache = New-PSFCache + $cache.Add('One', 1) + $cache.Add('Two', 2) + + $items = @($cache.GetEnumerator()) + $items.Count | Should -Be 2 + ($items | Where-Object Key -eq 'One').Value | Should -Be 1 + ($items | Where-Object Key -eq 'Two').Value | Should -Be 2 + } + + It "Should clone current values to a hashtable" { + $cache = New-PSFCache + $cache.Add('One', 1) + $cache.Add('Two', 2) + + $clone = $cache.Clone() + $clone | Should -BeOfType ([hashtable]) + $clone.Count | Should -Be 2 + $clone['one'] | Should -Be 1 + $clone['two'] | Should -Be 2 + } + + It "Should retrieve missing entries using Collector and cache them" { + $cache = New-PSFCache -Collector { + $script:collectorCounter++ + "Collected:$($_)" + } + + $cache['Key1'] | Should -Be 'Collected:Key1' + $cache['Key1'] | Should -Be 'Collected:Key1' + $script:collectorCounter | Should -Be 1 + $cache.ContainsKey('Key1') | Should -BeTrue + } + + It "Should not cache null collector results by default" { + $cache = New-PSFCache -Collector { + $script:collectorCounter++ + $null + } + + $cache['NullKey'] | Should -BeNullOrEmpty + $cache['NullKey'] | Should -BeNullOrEmpty + $script:collectorCounter | Should -Be 2 + $cache.ContainsKey('NullKey') | Should -BeFalse + } + + It "Should cache null collector results when CollectNull is set" { + $cache = New-PSFCache -CollectNull -Collector { + $script:collectorCounter++ + $null + } + + $cache['NullKey'] | Should -BeNullOrEmpty + $cache['NullKey'] | Should -BeNullOrEmpty + $script:collectorCounter | Should -Be 1 + $cache.ContainsKey('NullKey') | Should -BeTrue + $cache.Count | Should -Be 1 + } + + It "Should exclude expired entries from Count and key lookups" { + $cache = New-PSFCache -Lifetime 1s + $cache.Add('SoonExpired', 42) + + Start-Sleep -Milliseconds 1200 + + $cache.ContainsKey('SoonExpired') | Should -BeFalse + $cache.Count | Should -Be 0 + $cache.Keys | Should -Not -Contain 'SoonExpired' + } + + It "Should dispose contained IDisposable values when TryDispose is enabled" { + $cache = New-PSFCache -TryDispose + $stream = [System.IO.MemoryStream]::new() + $cache.Add('Stream', $stream) + + $cache.Remove('Stream') + + $stream.CanRead | Should -BeFalse + } + + It "Should not dispose contained IDisposable values when TryDispose is disabled" { + $cache = New-PSFCache + $stream = [System.IO.MemoryStream]::new() + $cache.Add('Stream', $stream) + + $cache.Remove('Stream') + + $stream.CanRead | Should -BeTrue + $stream.Dispose() + } +} diff --git a/PSFramework/tests/functions/message/Write-PSFMessage.Tests.ps1 b/PSFramework/tests/functions/message/Write-PSFMessage.Tests.ps1 new file mode 100644 index 00000000..0d562877 --- /dev/null +++ b/PSFramework/tests/functions/message/Write-PSFMessage.Tests.ps1 @@ -0,0 +1,141 @@ +Describe "Write-PSFMessage Unit Tests" -Tag "CI", "Pipeline", "Unit" { + BeforeAll { + function Invoke-ErrorStackTest { + [CmdletBinding()] + param () + + try { 1 / 0 } + catch { Write-PSFMessage -Message Failed -ErrorRecord $_ -ErrorStack } + } + } + BeforeEach { + Clear-PSFMessage + } + + AfterAll { + Clear-PSFMessage + } + + It "Should expose the expected parameter sets" { + $command = Get-Command Write-PSFMessage -ErrorAction Stop + $command.ParameterSets.Name | Should -Contain 'Message' + $command.ParameterSets.Name | Should -Contain 'String' + } + + It "Should expose key parameters used by callers" { + $command = Get-Command Write-PSFMessage -ErrorAction Stop + $command.Parameters.Keys | Should -Contain 'Message' + $command.Parameters.Keys | Should -Contain 'String' + $command.Parameters.Keys | Should -Contain 'StringValues' + $command.Parameters.Keys | Should -Contain 'Level' + $command.Parameters.Keys | Should -Contain 'Tag' + $command.Parameters.Keys | Should -Contain 'Data' + $command.Parameters.Keys | Should -Contain 'Target' + $command.Parameters.Keys | Should -Contain 'FunctionName' + $command.Parameters.Keys | Should -Contain 'ModuleName' + $command.Parameters.Keys | Should -Contain 'File' + $command.Parameters.Keys | Should -Contain 'Line' + $command.Parameters.Keys | Should -Contain 'Exception' + $command.Parameters.Keys | Should -Contain 'ErrorRecord' + $command.Parameters.Keys | Should -Contain 'OverrideExceptionMessage' + } + + It "Should write a LogEntry object retrievable through Get-PSFMessage" { + Write-PSFMessage -Message 'SimpleMessage' -Level Significant + $entry = Get-PSFMessage | Select-Object -Last 1 + + $entry | Should -Not -BeNullOrEmpty + $entry.GetType().FullName | Should -Be 'PSFramework.Message.LogEntry' + $entry.Message | Should -Be 'SimpleMessage' + $entry.LogMessage | Should -Be 'SimpleMessage' + $entry.Level.ToString() | Should -Be 'Significant' + $entry.Runspace | Should -Be ([runspace]::DefaultRunspace.InstanceId) + $entry.Username | Should -Not -BeNullOrEmpty + } + + It "Should persist tags, data and target metadata" { + $data = @{ Alpha = 1; Beta = 'two' } + $target = [PSCustomObject]@{ Name = 'Target1'; Id = 42 } + + Write-PSFMessage -Message 'MetadataMessage' -Tag 'tagA', 'tagB' -Data $data -Target $target -Level Host + $entry = Get-PSFMessage | Select-Object -Last 1 + + $entry.Tags | Should -Contain 'tagA' + $entry.Tags | Should -Contain 'tagB' + $entry.Data.Alpha | Should -Be 1 + $entry.Data.Beta | Should -Be 'two' + $entry.TargetObject | Should -Be $target + } + + It "Should allow explicitly setting caller metadata fields" { + Write-PSFMessage -Message 'MetaOverride' -FunctionName 'Invoke-TestFn' -ModuleName 'TestModule' -File 'C:\Temp\fake.ps1' -Line 321 -Level Important + $entry = Get-PSFMessage | Select-Object -Last 1 + + $entry.FunctionName | Should -Be 'Invoke-TestFn' + $entry.ModuleName | Should -Be 'TestModule' + $entry.File | Should -Be 'C:\Temp\fake.ps1' + $entry.Line | Should -Be 321 + } + + It "Should format the Message using StringValues when provided" { + Write-PSFMessage -Message 'Value: {0} / {1}' -StringValues 'A', 5 -Level Verbose + $entry = Get-PSFMessage | Select-Object -Last 1 + + $entry.Message | Should -Be 'Value: A / 5' + } + + It "Should strip color tags in the logged message" { + Write-PSFMessage -Message "Colored output" -Level Important + $entry = Get-PSFMessage | Select-Object -Last 1 + + $entry.Message | Should -Be 'Colored output' + $entry.LogMessage | Should -Be 'Colored output' + } + + It "Should append exception text to message by default" { + $exception = [System.InvalidOperationException]::new('Boom') + Write-PSFMessage -Message 'Failure happened' -Exception $exception -Level Warning + $entry = Get-PSFMessage | Select-Object -Last 1 + + $entry.Message | Should -Be 'Failure happened | Boom' + $entry.ErrorRecord | Should -Not -BeNullOrEmpty + } + + It "Should respect OverrideExceptionMessage when exception is specified" { + $exception = [System.InvalidOperationException]::new('Boom') + Write-PSFMessage -Message 'Failure happened' -Exception $exception -OverrideExceptionMessage -Level Warning + $entry = Get-PSFMessage | Select-Object -Last 1 + + $entry.Message | Should -Be 'Failure happened' + $entry.ErrorRecord | Should -Not -BeNullOrEmpty + } + + It "Should avoid duplicate exception text when already present in message" { + $exception = [System.InvalidOperationException]::new('Boom') + $record = [System.Management.Automation.ErrorRecord]::new($exception, 'UnitTestError', [System.Management.Automation.ErrorCategory]::NotSpecified, $null) + + Write-PSFMessage -Message 'Failure happened | Boom' -ErrorRecord $record -Level Warning + $entry = Get-PSFMessage | Select-Object -Last 1 + + $entry.Message | Should -Be 'Failure happened | Boom' + $entry.ErrorRecord | Should -Not -BeNullOrEmpty + } + + It "Should use the error script stack when ErrorStack is specified" { + { Invoke-ErrorStackTest } | Should -Not -Throw + + $entry = Get-PSFMessage | Select-Object -Last 1 + $firstStackLine = ($entry.ErrorRecord.ScriptStackTrace -split "`r?`n" | Where-Object { $_ } | Select-Object -First 1) + $expectedLine = [int]([regex]::Match($firstStackLine, '(\d+)$').Groups[1].Value) + + $entry.ErrorRecord | Should -Not -BeNullOrEmpty + $entry.Message | Should -Be 'Failed | Attempted to divide by zero.' + $entry.ErrorRecord.ScriptStackTrace | Should -Match 'Invoke-ErrorStackTest' + $entry.ErrorRecord.ScriptStackTrace | Should -Match 'Write-PSFMessage.Tests.ps1: line \d+' + $entry.CallStack.Entries | Should -Not -BeNullOrEmpty + $entry.CallStack.Entries[0].FunctionName | Should -Be 'Invoke-ErrorStackTest' + $entry.CallStack.Entries[0].File | Should -Match 'Write-PSFMessage.Tests.ps1' + $entry.CallStack.Entries[0].Line | Should -BeGreaterThan 0 + $entry.CallStack.Entries[0].Line | Should -Be $expectedLine + } +} diff --git a/library/PSFramework/Caching/CacheBase.cs b/library/PSFramework/Caching/CacheBase.cs index 9d48c35e..94972063 100644 --- a/library/PSFramework/Caching/CacheBase.cs +++ b/library/PSFramework/Caching/CacheBase.cs @@ -192,7 +192,7 @@ public override bool ContainsKey(object key) { if (!base.ContainsKey(key)) return false; - return ((CachedData)base[key]).IsExpired; + return !((CachedData)base[key]).IsExpired; } /// /// Verifies whether the key exists in the cache. diff --git a/library/PSFramework/Caching/CacheMemoryConcurrent.cs b/library/PSFramework/Caching/CacheMemoryConcurrent.cs index 87b338b2..5a211a4b 100644 --- a/library/PSFramework/Caching/CacheMemoryConcurrent.cs +++ b/library/PSFramework/Caching/CacheMemoryConcurrent.cs @@ -93,6 +93,22 @@ public override void Remove(object key) base.RemoveInt(Key); } + /// + /// Verifies whether the key exists in the cache. + /// Expired entries are not considered. + /// + /// The key to check + /// Whether the key is in the cache + public override bool ContainsKey(object key) + { + // The new ContainsKey operation comes in two steps + // Thread-Safety requires a lock so nobody kills the entry inbetween the check and the expiration test + lock (_WriteLock) + { + return base.ContainsKey(key); + } + } + /// /// Read or write an entry in the cache /// diff --git a/library/PSFramework/Message/CallStack.cs b/library/PSFramework/Message/CallStack.cs index bf99b731..f8a5cccf 100644 --- a/library/PSFramework/Message/CallStack.cs +++ b/library/PSFramework/Message/CallStack.cs @@ -103,11 +103,12 @@ public CallStack(string ScriptStackTrace) { foreach (string line in ScriptStackTrace.Split('\n')) { - string command = Regex.Replace(line, "^at (.+?),.+$", "$1", RegexOptions.IgnoreCase); - string file = Regex.Replace(line, "^at .+?,(.+): line .+$", "$1", RegexOptions.IgnoreCase); - string lineNr = Regex.Replace(line, "^.+?(\\d+)$", "$1"); + string command = Regex.Replace(line.Trim(), "^at (.+?),.+$", "$1", RegexOptions.IgnoreCase); + string file = Regex.Replace(line.Trim(), "^at .+?,(.+): line .+$", "$1", RegexOptions.IgnoreCase); + string lineNr = Regex.Replace(line.Trim(), "^.+?(\\d+)$", "$1"); - Entries.Add(new CallStackEntry(command, file, Int32.Parse(lineNr), null)); + try { Entries.Add(new CallStackEntry(command, file, Int32.Parse(lineNr), null)); } + catch { Entries.Add(new CallStackEntry(command, file, -1, null)); } } } } diff --git a/library/PSFramework/PSFramework.csproj b/library/PSFramework/PSFramework.csproj index 594f26a5..3246df40 100644 --- a/library/PSFramework/PSFramework.csproj +++ b/library/PSFramework/PSFramework.csproj @@ -263,6 +263,7 @@ + diff --git a/library/PSFramework/Utility/HumanizedTimeSpan.cs b/library/PSFramework/Utility/HumanizedTimeSpan.cs new file mode 100644 index 00000000..18263377 --- /dev/null +++ b/library/PSFramework/Utility/HumanizedTimeSpan.cs @@ -0,0 +1,107 @@ +using PSFramework.Parameter; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Text; +using System.Threading.Tasks; + +namespace PSFramework.Utility +{ + /// + /// Wraps a timespan and presents it in a human-friendly readable way. + /// + public class HumanizedTimeSpan : TimeSpanParameter + { + /// + /// How many digits after the dot are used for representing time + /// + public int Digits = 2; + + #region Constructors + /// + /// Creates a HumanizedTimeSpan from a TimeSpan object (not the hardest challenge) + /// + /// The timespan object to accept + public HumanizedTimeSpan(TimeSpan Value) + : base(Value) + { + + } + + /// + /// Creates a HumanizedTimeSpan from integer, assuming it to mean seconds + /// + /// The seconds to run + public HumanizedTimeSpan(int Seconds) + : base(Seconds) + { + + } + + /// + /// Creates a HumanizedTimeSpan from a string object + /// + /// The string to interpret + public HumanizedTimeSpan(string Value) + : base(Value) + { + + } + + /// + /// Creates a HumanizedTimeSpan from any kind of object it has been taught to understand + /// + /// The object to interpret + public HumanizedTimeSpan(object InputObject) + : base(InputObject) + { + + } + #endregion Constructors + + /// + /// Creates extra-nice timespan formats + /// + /// Humanly readable timespans + public override string ToString() + { + if (Value.TotalSeconds < 1) + return Math.Round(Value.TotalMilliseconds, Digits).ToString() + " ms"; + else if (Value.TotalSeconds <= 60) + return Math.Round(Value.TotalSeconds, Digits).ToString() + " s"; + else + { + if (Value.Ticks % 10000000 == 0) { return Value.ToString(); } + else + { + string temp = Value.ToString(); + temp = temp.Substring(0, temp.LastIndexOf(".")); + return temp; + } + } + } + + #region Implicit Operators + /// + /// Implicitly converts a DbaTimeSpan object into a TimeSpan object + /// + /// The original object to revert + public static implicit operator TimeSpan(HumanizedTimeSpan Base) + { + try { return Base.Value; } + catch { } + return new TimeSpan(); + } + + /// + /// Implicitly converts a TimeSpan object into a DbaTimeSpan object + /// + /// The original object to wrap + public static implicit operator HumanizedTimeSpan(TimeSpan Base) + { + return new HumanizedTimeSpan(Base); + } + #endregion Implicit Operators + } +}