@@ -70,39 +70,210 @@ jobs:
7070 $size = (Get-Item $env:BINARY).Length
7171 Add-Content $env:GITHUB_STEP_SUMMARY "✅ Binary found ($size bytes)"
7272
73- # pwsh (PowerShell Core) - explicit per-command timeouts via WaitForExit
74- - name : " [pwsh] --help with timeout"
73+ - name : Windows CLI scenario matrix (shell × launch-mode × env-shape × path-style)
7574 shell : pwsh
7675 run : |
77- $proc = Start-Process -FilePath $env:BINARY -ArgumentList "--help" -PassThru -NoNewWindow
78- if (-Not $proc.WaitForExit(30000)) {
79- $proc.Kill()
80- throw "TIMEOUT: --help hung for 30s in pwsh"
76+ Set-StrictMode -Version Latest
77+ $ErrorActionPreference = "Stop"
78+
79+ function Invoke-CliProcess {
80+ param(
81+ [Parameter(Mandatory)] [string] $FilePath,
82+ [string[]] $ArgumentList = @(),
83+ [int] $TimeoutMs = 30000,
84+ [string] $WorkingDirectory = (Get-Location).Path,
85+ [hashtable] $EnvironmentOverrides = @{},
86+ [string[]] $UnsetEnvironment = @(),
87+ [switch] $MinimalEnvironment,
88+ [switch] $ExpectFailure,
89+ [string] $ExpectedErrorPattern = "",
90+ [switch] $AssertNoAnsi
91+ )
92+
93+ $psi = [System.Diagnostics.ProcessStartInfo]::new()
94+ $psi.FileName = $FilePath
95+ $psi.WorkingDirectory = $WorkingDirectory
96+ $psi.UseShellExecute = $false
97+ $psi.RedirectStandardOutput = $true
98+ $psi.RedirectStandardError = $true
99+
100+ foreach ($arg in $ArgumentList) {
101+ [void]$psi.ArgumentList.Add($arg)
102+ }
103+
104+ if ($MinimalEnvironment) {
105+ $psi.Environment.Clear()
106+ foreach ($key in @("PATH", "TEMP", "TMP", "SystemRoot")) {
107+ $value = [System.Environment]::GetEnvironmentVariable($key)
108+ if ($null -ne $value -and $value -ne "") {
109+ $psi.Environment[$key] = $value
110+ }
111+ }
112+ }
113+
114+ foreach ($key in $UnsetEnvironment) {
115+ [void]$psi.Environment.Remove($key)
116+ }
117+ foreach ($key in $EnvironmentOverrides.Keys) {
118+ $value = $EnvironmentOverrides[$key]
119+ if ($null -eq $value) {
120+ [void]$psi.Environment.Remove($key)
121+ } else {
122+ $psi.Environment[$key] = [string]$value
123+ }
124+ }
125+
126+ $proc = [System.Diagnostics.Process]::Start($psi)
127+ if (-not $proc.WaitForExit($TimeoutMs)) {
128+ try { $proc.Kill($true) } catch {}
129+ throw "TIMEOUT: '$FilePath $($ArgumentList -join ' ')' exceeded ${TimeoutMs}ms"
130+ }
131+
132+ $stdout = $proc.StandardOutput.ReadToEnd()
133+ $stderr = $proc.StandardError.ReadToEnd()
134+ $output = "$stdout`n$stderr"
135+
136+ if ($AssertNoAnsi -and $output -match "\x1B\[[0-9;]*[A-Za-z]") {
137+ throw "Unexpected ANSI escape sequences in output"
138+ }
139+
140+ if ($ExpectFailure) {
141+ if ($proc.ExitCode -eq 0) {
142+ throw "Expected failure but command exited 0"
143+ }
144+ if ($ExpectedErrorPattern -and $output -notmatch $ExpectedErrorPattern) {
145+ throw "Expected error pattern '$ExpectedErrorPattern' not found"
146+ }
147+ } elseif ($proc.ExitCode -ne 0) {
148+ throw "Exit code $($proc.ExitCode) for '$FilePath $($ArgumentList -join ' ')'`n$output"
149+ }
150+
151+ return @{
152+ ExitCode = $proc.ExitCode
153+ Output = $output
154+ }
81155 }
82- if ($proc.ExitCode -ne 0) { throw "Exit code $($proc.ExitCode)" }
83- Add-Content $env:GITHUB_STEP_SUMMARY "✅ [pwsh] --help OK"
84156
85- - name : " [pwsh] version with timeout"
86- shell : pwsh
87- run : |
88- $proc = Start-Process -FilePath $env:BINARY -ArgumentList "version" -PassThru -NoNewWindow
89- if (-Not $proc.WaitForExit(30000)) {
90- $proc.Kill()
91- throw "TIMEOUT: version hung for 30s in pwsh"
157+ $workspace = $env:GITHUB_WORKSPACE
158+ $binary = $env:BINARY
159+ $originalPath = $env:PATH
160+ $toolcacheDir = Join-Path $env:RUNNER_TEMP "toolcache-like"
161+ $spaceDir = Join-Path $env:RUNNER_TEMP "path with spaces"
162+ $parenDir = Join-Path $env:RUNNER_TEMP "Program Files (x86)"
163+ $unicodeDir = Join-Path $env:TEMP "tëst-dïr-αβγ"
164+ foreach ($dir in @($toolcacheDir, $spaceDir, $parenDir, $unicodeDir)) {
165+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
92166 }
93- if ($proc.ExitCode -ne 0) { throw "Exit code $($proc.ExitCode)" }
94- Add-Content $env:GITHUB_STEP_SUMMARY "✅ [pwsh] version OK"
167+ Copy-Item -Path $binary -Destination (Join-Path $spaceDir "gh-aw.exe") -Force
168+ Copy-Item -Path $binary -Destination (Join-Path $parenDir "gh-aw.exe") -Force
95169
96- - name : " [pwsh] compile --help with timeout"
97- shell : pwsh
98- run : |
99- $proc = Start-Process -FilePath $env:BINARY -ArgumentList "compile","--help" -PassThru -NoNewWindow
100- if (-Not $proc.WaitForExit(30000)) {
101- $proc.Kill()
102- throw "TIMEOUT: compile --help hung for 30s in pwsh"
170+ $scenarioMatrix = @(
171+ @{ name = "pwsh-default/direct/default/default"; shell = "pwsh-default"; launch = "direct"; env = "default"; path = "default" },
172+ @{ name = "pwsh-noprofile/direct/default/default"; shell = "pwsh-noprofile"; launch = "direct"; env = "default"; path = "default" },
173+ @{ name = "powershell-default/direct/default/default"; shell = "powershell-default"; launch = "direct"; env = "default"; path = "default" },
174+ @{ name = "powershell-noprofile/direct/default/default"; shell = "powershell-noprofile"; launch = "direct"; env = "default"; path = "default" },
175+ @{ name = "pwsh-default/path/default/workspace-first"; shell = "pwsh-default"; launch = "path"; env = "default"; path = "workspace-first" },
176+ @{ name = "cmd/path/default/toolcache-first"; shell = "cmd"; launch = "path"; env = "default"; path = "toolcache-first" },
177+ @{ name = "pwsh-default/path/default/duplicate-workspace"; shell = "pwsh-default"; launch = "path"; env = "default"; path = "duplicate-workspace" },
178+ @{ name = "pwsh-default/path/no-color/workspace-first"; shell = "pwsh-default"; launch = "path"; env = "no-color"; path = "workspace-first" },
179+ @{ name = "pwsh-default/path/term-dumb/workspace-first"; shell = "pwsh-default"; launch = "path"; env = "term-dumb"; path = "workspace-first" },
180+ @{ name = "pwsh-default/path/term-unset/workspace-first"; shell = "pwsh-default"; launch = "path"; env = "term-unset"; path = "workspace-first" },
181+ @{ name = "pwsh-default/path/ci-true/workspace-first"; shell = "pwsh-default"; launch = "path"; env = "ci-true"; path = "workspace-first" },
182+ @{ name = "pwsh-default/path/ci-unset/workspace-first"; shell = "pwsh-default"; launch = "path"; env = "ci-unset"; path = "workspace-first" }
183+ )
184+
185+ foreach ($scenario in $scenarioMatrix) {
186+ $env:PATH = $originalPath
187+ switch ($scenario.path) {
188+ "workspace-first" { $env:PATH = "$workspace;$originalPath" }
189+ "toolcache-first" { $env:PATH = "$toolcacheDir;$workspace;$originalPath" }
190+ "duplicate-workspace" { $env:PATH = "$workspace;$workspace;$originalPath" }
191+ default {}
192+ }
193+
194+ $envOverrides = @{}
195+ $unsetVars = @()
196+ $assertNoAnsi = $false
197+ switch ($scenario.env) {
198+ "no-color" { $envOverrides["NO_COLOR"] = "1" }
199+ "term-dumb" { $envOverrides["TERM"] = "dumb"; $assertNoAnsi = $true }
200+ "term-unset" { $unsetVars += "TERM" }
201+ "ci-true" { $envOverrides["CI"] = "true" }
202+ "ci-unset" { $unsetVars += "CI" }
203+ default {}
204+ }
205+
206+ if ($scenario.launch -eq "path") {
207+ $whereResult = Invoke-CliProcess -FilePath "cmd" -ArgumentList @("/d", "/s", "/c", "where gh-aw") -EnvironmentOverrides $envOverrides -UnsetEnvironment $unsetVars
208+ $firstResolved = ($whereResult.Output -split "`r?`n" | Where-Object { $_ -match "gh-aw\.exe$" } | Select-Object -First 1)
209+ if (-not $firstResolved -or -not $firstResolved.StartsWith($workspace, [System.StringComparison]::OrdinalIgnoreCase)) {
210+ throw "PATH resolution mismatch for '$($scenario.name)': $firstResolved"
211+ }
212+ }
213+
214+ switch ($scenario.shell) {
215+ "pwsh-default" {
216+ $cmd = if ($scenario.launch -eq "path") { "& gh-aw --help" } else { "& '$binary' --help" }
217+ Invoke-CliProcess -FilePath "pwsh" -ArgumentList @("-Command", $cmd) -EnvironmentOverrides $envOverrides -UnsetEnvironment $unsetVars -AssertNoAnsi:$assertNoAnsi | Out-Null
218+ }
219+ "pwsh-noprofile" {
220+ $cmd = if ($scenario.launch -eq "path") { "& gh-aw --help" } else { "& '$binary' --help" }
221+ Invoke-CliProcess -FilePath "pwsh" -ArgumentList @("-NoProfile", "-Command", $cmd) -EnvironmentOverrides $envOverrides -UnsetEnvironment $unsetVars -AssertNoAnsi:$assertNoAnsi | Out-Null
222+ }
223+ "powershell-default" {
224+ $cmd = if ($scenario.launch -eq "path") { "& gh-aw --help" } else { "& '$binary' --help" }
225+ Invoke-CliProcess -FilePath "powershell" -ArgumentList @("-Command", $cmd) -EnvironmentOverrides $envOverrides -UnsetEnvironment $unsetVars -AssertNoAnsi:$assertNoAnsi | Out-Null
226+ }
227+ "powershell-noprofile" {
228+ $cmd = if ($scenario.launch -eq "path") { "& gh-aw --help" } else { "& '$binary' --help" }
229+ Invoke-CliProcess -FilePath "powershell" -ArgumentList @("-NoProfile", "-Command", $cmd) -EnvironmentOverrides $envOverrides -UnsetEnvironment $unsetVars -AssertNoAnsi:$assertNoAnsi | Out-Null
230+ }
231+ "cmd" {
232+ $cmd = if ($scenario.launch -eq "path") { "gh-aw --help" } else { """$binary"" --help" }
233+ Invoke-CliProcess -FilePath "cmd" -ArgumentList @("/d", "/s", "/c", $cmd) -EnvironmentOverrides $envOverrides -UnsetEnvironment $unsetVars -AssertNoAnsi:$assertNoAnsi | Out-Null
234+ }
235+ default {
236+ throw "Unknown shell scenario: $($scenario.shell)"
237+ }
238+ }
239+
240+ Add-Content $env:GITHUB_STEP_SUMMARY "✅ Matrix scenario passed: $($scenario.name)"
241+ }
242+
243+ if ($env:PATHEXT -notmatch "(?i)\.EXE") {
244+ throw "PATHEXT missing .EXE"
245+ }
246+ Invoke-CliProcess -FilePath "pwsh" -ArgumentList @("-NoProfile", "-Command", "& gh-aw --help") | Out-Null
247+ Invoke-CliProcess -FilePath "cmd" -ArgumentList @("/d", "/s", "/c", "gh-aw --help") | Out-Null
248+ Add-Content $env:GITHUB_STEP_SUMMARY "✅ PATHEXT/extension resolution checks passed from pwsh and cmd"
249+
250+ $spaceBinary = Join-Path $spaceDir "gh-aw.exe"
251+ $spaceBinaryForwardSlashes = $spaceBinary -replace "\\", "/"
252+ $parenBinary = Join-Path $parenDir "gh-aw.exe"
253+ Invoke-CliProcess -FilePath $spaceBinary -ArgumentList @("--help") | Out-Null
254+ Invoke-CliProcess -FilePath $spaceBinaryForwardSlashes -ArgumentList @("--help") | Out-Null
255+ Invoke-CliProcess -FilePath $parenBinary -ArgumentList @("--help") | Out-Null
256+ Add-Content $env:GITHUB_STEP_SUMMARY "✅ Paths with spaces, mixed slashes, and parentheses passed"
257+
258+ Invoke-CliProcess -FilePath $binary -ArgumentList @("--help") -WorkingDirectory $unicodeDir | Out-Null
259+ Add-Content $env:GITHUB_STEP_SUMMARY "✅ Unicode working directory check passed ($unicodeDir)"
260+
261+ Invoke-CliProcess -FilePath $binary -ArgumentList @("--help") -MinimalEnvironment | Out-Null
262+ Add-Content $env:GITHUB_STEP_SUMMARY "✅ Minimal environment execution passed"
263+
264+ foreach ($command in @(
265+ @{ name = "--help"; args = @("--help") },
266+ @{ name = "version"; args = @("version") },
267+ @{ name = "compile --help"; args = @("compile", "--help") },
268+ @{ name = "help"; args = @("help") },
269+ @{ name = "run --help"; args = @("run", "--help") }
270+ )) {
271+ Invoke-CliProcess -FilePath $binary -ArgumentList $command.args | Out-Null
272+ Add-Content $env:GITHUB_STEP_SUMMARY "✅ Command variant passed: $($command.name)"
103273 }
104- if ($proc.ExitCode -ne 0) { throw "Exit code $($proc.ExitCode)" }
105- Add-Content $env:GITHUB_STEP_SUMMARY "✅ [pwsh] compile --help OK"
274+
275+ Invoke-CliProcess -FilePath $binary -ArgumentList @("totally-unknown-subcommand-xyz") -TimeoutMs 10000 -ExpectFailure -ExpectedErrorPattern "unknown|not found|invalid|unrecognized" | Out-Null
276+ Add-Content $env:GITHUB_STEP_SUMMARY "✅ Unknown subcommand fails fast with explicit error output"
106277
107278 # Explicit check for stdin-blocking hang (common bubbletea/TUI regression)
108279 - name : " [pwsh] stdin hang detection"
@@ -119,51 +290,6 @@ jobs:
119290 }
120291 Add-Content $env:GITHUB_STEP_SUMMARY "✅ [pwsh] No stdin hang detected"
121292
122- # Windows PowerShell (legacy powershell.exe) - catches PS 5.x-specific regressions
123- - name : " [powershell] --help with timeout"
124- shell : powershell
125- run : |
126- $psi = New-Object System.Diagnostics.ProcessStartInfo
127- $psi.FileName = $env:BINARY
128- $psi.Arguments = "--help"
129- $psi.UseShellExecute = $false
130- $proc = [System.Diagnostics.Process]::Start($psi)
131- if (-Not $proc.WaitForExit(30000)) {
132- $proc.Kill()
133- throw "TIMEOUT: --help hung for 30s in Windows PowerShell"
134- }
135- if ($proc.ExitCode -ne 0) { throw "Exit code $($proc.ExitCode)" }
136- Write-Output "[powershell] --help OK"
137-
138- - name : " [powershell] version with timeout"
139- shell : powershell
140- run : |
141- $psi = New-Object System.Diagnostics.ProcessStartInfo
142- $psi.FileName = $env:BINARY
143- $psi.Arguments = "version"
144- $psi.UseShellExecute = $false
145- $proc = [System.Diagnostics.Process]::Start($psi)
146- if (-Not $proc.WaitForExit(30000)) {
147- $proc.Kill()
148- throw "TIMEOUT: version hung for 30s in Windows PowerShell"
149- }
150- if ($proc.ExitCode -ne 0) { throw "Exit code $($proc.ExitCode)" }
151- Write-Output "[powershell] version OK"
152-
153- # cmd.exe - catches Win32 console and PATH resolution regressions
154- # Per-command timeout not available in cmd; job-level timeout-minutes: 20 applies
155- - name : " [cmd] --help"
156- shell : cmd
157- run : |
158- "%BINARY%" --help
159- if errorlevel 1 exit /b 1
160-
161- - name : " [cmd] version"
162- shell : cmd
163- run : |
164- "%BINARY%" version
165- if errorlevel 1 exit /b 1
166-
167293 - name : Integration summary
168294 if : always()
169295 shell : pwsh
0 commit comments