Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/skills/psframework-testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<area>/.

2. Implement or update tests.
- Create a focused test file named <Command>.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.
2 changes: 1 addition & 1 deletion PSFramework/PSFramework.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Binary file modified PSFramework/bin/PSFramework.dll
Binary file not shown.
Binary file modified PSFramework/bin/PSFramework.pdb
Binary file not shown.
60 changes: 60 additions & 0 deletions PSFramework/bin/PSFramework.xml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,14 @@
</summary>
<param name="Key">The key of the entry to remove</param>
</member>
<member name="M:PSFramework.Caching.CacheMemoryConcurrent.ContainsKey(System.Object)">
<summary>
Verifies whether the key exists in the cache.
Expired entries are not considered.
</summary>
<param name="key">The key to check</param>
<returns>Whether the key is in the cache</returns>
</member>
<member name="P:PSFramework.Caching.CacheMemoryConcurrent.Item(System.Object)">
<summary>
Read or write an entry in the cache
Expand Down Expand Up @@ -11530,6 +11538,58 @@
</summary>
<param name="Script">The ScriptBlock to convert</param>
</member>
<member name="T:PSFramework.Utility.HumanizedTimeSpan">
<summary>
Wraps a timespan and presents it in a human-friendly readable way.
</summary>
</member>
<member name="F:PSFramework.Utility.HumanizedTimeSpan.Digits">
<summary>
How many digits after the dot are used for representing time
</summary>
</member>
<member name="M:PSFramework.Utility.HumanizedTimeSpan.#ctor(System.TimeSpan)">
<summary>
Creates a HumanizedTimeSpan from a TimeSpan object (not the hardest challenge)
</summary>
<param name="Value">The timespan object to accept</param>
</member>
<member name="M:PSFramework.Utility.HumanizedTimeSpan.#ctor(System.Int32)">
<summary>
Creates a HumanizedTimeSpan from integer, assuming it to mean seconds
</summary>
<param name="Seconds">The seconds to run</param>
</member>
<member name="M:PSFramework.Utility.HumanizedTimeSpan.#ctor(System.String)">
<summary>
Creates a HumanizedTimeSpan from a string object
</summary>
<param name="Value">The string to interpret</param>
</member>
<member name="M:PSFramework.Utility.HumanizedTimeSpan.#ctor(System.Object)">
<summary>
Creates a HumanizedTimeSpan from any kind of object it has been taught to understand
</summary>
<param name="InputObject">The object to interpret</param>
</member>
<member name="M:PSFramework.Utility.HumanizedTimeSpan.ToString">
<summary>
Creates extra-nice timespan formats
</summary>
<returns>Humanly readable timespans</returns>
</member>
<member name="M:PSFramework.Utility.HumanizedTimeSpan.op_Implicit(PSFramework.Utility.HumanizedTimeSpan)~System.TimeSpan">
<summary>
Implicitly converts a DbaTimeSpan object into a TimeSpan object
</summary>
<param name="Base">The original object to revert</param>
</member>
<member name="M:PSFramework.Utility.HumanizedTimeSpan.op_Implicit(System.TimeSpan)~PSFramework.Utility.HumanizedTimeSpan">
<summary>
Implicitly converts a TimeSpan object into a DbaTimeSpan object
</summary>
<param name="Base">The original object to wrap</param>
</member>
<member name="T:PSFramework.Utility.RegexHelper">
<summary>
Static class that holds useful regex patterns, ready for use
Expand Down
7 changes: 7 additions & 0 deletions PSFramework/changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions PSFramework/functions/data/Import-PSFJson.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
'System.Int64'
'System.Double'
'System.Bool'
'System.Boolean'
'System.DateTime'
)
}
Expand Down Expand Up @@ -162,8 +163,10 @@
$jsonTypes = @(
'System.String'
'System.Int32'
'System.Int64'
'System.Double'
'System.Bool'
'System.Boolean'
'System.DateTime'
)

Expand Down
4 changes: 2 additions & 2 deletions PSFramework/internal/loggingProviders/eventlog.provider.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions PSFramework/internal/loggingProviders/filesystem.provider.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
172 changes: 172 additions & 0 deletions PSFramework/tests/functions/caching/New-PSFCache.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading