From 8f5ad152944a4cc16c9c82cd57b22d169740a3d3 Mon Sep 17 00:00:00 2001 From: Mike Campbell Date: Fri, 28 Aug 2026 18:36:09 -0400 Subject: [PATCH] Phase 1: PS7 correctness fixes (issues #110, #98, #97, #104, #66, #75/#88/#85) - Export-PSGraph: replace Invoke-Expression with Invoke-Item for -ShowGraph so destination paths containing spaces work on PS7 (#110/PR-102). - Export-PSGraph: pin $OutputEncoding to UTF8-no-BOM around the pipe to dot's stdin so an ambient BOM-emitting encoding (e.g. in CI) can't break dot's parser; verified non-ASCII labels already round-trip correctly once the BOM is gone (#97, #104). - Export-PSGraph: prefer a cross-platform `Get-Command dot` PATH lookup over the hardcoded path glob when -GraphVizPath isn't explicitly given; an explicit -GraphVizPath still wins outright (#75, #88, #85). - Graph: only default the compound attribute to 'true' when the caller hasn't already supplied one, so compound=$false survives (#98). - docs/Command-SubGraph.md: fix the documented SubGraph example to pass -ScriptBlock by name, matching how PowerShell's parameter-set binding actually resolves an unnamed subgraph with -Attributes (#66). Added regression tests for all of the above. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AgrjdhkmUCswsFGbn3EwCg --- PSGraph/Public/Export-PSGraph.ps1 | 27 ++++++++++--- PSGraph/Public/Graph.ps1 | 5 ++- Tests/Export-PSGraph.Tests.ps1 | 64 +++++++++++++++++++++++++++++++ Tests/Regression.Tests.ps1 | 40 ++++++++++++++++++- docs/Command-SubGraph.md | 2 +- 5 files changed, 128 insertions(+), 10 deletions(-) diff --git a/PSGraph/Public/Export-PSGraph.ps1 b/PSGraph/Public/Export-PSGraph.ps1 index ec6f0c6..1642cc6 100644 --- a/PSGraph/Public/Export-PSGraph.ps1 +++ b/PSGraph/Public/Export-PSGraph.ps1 @@ -30,7 +30,6 @@ function Export-PSGraph It checks the piped data for file paths. If it cannot find a file, it assumes it is graph data. This may give unexpected errors when the file does not exist. #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingInvokeExpression", "")] [cmdletbinding()] param( # The GraphViz file to process or contents of the graph in Dot notation @@ -89,18 +88,34 @@ function Export-PSGraph { try { - # Use Resolve-Path to test all passed paths - # Select only items with 'dot' BaseName and use first one - $graphViz = Resolve-Path -path $GraphVizPath -ErrorAction SilentlyContinue | Get-Item | Where-Object BaseName -eq 'dot' | Select-Object -First 1 + $graphViz = $null + + # Unless the caller explicitly pinned a path, prefer a cross-platform + # PATH lookup (works regardless of install location/OS). + if ( -Not $PSBoundParameters.ContainsKey('GraphVizPath') ) + { + $graphViz = Get-Command -Name 'dot' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + } + + if ( $null -eq $graphViz ) + { + # Use Resolve-Path to test all passed/default paths + # Select only items with 'dot' BaseName and use first one + $graphViz = Resolve-Path -path $GraphVizPath -ErrorAction SilentlyContinue | Get-Item | Where-Object BaseName -eq 'dot' | Select-Object -First 1 + } if ( $null -eq $graphViz ) { $GraphvizPathString = $GraphVizPath -Join " or " - throw "Could not find GraphViz installed on this system. Please run 'Install-GraphViz' to install the needed binaries and libraries. This module just a wrapper around GraphViz and is looking for it in the following paths: $($GraphvizPathString). Optionally pass a path to your dot.exe file with the GraphVizPath parameter" + throw "Could not find GraphViz installed on this system. Please run 'Install-GraphViz' to install the needed binaries and libraries. This module looked for a 'dot' executable on PATH and in the following paths: $($GraphvizPathString). Optionally pass a path to your dot.exe file with the GraphVizPath parameter" } $useStandardInput = $false $standardInput = New-Object System.Text.StringBuilder + + # Pipe DOT source to graphviz as UTF-8 without a BOM, regardless of the + # caller's ambient $OutputEncoding (a BOM here breaks dot's parser). + $OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch { @@ -195,7 +210,7 @@ function Export-PSGraph { # Launches image with default viewer as decided by explorer Write-Verbose "Launching $($PSBoundParameters["DestinationPath"])" - Invoke-Expression $PSBoundParameters["DestinationPath"] + Invoke-Item -Path $PSBoundParameters["DestinationPath"] } Get-ChildItem $PSBoundParameters["DestinationPath"] diff --git a/PSGraph/Public/Graph.ps1 b/PSGraph/Public/Graph.ps1 index 9697a70..b10abb0 100644 --- a/PSGraph/Public/Graph.ps1 +++ b/PSGraph/Public/Graph.ps1 @@ -91,7 +91,10 @@ function Graph if ($Type -eq 'digraph') { $script:indent = 0 - $Attributes.compound = 'true' + if (-not $Attributes.ContainsKey('compound')) + { + $Attributes.compound = 'true' + } $script:SubGraphList = @{} } diff --git a/Tests/Export-PSGraph.Tests.ps1 b/Tests/Export-PSGraph.Tests.ps1 index ba61bc2..635ee33 100644 --- a/Tests/Export-PSGraph.Tests.ps1 +++ b/Tests/Export-PSGraph.Tests.ps1 @@ -30,4 +30,68 @@ Describe "$ModuleName Export-PSGraph" -Tag graphviz { "$path.png" | Should Exist } } + + Context "#110 ShowGraph and destination path with spaces" { + + It "Exports to a destination path containing spaces without throwing" { + $dir = Join-Path $testdrive "spaced dir" + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $path = Join-Path $dir "spaced graph.png" + + { Export-PSGraph -Source $dot -DestinationPath $path -ErrorAction Stop } | Should Not Throw + + $path | Should Exist + } + + It "-ShowGraph launches a destination path containing spaces without throwing" { + $dir = Join-Path $testdrive "spaced dir 2" + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $path = Join-Path $dir "spaced graph.png" + + { Export-PSGraph -Source $dot -DestinationPath $path -ShowGraph -ErrorAction Stop } | Should Not Throw + + $path | Should Exist + } + } + + Context "#97 BOM in generated DOT breaks dot's parser" { + + AfterEach { + $OutputEncoding = [System.Text.UTF8Encoding]::new($false) + } + + It "Exports successfully even when the caller's `$OutputEncoding would inject a BOM" { + $OutputEncoding = [System.Text.UTF8Encoding]::new($true) + $path = "$testdrive\bom.dot" + + { Export-PSGraph -Source $dot -DestinationPath $path -OutputFormat dot -ErrorAction Stop } | Should Not Throw + + $bytes = [System.IO.File]::ReadAllBytes($path) + ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) | Should Be $false + } + } + + Context "#104 Non-ASCII characters in labels" { + + It "Round-trips accented/non-ASCII labels through dot without garbling" { + $accented = graph g { node cafe @{label = 'héllo wörld'} } + $path = "$testdrive\accented.dot" + + Export-PSGraph -Source $accented -DestinationPath $path -OutputFormat dot + + $text = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) + $text | Should Match 'héllo wörld' + } + } + + Context "#75 #88 #85 Graphviz path detection" { + + It "Finds dot via PATH when -GraphVizPath is not specified" { + { Export-PSGraph -Source $dot -DestinationPath "$testdrive\pathlookup.png" -ErrorAction Stop } | Should Not Throw + } + + It "Honors an explicitly-supplied -GraphVizPath instead of silently falling back to PATH" { + { Export-PSGraph -Source $dot -DestinationPath "$testdrive\badpath.png" -GraphVizPath 'C:\does\not\exist\dot.exe' -ErrorAction Stop } | Should Throw + } + } } diff --git a/Tests/Regression.Tests.ps1 b/Tests/Regression.Tests.ps1 index a651f8d..bcec1c6 100644 --- a/Tests/Regression.Tests.ps1 +++ b/Tests/Regression.Tests.ps1 @@ -113,10 +113,46 @@ Describe "Regression tests for Github issues" -Tag Build { Context "Sequential edges require parameter name for attributes #40" { It "#40 correctly handles the positional attributes" { - $graph = Graph g { - Edge a, b, c, d, a @{label = 'to'} + $graph = Graph g { + Edge a, b, c, d, a @{label = 'to'} } $graph | Out-String | Should Not Match 'System.Collections.Hashtable' } } + + Context "#98 Setting compound=false fails" { + + It "#98 an explicit compound=`$false attribute is not overridden" { + $graph = Graph g -Attributes @{compound = $false} {} | Out-String + $graph | Should Match 'compound="False"' + $graph | Should Not Match 'compound="true"' + } + + It "#98 compound still defaults to true when not specified" { + $graph = Graph g {} | Out-String + $graph | Should Match 'compound="true"' + } + } + + Context "#66 SubGraph example from help/docs" { + + It "#66 unnamed subgraph binds with -Attributes and -ScriptBlock passed by name" { + { + graph { + subgraph -Attributes @{label = 'DMZ'} -ScriptBlock { + node web1, web2 + } + } + } | Should Not Throw + } + + It "#66 unnamed subgraph with named parameters applies the attributes" { + $graph = graph { + subgraph -Attributes @{label = 'DMZ'} -ScriptBlock { + node web1 + } + } | Out-String + $graph | Should Match 'label="DMZ"' + } + } } \ No newline at end of file diff --git a/docs/Command-SubGraph.md b/docs/Command-SubGraph.md index c3c54b2..14ba804 100644 --- a/docs/Command-SubGraph.md +++ b/docs/Command-SubGraph.md @@ -3,7 +3,7 @@ This allows you to define a graph within the graph. This will group those objects together in some engines. graph { - subgraph -Attributes @{label='DMZ'} { + subgraph -Attributes @{label='DMZ'} -ScriptBlock { node web1,web2,reports edge report -To web1,web2 }