From d5acb791a92d5d6c1ea844629d9bfd9de4340f6f Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Sun, 23 Aug 2026 22:02:59 +0200 Subject: [PATCH 1/4] refactor v1 --- .github/workflows/performance.yml | 43 ++++ .github/workflows/tests.yml | 45 ++++ PowerTree.psm1 | 37 ++- .../Build-ChildItemDirectoryParams.ps1 | 16 -- Private/PowerTree/Output/Build-OutputLine.ps1 | 12 +- Private/PowerTree/Output/Get-TreeView.ps1 | 237 +----------------- .../Output/ToFile/Invoke-OutputBuilder.ps1 | 2 +- .../PowerTree/Output/Write-TreeSnapshot.ps1 | 87 +++++++ .../PowerTree/Scanning/Get-TreeSnapshot.ps1 | 157 ++++++++++++ .../Size/Get-FilesByFilteredSize.ps1 | 33 --- Private/PowerTree/Size/Get-FolderSize.ps1 | 48 ---- .../PowerTree/Sorting/Get-SortingMethod.ps1 | 58 +---- .../Output/Get-RegistryConfigurationData.ps1 | 4 +- .../Output/Get-TreeRegistryView.ps1 | 6 +- .../Output/Show-RegistryStats.ps1 | 4 +- .../ToFile/Invoke-OutputBuilderRegistry.ps1 | 4 +- Private/Shared/DataModel/ClassLoader.ps1 | 31 --- Private/Shared/DataModel/Classes.ps1 | 19 +- .../Output/Convert-StatsInOutputFile.ps1 | 6 - .../Output/Write-ConfigurationToHost.ps1 | 6 +- Public/Show-PowerTree.ps1 | 6 +- Public/Show-PowerTreeRegistry.ps1 | 2 +- Tests/Measure-PowerTreePerformance.ps1 | 220 ++++++++++++++++ Tests/PowerTree.Core.Tests.ps1 | 159 ++++++++++++ Tests/PowerTree.FileSystem.Tests.ps1 | 177 +++++++++++++ Tests/PowerTree.Integration.Tests.ps1 | 133 ++++++++++ Tests/PowerTree.Output.Tests.ps1 | 192 ++++++++++++++ Tests/PowerTree.Registry.Tests.ps1 | 205 +++++++++++++++ 28 files changed, 1491 insertions(+), 458 deletions(-) create mode 100644 .github/workflows/performance.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 Private/PowerTree/Configuration/ParamHelpers/Build-ChildItemDirectoryParams.ps1 create mode 100644 Private/PowerTree/Output/Write-TreeSnapshot.ps1 create mode 100644 Private/PowerTree/Scanning/Get-TreeSnapshot.ps1 delete mode 100644 Private/PowerTree/Size/Get-FilesByFilteredSize.ps1 delete mode 100644 Private/PowerTree/Size/Get-FolderSize.ps1 delete mode 100644 Private/Shared/DataModel/ClassLoader.ps1 delete mode 100644 Private/Shared/Output/Convert-StatsInOutputFile.ps1 create mode 100644 Tests/Measure-PowerTreePerformance.ps1 create mode 100644 Tests/PowerTree.Core.Tests.ps1 create mode 100644 Tests/PowerTree.FileSystem.Tests.ps1 create mode 100644 Tests/PowerTree.Integration.Tests.ps1 create mode 100644 Tests/PowerTree.Output.Tests.ps1 create mode 100644 Tests/PowerTree.Registry.Tests.ps1 diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml new file mode 100644 index 0000000..418eabe --- /dev/null +++ b/.github/workflows/performance.yml @@ -0,0 +1,43 @@ +name: Performance + +on: + pull_request: + +permissions: + contents: read + +jobs: + benchmark: + name: Benchmark PowerTree + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out PR + uses: actions/checkout@v6 + with: + path: pr + persist-credentials: false + + - name: Check out base commit + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + persist-credentials: false + + - name: Benchmark base + shell: pwsh + run: | + & ./pr/Tests/Measure-PowerTreePerformance.ps1 ` + -ModulePath ./base/PowerTree.psd1 ` + -JsonPath "$env:RUNNER_TEMP/base-performance.json" + + - name: Benchmark PR and publish summary + shell: pwsh + run: | + & ./pr/Tests/Measure-PowerTreePerformance.ps1 ` + -ModulePath ./pr/PowerTree.psd1 ` + -JsonPath "$env:RUNNER_TEMP/pr-performance.json" ` + -BaselinePath "$env:RUNNER_TEMP/base-performance.json" ` + -MarkdownPath $env:GITHUB_STEP_SUMMARY diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2fcfb4c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,45 @@ +name: Tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: PowerShell ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Install Pester + shell: pwsh + run: Install-Module Pester -RequiredVersion 6.1.0 -Scope CurrentUser -Force -SkipPublisherCheck + + - name: Run Pester tests + shell: pwsh + run: | + $paths = Get-ChildItem ./Tests -Filter '*.Tests.ps1' | + Where-Object Name -ne 'PowerTree.Integration.Tests.ps1' | + Select-Object -ExpandProperty FullName + $result = Invoke-Pester -Path $paths -Output Detailed -PassThru + if ($result.FailedCount -gt 0) { + throw "$($result.FailedCount) Pester test(s) failed" + } + + - name: Run integration tests + shell: pwsh + run: ./Tests/PowerTree.Integration.Tests.ps1 diff --git a/PowerTree.psm1 b/PowerTree.psm1 index 3c37ca8..116e4f5 100644 --- a/PowerTree.psm1 +++ b/PowerTree.psm1 @@ -1,29 +1,24 @@ # Define script-level variables $script:ModuleRoot = $PSScriptRoot -New-Alias -Name "ptree" -Value "Show-PowerTree" -New-Alias -Name "PowerTree" -Value "Show-PowerTree" -New-Alias -Name "Start-PowerTree" -Value "Show-PowerTree" -New-Alias -Name "Edit-PtreeConfig" -Value "Edit-PowerTreeConfig" -New-Alias -Name "Edit-Ptree" -Value "Edit-PowerTreeConfig" -New-Alias -Name "Edit-PowerTree" -Value "Edit-PowerTreeConfig" -New-Alias -Name "ptreer" -Value "Show-PowerTreeRegistry" -New-Alias -Name "PowerRegistry" -Value "Show-PowerTreeRegistry" +Set-Alias -Name "ptree" -Value "Show-PowerTree" +Set-Alias -Name "PowerTree" -Value "Show-PowerTree" +Set-Alias -Name "Start-PowerTree" -Value "Show-PowerTree" +Set-Alias -Name "Edit-PtreeConfig" -Value "Edit-PowerTreeConfig" +Set-Alias -Name "Edit-Ptree" -Value "Edit-PowerTreeConfig" +Set-Alias -Name "Edit-PowerTree" -Value "Edit-PowerTreeConfig" +Set-Alias -Name "ptreer" -Value "Show-PowerTreeRegistry" +Set-Alias -Name "PowerRegistry" -Value "Show-PowerTreeRegistry" -# Import classes first to ensure they are available to all functions -$ClassFiles = @(Get-ChildItem -Path "$PSScriptRoot\Private\Shared\DataModel\Classes.ps1" -ErrorAction SilentlyContinue) -foreach ($import in $ClassFiles) { - try { - . $import.FullName - Write-Verbose "Imported class definitions from $($import.FullName)" - } - catch { - Write-Error "Failed to import class definitions from $($import.FullName): $_" - } +$classPath = Join-Path $PSScriptRoot "Private/Shared/DataModel/Classes.ps1" +try { + . $classPath +} catch { + Write-Error "Failed to import class definitions from $classPath`: $_" } # Import all remaining private functions (recursively) -$Private = @(Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -Recurse -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -ne "$PSScriptRoot\Private\Shared\DataModel\Classes.ps1" }) +$Private = @(Get-ChildItem -Path "$PSScriptRoot/Private/*.ps1" -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -ne $classPath }) foreach ($import in $Private) { try { . $import.FullName @@ -46,4 +41,4 @@ foreach ($import in $Public) { # Export public functions Export-ModuleMember -Function $Public.BaseName -Export-ModuleMember -Function $Public.BaseName -Alias "ptree", "PowerTree", "Start-PowerTree", "Edit-PtreeConfig", "Edit-Ptree", "Edit-PowerTree", "ptreer", "PowerRegistry" \ No newline at end of file +Export-ModuleMember -Function $Public.BaseName -Alias "ptree", "PowerTree", "Start-PowerTree", "Edit-PtreeConfig", "Edit-Ptree", "Edit-PowerTree", "ptreer", "PowerRegistry" diff --git a/Private/PowerTree/Configuration/ParamHelpers/Build-ChildItemDirectoryParams.ps1 b/Private/PowerTree/Configuration/ParamHelpers/Build-ChildItemDirectoryParams.ps1 deleted file mode 100644 index 7267963..0000000 --- a/Private/PowerTree/Configuration/ParamHelpers/Build-ChildItemDirectoryParams.ps1 +++ /dev/null @@ -1,16 +0,0 @@ - -function Build-ChildItemDirectoryParams { - param( - [boolean]$ShowHiddenFiles - ) - - $dirParams = @{ - Directory = $true - ErrorAction = "SilentlyContinue" - } - - if ($ShowHiddenFiles) { - $dirParams.Add("Force", $true) - } - return $dirParams -} \ No newline at end of file diff --git a/Private/PowerTree/Output/Build-OutputLine.ps1 b/Private/PowerTree/Output/Build-OutputLine.ps1 index 57e9963..a7e1bb3 100644 --- a/Private/PowerTree/Output/Build-OutputLine.ps1 +++ b/Private/PowerTree/Output/Build-OutputLine.ps1 @@ -12,7 +12,9 @@ function Build-OutputLine { [string]$TreePrefix, [Parameter(Mandatory=$true)] - [bool]$HumanReadableSizes + [bool]$HumanReadableSizes, + + [long]$DirectorySize = 0 ) $dirSize = 0 @@ -63,10 +65,8 @@ function Build-OutputLine { # Set to zero for files $dirSize = 0 } else { - # Calculate directory size - $dirSize = (Get-ChildItem -LiteralPath $Item.FullName -Recurse -File -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum - if($HumanReadableSizes){ + $dirSize = $DirectorySize + if($HumanReadableSizes){ $content = Get-HumanReadableSize -Bytes $dirSize -Format "Padded" } else { $content = $dirSize @@ -124,4 +124,4 @@ function Build-OutputLine { 0 } } -} \ No newline at end of file +} diff --git a/Private/PowerTree/Output/Get-TreeView.ps1 b/Private/PowerTree/Output/Get-TreeView.ps1 index 5559f73..4b9e841 100644 --- a/Private/PowerTree/Output/Get-TreeView.ps1 +++ b/Private/PowerTree/Output/Get-TreeView.ps1 @@ -1,237 +1,18 @@ function Get-TreeView { param ( [Parameter(Mandatory=$true)] - [TreeConfig]$TreeConfig, - [Parameter(Mandatory=$true)] - [hashtable]$ChildItemDirectoryParams, + [object]$TreeConfig, [Parameter(Mandatory=$true)] [hashtable]$ChildItemFileParams, [Parameter(Mandatory=$true)] - [TreeStats]$TreeStats, - [string]$CurrentPath = $TreeConfig.Path, - [string]$TreeIndent = "", - [bool]$Last = $false, - [bool]$IsRoot = $true, - [int]$CurrentDepth = 0, - [Parameter(Mandatory=$false)] - [System.Text.StringBuilder]$OutputBuilder = $null, - [Parameter(Mandatory=$false)] - [switch]$IsEmptyCheck = $false + [object]$TreeStats, + [System.Text.StringBuilder]$OutputBuilder = $null ) - if ($TreeConfig.MaxDepth -ne -1 -and $CurrentDepth -ge $TreeConfig.MaxDepth) { - return $false - } - - if ($IsRoot) { - $TreeStats.MaxDepth += 1 - } - - $TreeStats.UpdateMaxDepth($CurrentDepth) - - # Get directories filtering out excluded directories - $dirItems = Get-ChildItem @ChildItemDirectoryParams -LiteralPath $CurrentPath - $directories = if ($null -ne $dirItems -and $dirItems.Count -gt 0) { - $filteredDirs = $dirItems | Where-Object { - $TreeConfig.ExcludeDirectories.Count -eq 0 -or $TreeConfig.ExcludeDirectories -notcontains $_.Name - } - - if ($null -ne $filteredDirs -and $filteredDirs.Count -gt 0) { - if ($TreeConfig.SortFolders) { - Group-Items -Items $filteredDirs -SortBy $TreeConfig.SortBy -SortDescending $TreeConfig.SortDescending - } else { - $filteredDirs - } - } else { - @() - } - } else { - @() - } - - $filteredFiles = @() - if (-not $TreeConfig.DirectoryOnly -or $IsEmptyCheck) { - $fileList = Get-ChildItem -LiteralPath $CurrentPath @ChildItemFileParams - - if ($null -ne $fileList -and $fileList.Count -gt 0) { - $filteredFiles = @(Get-FilesByFilteredSize $fileList -FileSizeBounds $TreeConfig.FileSizeBounds) - } - } - - $files = if (-not $TreeConfig.DirectoryOnly -and $filteredFiles.Count -gt 0) { - $sortedFiles = @(Group-Items -Items $filteredFiles -SortBy $TreeConfig.SortBy -SortDescending $TreeConfig.SortDescending) - - if ($TreeConfig.FileLimit -eq 0) { - @() - } elseif ($TreeConfig.FileLimit -gt 0) { - @($sortedFiles | Select-Object -First $TreeConfig.FileLimit) - } else { - $sortedFiles - } - } else { - @() - } - - # Return true immediately if this is just an empty check and we have files - if ($IsEmptyCheck -and $filteredFiles.Count -gt 0) { - return $true - } - - # If this is just an empty check and we have no files but we do have directories, - # we need to check if any of those directories are non-empty after filtering - if ($IsEmptyCheck -and $files.Count -eq 0 -and $directories.Count -gt 0) { - foreach ($dir in $directories) { - $dirHasContent = Get-TreeView -TreeConfig $TreeConfig ` - -TreeStats $TreeStats ` - -ChildItemDirectoryParams $ChildItemDirectoryParams ` - -ChildItemFileParams $ChildItemFileParams ` - -CurrentPath $dir.FullName ` - -TreeIndent "" ` - -Last $false ` - -IsRoot $false ` - -CurrentDepth ($CurrentDepth + 1) ` - -OutputBuilder $null ` - -IsEmptyCheck:$true - - if ($dirHasContent) { - return $true - } - } - # If we get here, all subdirectories were empty or filtered out - return $false - } - - # For empty check with no files and no directories, return false - if ($IsEmptyCheck -and $files.Count -eq 0 -and $directories.Count -eq 0) { - return $false - } - - # Initialize the hasVisibleContent variable - true if we have visible files - $hasVisibleContent = (-not $TreeConfig.DirectoryOnly -and $files.Count -gt 0) - - # Filter directories for pruning if enabled - $visibleDirectories = @() - if ($directories.Count -gt 0) { - foreach ($dir in $directories) { - $skipDir = $false - if ($TreeConfig.PruneEmptyFolders) { - $dirHasContent = Get-TreeView -TreeConfig $TreeConfig ` - -TreeStats $TreeStats ` - -ChildItemDirectoryParams $ChildItemDirectoryParams ` - -ChildItemFileParams $ChildItemFileParams ` - -CurrentPath $dir.FullName ` - -TreeIndent "" ` - -Last $false ` - -IsRoot $false ` - -CurrentDepth ($CurrentDepth + 1) ` - -OutputBuilder $null ` - -IsEmptyCheck:$true - - if (-not $dirHasContent) { - $skipDir = $true - } - } - - if (-not $skipDir) { - $visibleDirectories += $dir - $hasVisibleContent = $true - } - } - } - - # Calculate total items and process them in the correct order - $totalItems = $files.Count + $visibleDirectories.Count - $currentItemIndex = 0 - - # Process files first (they appear before directories in tree output) - if (-not $TreeConfig.DirectoryOnly -and $files.Count -gt 0) { - foreach ($file in $files) { - $currentItemIndex++ - $isLastItem = ($currentItemIndex -eq $totalItems) - - # Build the tree prefix for files - $treeBranch = if ($isLastItem) { $TreeConfig.lineStyle.LastBranch } else { $TreeConfig.lineStyle.Branch } - $treePrefix = if ($IsRoot) { $treeBranch } else { "$TreeIndent$treeBranch" } - - $outputInfo = Build-OutputLine -HeaderTable $TreeConfig.HeaderTable ` - -Item $file ` - -TreePrefix $treePrefix ` - -HumanReadableSizes $TreeConfig.HumanReadableSizes - - if ($outputInfo.SizeColor -and $outputInfo.SizePosition -ge 0 -and $outputInfo.SizeLength -gt 0) { - $before = $outputInfo.Line.Substring(0, $outputInfo.SizePosition) - $size = $outputInfo.Line.Substring($outputInfo.SizePosition, $outputInfo.SizeLength) - $after = $outputInfo.Line.Substring($outputInfo.SizePosition + $outputInfo.SizeLength) - - if ($null -ne $OutputBuilder) { - [void]$OutputBuilder.AppendLine($outputInfo.Line) - } else { - Write-Host $before -NoNewline - Write-Host $size -ForegroundColor $outputInfo.SizeColor -NoNewline - Write-Host $after - } - } else { - Write-OutputLine -Line $outputInfo.Line ` - -OutputBuilder $OutputBuilder - } - - $TreeStats.AddFile($file) - } - } - - # Process directories - foreach ($dir in $visibleDirectories) { - $currentItemIndex++ - $isLastItem = ($currentItemIndex -eq $totalItems) - - # Print connector line to make it look prettier, can be turned on/off in settings - if($TreeConfig.ShowConnectorLines -and $files.Count -gt 0) { - $hierarchyPos = $TreeConfig.HeaderTable.Indentations["Hierarchy"] - $connector = " " * $hierarchyPos + "$TreeIndent$($TreeConfig.lineStyle.Vertical)" - Write-OutputLine -Line $connector ` - -OutputBuilder $OutputBuilder - } - - # Create the directory prefix with appropriate tree symbols - $treeBranch = if ($isLastItem) { $TreeConfig.lineStyle.LastBranch } else { $TreeConfig.lineStyle.Branch } - $treePrefix = if ($IsRoot) { $treeBranch } else { "$TreeIndent$treeBranch" } - - # Build and output the directory line - $outputInfo = Build-OutputLine -HeaderTable $TreeConfig.HeaderTable ` - -Item $dir ` - -TreePrefix $treePrefix ` - -HumanReadableSizes $TreeConfig.HumanReadableSizes - - Write-OutputLine -Line $outputInfo.Line ` - -OutputBuilder $OutputBuilder - - $TreeStats.FoldersPrinted++ - - # Use the already calculated folder size for the stats - if ($outputInfo.DirSize -gt 0) { - $TreeStats.UpdateLargestFolder($dir.FullName, $outputInfo.DirSize) - } - - # Calculate the new tree indent for child items - $newTreeIndent = if ($IsRoot) { - if ($isLastItem) { $TreeConfig.lineStyle.Space } else { $TreeConfig.lineStyle.VerticalLine } - } else { - if ($isLastItem) { "$TreeIndent$($TreeConfig.lineStyle.Space)" } else { "$TreeIndent$($TreeConfig.lineStyle.VerticalLine)" } - } - - # Recursively process the directory - Get-TreeView -TreeConfig $TreeConfig ` - -TreeStats $TreeStats ` - -ChildItemDirectoryParams $ChildItemDirectoryParams ` - -ChildItemFileParams $ChildItemFileParams ` - -CurrentPath $dir.FullName ` - -TreeIndent $newTreeIndent ` - -Last $isLastItem ` - -IsRoot $false ` - -CurrentDepth ($CurrentDepth + 1) ` - -OutputBuilder $OutputBuilder - } - - # Return whether this directory has any visible content after filtering - return $hasVisibleContent + $TreeStats.MaxDepth = 1 + $snapshot = Get-TreeSnapshot -TreeConfig $TreeConfig -ChildItemFileParams $ChildItemFileParams + Write-TreeSnapshot -Node $snapshot ` + -TreeConfig $TreeConfig ` + -TreeStats $TreeStats ` + -OutputBuilder $OutputBuilder } diff --git a/Private/PowerTree/Output/ToFile/Invoke-OutputBuilder.ps1 b/Private/PowerTree/Output/ToFile/Invoke-OutputBuilder.ps1 index 225cfd4..15c62f9 100644 --- a/Private/PowerTree/Output/ToFile/Invoke-OutputBuilder.ps1 +++ b/Private/PowerTree/Output/ToFile/Invoke-OutputBuilder.ps1 @@ -2,7 +2,7 @@ function Invoke-OutputBuilder { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] - [TreeConfig]$TreeConfig, + [object]$TreeConfig, [bool]$ShowExecutionStats, [bool]$ShowConfigurations = $true ) diff --git a/Private/PowerTree/Output/Write-TreeSnapshot.ps1 b/Private/PowerTree/Output/Write-TreeSnapshot.ps1 new file mode 100644 index 0000000..6dceeca --- /dev/null +++ b/Private/PowerTree/Output/Write-TreeSnapshot.ps1 @@ -0,0 +1,87 @@ +function Write-TreeSnapshot { + param( + [Parameter(Mandatory=$true)] + [object]$Node, + [Parameter(Mandatory=$true)] + [object]$TreeConfig, + [Parameter(Mandatory=$true)] + [object]$TreeStats, + [string]$TreeIndent = "", + [bool]$IsRoot = $true, + [System.Text.StringBuilder]$OutputBuilder = $null + ) + + $directories = @($Node.Directories | Where-Object { + $_.IsVisible -and (-not $TreeConfig.PruneEmptyFolders -or $_.HasVisibleContent) + }) + $totalItems = $Node.Files.Count + $directories.Count + $currentItemIndex = 0 + + foreach ($file in $Node.Files) { + $currentItemIndex++ + $isLastItem = $currentItemIndex -eq $totalItems + $treeBranch = if ($isLastItem) { $TreeConfig.LineStyle.LastBranch } else { $TreeConfig.LineStyle.Branch } + $treePrefix = if ($IsRoot) { $treeBranch } else { "$TreeIndent$treeBranch" } + $outputInfo = Build-OutputLine -HeaderTable $TreeConfig.HeaderTable ` + -Item $file ` + -TreePrefix $treePrefix ` + -HumanReadableSizes $TreeConfig.HumanReadableSizes + + if ($outputInfo.SizeColor -and $outputInfo.SizePosition -ge 0 -and $outputInfo.SizeLength -gt 0) { + if ($null -ne $OutputBuilder) { + [void]$OutputBuilder.AppendLine($outputInfo.Line) + } else { + $before = $outputInfo.Line.Substring(0, $outputInfo.SizePosition) + $size = $outputInfo.Line.Substring($outputInfo.SizePosition, $outputInfo.SizeLength) + $after = $outputInfo.Line.Substring($outputInfo.SizePosition + $outputInfo.SizeLength) + Write-Host $before -NoNewline + Write-Host $size -ForegroundColor $outputInfo.SizeColor -NoNewline + Write-Host $after + } + } else { + Write-OutputLine -Line $outputInfo.Line -OutputBuilder $OutputBuilder + } + + $TreeStats.AddFile($file) + } + + foreach ($child in $directories) { + $currentItemIndex++ + $isLastItem = $currentItemIndex -eq $totalItems + + if ($TreeConfig.ShowConnectorLines -and $Node.Files.Count -gt 0) { + $hierarchyPosition = $TreeConfig.HeaderTable.Indentations["Hierarchy"] + $connector = " " * $hierarchyPosition + "$TreeIndent$($TreeConfig.LineStyle.Vertical)" + Write-OutputLine -Line $connector -OutputBuilder $OutputBuilder + } + + $treeBranch = if ($isLastItem) { $TreeConfig.LineStyle.LastBranch } else { $TreeConfig.LineStyle.Branch } + $treePrefix = if ($IsRoot) { $treeBranch } else { "$TreeIndent$treeBranch" } + $outputInfo = Build-OutputLine -HeaderTable $TreeConfig.HeaderTable ` + -Item $child.Item ` + -TreePrefix $treePrefix ` + -HumanReadableSizes $TreeConfig.HumanReadableSizes ` + -DirectorySize $child.Size + + Write-OutputLine -Line $outputInfo.Line -OutputBuilder $OutputBuilder + $TreeStats.FoldersPrinted++ + $TreeStats.UpdateMaxDepth($child.Depth) + + if ($TreeConfig.HeaderTable.HeaderColumns -contains "Size") { + $TreeStats.UpdateLargestFolder($child.Item.FullName, $child.Size) + } + + $newTreeIndent = if ($IsRoot) { + if ($isLastItem) { $TreeConfig.LineStyle.Space } else { $TreeConfig.LineStyle.VerticalLine } + } else { + if ($isLastItem) { "$TreeIndent$($TreeConfig.LineStyle.Space)" } else { "$TreeIndent$($TreeConfig.LineStyle.VerticalLine)" } + } + + Write-TreeSnapshot -Node $child ` + -TreeConfig $TreeConfig ` + -TreeStats $TreeStats ` + -TreeIndent $newTreeIndent ` + -IsRoot $false ` + -OutputBuilder $OutputBuilder + } +} diff --git a/Private/PowerTree/Scanning/Get-TreeSnapshot.ps1 b/Private/PowerTree/Scanning/Get-TreeSnapshot.ps1 new file mode 100644 index 0000000..a0f3d15 --- /dev/null +++ b/Private/PowerTree/Scanning/Get-TreeSnapshot.ps1 @@ -0,0 +1,157 @@ +function Get-TreeSnapshot { + param( + [Parameter(Mandatory=$true)] + [object]$TreeConfig, + [Parameter(Mandatory=$true)] + [hashtable]$ChildItemFileParams + ) + + $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($TreeConfig.Path) + $root = [TreeNode]::new([System.IO.DirectoryInfo]::new($resolvedPath), 0) + $root.CanRenderContents = $TreeConfig.MaxDepth -ne 0 + + $includePatterns = if ($ChildItemFileParams.ContainsKey("Include")) { + @($ChildItemFileParams.Include) + } else { + @() + } + $excludePatterns = if ($ChildItemFileParams.ContainsKey("Exclude")) { + @($ChildItemFileParams.Exclude) + } else { + @() + } + $hiddenAttributes = [System.IO.FileAttributes]::Hidden -bor [System.IO.FileAttributes]::System + $needFullSizes = $TreeConfig.HeaderTable.HeaderColumns -contains "Size" -or + ($TreeConfig.SortFolders -and $TreeConfig.SortBy -eq "Size") + $nodes = [System.Collections.Generic.List[object]]::new() + $pending = [System.Collections.Generic.Stack[object]]::new() + $pending.Push($root) + + while ($pending.Count -gt 0) { + $node = $pending.Pop() + $nodes.Add($node) + + if (-not $node.CanRenderContents -and -not $needFullSizes) { + continue + } + + $files = [System.Collections.Generic.List[System.IO.FileInfo]]::new() + $directories = [System.Collections.Generic.List[object]]::new() + + try { + foreach ($item in $node.Item.EnumerateFileSystemInfos()) { + if (-not $TreeConfig.ShowHiddenFiles -and ($item.Attributes -band $hiddenAttributes)) { + continue + } + + if ($item -is [System.IO.DirectoryInfo]) { + $child = [TreeNode]::new($item, $node.Depth + 1) + $child.IsVisible = $node.CanRenderContents -and + $TreeConfig.ExcludeDirectories -notcontains $item.Name + $child.CanRenderContents = $child.IsVisible -and + ($TreeConfig.MaxDepth -eq -1 -or $child.Depth -lt $TreeConfig.MaxDepth) + $directories.Add($child) + + if (($child.CanRenderContents -or $needFullSizes) -and + -not ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + $pending.Push($child) + } + continue + } + + if ($item -isnot [System.IO.FileInfo]) { + continue + } + + $node.DirectSize += $item.Length + if (-not $node.CanRenderContents) { + continue + } + + $included = $includePatterns.Count -eq 0 + foreach ($pattern in $includePatterns) { + if ($item.Name -like $pattern) { + $included = $true + break + } + } + if (-not $included) { + continue + } + + $excluded = $false + foreach ($pattern in $excludePatterns) { + if ($item.Name -like $pattern) { + $excluded = $true + break + } + } + if ($excluded) { + continue + } + + if ($TreeConfig.FileSizeBounds.ShouldFilter) { + if ($TreeConfig.FileSizeBounds.LowerBound -ge 0 -and + $item.Length -lt $TreeConfig.FileSizeBounds.LowerBound) { + continue + } + if ($TreeConfig.FileSizeBounds.UpperBound -ge 0 -and + $item.Length -gt $TreeConfig.FileSizeBounds.UpperBound) { + continue + } + } + + $node.HasFilteredFiles = $true + if (-not $TreeConfig.DirectoryOnly) { + $files.Add($item) + } + } + } catch { + Write-Verbose "Could not enumerate '$($node.Item.FullName)': $($_.Exception.Message)" + } + + $node.Files = $files.ToArray() + $node.Directories = $directories.ToArray() + } + + $nodeArray = $nodes.ToArray() + [array]::Reverse($nodeArray) + + foreach ($node in $nodeArray) { + $node.Size = $node.DirectSize + foreach ($child in $node.Directories) { + $node.Size += $child.Size + if ($child.IsVisible -and $child.HasVisibleContent) { + $node.HasVisibleContent = $true + } + } + if ($node.HasFilteredFiles) { + $node.HasVisibleContent = $true + } + + if ($node.Files.Count -gt 0) { + $node.Files = @(Group-Items -Items $node.Files ` + -SortBy $TreeConfig.SortBy ` + -SortDescending $TreeConfig.SortDescending) + + if ($TreeConfig.FileLimit -eq 0) { + $node.Files = @() + } elseif ($TreeConfig.FileLimit -gt 0 -and $node.Files.Count -gt $TreeConfig.FileLimit) { + $node.Files = @($node.Files[0..($TreeConfig.FileLimit - 1)]) + } + } + + if ($TreeConfig.SortFolders -and $node.Directories.Count -gt 0) { + $property = switch ($TreeConfig.SortBy) { + "Modification Date" { { $_.Item.LastWriteTime } } + "Creation Date" { { $_.Item.CreationTime } } + "Last Access Date" { { $_.Item.LastAccessTime } } + "Size" { { $_.Size } } + default { { $_.Item.Name } } + } + $node.Directories = @($node.Directories | Sort-Object -Property $property -Descending:$TreeConfig.SortDescending) + } + } + + return $root +} diff --git a/Private/PowerTree/Size/Get-FilesByFilteredSize.ps1 b/Private/PowerTree/Size/Get-FilesByFilteredSize.ps1 deleted file mode 100644 index eedb34f..0000000 --- a/Private/PowerTree/Size/Get-FilesByFilteredSize.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -function Get-FilesByFilteredSize { - param ( - [Parameter(Mandatory=$true)] - [System.IO.FileInfo[]]$Files, - [Parameter(Mandatory=$true)] - [hashtable]$FileSizeBounds - ) - - # If filtering is not needed, return all files - if (-not $FileSizeBounds.ShouldFilter) { - return $Files - } - - # Filter files based on size bounds - $filteredFiles = $Files | Where-Object { - $fileSizeInBytes = $_.Length - - # Check lower bound - $lowerBoundCheck = if ($FileSizeBounds.LowerBound -ge 0) { - $fileSizeInBytes -ge $FileSizeBounds.LowerBound - } else { $true } - - # Check upper bound - $upperBoundCheck = if ($FileSizeBounds.UpperBound -ge 0) { - $fileSizeInBytes -le $FileSizeBounds.UpperBound - } else { $true } - - # Return true only if both conditions are met - $lowerBoundCheck -and $upperBoundCheck - } - - return $filteredFiles -} \ No newline at end of file diff --git a/Private/PowerTree/Size/Get-FolderSize.ps1 b/Private/PowerTree/Size/Get-FolderSize.ps1 deleted file mode 100644 index 3b2d88a..0000000 --- a/Private/PowerTree/Size/Get-FolderSize.ps1 +++ /dev/null @@ -1,48 +0,0 @@ -function Get-FolderSize { - [CmdletBinding()] - param ( - [Parameter(Mandatory=$true, Position=0)] - [string]$Path, - - [Parameter()] - [switch]$Recurse, - - [Parameter()] - [switch]$HumanReadable - ) - - $folders = if ($Recurse) { - Get-ChildItem -LiteralPath $Path -Directory -Recurse - } else { - Get-ChildItem -LiteralPath $Path -Directory - } - - $results = foreach ($folder in $folders) { - $size = (Get-ChildItem -LiteralPath $folder.FullName -Recurse -File -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum - - $readableSize = if ($HumanReadable) { - # Convert to human-readable format - $sizes = @(' B', 'KB', 'MB', 'GB', 'TB') - $order = 0 - $value = $size - - while ($value -ge 1024 -and $order -lt 4) { - $order++ - $value /= 1024.0 - } - - "{0:0.##}{1}" -f $value, $sizes[$order] - } else { - $size - } - - [PSCustomObject]@{ - Folder = $folder.FullName - Size = $size - ReadableSize = $readableSize - } - } - - return $results | Sort-Object Size -Descending -} \ No newline at end of file diff --git a/Private/PowerTree/Sorting/Get-SortingMethod.ps1 b/Private/PowerTree/Sorting/Get-SortingMethod.ps1 index 11da761..716d8f8 100644 --- a/Private/PowerTree/Sorting/Get-SortingMethod.ps1 +++ b/Private/PowerTree/Sorting/Get-SortingMethod.ps1 @@ -48,55 +48,13 @@ function Group-Items { return @() } - $sorted = switch ($SortBy) { - "Modification Date" { - $Items | Sort-Object -Property LastWriteTime - } - "Creation Date" { - $Items | Sort-Object -Property CreationTime - } - "Last Access Date" { - $Items | Sort-Object -Property LastAccessTime - } - "Name" { - $Items | Sort-Object -Property Name - } - "Size" { - $Items | Sort-Object -Property { if ($_ -is [System.IO.DirectoryInfo]) { - # For directories, calculate total size of contents - (Get-ChildItem -LiteralPath $_.FullName -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum - } else { - # For files, use file length - $_.Length - }} - } - - Default { - $Items | Sort-Object -Property Name - } + $property = switch ($SortBy) { + "Modification Date" { "LastWriteTime" } + "Creation Date" { "CreationTime" } + "Last Access Date" { "LastAccessTime" } + "Size" { "Length" } + default { "Name" } } - - if ($SortDescending) { - # When sorting in descending order, we need to be specific about the property - switch ($SortBy) { - "Modification Date" { return $Items | Sort-Object -Property LastWriteTime -Descending } - "Creation Date" { return $Items | Sort-Object -Property CreationTime -Descending } - "Last Access Date" { return $Items | Sort-Object -Property LastAccessTime -Descending } - "Name" { return $Items | Sort-Object -Property Name -Descending } - "Size" { - return $Items | Sort-Object -Property { - if ($_ -is [System.IO.DirectoryInfo]) { - (Get-ChildItem -LiteralPath $_.FullName -Recurse -File -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum - } else { - $_.Length - } - } -Descending - } - Default { return $Items | Sort-Object -Property Name -Descending } - } - } else { - return $sorted - } -} \ No newline at end of file + return $Items | Sort-Object -Property $property -Descending:$SortDescending +} diff --git a/Private/PowerTreeRegistry/Output/Get-RegistryConfigurationData.ps1 b/Private/PowerTreeRegistry/Output/Get-RegistryConfigurationData.ps1 index a315b47..b8a8506 100644 --- a/Private/PowerTreeRegistry/Output/Get-RegistryConfigurationData.ps1 +++ b/Private/PowerTreeRegistry/Output/Get-RegistryConfigurationData.ps1 @@ -2,7 +2,7 @@ function Get-RegistryConfigurationData { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] - [TreeRegistryConfig]$TreeRegistryConfig + [object]$TreeRegistryConfig ) $configData = @() @@ -48,4 +48,4 @@ function Get-RegistryConfigurationData { } return $formattedData -} \ No newline at end of file +} diff --git a/Private/PowerTreeRegistry/Output/Get-TreeRegistryView.ps1 b/Private/PowerTreeRegistry/Output/Get-TreeRegistryView.ps1 index b54d9cd..34bdb95 100644 --- a/Private/PowerTreeRegistry/Output/Get-TreeRegistryView.ps1 +++ b/Private/PowerTreeRegistry/Output/Get-TreeRegistryView.ps1 @@ -1,14 +1,14 @@ function Get-TreeRegistryView { param ( [Parameter(Mandatory=$true)] - [TreeRegistryConfig]$TreeRegistryConfig, + [object]$TreeRegistryConfig, [string]$CurrentPath = $TreeRegistryConfig.Path, [bool]$EscapeWildcards = $false, [string]$TreeIndent = "", [bool]$IsRoot = $true, [int]$CurrentDepth = 0, [System.Collections.Generic.List[string]]$OutputCollection = $null, - [RegistryStats]$Stats = $null + [object]$Stats = $null ) if ($IsRoot -and $null -eq $Stats) { @@ -113,4 +113,4 @@ function Get-TreeRegistryView { if ($IsRoot) { return $Stats } -} \ No newline at end of file +} diff --git a/Private/PowerTreeRegistry/Output/Show-RegistryStats.ps1 b/Private/PowerTreeRegistry/Output/Show-RegistryStats.ps1 index e1e532e..b134c5a 100644 --- a/Private/PowerTreeRegistry/Output/Show-RegistryStats.ps1 +++ b/Private/PowerTreeRegistry/Output/Show-RegistryStats.ps1 @@ -2,7 +2,7 @@ function Show-RegistryStats { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] - [RegistryStats]$RegistryStats, + [object]$RegistryStats, [Parameter(Mandatory=$true)] [System.TimeSpan]$ExecutionTime, @@ -71,4 +71,4 @@ $valuesLine Write-Host $valuesLine Write-Host "" } -} \ No newline at end of file +} diff --git a/Private/PowerTreeRegistry/Output/ToFile/Invoke-OutputBuilderRegistry.ps1 b/Private/PowerTreeRegistry/Output/ToFile/Invoke-OutputBuilderRegistry.ps1 index 33d7437..f479663 100644 --- a/Private/PowerTreeRegistry/Output/ToFile/Invoke-OutputBuilderRegistry.ps1 +++ b/Private/PowerTreeRegistry/Output/ToFile/Invoke-OutputBuilderRegistry.ps1 @@ -2,7 +2,7 @@ function Invoke-OutputBuilderRegistry { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] - [TreeRegistryConfig]$TreeRegistryConfig, + [object]$TreeRegistryConfig, [bool]$ShowExecutionStats = $true, [bool]$ShowConfigurations = $true ) @@ -33,4 +33,4 @@ function Invoke-OutputBuilderRegistry { } return $outputBuilder -} \ No newline at end of file +} diff --git a/Private/Shared/DataModel/ClassLoader.ps1 b/Private/Shared/DataModel/ClassLoader.ps1 deleted file mode 100644 index 72e2f75..0000000 --- a/Private/Shared/DataModel/ClassLoader.ps1 +++ /dev/null @@ -1,31 +0,0 @@ -function Initialize-PowerTreeClasses { - [CmdletBinding()] - param() - - # Get the content of the Classes.ps1 file - $classesPath = Join-Path -Path $PSScriptRoot -ChildPath "Classes.ps1" - $classesContent = Get-Content -Path $classesPath -Raw - - # Execute the classes definition in the current scope - . ([ScriptBlock]::Create($classesContent)) - - # Force reload by creating test instances - try { - # Remove any existing types from the session if they exist - if ([System.AppDomain]::CurrentDomain.GetAssemblies() | - Where-Object { $_.GetTypes() | Where-Object { $_.Name -eq 'TreeConfig' } }) { - Write-Verbose "Existing TreeConfig type found, forcing refresh" - } - - $null = [TreeConfig]::new() - $null = [TreeStats]::new() - Write-Verbose "PowerTree classes initialized successfully" - return $true - } catch { - Write-Error "Failed to initialize PowerTree classes: $_" - return $false - } -} - -# Run the initialization immediately when this module loads -$null = Initialize-PowerTreeClasses \ No newline at end of file diff --git a/Private/Shared/DataModel/Classes.ps1 b/Private/Shared/DataModel/Classes.ps1 index d8f8716..044efb4 100644 --- a/Private/Shared/DataModel/Classes.ps1 +++ b/Private/Shared/DataModel/Classes.ps1 @@ -5,7 +5,6 @@ class TreeConfig { [string]$SortBy [bool]$SortDescending [bool]$SortFolders - [hashtable]$ChildItemDirectoryParams [hashtable]$ChildItemFileParams [hashtable]$HeaderTable [bool]$ShowConnectorLines @@ -19,6 +18,24 @@ class TreeConfig { [bool]$HumanReadableSizes } +class TreeNode { + [System.IO.DirectoryInfo]$Item + [System.IO.FileInfo[]]$Files = @() + [object[]]$Directories = @() + [long]$DirectSize = 0 + [long]$Size = 0 + [bool]$HasFilteredFiles = $false + [bool]$HasVisibleContent = $false + [bool]$IsVisible = $false + [bool]$CanRenderContents = $false + [int]$Depth = 0 + + TreeNode([System.IO.DirectoryInfo]$item, [int]$depth) { + $this.Item = $item + $this.Depth = $depth + } +} + class TreeRegistryConfig { [string]$Path [bool]$NoValues diff --git a/Private/Shared/Output/Convert-StatsInOutputFile.ps1 b/Private/Shared/Output/Convert-StatsInOutputFile.ps1 deleted file mode 100644 index 704ce3c..0000000 --- a/Private/Shared/Output/Convert-StatsInOutputFile.ps1 +++ /dev/null @@ -1,6 +0,0 @@ -function Convert-StatsInOutputFile { - param( - [Parameter(Mandatory=$false)] - [System.Text.StringBuilder]$OutputBuilder = $null - ) -} \ No newline at end of file diff --git a/Private/Shared/Output/Write-ConfigurationToHost.ps1 b/Private/Shared/Output/Write-ConfigurationToHost.ps1 index 8a49b4d..3a45db8 100644 --- a/Private/Shared/Output/Write-ConfigurationToHost.ps1 +++ b/Private/Shared/Output/Write-ConfigurationToHost.ps1 @@ -13,10 +13,10 @@ function Write-ConfigurationToHost { return } - if ($Config -is [TreeRegistryConfig]) { + if ($null -ne $Config.PSObject.Properties["NoValues"]) { $configData = Get-RegistryConfigurationData -TreeRegistryConfig $Config $lineStyle = $Config.LineStyle.SingleLine - } elseif ($Config -is [TreeConfig]) { + } elseif ($null -ne $Config.PSObject.Properties["HeaderTable"]) { $configData = Get-TreeConfigurationData -TreeConfig $Config $lineStyle = "─" } else { @@ -35,4 +35,4 @@ function Write-ConfigurationToHost { } Write-Host "" -} \ No newline at end of file +} diff --git a/Public/Show-PowerTree.ps1 b/Public/Show-PowerTree.ps1 index 82567bc..9b4c661 100644 --- a/Public/Show-PowerTree.ps1 +++ b/Public/Show-PowerTree.ps1 @@ -130,14 +130,14 @@ $DisplayMode= $true } - $treeStats = New-Object TreeStats + $treeStats = [TreeStats]::new() # Ensure config file exists before loading settings Initialize-ConfigFile $jsonSettings = Get-SettingsFromJson -Mode "FileSystem" - $treeConfig = New-Object TreeConfig + $treeConfig = [TreeConfig]::new() $treeConfig.Path = $LiteralPath $treeConfig.LineStyle = Build-TreeLineStyle -Style $jsonSettings.LineStyle $treeConfig.DirectoryOnly = $DirectoryOnly @@ -184,7 +184,6 @@ throw "Cannot find path '$LiteralPath'" } - $ChildItemDirectoryParams = Build-ChildItemDirectoryParams $ShowHiddenFiles $ChildItemFileParams = Build-ChildItemFileParams -ShowHiddenFiles $ShowHiddenFiles ` -CommandLineIncludeExt $IncludeExtensions ` -CommandLineExcludeExt $ExcludeExtensions ` @@ -201,7 +200,6 @@ Get-TreeView -TreeConfig $treeConfig ` -TreeStats $treeStats ` - -ChildItemDirectoryParams $ChildItemDirectoryParams ` -ChildItemFileParams $ChildItemFileParams ` -OutputBuilder $outputBuilder diff --git a/Public/Show-PowerTreeRegistry.ps1 b/Public/Show-PowerTreeRegistry.ps1 index 0386966..646d6b9 100644 --- a/Public/Show-PowerTreeRegistry.ps1 +++ b/Public/Show-PowerTreeRegistry.ps1 @@ -51,7 +51,7 @@ function Show-PowerTreeRegistry { $jsonSettings = Get-SettingsFromJson -Mode "Registry" - $treeRegistryConfig = New-Object treeRegistryConfig + $treeRegistryConfig = [TreeRegistryConfig]::new() $treeRegistryConfig.Path = Get-Path -Path $Path $treeRegistryConfig.NoValues = $NoValues $treeRegistryConfig.Exclude = $Exclude diff --git a/Tests/Measure-PowerTreePerformance.ps1 b/Tests/Measure-PowerTreePerformance.ps1 new file mode 100644 index 0000000..2cd3cea --- /dev/null +++ b/Tests/Measure-PowerTreePerformance.ps1 @@ -0,0 +1,220 @@ +[CmdletBinding()] +param( + [string]$ModulePath = (Join-Path $PSScriptRoot "../PowerTree.psd1"), + [string]$JsonPath, + [string]$BaselinePath, + [string]$MarkdownPath, + [ValidateRange(1, 20)] + [int]$Iterations = 3 +) + +$ErrorActionPreference = "Stop" +$resolvedModulePath = (Resolve-Path $ModulePath).Path +$benchmarkRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("powertree-performance-" + [guid]::NewGuid().ToString("N")) +$fixturePath = Join-Path $benchmarkRoot "fixture" +$configPath = Join-Path $benchmarkRoot ".PowerTree/config.json" +$originalHome = $env:HOME +$originalUserProfile = $env:USERPROFILE +$module = $null + +function Get-Median { + param([double[]]$Values) + + $ordered = @($Values | Sort-Object) + $middle = [math]::Floor($ordered.Count / 2) + if ($ordered.Count % 2 -eq 1) { + return $ordered[$middle] + } + return ($ordered[$middle - 1] + $ordered[$middle]) / 2 +} + +function Format-Milliseconds { + param([double]$Value) + + return [string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:0.0}", $Value) +} + +function Format-Percentage { + param([double]$Value) + + return [string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:+0.0;-0.0;0.0}%", $Value) +} + +try { + [void][System.IO.Directory]::CreateDirectory($fixturePath) + $level = @($fixturePath) + $extensions = @("txt", "log", "ps1") + + foreach ($depth in 1..5) { + $nextLevel = [System.Collections.Generic.List[string]]::new() + foreach ($parent in $level) { + foreach ($directoryIndex in 1..4) { + $directory = Join-Path $parent "d${depth}_${directoryIndex}" + [void][System.IO.Directory]::CreateDirectory($directory) + $nextLevel.Add($directory) + + foreach ($fileIndex in 1..3) { + $extension = $extensions[$fileIndex - 1] + $filePath = Join-Path $directory "f${fileIndex}.${extension}" + $size = ($depth * 128) + ($directoryIndex * 16) + $fileIndex + [System.IO.File]::WriteAllBytes($filePath, [byte[]]::new($size)) + } + } + } + $level = $nextLevel + } + + $config = @{ + Shared = @{ + ShowConnectorLines = $true + ShowExecutionStats = $false + ShowConfigurations = $false + LineStyle = "Unicode" + OpenOutputFileOnFinish = $false + } + FileSystem = @{ + MaxDepth = -1 + ExcludeDirectories = @() + HumanReadableSizes = $true + Files = @{ + ExcludeExtensions = @() + IncludeExtensions = @() + FileSizeMinimum = "-1kb" + FileSizeMaximum = "-1kb" + FileLimit = -1 + } + Sorting = @{ + By = "Name" + SortFolders = $true + } + } + Registry = @{ + MaxDepth = -1 + ExcludeKeys = @() + } + } + + [void][System.IO.Directory]::CreateDirectory((Split-Path $configPath -Parent)) + [System.IO.File]::WriteAllText($configPath, ($config | ConvertTo-Json -Depth 5)) + $env:HOME = $benchmarkRoot + $env:USERPROFILE = $benchmarkRoot + $module = Import-Module $resolvedModulePath -Force -PassThru + + $cases = @( + @{ + Name = "Default" + Command = "ptree fixture" + Parameters = @{} + } + @{ + Name = "Display size" + Command = "ptree fixture -DisplaySize" + Parameters = @{ DisplaySize = $true } + } + @{ + Name = "Size sort" + Command = "ptree fixture -DisplaySize -SortBySize -Descending" + Parameters = @{ + DisplaySize = $true + SortBySize = $true + Descending = $true + } + } + @{ + Name = "Prune and filter" + Command = "ptree fixture -PruneEmptyFolders -IncludeExtensions txt" + Parameters = @{ + PruneEmptyFolders = $true + IncludeExtensions = @("txt") + } + } + ) + + $results = @( + foreach ($case in $cases) { + $parameters = @{ LiteralPath = $fixturePath } + foreach ($entry in $case.Parameters.GetEnumerator()) { + $parameters[$entry.Key] = $entry.Value + } + + Show-PowerTree @parameters *> $null + $samples = @( + foreach ($iteration in 1..$Iterations) { + (Measure-Command { Show-PowerTree @parameters *> $null }).TotalMilliseconds + } + ) + + [pscustomobject]@{ + Name = $case.Name + Command = $case.Command + MedianMilliseconds = [math]::Round((Get-Median $samples), 3) + MinimumMilliseconds = [math]::Round(($samples | Measure-Object -Minimum).Minimum, 3) + MaximumMilliseconds = [math]::Round(($samples | Measure-Object -Maximum).Maximum, 3) + Iterations = $Iterations + } + } + ) + + $results | Format-Table Name, MedianMilliseconds, MinimumMilliseconds, MaximumMilliseconds -AutoSize + + if ($JsonPath) { + $resolvedJsonPath = [System.IO.Path]::GetFullPath($JsonPath) + [System.IO.File]::WriteAllText($resolvedJsonPath, ($results | ConvertTo-Json -Depth 3)) + } + + if ($MarkdownPath) { + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add("## PowerTree performance") + $lines.Add("") + $lines.Add("Fixture: 1,364 directories and 4,092 files. Median of $Iterations measured runs after one warm-up.") + $lines.Add("") + + $baselineResults = if ($BaselinePath -and (Test-Path $BaselinePath)) { + @(Get-Content $BaselinePath -Raw | ConvertFrom-Json) + } else { + @() + } + + if ($baselineResults.Count -gt 0) { + $lines.Add("| Command | Base median | PR median | Change | PR range |") + $lines.Add("|---|---:|---:|---:|---:|") + foreach ($result in $results) { + $baseline = $baselineResults | Where-Object Name -eq $result.Name | Select-Object -First 1 + $change = if ($baseline.MedianMilliseconds -gt 0) { + (($result.MedianMilliseconds - $baseline.MedianMilliseconds) / $baseline.MedianMilliseconds) * 100 + } else { + 0 + } + $baseMedian = Format-Milliseconds $baseline.MedianMilliseconds + $prMedian = Format-Milliseconds $result.MedianMilliseconds + $changeText = Format-Percentage $change + $minimum = Format-Milliseconds $result.MinimumMilliseconds + $maximum = Format-Milliseconds $result.MaximumMilliseconds + $lines.Add("| ``$($result.Command)`` | ${baseMedian} ms | ${prMedian} ms | $changeText | ${minimum}–${maximum} ms |") + } + } else { + $lines.Add("| Command | Median | Range |") + $lines.Add("|---|---:|---:|") + foreach ($result in $results) { + $median = Format-Milliseconds $result.MedianMilliseconds + $minimum = Format-Milliseconds $result.MinimumMilliseconds + $maximum = Format-Milliseconds $result.MaximumMilliseconds + $lines.Add("| ``$($result.Command)`` | ${median} ms | ${minimum}–${maximum} ms |") + } + } + + $lines.Add("") + $lines.Add("Negative change is faster. Results are informational because GitHub-hosted runner performance varies.") + $markdown = ($lines -join [Environment]::NewLine) + [Environment]::NewLine + [System.IO.File]::AppendAllText([System.IO.Path]::GetFullPath($MarkdownPath), $markdown) + } +} finally { + if ($null -ne $module) { + Remove-Module $module -Force -ErrorAction Ignore + } + $env:HOME = $originalHome + $env:USERPROFILE = $originalUserProfile + if ($benchmarkRoot.StartsWith([System.IO.Path]::GetTempPath() + "powertree-performance-")) { + [System.IO.Directory]::Delete($benchmarkRoot, $true) + } +} diff --git a/Tests/PowerTree.Core.Tests.ps1 b/Tests/PowerTree.Core.Tests.ps1 new file mode 100644 index 0000000..91f99dd --- /dev/null +++ b/Tests/PowerTree.Core.Tests.ps1 @@ -0,0 +1,159 @@ +$ErrorActionPreference = "Stop" +$modulePath = Join-Path $PSScriptRoot "../PowerTree.psd1" +Import-Module $modulePath -Force + +Describe "PowerTree module" { + It "exports every public command and primary alias" { + (Get-Command Show-PowerTree).CommandType | Should -Be "Function" + (Get-Command Show-PowerTreeRegistry).CommandType | Should -Be "Function" + (Get-Command Edit-PowerTreeConfig).CommandType | Should -Be "Function" + (Get-Alias ptree).Definition | Should -Be "Show-PowerTree" + (Get-Alias ptreer).Definition | Should -Be "Show-PowerTreeRegistry" + } + + It "loads every declared script function" { + $declared = Get-ChildItem (Join-Path $PSScriptRoot "../Public"), (Join-Path $PSScriptRoot "../Private") -Filter "*.ps1" -Recurse | + Where-Object Name -ne "Classes.ps1" | + ForEach-Object { + [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$null, [ref]$null). + FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true).Name + } | Sort-Object -Unique + $loaded = & (Get-Module PowerTree) { (Get-Command -CommandType Function).Name } + $declared | ForEach-Object { $_ | Should -BeIn $loaded } + } +} + +Describe "data models" { + InModuleScope PowerTree { + It "tracks file and folder statistics" { + $path = Join-Path $TestDrive "large.bin" + [System.IO.File]::WriteAllBytes($path, [byte[]]::new(12)) + $stats = [TreeStats]::new() + $stats.AddFile((Get-Item $path)) + $stats.UpdateLargestFolder("folder", 20) + $stats.UpdateLargestFolder("smaller", 10) + $stats.UpdateMaxDepth(3) + $stats.UpdateMaxDepth(2) + $stats.FilesPrinted | Should -Be 1 + $stats.TotalSize | Should -Be 12 + $stats.LargestFile.Name | Should -Be "large.bin" + $stats.LargestFolder | Should -Be "folder" + $stats.MaxDepth | Should -Be 3 + } + + It "tracks registry depth" { + $stats = [RegistryStats]::new() + $stats.UpdateDepth(4) + $stats.UpdateDepth(2) + $stats.MaxDepthReached | Should -Be 4 + } + + It "initializes tree nodes" { + $directory = [System.IO.DirectoryInfo]::new($TestDrive) + $node = [TreeNode]::new($directory, 2) + $node.Item.FullName | Should -Be $directory.FullName + $node.Depth | Should -Be 2 + $node.Files.Count | Should -Be 0 + } + } +} + +Describe "formatting helpers" { + InModuleScope PowerTree { + It "builds ASCII and Unicode tree styles" { + (Build-TreeLineStyle ASCII).Branch | Should -Be "+----" + (Build-TreeLineStyle Unicode).LastBranch | Should -Be "└───" + } + + It "normalizes file extensions" { + @(Format-FileExtensions @(" PS1 ", ".MD", "*.JSON", "*txt", " ")) | + Should -Be @("*.ps1", "*.md", "*.json", "*.txt") + } + + It "converts supported byte sizes" { + ConvertTo-Bytes "1.5kb" | Should -Be 1536 + ConvertTo-Bytes "2 MB" | Should -Be 2MB + ConvertTo-Bytes "1,5kb" | Should -Be 1536 + ConvertTo-Bytes "42" | Should -Be 42 + ConvertTo-Bytes "-1kb" | Should -Be -1 + ConvertTo-Bytes $null | Should -Be -1 + } + + It "rejects invalid byte sizes" { + ConvertTo-Bytes "large" -WarningVariable warning | Should -Be -1 + $warning.Message | Should -Match "Invalid size format" + } + + It "formats byte sizes" { + Get-HumanReadableSize 1536 Compact | Should -Match "^1[.,]5KB$" + (Get-HumanReadableSize 1536 Padded).Length | Should -Be 10 + Get-HumanReadableSize 1TB Compact | Should -Be "1TB" + } + + It "selects colors at every size boundary" { + Get-SizeColor 1 | Should -Be "Green" + Get-SizeColor 100KB | Should -Be "Cyan" + Get-SizeColor 1MB | Should -Be "Blue" + Get-SizeColor 10MB | Should -Be "DarkYellow" + Get-SizeColor 100MB | Should -Be "Red" + } + + It "formats execution durations" { + Format-ExecutionTime ([timespan]::FromMilliseconds(250)) | Should -Match "250.*ms" + Format-ExecutionTime ([timespan]::FromSeconds(2.5)) | Should -Match "^2[.,]50 sec$" + Format-ExecutionTime ([timespan]::FromSeconds(65)) | Should -Be "1 min, 5 sec" + } + + It "adds a default extension only when needed" { + Add-DefaultExtension "report" $false | Should -Be "report.txt" + Add-DefaultExtension "report.md" $false | Should -Be "report.md" + Add-DefaultExtension "" $true | Should -Be "" + } + } +} + +Describe "parameter builders" { + InModuleScope PowerTree { + It "merges configured and command-line directory exclusions" { + $result = @(Build-ExcludedDirectoryParams @("obj", "node_modules") @{ ExcludeDirectories = @("bin", "obj") }) + $result | Should -Be @("bin", "obj", "node_modules") + } + + It "prefers command-line file filters and includes hidden files" { + $settings = @{ IncludeExtensions = @("json"); ExcludeExtensions = @("tmp") } + $result = Build-ChildItemFileParams $true @("ps1") @() $settings + $result.File | Should -BeTrue + $result.Force | Should -BeTrue + $result.Include | Should -Be @("*.ps1") + $result.Exclude | Should -Be @("*.tmp") + } + + It "combines converted command-line and configured file-size bounds" { + $result = Build-FileSizeParams $null "2kb" "8kb" "1kb" + $result.LowerBound | Should -Be 2KB + $result.UpperBound | Should -Be 8KB + $result.ShouldFilter | Should -BeTrue + (Build-FileSizeParams "-1kb" "-1kb" "-1kb" "-1kb").ShouldFilter | Should -BeFalse + } + } +} + +Describe "sorting helpers" { + InModuleScope PowerTree { + It "resolves consolidated, switch, and default sorting" { + Get-SortingMethod -Sort size -DefaultSort Name | Should -Be "Size" + Get-SortingMethod -SortByCreationDate $true -DefaultSort Name | Should -Be "Creation Date" + Get-SortingMethod -DefaultSort "Modification Date" | Should -Be "Modification Date" + } + + It "groups items by name, size, and direction" { + $items = @( + [pscustomobject]@{ Name = "b"; Length = 1 }, + [pscustomobject]@{ Name = "a"; Length = 3 } + ) + @(Group-Items $items Name).Name | Should -Be @("a", "b") + @(Group-Items $items Size $true).Name | Should -Be @("a", "b") + @(Group-Items @() Name).Count | Should -Be 0 + } + } +} diff --git a/Tests/PowerTree.FileSystem.Tests.ps1 b/Tests/PowerTree.FileSystem.Tests.ps1 new file mode 100644 index 0000000..9dec0c4 --- /dev/null +++ b/Tests/PowerTree.FileSystem.Tests.ps1 @@ -0,0 +1,177 @@ +$ErrorActionPreference = "Stop" +$modulePath = Join-Path $PSScriptRoot "../PowerTree.psd1" +Import-Module $modulePath -Force + +Describe "file-system traversal" { + InModuleScope PowerTree { + BeforeAll { + $script:fixture = Join-Path $TestDrive "fixture" + [void][System.IO.Directory]::CreateDirectory((Join-Path $fixture "alpha/nested")) + [void][System.IO.Directory]::CreateDirectory((Join-Path $fixture "beta")) + [void][System.IO.Directory]::CreateDirectory((Join-Path $fixture "empty")) + [System.IO.File]::WriteAllBytes((Join-Path $fixture "root.txt"), [byte[]]::new(4)) + [System.IO.File]::WriteAllBytes((Join-Path $fixture "root.log"), [byte[]]::new(8)) + [System.IO.File]::WriteAllBytes((Join-Path $fixture "alpha/small.txt"), [byte[]]::new(1)) + [System.IO.File]::WriteAllBytes((Join-Path $fixture "alpha/large.txt"), [byte[]]::new(6)) + [System.IO.File]::WriteAllBytes((Join-Path $fixture "alpha/nested/deep.txt"), [byte[]]::new(3)) + [System.IO.File]::WriteAllBytes((Join-Path $fixture "beta/ignored.txt"), [byte[]]::new(10)) + } + + BeforeEach { + $script:lineStyle = Build-TreeLineStyle Unicode + $script:header = Get-HeaderTable -DisplayCreationDate $false -DisplayLastAccessDate $false ` + -DisplayModificationDate $false -DisplaySize $true -DisplayMode $false -LineStyle $lineStyle + $script:config = [pscustomobject]@{ + Path = $fixture + DirectoryOnly = $false + ExcludeDirectories = @() + SortBy = "Name" + SortDescending = $false + SortFolders = $true + HeaderTable = $header + ShowConnectorLines = $true + ShowHiddenFiles = $true + MaxDepth = -1 + FileSizeBounds = @{ LowerBound = -1; UpperBound = -1; ShouldFilter = $false } + FileLimit = -1 + PruneEmptyFolders = $false + LineStyle = $lineStyle + HumanReadableSizes = $false + } + } + + It "scans, filters, limits, and sorts one snapshot" { + $config.ExcludeDirectories = @("beta") + $config.SortBy = "Size" + $config.SortDescending = $true + $config.FileLimit = 1 + $config.FileSizeBounds = @{ LowerBound = 2; UpperBound = 8; ShouldFilter = $true } + $params = Build-ChildItemFileParams -ShowHiddenFiles $true -CommandLineIncludeExt @("txt") + + $snapshot = Get-TreeSnapshot -TreeConfig $config -ChildItemFileParams $params + $alpha = $snapshot.Directories | Where-Object { $_.Item.Name -eq "alpha" } + $beta = $snapshot.Directories | Where-Object { $_.Item.Name -eq "beta" } + + $snapshot.Files.Name | Should -Be @("root.txt") + $alpha.Files.Name | Should -Be @("large.txt") + $alpha.Size | Should -Be 10 + $beta.IsVisible | Should -BeFalse + $snapshot.Size | Should -Be 32 + } + + It "prunes empty directories when snapshot and writer are composed" { + $config.PruneEmptyFolders = $true + $params = Build-ChildItemFileParams -ShowHiddenFiles $true -CommandLineIncludeExt @("txt") + $snapshot = Get-TreeSnapshot -TreeConfig $config -ChildItemFileParams $params + $stats = [TreeStats]::new() + $builder = [System.Text.StringBuilder]::new() + + Write-TreeSnapshot -Node $snapshot -TreeConfig $config -TreeStats $stats -OutputBuilder $builder + + $builder.ToString() | Should -Match "root\.txt" + $builder.ToString() | Should -Match "deep\.txt" + $builder.ToString() | Should -Not -Match "(?m)empty$" + $stats.FilesPrinted | Should -Be 5 + $stats.FoldersPrinted | Should -Be 3 + $stats.TotalSize | Should -Be 24 + $stats.MaxDepth | Should -Be 2 + } + + It "honors directory-only and depth together" { + $config.DirectoryOnly = $true + $config.MaxDepth = 1 + $config.HeaderTable = Get-HeaderTable -DisplayCreationDate $false -DisplayLastAccessDate $false ` + -DisplayModificationDate $false -DisplaySize $false -DisplayMode $false -LineStyle $lineStyle + $stats = [TreeStats]::new() + $builder = [System.Text.StringBuilder]::new() + + Get-TreeView -TreeConfig $config -ChildItemFileParams @{ File = $true } ` + -TreeStats $stats -OutputBuilder $builder + + $builder.ToString() | Should -Match "alpha" + $builder.ToString() | Should -Match "beta" + $builder.ToString() | Should -Not -Match "nested" + $builder.ToString() | Should -Not -Match "root\.txt" + $stats.FilesPrinted | Should -Be 0 + } + + It "sorts folders by their cached recursive size" { + $config.SortBy = "Size" + $config.SortDescending = $true + $snapshot = Get-TreeSnapshot -TreeConfig $config -ChildItemFileParams @{ File = $true } + @($snapshot.Directories | ForEach-Object { $_.Item.Name }) | Should -Be @("beta", "alpha", "empty") + } + } +} + +Describe "Show-PowerTree combinations" { + BeforeEach { + $script:fixture = Join-Path $TestDrive "public-fixture" + $script:homePath = Join-Path $TestDrive "home" + [void][System.IO.Directory]::CreateDirectory((Join-Path $fixture "src/empty")) + [void][System.IO.Directory]::CreateDirectory((Join-Path $fixture "logs")) + [System.IO.File]::WriteAllBytes((Join-Path $fixture "src/a.ps1"), [byte[]]::new(4)) + [System.IO.File]::WriteAllBytes((Join-Path $fixture "src/b.txt"), [byte[]]::new(2)) + [System.IO.File]::WriteAllBytes((Join-Path $fixture "logs/app.log"), [byte[]]::new(8)) + [void][System.IO.Directory]::CreateDirectory((Join-Path $homePath ".PowerTree")) + $script:configPath = Join-Path $homePath ".PowerTree/config.json" + @{ + Shared = @{ + ShowConnectorLines = $true + ShowExecutionStats = $false + ShowConfigurations = $false + LineStyle = "Unicode" + OpenOutputFileOnFinish = $false + } + FileSystem = @{ + MaxDepth = -1 + ExcludeDirectories = @() + HumanReadableSizes = $false + Files = @{ + ExcludeExtensions = @() + IncludeExtensions = @() + FileSizeMinimum = "-1kb" + FileSizeMaximum = "-1kb" + FileLimit = -1 + } + Sorting = @{ By = "Name"; SortFolders = $true } + } + Registry = @{ MaxDepth = -1; ExcludeKeys = @() } + } | ConvertTo-Json -Depth 5 | Set-Content $configPath + $script:originalHome = $env:HOME + $script:originalUserProfile = $env:USERPROFILE + $env:HOME = $homePath + $env:USERPROFILE = $homePath + } + + AfterEach { + $env:HOME = $originalHome + $env:USERPROFILE = $originalUserProfile + } + + It "combines extension filters, pruning, size display, sorting, and output" { + $outputPath = Join-Path $TestDrive "result" + Show-PowerTree -LiteralPath $fixture -IncludeExtensions @("ps1", "txt") ` + -PruneEmptyFolders -DisplaySize -SortBySize -Descending -OutFile $outputPath *> $null + + $savedPath = "$outputPath.txt" + $content = [System.IO.File]::ReadAllText($savedPath) + $content | Should -Match "a\.ps1" + $content | Should -Match "b\.txt" + $content | Should -Not -Match "app\.log" + $content | Should -Not -Match "(?m)empty$" + $content | Should -Match "(?m)^6\s+.*src$" + } + + It "combines directory exclusion, depth, and a zero file limit" { + $outputPath = Join-Path $TestDrive "limited.txt" + Show-PowerTree -LiteralPath $fixture -ExcludeDirectories @("logs") -Depth 1 ` + -FileLimit 0 -OutFile $outputPath *> $null + + $content = [System.IO.File]::ReadAllText($outputPath) + $content | Should -Match "src" + $content | Should -Not -Match "logs" + $content | Should -Not -Match "a\.ps1" + $content | Should -Not -Match "b\.txt" + } +} diff --git a/Tests/PowerTree.Integration.Tests.ps1 b/Tests/PowerTree.Integration.Tests.ps1 new file mode 100644 index 0000000..ca05660 --- /dev/null +++ b/Tests/PowerTree.Integration.Tests.ps1 @@ -0,0 +1,133 @@ +$ErrorActionPreference = "Stop" +$modulePath = Join-Path $PSScriptRoot "../PowerTree.psd1" +$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("powertree-tests-" + [guid]::NewGuid().ToString("N")) +$fixturePath = Join-Path $testRoot "fixture" +$configPath = Join-Path $testRoot ".PowerTree/config.json" +$originalHome = $env:HOME +$originalUserProfile = $env:USERPROFILE +$passed = 0 + +function Assert-Match { + param([string]$Content, [string]$Pattern, [string]$Name) + + if ($Content -notmatch $Pattern) { + throw "$Name failed: expected '$Pattern'" + } + $script:passed++ +} + +function Assert-NotMatch { + param([string]$Content, [string]$Pattern, [string]$Name) + + if ($Content -match $Pattern) { + throw "$Name failed: did not expect '$Pattern'" + } + $script:passed++ +} + +function Invoke-TestTree { + param([string]$Name, [hashtable]$Parameters = @{}) + + $outputPath = Join-Path $testRoot "$Name.txt" + Show-PowerTree -LiteralPath $fixturePath -OutFile $outputPath @Parameters *> $null + return [System.IO.File]::ReadAllText($outputPath) +} + +try { + [void][System.IO.Directory]::CreateDirectory((Join-Path $fixturePath "alpha/empty")) + [void][System.IO.Directory]::CreateDirectory((Join-Path $fixturePath "beta/nested")) + [void][System.IO.Directory]::CreateDirectory((Join-Path $fixturePath "empty")) + [System.IO.File]::WriteAllBytes((Join-Path $fixturePath "root.md"), [byte[]]::new(2)) + [System.IO.File]::WriteAllBytes((Join-Path $fixturePath ".secret.txt"), [byte[]]::new(4)) + [System.IO.File]::WriteAllBytes((Join-Path $fixturePath "alpha/a.txt"), [byte[]]::new(1)) + [System.IO.File]::WriteAllBytes((Join-Path $fixturePath "beta/b.log"), [byte[]]::new(3)) + [System.IO.File]::WriteAllBytes((Join-Path $fixturePath "beta/nested/c.txt"), [byte[]]::new(5)) + + $config = @{ + Shared = @{ + ShowConnectorLines = $true + ShowExecutionStats = $false + ShowConfigurations = $false + LineStyle = "Unicode" + OpenOutputFileOnFinish = $false + } + FileSystem = @{ + MaxDepth = -1 + ExcludeDirectories = @() + HumanReadableSizes = $false + Files = @{ + ExcludeExtensions = @() + IncludeExtensions = @() + FileSizeMinimum = "-1kb" + FileSizeMaximum = "-1kb" + FileLimit = -1 + } + Sorting = @{ + By = "Name" + SortFolders = $true + } + } + Registry = @{ + MaxDepth = -1 + ExcludeKeys = @() + } + } + [void][System.IO.Directory]::CreateDirectory((Split-Path $configPath -Parent)) + [System.IO.File]::WriteAllText($configPath, ($config | ConvertTo-Json -Depth 5)) + $env:HOME = $testRoot + $env:USERPROFILE = $testRoot + Import-Module $modulePath -Force + + $defaultOutput = Invoke-TestTree "default" + Assert-Match $defaultOutput "root\.md" "default files" + Assert-Match $defaultOutput "alpha" "default directories" + Assert-NotMatch $defaultOutput "\.secret\.txt" "hidden files" + + $hiddenOutput = Invoke-TestTree "hidden" @{ ShowHiddenFiles = $true } + Assert-Match $hiddenOutput "\.secret\.txt" "show hidden files" + + $depthOutput = Invoke-TestTree "depth" @{ Depth = 1 } + Assert-Match $depthOutput "alpha" "depth directories" + Assert-NotMatch $depthOutput "a\.txt" "depth files" + + $filteredOutput = Invoke-TestTree "filtered" @{ + IncludeExtensions = @("txt") + PruneEmptyFolders = $true + } + Assert-Match $filteredOutput "a\.txt" "included extension" + Assert-Match $filteredOutput "c\.txt" "included nested extension" + Assert-NotMatch $filteredOutput "b\.log" "excluded extension" + Assert-NotMatch $filteredOutput "root\.md" "non-included extension" + Assert-NotMatch $filteredOutput "(?m)[^a-z]empty\r?$" "pruned empty directories" + + $excludedOutput = Invoke-TestTree "excluded" @{ ExcludeDirectories = @("beta") } + Assert-NotMatch $excludedOutput "beta" "excluded directory" + Assert-NotMatch $excludedOutput "c\.txt" "excluded subtree" + + $limitedOutput = Invoke-TestTree "limited" @{ FileLimit = 0 } + Assert-NotMatch $limitedOutput "root\.md" "zero file limit" + + $sizeOutput = Invoke-TestTree "size" @{ + DisplaySize = $true + SortBySize = $true + Descending = $true + } + Assert-Match $sizeOutput "(?m)^8\s+.*beta\r?$" "cached folder size" + if ($sizeOutput.IndexOf("beta") -gt $sizeOutput.IndexOf("alpha")) { + throw "folder size sorting failed" + } + $passed++ + + Import-Module $modulePath -Force + $reloadOutput = Invoke-TestTree "reload" @{ Depth = 1 } + Assert-Match $reloadOutput "root\.md" "same-session module reload" + + "Passed $passed integration assertions" +} finally { + Remove-Module PowerTree -ErrorAction Ignore + $env:HOME = $originalHome + $env:USERPROFILE = $originalUserProfile + if ($testRoot.StartsWith([System.IO.Path]::GetTempPath() + "powertree-tests-")) { + [System.IO.Directory]::Delete($testRoot, $true) + } +} diff --git a/Tests/PowerTree.Output.Tests.ps1 b/Tests/PowerTree.Output.Tests.ps1 new file mode 100644 index 0000000..3b7bdf4 --- /dev/null +++ b/Tests/PowerTree.Output.Tests.ps1 @@ -0,0 +1,192 @@ +$ErrorActionPreference = "Stop" +$modulePath = Join-Path $PSScriptRoot "../PowerTree.psd1" +Import-Module $modulePath -Force + +Describe "configuration helpers" { + InModuleScope PowerTree { + It "returns and flattens default settings" { + $defaults = Get-DefaultConfig + $defaults.Shared.LineStyle | Should -Be "Unicode" + $defaults.FileSystem.Sorting.By | Should -Be "Name" + (Get-FlattenedDefaultSettings FileSystem $defaults).Files.FileLimit | Should -Be -1 + (Get-FlattenedDefaultSettings Registry $defaults).MaxDepth | Should -Be -1 + } + + It "returns local and user config candidates" { + $paths = @(Get-ConfigPaths) + $paths.Count | Should -Be 5 + $paths[-1] | Should -Match "\.PowerTree[/\\]config\.json$" + } + + It "loads and merges file-system JSON settings" { + $path = Join-Path $TestDrive "config.json" + @{ + Shared = @{ ShowExecutionStats = $false; LineStyle = "ASCII" } + FileSystem = @{ + MaxDepth = 2 + ExcludeDirectories = @("bin") + HumanReadableSizes = $false + Files = @{ IncludeExtensions = @("ps1"); FileLimit = 3 } + Sorting = @{ By = "Size"; SortFolders = $true } + } + } | ConvertTo-Json -Depth 5 | Set-Content $path + $settings = Get-SettingsFromJson FileSystem @($path) + $settings.LineStyle | Should -Be "ASCII" + $settings.MaxDepth | Should -Be 2 + $settings.Files.IncludeExtensions | Should -Be @("ps1") + $settings.Files.FileLimit | Should -Be 3 + $settings.Sorting.SortFolders | Should -BeTrue + } + + It "uses defaults for malformed JSON" { + $path = Join-Path $TestDrive "invalid.json" + Set-Content $path "{invalid" + $settings = Get-SettingsFromJson Registry @($path) -WarningAction SilentlyContinue + $settings.MaxDepth | Should -Be -1 + $settings.LineStyle | Should -Be "Unicode" + } + + It "initializes a missing config and preserves an existing config" { + $originalHome = $env:HOME + $originalUserProfile = $env:USERPROFILE + $env:HOME = Join-Path $TestDrive "home" + $env:USERPROFILE = $env:HOME + try { + Initialize-ConfigFile + $path = Join-Path $env:HOME ".PowerTree/config.json" + $path | Should -Exist + Set-Content $path "sentinel" + Initialize-ConfigFile + Get-Content $path -Raw | Should -Be "sentinel`n" + } finally { + $env:HOME = $originalHome + $env:USERPROFILE = $originalUserProfile + } + } + } +} + +Describe "file-system output helpers" { + InModuleScope PowerTree { + BeforeEach { + $script:lineStyle = Build-TreeLineStyle Unicode + $script:header = Get-HeaderTable $false $false $false $true $false $lineStyle + } + + It "composes headers and file output lines" { + $path = Join-Path $TestDrive "file.txt" + [System.IO.File]::WriteAllBytes($path, [byte[]]::new(12)) + $output = Build-OutputLine $header (Get-Item $path) "└───" $false + $header.HeaderColumns | Should -Be @("Size", "Hierarchy") + $output.Line | Should -Match "^12\s+└───file\.txt$" + $output.SizeColor | Should -Be "Green" + $output.SizePosition | Should -Be 0 + } + + It "uses recursive size for directory lines" { + $output = Build-OutputLine $header (Get-Item $TestDrive) "└───" $false 99 + $output.Line | Should -Match "^99\s+└───" + $output.DirSize | Should -Be 99 + $output.SizeColor | Should -BeNullOrEmpty + } + + It "writes headers and lines to one builder" { + $builder = [System.Text.StringBuilder]::new() + Write-HeaderToOutput $header $builder $lineStyle + Write-OutputLine "body" $builder + $builder.ToString() | Should -Match "Size.*Hierarchy" + $builder.ToString() | Should -Match "body" + } + + It "formats tree configuration" { + $config = [pscustomobject]@{ + SortBy = "Size"; SortDescending = $true; HeaderTable = $header + HumanReadableSize = $true; DirectoryOnly = $false; ShowHiddenFiles = $true + PruneEmptyFolders = $true; MaxDepth = 2; FileLimit = 4 + ExcludeDirectories = @("bin") + ChildItemFileParams = @{ Include = @("*.ps1"); Exclude = @("*.tmp") } + FileSizeBounds = @{ LowerBound = 1KB; UpperBound = 2KB } + } + $content = (Get-TreeConfigurationData $config) -join "`n" + $content | Should -Match "Size Descending" + $content | Should -Match "Between 1KB and 2KB" + $content | Should -Match "\*\.ps1" + } + + It "builds file output with configuration and stats placeholder" { + $config = [pscustomobject]@{ + OutFile = "tree.txt"; Path = $TestDrive; SortBy = "Name"; SortDescending = $false + HeaderTable = @{ HeaderColumns = @("Hierarchy") }; HumanReadableSize = $false + DirectoryOnly = $false; ShowHiddenFiles = $false; PruneEmptyFolders = $false + MaxDepth = -1; FileLimit = -1; ExcludeDirectories = @() + ChildItemFileParams = @{}; FileSizeBounds = $null + } + $builder = Invoke-OutputBuilder $config $true $true + $builder.ToString() | Should -Match "# PowerTree Output" + $builder.ToString() | Should -Match "Sort By" + $builder.ToString() | Should -Match "Append the stats here later!!" + $config.OutFile = "" + Invoke-OutputBuilder $config | Should -BeNullOrEmpty + } + + It "replaces the tree stats placeholder" { + $stats = [TreeStats]::new() + $stats.FilesPrinted = 2 + $stats.FoldersPrinted = 1 + $stats.TotalSize = 12 + $builder = [System.Text.StringBuilder]::new("Append the stats here later!!") + Show-TreeStats $stats ([timespan]::FromMilliseconds(10)) $builder $lineStyle + $builder.ToString() | Should -Match "Files\s+Folders" + $builder.ToString() | Should -Match "2\s+1\s+3" + $builder.ToString() | Should -Not -Match "Append the stats" + } + + It "writes piped content and creates parent folders" { + $path = Join-Path $TestDrive "nested/output.txt" + @("first", "second") | Write-ToFile -FilePath $path -OpenOutputFileOnFinish $false + Get-Content $path | Should -Be @("first", "second") + } + + It "dispatches host configuration formatting" { + Mock Write-Host + Mock Get-TreeConfigurationData { @("setting") } + Write-ConfigurationToHost ([pscustomobject]@{ OutFile = ""; HeaderTable = @{} }) + Should -Invoke Get-TreeConfigurationData -Times 1 + Should -Invoke Write-Host -ParameterFilter { $Object -eq "Configuration" } -Times 1 + } + + It "prints help and examples" { + Mock Write-Host + Set-Item Function:Write-CheckForUpdates {} + Mock Write-CheckForUpdates + Write-Examples + Write-Help + Should -Invoke Write-Host -ParameterFilter { $Object -eq "EXAMPLES:" } -Times 1 + Should -Invoke Write-Host -ParameterFilter { $Object -eq "BASIC OPTIONS:" } -Times 1 + Should -Invoke Write-CheckForUpdates -Times 1 + } + } +} + +Describe "Edit-PowerTreeConfig" { + InModuleScope PowerTree { + It "creates the default config and opens it" { + $originalHome = $env:HOME + $originalUserProfile = $env:USERPROFILE + $env:HOME = Join-Path $TestDrive "editor-home" + $env:USERPROFILE = $env:HOME + Mock Get-ConfigPaths { @() } + Mock Start-Process + try { + Edit-PowerTreeConfig + $path = Join-Path $env:HOME ".PowerTree/config.json" + $path | Should -Exist + (Get-Content $path -Raw | ConvertFrom-Json).Shared.LineStyle | Should -Be "Unicode" + Should -Invoke Start-Process -Times 1 + } finally { + $env:HOME = $originalHome + $env:USERPROFILE = $originalUserProfile + } + } + } +} diff --git a/Tests/PowerTree.Registry.Tests.ps1 b/Tests/PowerTree.Registry.Tests.ps1 new file mode 100644 index 0000000..1ebb586 --- /dev/null +++ b/Tests/PowerTree.Registry.Tests.ps1 @@ -0,0 +1,205 @@ +$ErrorActionPreference = "Stop" +$modulePath = Join-Path $PSScriptRoot "../PowerTree.psd1" +Import-Module $modulePath -Force + +Describe "registry filtering and output" { + InModuleScope PowerTree { + It "matches wildcard filters and handles empty patterns" { + Test-FilterMatch -ItemName "PowerTree" -Patterns @("Power*") | Should -BeTrue + Test-FilterMatch -ItemName "PowerTree" -Patterns @("Other*") | Should -BeFalse + Test-FilterMatch -ItemName "PowerTree" -Patterns @() | Should -BeFalse + } + + It "resets flags and marks only the final item" { + $items = @( + [pscustomobject]@{ Name = "a"; IsLast = $true }, + [pscustomobject]@{ Name = "b"; IsLast = $false } + ) + $result = @(Set-LastItemFlag $items) + $result[0].IsLast | Should -BeFalse + $result[1].IsLast | Should -BeTrue + } + + It "sorts default values, named values, and keys together" { + $values = @( + [pscustomobject]@{ TypeName = "String"; Name = "z" }, + [pscustomobject]@{ TypeName = "String"; Name = "(Default)" }, + [pscustomobject]@{ TypeName = "DWord"; Name = "a" } + ) + $keys = @( + [pscustomobject]@{ TypeName = "Key"; Name = "Beta" }, + [pscustomobject]@{ TypeName = "Key"; Name = "Alpha" } + ) + + @(Invoke-RegistryItemSorting $values $keys $false $false).Name | + Should -Be @("(Default)", "a", "z", "Alpha", "Beta") + @(Invoke-RegistryItemSorting $values $keys $true $false).TypeName | + Should -Be @("DWord", "Key", "Key", "String", "String") + } + + It "formats registry configuration" { + $config = [pscustomobject]@{ + SortValuesByType = $true + SortDescending = $true + UseRegistryDataTypes = $true + NoValues = $true + DisplayItemCounts = $true + MaxDepth = 2 + Include = @("Name*") + Exclude = @("Secret*") + } + $content = (Get-RegistryConfigurationData $config) -join "`n" + $content | Should -Match "Type Descending" + $content | Should -Match "REG_SZ" + $content | Should -Match "Name\*" + $content | Should -Match "Secret\*" + } + + It "builds registry output and replaces its stats placeholder" { + $lineStyle = Build-TreeLineStyle Unicode + $config = [pscustomobject]@{ + Path = "HKCU:\Software" + LineStyle = $lineStyle + SortValuesByType = $false + SortDescending = $false + UseRegistryDataTypes = $false + NoValues = $false + DisplayItemCounts = $false + MaxDepth = -1 + Include = @() + Exclude = @() + } + $builder = Invoke-OutputBuilderRegistry -TreeRegistryConfig $config ` + -ShowExecutionStats $true -ShowConfigurations $true + $stats = [RegistryStats]::new() + $stats.KeysProcessed = 2 + $stats.ValuesProcessed = 3 + $stats.UpdateDepth(2) + + Show-RegistryStats -RegistryStats $stats -ExecutionTime ([timespan]::FromSeconds(1)) ` + -LineStyle $lineStyle -OutputBuilder $builder + + $builder.ToString() | Should -Match "# PowerTreeRegistry Output" + $builder.ToString() | Should -Match "Configuration:" + $builder.ToString() | Should -Match "2\s+3\s+5" + $builder.ToString() | Should -Not -Match "Append the stats" + } + } +} + +Describe "Windows registry traversal" -Skip:(-not $IsWindows) { + InModuleScope PowerTree { + BeforeAll { + $script:keyName = "PowerTreeTests-" + [guid]::NewGuid().ToString("N") + $script:registryPath = "HKCU:\Software\$keyName" + [void](New-Item -Path $registryPath -Force) + [void](New-Item -Path "$registryPath\Alpha" -Force) + [void](New-Item -Path "$registryPath\Excluded" -Force) + New-ItemProperty -Path $registryPath -Name "Name" -Value "PowerTree" -PropertyType String -Force | Out-Null + New-ItemProperty -Path $registryPath -Name "Number" -Value 42 -PropertyType DWord -Force | Out-Null + New-ItemProperty -Path $registryPath -Name "SecretValue" -Value "hidden" -PropertyType String -Force | Out-Null + New-ItemProperty -Path "$registryPath\Alpha" -Name "ChildValue" -Value "child" -PropertyType String -Force | Out-Null + } + + AfterAll { + if ($registryPath -match '^HKCU:\\Software\\PowerTreeTests-[a-f0-9]{32}$') { + Remove-Item -LiteralPath $registryPath -Recurse -Force + } + } + + It "normalizes and validates a registry path" { + $fullPath = "HKEY_CURRENT_USER\Software\$keyName" + Get-Path $fullPath | Should -Be $registryPath + } + + It "processes child keys with exclusions and item counts" { + $keys = @(Get-ProcessedRegistryKeys -RegistryPath $registryPath -Exclude @("Excluded") ` + -HasKeyFilters $true -DisplayItemCounts $true) + $keys.Count | Should -Be 1 + $keys[0].Name | Should -Be "Alpha" + $keys[0].ValueCount | Should -Be 1 + $keys[0].SubKeyCount | Should -Be 0 + } + + It "processes values with filters and registry data type names" { + $regKey = Get-Item -LiteralPath $registryPath + $typeMap = @{ String = "REG_SZ"; DWord = "REG_DWORD" } + $values = @(Get-ProcessedRegistryValues -RegKey $regKey -RegistryTypeMap $typeMap ` + -UseRegistryDataTypes $true -Include @("N*") -Exclude @("Secret*") -HasValueFilters $true) + + $values.Name | Should -Be @("Name", "Number") + $values.TypeName | Should -Be @("REG_SZ", "REG_DWORD") + } + + It "composes values, child keys, sorting, filtering, and last flags" { + $items = @(Get-RegistryItems -RegistryPath $registryPath -DisplayItemCounts $true ` + -SortValuesByType $false -UseRegistryDataTypes $false -Exclude @("Secret*", "Excluded")) + + $items.Name | Should -Contain "Name" + $items.Name | Should -Contain "Number" + $items.Name | Should -Contain "Alpha" + $items.Name | Should -Not -Contain "SecretValue" + $items.Name | Should -Not -Contain "Excluded" + @($items | Where-Object IsLast).Count | Should -Be 1 + $items[-1].IsLast | Should -BeTrue + } + + It "renders a recursive registry tree into a collection" { + $config = [pscustomobject]@{ + Path = $registryPath + NoValues = $false + Exclude = @("Secret*", "Excluded") + Include = @() + MaxDepth = -1 + LineStyle = Build-TreeLineStyle Unicode + DisplayItemCounts = $true + SortValuesByType = $false + SortDescending = $false + UseRegistryDataTypes = $false + } + $output = [System.Collections.Generic.List[string]]::new() + $stats = Get-TreeRegistryView -TreeRegistryConfig $config -OutputCollection $output + + $output -join "`n" | Should -Match "Type\s+Hierarchy" + $output -join "`n" | Should -Match "Alpha" + $output -join "`n" | Should -Match "ChildValue = child" + $stats.KeysProcessed | Should -Be 1 + $stats.ValuesProcessed | Should -Be 3 + $stats.MaxDepthReached | Should -Be 1 + } + + It "runs the public registry command with filters and file output" { + $homePath = Join-Path $TestDrive "registry-home" + [void][System.IO.Directory]::CreateDirectory((Join-Path $homePath ".PowerTree")) + $configPath = Join-Path $homePath ".PowerTree/config.json" + @{ + Shared = @{ + ShowConnectorLines = $true + ShowExecutionStats = $true + ShowConfigurations = $false + LineStyle = "Unicode" + OpenOutputFileOnFinish = $false + } + FileSystem = @{} + Registry = @{ MaxDepth = -1; ExcludeKeys = @() } + } | ConvertTo-Json -Depth 5 | Set-Content $configPath + $originalHome = $env:HOME + $originalUserProfile = $env:USERPROFILE + $env:HOME = $homePath + $env:USERPROFILE = $homePath + try { + $outputPath = Join-Path $TestDrive "registry.txt" + Show-PowerTreeRegistry -Path $registryPath -Exclude @("Secret*", "Excluded") ` + -UseRegistryDataTypes -DisplayItemCounts -OutFile $outputPath *> $null + $content = [System.IO.File]::ReadAllText($outputPath) + $content | Should -Match "REG_SZ" + $content | Should -Match "Alpha" + $content | Should -Not -Match "SecretValue" + $content | Should -Match "Keys\s+Values" + } finally { + $env:HOME = $originalHome + $env:USERPROFILE = $originalUserProfile + } + } + } +} From 955c6cad356c5514aab0035fa9fa0f05e515cd78 Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Sun, 23 Aug 2026 22:06:13 +0200 Subject: [PATCH 2/4] fixes --- Tests/PowerTree.FileSystem.Tests.ps1 | 8 ++++---- Tests/PowerTree.Output.Tests.ps1 | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/PowerTree.FileSystem.Tests.ps1 b/Tests/PowerTree.FileSystem.Tests.ps1 index 9dec0c4..2d4d5b4 100644 --- a/Tests/PowerTree.FileSystem.Tests.ps1 +++ b/Tests/PowerTree.FileSystem.Tests.ps1 @@ -14,7 +14,7 @@ Describe "file-system traversal" { [System.IO.File]::WriteAllBytes((Join-Path $fixture "alpha/small.txt"), [byte[]]::new(1)) [System.IO.File]::WriteAllBytes((Join-Path $fixture "alpha/large.txt"), [byte[]]::new(6)) [System.IO.File]::WriteAllBytes((Join-Path $fixture "alpha/nested/deep.txt"), [byte[]]::new(3)) - [System.IO.File]::WriteAllBytes((Join-Path $fixture "beta/ignored.txt"), [byte[]]::new(10)) + [System.IO.File]::WriteAllBytes((Join-Path $fixture "beta/ignored.txt"), [byte[]]::new(11)) } BeforeEach { @@ -56,7 +56,7 @@ Describe "file-system traversal" { $alpha.Files.Name | Should -Be @("large.txt") $alpha.Size | Should -Be 10 $beta.IsVisible | Should -BeFalse - $snapshot.Size | Should -Be 32 + $snapshot.Size | Should -Be 33 } It "prunes empty directories when snapshot and writer are composed" { @@ -73,7 +73,7 @@ Describe "file-system traversal" { $builder.ToString() | Should -Not -Match "(?m)empty$" $stats.FilesPrinted | Should -Be 5 $stats.FoldersPrinted | Should -Be 3 - $stats.TotalSize | Should -Be 24 + $stats.TotalSize | Should -Be 25 $stats.MaxDepth | Should -Be 2 } @@ -160,7 +160,7 @@ Describe "Show-PowerTree combinations" { $content | Should -Match "b\.txt" $content | Should -Not -Match "app\.log" $content | Should -Not -Match "(?m)empty$" - $content | Should -Match "(?m)^6\s+.*src$" + $content | Should -Match "(?m)^6\s+.*src\r?$" } It "combines directory exclusion, depth, and a zero file limit" { diff --git a/Tests/PowerTree.Output.Tests.ps1 b/Tests/PowerTree.Output.Tests.ps1 index 3b7bdf4..1e49789 100644 --- a/Tests/PowerTree.Output.Tests.ps1 +++ b/Tests/PowerTree.Output.Tests.ps1 @@ -57,7 +57,7 @@ Describe "configuration helpers" { $path | Should -Exist Set-Content $path "sentinel" Initialize-ConfigFile - Get-Content $path -Raw | Should -Be "sentinel`n" + (Get-Content $path -Raw).TrimEnd() | Should -Be "sentinel" } finally { $env:HOME = $originalHome $env:USERPROFILE = $originalUserProfile From 7b04f53e2c9c169dcfee68b5d71aaf2ab283a7ad Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Sun, 23 Aug 2026 22:09:36 +0200 Subject: [PATCH 3/4] performance --- .github/workflows/performance.yml | 61 ++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 418eabe..006de80 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -3,8 +3,13 @@ name: Performance on: pull_request: +concurrency: + group: performance-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read + pull-requests: write jobs: benchmark: @@ -40,4 +45,58 @@ jobs: -ModulePath ./pr/PowerTree.psd1 ` -JsonPath "$env:RUNNER_TEMP/pr-performance.json" ` -BaselinePath "$env:RUNNER_TEMP/base-performance.json" ` - -MarkdownPath $env:GITHUB_STEP_SUMMARY + -MarkdownPath "$env:RUNNER_TEMP/performance-summary.md" + Get-Content "$env:RUNNER_TEMP/performance-summary.md" -Raw | + Add-Content -Path $env:GITHUB_STEP_SUMMARY + + - name: Update PR performance comment + if: github.event.pull_request.head.repo.full_name == github.repository + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + $ErrorActionPreference = "Stop" + $marker = "" + $report = Get-Content "$env:RUNNER_TEMP/performance-summary.md" -Raw + $runUrl = "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID" + $body = "$marker`n$report`n[View workflow run]($runUrl)" + $headers = @{ + Accept = "application/vnd.github+json" + Authorization = "Bearer $env:GITHUB_TOKEN" + "X-GitHub-Api-Version" = "2022-11-28" + } + $commentsUrl = "$env:GITHUB_API_URL/repos/$env:GITHUB_REPOSITORY/issues/$env:PR_NUMBER/comments" + $existing = $null + $page = 1 + + do { + $comments = @(Invoke-RestMethod ` + -Uri "${commentsUrl}?per_page=100&page=$page" ` + -Headers $headers) + $existing = $comments | + Where-Object { + $_.user.login -eq "github-actions[bot]" -and $_.body.Contains($marker) + } | + Select-Object -First 1 + $page++ + } while ($null -eq $existing -and $comments.Count -eq 100) + + $payload = @{ body = $body } | ConvertTo-Json + if ($null -ne $existing) { + Invoke-RestMethod ` + -Method Patch ` + -Uri "$env:GITHUB_API_URL/repos/$env:GITHUB_REPOSITORY/issues/comments/$($existing.id)" ` + -Headers $headers ` + -ContentType "application/json" ` + -Body $payload | Out-Null + "Updated PR performance comment" + } else { + Invoke-RestMethod ` + -Method Post ` + -Uri $commentsUrl ` + -Headers $headers ` + -ContentType "application/json" ` + -Body $payload | Out-Null + "Created PR performance comment" + } From a922a8ca6eba067128eff5969ec1c929fe7e0893 Mon Sep 17 00:00:00 2001 From: Bart Spaans Date: Sun, 23 Aug 2026 22:11:21 +0200 Subject: [PATCH 4/4] test: cover terminating validation paths --- Tests/PowerTree.Integration.Tests.ps1 | 6 ++- Tests/PowerTree.Validation.Tests.ps1 | 53 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 Tests/PowerTree.Validation.Tests.ps1 diff --git a/Tests/PowerTree.Integration.Tests.ps1 b/Tests/PowerTree.Integration.Tests.ps1 index ca05660..22984cc 100644 --- a/Tests/PowerTree.Integration.Tests.ps1 +++ b/Tests/PowerTree.Integration.Tests.ps1 @@ -38,7 +38,11 @@ try { [void][System.IO.Directory]::CreateDirectory((Join-Path $fixturePath "beta/nested")) [void][System.IO.Directory]::CreateDirectory((Join-Path $fixturePath "empty")) [System.IO.File]::WriteAllBytes((Join-Path $fixturePath "root.md"), [byte[]]::new(2)) - [System.IO.File]::WriteAllBytes((Join-Path $fixturePath ".secret.txt"), [byte[]]::new(4)) + $hiddenFilePath = Join-Path $fixturePath ".secret.txt" + [System.IO.File]::WriteAllBytes($hiddenFilePath, [byte[]]::new(4)) + if ($IsWindows) { + [System.IO.File]::SetAttributes($hiddenFilePath, [System.IO.FileAttributes]::Hidden) + } [System.IO.File]::WriteAllBytes((Join-Path $fixturePath "alpha/a.txt"), [byte[]]::new(1)) [System.IO.File]::WriteAllBytes((Join-Path $fixturePath "beta/b.log"), [byte[]]::new(3)) [System.IO.File]::WriteAllBytes((Join-Path $fixturePath "beta/nested/c.txt"), [byte[]]::new(5)) diff --git a/Tests/PowerTree.Validation.Tests.ps1 b/Tests/PowerTree.Validation.Tests.ps1 new file mode 100644 index 0000000..913cc17 --- /dev/null +++ b/Tests/PowerTree.Validation.Tests.ps1 @@ -0,0 +1,53 @@ +$ErrorActionPreference = "Stop" + +Describe "terminating validation" { + BeforeAll { + $modulePath = (Resolve-Path (Join-Path $PSScriptRoot "../PowerTree.psd1")).Path + $pwshPath = Join-Path $PSHOME $(if ($IsWindows) { "pwsh.exe" } else { "pwsh" }) + + function Invoke-PowerTreeSubprocess { + param([string]$Body) + + $escapedModulePath = $modulePath.Replace("'", "''") + $script = "Import-Module '$escapedModulePath' -Force`n$Body" + $output = & $pwshPath -NoLogo -NoProfile -Command $script 2>&1 + + return @{ + ExitCode = $LASTEXITCODE + Output = $output -join "`n" + } + } + } + + It "exits when maximum file size is smaller than minimum file size" { + $result = Invoke-PowerTreeSubprocess @' +& (Get-Module PowerTree) { + Build-FileSizeParams -CommandLineMaxSize '1kb' -CommandLineMinSize '2kb' ` + -SettingsLineMaxSize '-1kb' -SettingsLineMinSize '-1kb' +} +'@ + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match "Maximum file size cannot be smaller" + } + + It "exits for missing and non-registry paths" { + $missingResult = Invoke-PowerTreeSubprocess @' +& (Get-Module PowerTree) { Get-Path '__powertree_missing_path__' } +'@ + $escapedFilePath = $PSCommandPath.Replace("'", "''") + $fileResult = Invoke-PowerTreeSubprocess "& (Get-Module PowerTree) { Get-Path '$escapedFilePath' }" + + $missingResult.ExitCode | Should -Be 1 + $missingResult.Output | Should -Match "Path does not exist" + $fileResult.ExitCode | Should -Be 1 + $fileResult.Output | Should -Match "Path is not a registry key" + } + + It "exits when registry output is requested outside Windows" -Skip:$IsWindows { + $result = Invoke-PowerTreeSubprocess "Show-PowerTreeRegistry" + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match "can only be run on Windows" + } +}